普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月12日首页

到底滚动了没有?用 CSS @container scroll-state 查询判断

2026年2月12日 10:26

原文:Is it scrolled? Is it not? Let's find out with CSS @container scroll-state() queries

翻译:TUARAN

欢迎关注 {{前端周刊}},每周更新国外论坛的前端热门文章,紧跟时事,掌握前端技术动态。

image.png

过去几年里,我们经常需要用 JavaScript(滚动事件、Intersection Observer)来回答一些看似简单的问题:

  • 这个 sticky 头部现在真的“贴住”了吗?
  • 这个 scroll-snap 列表现在“吸附到哪一项”了?
  • 这个容器是否还能继续滚?左边/右边还有没有内容?

@container scroll-state(本文简称“scroll-state 查询”)提供了一种 CSS 原生的状态查询方式:容器可以根据自己的滚动状态,去样式化子元素。

快速回顾:scroll-state 查询怎么用

先把某个祖先设置为 scroll-state 容器:

.scroll-ancestor {
  container-type: scroll-state;
}

然后用容器查询按状态应用样式:

@container scroll-state(stuck: top) {
  .child-of-scroll-parent {
    /* 只有“贴住顶部”时才生效 */
  }
}

Chrome 133:三件套(stuck / snapped / scrollable)

1) stuck:sticky 是否真的“贴住”了

当你用 position: sticky 做吸顶 header 时,常见需求是:只有在 header 真的贴住时才加背景、阴影。

.sticky-header-wrapper {
  position: sticky;
  inset-block-start: 0;
  container-type: scroll-state;
}

@container scroll-state(stuck: top) {
  .main-header {
    background-color: var(--color-header-bg);
    box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
  }
}

2) snapped:当前吸附项

对于 scroll-snap 画廊,你往往想高亮当前吸附项,例如放大当前卡片、改变滤镜。

.horizontal-track li {
  container-type: scroll-state;
}

@container scroll-state(snapped: inline) {
  .card-content img {
    transform: scale(1.1);
    filter: sepia(0);
  }
}

3) scrollable:某个方向上是否“还能滚”

这类需求过去常靠 JS 读 scrollLeft/scrollWidth/clientWidth。现在可以按方向做样式:

@container scroll-state(scrollable: left) {
  .scroll-arrow.left {
    opacity: 1;
  }
}

@container scroll-state(scrollable: right) {
  .scroll-arrow.right {
    opacity: 1;
  }
}

Chrome 144:新增 scrolled(最近一次滚动方向)

写作时 Chrome 144 带来了 scrolled,用于判断“最近一次滚动的方向”。这让一些常见的 UI 模式可以不写 JS:

经典的“hidey-bar” 头部

html {
  container-type: scroll-state;
}

@container scroll-state(scrolled: bottom) {
  .main-header {
    transform: translateY(-100%);
  }
}

@container scroll-state(scrolled: top) {
  .main-header {
    transform: translateY(0);
  }
}

“滚动提示”只在第一次交互后消失

例如横向滚动容器:用户一旦横向滚过,就隐藏提示。

@container scroll-state(scrolled: inline) {
  .scroll-indicator {
    opacity: 0;
  }
}

小结

scroll-state 查询把一部分“滚动状态机”的能力下放给 CSS:

  • 能做渐进增强时,UI 代码会更轻、更稳定;
  • 状态可由浏览器内部实现,避免滚动事件带来的性能与时序问题;
  • 但要大规模依赖,还需要更完整的跨浏览器支持。

进一步阅读:

测量 SVG 渲染时间

2026年2月12日 10:24

原文:Measuring SVG rendering time

翻译:TUARAN

欢迎关注 {{前端周刊}},每周更新国外论坛的前端热门文章,紧跟时事,掌握前端技术动态。

本文想回答两个很直接的问题:

  • 大型 SVG 的渲染是否显著比小 SVG 慢?有没有一个“超过就很糟糕”的尺寸阈值?
  • 如果把这些 SVG 转成 PNG,渲染表现会怎样?

为此,作者生成了一批测试图片,并用自动化脚本测量“点击插入图片到下一次绘制”的时间(INP 相关)。

测试图片

一个 Python 脚本(gen.py)生成了 199 个 SVG 文件:

  • 1KB 到 100KB:每 1KB 一个
  • 200KB 到 10MB:每 100KB 一个

每个 SVG 都是 1000×1000,包含随机的路径、圆、矩形等形状;颜色、位置、线宽随机化。

然后用 convert-to-png.js(Puppeteer)把所有 SVG 转成 PNG:

  • omitBackground: true(保持透明背景)
  • 转完再过一遍 ImageOptim

作者用 chart-sizes.html 展示了 SVG 与 PNG 的文件大小分布:SVG 一路可以到 10MB,但 PNG 很少到那么大;在小尺寸区间往往 SVG 更小,而超过约 2MB 后,PNG 反而更小。

(原文附图)

接下来是渲染测试页:一次只渲染一张图。

测试页面

test.html 接受文件名参数,例如:?file=test_100KB&type=svg

页面逻辑:

  • new Image() 预加载图片(因为我们不关心下载时间,只关心渲染)
  • 预加载完成后显示一个 “inject” 按钮
  • 点击按钮后,把图片 append 到 DOM

为了捕获交互到绘制的成本,用 PerformanceObserver 监听 event entries,并计算 INP 分解:

  • input delay
  • processing duration
  • presentation delay

其中 presentation delay 指点击处理结束到浏览器实际绘制的时间;作者主要关注最终的 INP。

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.name === 'pointerup' || entry.name === 'click') {
      const inputDelay = entry.processingStart - entry.startTime;
      const processingDuration = entry.processingEnd - entry.processingStart;
      const presentationDelay =
        entry.duration - (entry.processingEnd - entry.startTime);
      const totalINP = entry.duration;
      // ...
    }
  }
}).observe({ type: 'event', buffered: true, durationThreshold: 16 });

自动化测量

measure.js 是一个 Puppeteer 脚本,流程大致是:

  • 启动 Chrome
  • 对每个测试文件:
    • 先打开 blank.html 重置状态
    • 再打开带参数的 test.html
    • 等预加载完成
    • 开始 DevTools trace
    • 点击 inject,把图片插入 DOM
    • 等待 PerformanceObserver 回报
    • 停止 trace
    • 从 observer 与 trace 中提取 INP
  • 每个文件跑 3 次,取中位数
  • 输出 JSON 结果

命令行参数:

  • --png:测 PNG(默认测 SVG)
  • --throttle=N:CPU 降速(例如 --throttle=4 表示 4× 变慢)
  • --output=file.json:输出文件名

作者试过开/不开 throttle,整体趋势不变,差别主要体现在绝对耗时变大。

开跑

node measure.js --svg --output=results-svg.json
node measure.js --png --output=results-png.json

结果

可以在 chart.html 查看完整图表。

SVG 结果(全量):

SVG 结果(<= 1MB):

PNG 结果:

作者观察到:

  • PerformanceObserver 的 INP 与 profiler 的 INP 很接近
  • SVG 的渲染时间呈现一种“阶梯式”增长:
    • 小于约 400KB 的 SVG,渲染耗时差不多
    • 之后会在某些区间出现明显跃迁(例如约 1.2MB)
  • PNG 也似乎有类似阶梯,但由于 1–2MB 区间样本较少,不如 SVG 明显
  • 不管格式如何,400KB 以下基本都在同一渲染档位;当文件更大时,尤其是非常大时,PNG 倾向更快

作者还展示了生成图片的样子(例如 60KB 的 SVG),更大文件只是叠加更多形状以提高体积:

昨天 — 2026年2月11日首页

JS Bin 在 2026 年宕机

2026年2月11日 08:47

原文:JS Bin down in 2026

翻译:TUARAN

欢迎关注 {{前端周刊}},每周更新国外论坛的前端热门文章,紧跟时事,掌握前端技术动态。

1 月 27 日,我收到了邮件通知:JS Bin 挂了。第二天,真实的人类用户开始问「怎么回事」。一直到 30 日晚上 11 点左右,最后一批问题才被解决。

前些天 Jake 问我:到底哪里出了问题?

答案是:几乎所有环节都出了问题。

TL;DR

我知道这篇文章又长又碎。我写嗨了,也很享受把这段经历讲清楚。

简短版是:

  • 早点把基础设施升级到最新(但我当时做不到)。
  • 520 并不等于「服务器没响应」,它很可能是 Cloudflare 与源站之间的 TLS 协商/响应不兼容。
  • 服务器在着火时,别过度依赖 LLM:它能帮你补知识,但也可能把你带进更复杂的岔路。尽量先退一步,盘点现状,再做改变。

如果你愿意读长版,那就开始。

处于“维护模式”的这些年

过去大概 5 年(也可能更久),JS Bin 基本处于一种半自动「维护模式」:每隔 3~6 个月会有一阵小故障需要我出手。JS Bin 快 18 岁了,以 Web 的标准来说已经是「高龄应用」。

通常我需要处理的是站内的不良内容、来自 AWS(JS Bin 托管在那儿)的下架请求,或者偶发的内存异常——有的恢复要花更久。

看过去 11 年的可用性曲线,宕机并不罕见,但这次完全不同(左边那次大故障先不算)。

很多「长时间」宕机(我最近才更清楚)是因为机器内存耗尽,系统会像“塌方”一样:我甚至无法 SSH 登录去恢复,只能在 AWS 控制台里强制重启。

而这次更离谱:连「控制台重启」这种真正的「关机重开」都不灵。

重启也起不来

这次宕机,无论怎么重启都回不来。我触发重启后,一直在控制台等 SSH,但完全连不上。

机器的行为像是:重启 → 立刻锁死。

这暗示着:机器外面有持续的巨大压力,而且没有减弱。

我手上唯一的办法,是把机器彻底关机一小时左右,希望“敲门的东西”能先走开。

后来我看 CloudWatch,才知道入站流量把机器摁在地上摩擦:几天前还是正常水平,突然变成前所未有的网络入站峰值。图里 cliff edge 之后的那些下跌,是机器扛不住而失去响应。

先把“该死的进程”杀掉

我终于进到机器后,第一件事是看 syslog,找出导致崩溃的东西(更准确说:找出症状)。

很快我看到了 Node 因为 OOM(内存耗尽)而产生的 GC dump 与堆栈。

第一优先级:不要让系统“全盘崩溃”,先让最耗内存的进程被杀掉,至少保证我还能登录继续排查。

我加了这条配置:

# /etc/sysctl.conf,然后用 `sudo sysctl -p` 重新加载
vm.oom_kill_allocating_task=1

这样内存被打爆时,系统会优先杀掉正在疯狂分配内存的进程(Node),而不是随便杀掉别的关键进程(导致 SSH 也没了)。

这不会让机器“很快”,但能让我在流量继续轰炸时至少还能诊断(当然终端会非常慢)。

我能看到 CPU 长时间接近 100%,Node 的内存占用持续上涨(我用 htop)。

然后,ChatGPT 建议我升级 Node——奇怪的是,我并没有告诉它我在用什么版本。

支线任务:Node 真的很老

因为长期“维护模式”,我几乎从没动过 Node。结果 JS Bin 居然跑在 Node 7 上(甚至不是“稳定”的 Node 8),可能已经超过十年。

我问 ChatGPT 为什么知道,它说「你告诉过我」。这完全是胡扯。

我追问了很久才发现:当时我的终端屏幕上显示了 Node 版本;在我之后调 nginx 的过程中,ChatGPT 直接“看”到了屏幕内容。

我不知道它是否能读取滚动历史(希望不能),但这依然很不舒服。

我平常用的是浏览器版 ChatGPT,不怎么用“应用”形态;这次给我一个教训:只要屏幕上出现过敏感信息(比如我刚 cat.env),就要假设 LLM 可能看得到。

总之,我把 Node 从 7 升到了 22,居然没出事。幸运的是我在 2024 年做过一轮现代化改造,好让我能在本机跑起来(现代系统当然不愿意跑 Node 7)。

至少,事件循环性能变好了,CPU 压力会小一点。

……但问题依然没解决。

服务器太小,但我当时不想“加钱”

JS Bin 的主服务跑在 AWS t2.micro 上:单核 CPU、1GB 内存。我一直很惊讶它能在这么少资源上撑这么久。

当然,换更大的机器(加钱)可能有用。但当时我没有一键构建新机器的脚本——这台服务器已经多年“稳定运行”,我也没有随时可用的重建流程。

而且 JS Bin 虽然有 pro 版,但现金流并不充裕。简而言之:我需要“立刻能做”的事。

在 ChatGPT、Gemini 和 Claude 的帮助下(我用多个 LLM 来交叉验证建议,但说实话没有我希望的那么严谨),我尝试调优 nginx,比如:

  • worker 配置
  • proxy timeout
  • 文件描述符上限
  • keep-alive
  • 甚至移除 http2 来省内存

例如:

worker_connections 1024;
worker_processes auto;

keepalive_timeout 10;
keepalive_requests 100;

在每秒 1000+ 请求、还有更多请求在排队的情况下,这些改动几乎没有立竿见影的效果。

接着有人(也包括 LLM)提了一个问题:你考虑过 Cloudflare 吗?

把 Cloudflare 放到前面

说实话,把 Cloudflare 放到 JS Bin 前面出奇地顺利。它识别了大多数域名与指向,我只要把 DNS 的 nameserver 从 Route 53 换成 Cloudflare。

1 月 29 日接近午夜时,我开始能在浏览器里打开 jsbin.com 了。

但很快 GitHub 和邮件反馈告诉我:很多人依然在报错。

尤其是 Cloudflare 的 520——后来我才知道,它可能对应很多不同的问题。

请求仍然绕过 Cloudflare

即便能打开页面,我也能在服务器上看到有流量还在直冲源站。

我找到了 Cloudflare 的 IP 段列表,发现还有大量请求并不来自这些 IP 段。于是下一步是:丢弃非 Cloudflare 的流量。

这里开始,LLM 的建议让我引入了更多问题(直到第二天才彻底暴露)。当你忙到“救火模式”,很容易在复杂度上失手。

我最先用的方案是:在 nginx 里判断 Cloudflare 的请求头,没带头就丢弃。

if ($http_cf_ray = "") {
    return 444;
}

444 会直接断开连接,不返回任何内容。

我还按「Captain GPT」的指引配置了 set_real_ip_from 等(这在之后会坑到我):

# /etc/nginx/cloudflare.conf
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
set_real_ip_from 103.31.4.0/22;
set_real_ip_from 141.101.64.0/18;
set_real_ip_from 108.162.192.0/18;
set_real_ip_from 190.93.240.0/20;
set_real_ip_from 188.114.96.0/20;
set_real_ip_from 197.234.240.0/22;
set_real_ip_from 198.41.128.0/17;
set_real_ip_from 162.158.0.0/15;
set_real_ip_from 104.16.0.0/13;
set_real_ip_from 104.24.0.0/14;
set_real_ip_from 172.64.0.0/13;
set_real_ip_from 131.0.72.0/22;

real_ip_header CF-Connecting-IP;
real_ip_recursive on;

并在 http {} 里 include:

http {
    include /etc/nginx/cloudflare.conf;
    ...
}

这会让 $remote_addr 变成真实用户 IP,而不是 Cloudflare 的边缘节点 IP。

但问题是:即便这样做,流量仍然很大;而且这种“在 nginx 层处理再丢弃”的方式,依然会消耗资源。

把油继续往火里浇

我还用 ss 查看连接:

ss -tan state established '( sport = :443 )'

我依然能看到一些非 Cloudflare 的请求成功建立连接(当时我正处于「改配置 → 失败 → 焦虑 → 问 LLM → 重来」的循环中)。

于是我又换了一种策略:不用请求头,改用来源 IP 是否属于 Cloudflare 的 IP 段来判断。

geo $is_cloudflare {
    default 0;

    173.245.48.0/20    1;
    103.21.244.0/22    1;
    # etc
}

然后在 server block 里:

server {
    listen 443 ssl http2 default_server;
    if ($is_cloudflare = 0) { return 444; }
    if ($cf_valid = 0) { return 444; }

    # rest of config unchanged
}

我当时没意识到的一点(花了 24 小时才想明白):

  • 之前那套配置会说「如果请求来自 Cloudflare,把 $remote_addr 改成真实用户 IP」。
  • 然后这套规则又说「如果 $remote_addr 不是 Cloudflare IP,就丢弃」。

结果:真正的用户(经由 Cloudflare)反而被我丢掉了。

现场变成了最糟糕的两件事同时发生:

  • 仍然有绕过 Cloudflare 的流量打进来,继续耗资源
  • 真实用户经由 Cloudflare 访问时,大多数却拿到 520

我终于意识到:应该在更靠外的地方(防火墙/安全组)直接丢弃流量。

直接丢弃流量(ufw + AWS 安全组)

我做了“双保险”:

  • 在服务器上用 ufw(我一直把它当成 iptables)按 IP 段 ALLOW/DROP
  • 在 AWS security group 同样只允许 Cloudflare IP 段访问 80/443

ufw 相对简单:

ufw allow from 173.245.48.0/20 to any port 443
ufw allow from 103.21.244.0/22 to any port 443
# etc
ufw deny 443

80 端口同理。

我在测试时还踩了个小坑:想临时放行自己 IP 时搞乱了规则;后来用 ufw status numbered + ufw delete N 解决。

AWS 安全组就麻烦多了:Web UI 不适合大批量改动,非常笨重。

我最后用 AWS CLI 写了脚本(但它不能批量修改,一条条执行还得等待返回再按回车,效率很低):

for CIDR in \
   103.21.244.0/22 \
   103.22.200.0/22 \
   103.31.4.0/22 \
   # etc
do
   aws ec2 authorize-security-group-ingress --group-id $SG_ID --protocol tcp --port 80  --cidr $CIDR --region us-east-1
   aws ec2 authorize-security-group-ingress --group-id $SG_ID --protocol tcp --port 443 --cidr $CIDR --region us-east-1
done

做到这一步,服务器终于开始“喘气”了。

但很多用户仍然被 Cloudflare 的 520 拦住(奇怪的是我自己还能访问……)。

520:不是 503/504,而是“不兼容”

我原本以为 520 类似 503(源站挂了)或 504(网关超时),但实际上更像是:

Cloudflare 发起请求,但源站的响应对 Cloudflare 来说“不兼容”。

我能确定一个线索:80 端口的纯 HTTP 没问题,只有 HTTPS 出问题。

有个很关键的观察来自 @robobuljan

curl jsbin.com             # (正常)
curl http://jsbin.com      # (正常)
curl https://jsbin.com     # 520

这一天里,LLM 基本帮不上忙(我也干脆把它们放一边,专心啃问题)。

我在 Cloudflare 的 SSL/TLS 页里看到「Traffic Served Over TLS」显示 TLS 版本分布(我没截图,这些数来自它们的 API):

  • TLSv1:36
  • TLSv1.1:56
  • TLSv1.2:1,922,523
  • TLSv1.3:5,216,795

我的 nginx 配置里反复出现这行:

ssl_protocols TLSv1 TLSv1.1 TLSv1.2;

没有 TLSv1.3。

我先尝试在 nginx 加上 TLSv1.3,但 nginx -t 直接失败:当前环境缺模块,而且要做大升级才能装上。

那就换思路:能不能在 Cloudflare 侧禁用 TLS 1.3?

答案是能,但入口很隐蔽:

  • 查看支持情况在「Speed / Settings」
  • 真正关闭在「SSL/TLS → Edge Certificates」,页面靠后的位置

禁用 TLS 1.3 后,大量真实流量恢复正常。

仍然有部分用户加载失败(static/null 域名)

接着又出现一个问题:部分用户的静态资源加载不了,或者用于运行代码的 iframe 域名(null.jsbin.com)异常。

我花了几个小时才理清:我曾让 nginx 在「请求来自 Cloudflare 时」使用 set_real_ip_from$remote_addr 改写为真实用户 IP,而后续又用 return 444 去丢弃“不符合条件”的请求。

不知为何,这套混乱的规则并没有影响主站首页,但却影响了 static.jsbin.comnull.jsbin.com

这就是熬夜 + 危机模式的典型后果:配置变成一团糟,自己也说不清到底哪些规则在什么时候生效。

当我最终移除这些临时拼凑的检查、IP 改写与一堆由 LLM 引入的“杂质”后,最后那部分流量也恢复了。

JS Bin 全面恢复。

事后复盘

现在 Cloudflare 顶在源站前面,这台 1GB 单核的小机器居然非常从容:CPU 常态只有 4%~5%。

我怀疑如果当时没那么依赖 LLM,我可能会更早意识到自己在不断增加复杂度。但另一方面:我也早该把 Cloudflare 放到 JS Bin 前面——不该等到危机时刻才做。

这次我学到的“坑”主要是:

  • TLS 版本不匹配会导致 520
  • 520 的语义远比我以为的复杂

从 CloudWatch 看,流量确实回落了;我相信 Cloudflare 已经替我挡掉了很多垃圾流量。看起来其中一大块来自香港——我给那边开了 Cloudflare 的 JS Challenge 才能继续访问:

拍截图后 24 小时内香港有 1000 万请求。

至于到底是什么导致这波流量把一切打崩,我恐怕永远无法确定。我直觉怀疑是 AI/LLM 的爬虫在“吸”整个互联网;但反证是:流量并不来自单一 IP。

讽刺的是,我倒是在自己的博客上看到过一个单 IP 爬虫(见这个链接):几个小时内 3GB 数据、32.5 万次请求。好在博客是跑在 Netlify 的纯静态站上,不像 JS Bin 还在跑 Node 7(是的,真的是 Node 7)。

昨天以前首页

离了大谱!一个 prompt 生成了 7 万字!mark

2026年2月10日 16:11

背景

我也不明所以,糖糖,先记下来!

image.png

原 prompt

评价这个技术框架,列表:交付一款成品感很强的桌面软件,名字叫「短信智标官(SMS Tagging Officer)」。它用于对几千条短信做离线分类打标与结构化抽取,运行环境完全离线,推理引擎内嵌 llama.cpp,前端用 Tauri + Vue 3,数据落 SQLite,用户通过桌面界面完成导入、批处理、复核、导出,最后能用于行业报表与短信治理。你需要把它当作真实交付项目来做,输出的内容必须是可复制运行的完整工程骨架与关键代码文件,包含打包说明,能够在没有网络的环境里直接跑通。

产品能力边界要明确:短信进入系统后,需要给出两层标签与一套实体抽取字段。一级标签是行业大类,固定为金融、通用、政务、渠道、互联网、其他;二级标签是短信类型,固定为验证码、交易提醒、账单催缴、保险续保、物流取件、会员账号变更、政务通知、风险提示、营销推广、其他。实体抽取必须覆盖 brand、verification_code、amount、balance、account_suffix、time_text、url、phone_in_text,字段缺失时填 null。每条短信的最终输出要求是稳定 JSON,字段齐全,便于解析与回放,必须包含 confidence、reasons、rules_version、model_version、schema_version,并且支持 needs_review 标记用于人工复核队列。

分类策略采用规则引擎与小模型协同,先走规则兜底,把强模式(验证码、物流取件、显式政务机构、显式银行证券保险交易提醒)优先判定并高置信输出,同时完成实体抽取。规则层输出要带 signals,用于 reasons 的可解释性。进入模型层时,把短信 content 与规则抽取的 entities、signals 一并作为上下文输入,让模型只做剩余灰区判断与补全,并且强约束输出枚举值与严格 JSON。融合阶段需要处理冲突,依据置信度与规则强命中程度做决策,发生冲突时自动设置 needs_review 并适度下调 confidence,保证复核入口聚焦在少数难例上。

本地推理必须完全离线内嵌,采用 llama.cpp 作为推理后端,模型文件用 GGUF 量化格式,应用启动后可以在设置页选择模型文件路径并做一次健康检查。你需要提供一套可替换的 Provider 抽象接口,核心是 classify(payload) -> result,默认实现为 llama.cpp 内嵌推理,后续也能扩展成其他本地推理方式。推理侧必须做并发与超时控制,提供队列化批处理能力,保证几千条文本不会把 UI 卡死,并且支持失败重试与错误日志落盘。

数据存储采用 SQLite,要求至少三张表:messages 存原始短信与元信息,labels 存模型输出标签与抽取字段,audit_logs 记录人工改动前后差异与操作者信息,任何人工修改都必须落审计日志。你需要实现查询与过滤能力,支持按行业、类型、needs_review、置信度区间、含链接、含验证码、含金额等条件筛选,保证复核效率。导入时允许用户映射 CSV/Excel 的列到 content、received_at、sender、phone、source 等字段,导出支持 CSV 与 JSONL,允许只导出已复核样本或导出全量。

桌面端采用 Tauri + Vue 3 + TypeScript 实现,界面至少包括导入页、批处理页、列表页、复核编辑抽屉、导出页与设置页。列表页需要一眼能看到 content、industry、type、confidence、brand 与关键实体,复核抽屉支持直接修改 industry/type/brand/entities 并保存,保存后自动写入 labels 与 audit_logs。批处理页需要进度条、耗时、当前处理条目、失败计数、重试按钮,并提供“只跑未标注/只跑 needs_review”的增量处理选项,保证可迭代。

交付形式要求你输出一个完整项目目录树,然后逐文件给出可直接复制的完整代码,包含 README.md、.env.example 或 settings 配置说明、SQLite 初始化脚本、llama.cpp 绑定与调用代码、规则引擎与实体抽取代码、分类 prompt 模板、融合决策器代码、前端页面与组件代码、打包与分发说明。你提供的代码不能是伪代码,不能省略关键文件,所有可配置项必须写明默认值与作用。你需要同时提供至少 10 条覆盖各类场景的样例短信,给出一键自测步骤,让用户导入样例数据即可跑通分类与导出,并说明如何验证输出 JSON 是否符合约束、实体抽取是否正确、冲突样本是否进入 needs_review 队列。

从输出顺序上,你直接开始产出工程化结果,先给项目目录树与依赖说明,再给 schema 与枚举常量文件,再给规则提取器与测试样例,再给 llama.cpp 推理 Provider 与分类 prompt,再给融合决策器,再给 SQLite 初始化脚本与 DAO 层,再给前端页面与核心组件,最后给 README 的运行与打包步骤。你输出的所有内容以可运行与可验收为准,任何不确定之处你给出默认实现,并把扩展点写清楚。

以下内容为一次性生成(7w字)

下面按“真实可交付项目”的标准,直接给你一套 可复制运行的完整工程骨架 + 关键代码文件(Tauri + Vue3 + TS + SQLite + llama.cpp sidecar),并把产品边界、枚举约束、规则引擎、Provider 抽象、融合决策、审计、批处理队列、导入导出、前端页面全部落到可跑通的实现上。

说明:这里的 llama.cpp 集成采用 sidecar 方式(把 llama-cli/llama 可执行文件随应用打包,Rust 侧通过 Command 调用并做并发/超时/重试/日志),这在桌面离线交付里最稳,跨平台也最好控。后续你要换成 FFI 直连也能沿 Provider 接口扩展。


1) 项目目录树与依赖说明

sms-tagging-officer/
  README.md
  package.json
  pnpm-lock.yaml
  pnpm-workspace.yaml
  tsconfig.json
  vite.config.ts
  .env.example

  /apps/desktop/
    index.html
    src/
      main.ts
      App.vue
      router.ts
      api/tauri.ts
      api/types.ts
      api/schema.ts
      stores/settings.ts
      stores/batch.ts
      utils/validators.ts
      utils/sample.ts
      pages/
        ImportPage.vue
        BatchPage.vue
        ListPage.vue
        ExportPage.vue
        SettingsPage.vue
      components/
        ColumnMapper.vue
        MessageTable.vue
        ReviewDrawer.vue
        FilterBar.vue
        ProgressPanel.vue

  /src-tauri/
    Cargo.toml
    tauri.conf.json
    build.rs
    src/
      main.rs
      commands.rs
      domain/
        schema.rs
        enums.rs
        dto.rs
      infra/
        log.rs
        time.rs
      db/
        mod.rs
        migrations.sql
        dao.rs
      rules/
        mod.rs
        extractor.rs
        signals.rs
        rule_engine.rs
      providers/
        mod.rs
        provider.rs
        llama_cpp.rs
        prompt.rs
      fusion/
        mod.rs
        decision.rs
      batch/
        mod.rs
        worker.rs

  /assets/
    sample_messages.csv
    sample_messages.jsonl
    json_schema_output.json

  /vendor/
    llama/
      (放置 llama.cpp 可执行文件与说明,构建阶段由你下载)
      README-LLAMA.md

运行时依赖

  • 完全离线运行:SQLite 本地文件、GGUF 模型文件、llama.cpp sidecar 二进制都在本地。
  • 前端:Tauri + Vue3 + TS(Vite)。
  • 后端:Rust + rusqlite + tokio(队列、并发、超时)+ serde(严格 JSON)+ anyhow(错误栈)。

2) Schema 与枚举常量文件

apps/desktop/src/api/schema.ts

export const SCHEMA_VERSION = "1.0.0";
export const RULES_VERSION = "1.0.0";

export const INDUSTRY_ENUM = [
  "金融",
  "通用",
  "政务",
  "渠道",
  "互联网",
  "其他",
] as const;

export const TYPE_ENUM = [
  "验证码",
  "交易提醒",
  "账单催缴",
  "保险续保",
  "物流取件",
  "会员账号变更",
  "政务通知",
  "风险提示",
  "营销推广",
  "其他",
] as const;

export type Industry = typeof INDUSTRY_ENUM[number];
export type SmsType = typeof TYPE_ENUM[number];

export type ExtractedEntities = {
  brand: string | null;
  verification_code: string | null;
  amount: number | null;
  balance: number | null;
  account_suffix: string | null;
  time_text: string | null;
  url: string | null;
  phone_in_text: string | null;
};

export type LabelResult = {
  message_id: string;
  industry: Industry;
  type: SmsType;
  entities: ExtractedEntities;
  confidence: number; // 0..1
  reasons: string[];  // 可解释性
  signals: Record<string, unknown>; // 规则信号
  needs_review: boolean;

  rules_version: string;
  model_version: string;
  schema_version: string;
};

src-tauri/src/domain/enums.rs

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Industry {
    #[serde(rename = "金融")]
    Finance,
    #[serde(rename = "通用")]
    General,
    #[serde(rename = "政务")]
    Gov,
    #[serde(rename = "渠道")]
    Channel,
    #[serde(rename = "互联网")]
    Internet,
    #[serde(rename = "其他")]
    Other,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum SmsType {
    #[serde(rename = "验证码")]
    Otp,
    #[serde(rename = "交易提醒")]
    Transaction,
    #[serde(rename = "账单催缴")]
    BillCollect,
    #[serde(rename = "保险续保")]
    InsuranceRenew,
    #[serde(rename = "物流取件")]
    LogisticsPickup,
    #[serde(rename = "会员账号变更")]
    AccountChange,
    #[serde(rename = "政务通知")]
    GovNotice,
    #[serde(rename = "风险提示")]
    RiskAlert,
    #[serde(rename = "营销推广")]
    Marketing,
    #[serde(rename = "其他")]
    Other,
}

src-tauri/src/domain/schema.rs

pub const SCHEMA_VERSION: &str = "1.0.0";
pub const RULES_VERSION: &str = "1.0.0";

src-tauri/src/domain/dto.rs

use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use super::enums::{Industry, SmsType};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtractedEntities {
    pub brand: Option<String>,
    pub verification_code: Option<String>,
    pub amount: Option<f64>,
    pub balance: Option<f64>,
    pub account_suffix: Option<String>,
    pub time_text: Option<String>,
    pub url: Option<String>,
    pub phone_in_text: Option<String>,
}

impl Default for ExtractedEntities {
    fn default() -> Self {
        Self {
            brand: None,
            verification_code: None,
            amount: None,
            balance: None,
            account_suffix: None,
            time_text: None,
            url: None,
            phone_in_text: None,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuleOutput {
    pub hit: bool,
    pub industry: Option<Industry>,
    pub sms_type: Option<SmsType>,
    pub entities: ExtractedEntities,
    pub confidence: f64,
    pub reasons: Vec<String>,
    pub signals: HashMap<String, serde_json::Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelOutput {
    pub industry: Industry,
    pub sms_type: SmsType,
    pub entities: ExtractedEntities,
    pub confidence: f64,
    pub reasons: Vec<String>,
    pub model_version: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FinalLabel {
    pub message_id: String,
    pub industry: Industry,
    pub sms_type: SmsType,
    pub entities: ExtractedEntities,
    pub confidence: f64,
    pub reasons: Vec<String>,
    pub signals: HashMap<String, serde_json::Value>,
    pub needs_review: bool,
    pub rules_version: String,
    pub model_version: String,
    pub schema_version: String,
}

3) 规则提取器与测试样例(含 signals、实体抽取)

src-tauri/src/rules/signals.rs

use serde_json::json;
use std::collections::HashMap;

pub fn signal_bool(map: &mut HashMap<String, serde_json::Value>, k: &str, v: bool) {
    map.insert(k.to_string(), json!(v));
}

pub fn signal_str(map: &mut HashMap<String, serde_json::Value>, k: &str, v: &str) {
    map.insert(k.to_string(), json!(v));
}

pub fn signal_num(map: &mut HashMap<String, serde_json::Value>, k: &str, v: f64) {
    map.insert(k.to_string(), json!(v));
}

src-tauri/src/rules/extractor.rs

use regex::Regex;
use crate::domain::dto::ExtractedEntities;

pub fn extract_entities(content: &str) -> ExtractedEntities {
    let mut e = ExtractedEntities::default();

    // URL
    let re_url = Regex::new(r"(https?://[^\s]+)").unwrap();
    if let Some(cap) = re_url.captures(content) {
        e.url = Some(cap.get(1).unwrap().as_str().to_string());
    }

    // 手机号(文本中)
    let re_phone = Regex::new(r"(?:+?86[-\s]?)?(1[3-9]\d{9})").unwrap();
    if let Some(cap) = re_phone.captures(content) {
        e.phone_in_text = Some(cap.get(1).unwrap().as_str().to_string());
    }

    // 验证码:4-8 位数字,常见关键词附近
    let re_otp = Regex::new(r"(?:验证码|校验码|动态码|OTP|验证代码)[^\d]{0,6}(\d{4,8})").unwrap();
    if let Some(cap) = re_otp.captures(content) {
        e.verification_code = Some(cap.get(1).unwrap().as_str().to_string());
    } else {
        // 兜底:孤立 6 位码(谨慎)
        let re_6 = Regex::new(r"(?<!\d)(\d{6})(?!\d)").unwrap();
        if let Some(cap) = re_6.captures(content) {
            e.verification_code = Some(cap.get(1).unwrap().as_str().to_string());
        }
    }

    // 金额:¥/¥/元/人民币 + 数字(允许小数)
    let re_amount = Regex::new(r"(?:¥|¥|人民币)?\s*([0-9]+(?:.[0-9]{1,2})?)\s*(?:元|RMB)?").unwrap();
    // 这里会命中很多数字,按关键词优先提取
    let re_amount_kw = Regex::new(r"(?:金额|支付|扣款|入账|转账|消费|还款|应还|应缴|欠费)[^\d]{0,10}([0-9]+(?:.[0-9]{1,2})?)").unwrap();
    if let Some(cap) = re_amount_kw.captures(content) {
        e.amount = cap.get(1).unwrap().as_str().parse::<f64>().ok();
    } else if let Some(cap) = re_amount.captures(content) {
        e.amount = cap.get(1).unwrap().as_str().parse::<f64>().ok();
    }

    // 余额
    let re_balance = Regex::new(r"(?:余额|可用余额)[^\d]{0,10}([0-9]+(?:.[0-9]{1,2})?)").unwrap();
    if let Some(cap) = re_balance.captures(content) {
        e.balance = cap.get(1).unwrap().as_str().parse::<f64>().ok();
    }

    // 尾号
    let re_suffix = Regex::new(r"(?:尾号|末四位|后四位)[^\d]{0,6}(\d{3,4})").unwrap();
    if let Some(cap) = re_suffix.captures(content) {
        e.account_suffix = Some(cap.get(1).unwrap().as_str().to_string());
    }

    // time_text:粗提(原样保留便于审计/复核)
    let re_time = Regex::new(r"(\d{4}[-/年]\d{1,2}[-/月]\d{1,2}日?\s*\d{1,2}:\d{2})").unwrap();
    if let Some(cap) = re_time.captures(content) {
        e.time_text = Some(cap.get(1).unwrap().as_str().to_string());
    } else {
        let re_time2 = Regex::new(r"(\d{1,2}:\d{2})").unwrap();
        if let Some(cap) = re_time2.captures(content) {
            e.time_text = Some(cap.get(1).unwrap().as_str().to_string());
        }
    }

    // brand:按常见机构/平台关键词提取(可扩展为词典)
    let brands = [        ("中国银行", "中国银行"),        ("工商银行", "工商银行"),        ("建设银行", "建设银行"),        ("农业银行", "农业银行"),        ("招商银行", "招商银行"),        ("平安", "平安"),        ("支付宝", "支付宝"),        ("微信", "微信"),        ("京东", "京东"),        ("美团", "美团"),        ("顺丰", "顺丰"),        ("中通", "中通"),        ("圆通", "圆通"),        ("邮政", "邮政"),        ("12345", "12345"),    ];
    for (kw, name) in brands {
        if content.contains(kw) {
            e.brand = Some(name.to_string());
            break;
        }
    }

    e
}

src-tauri/src/rules/rule_engine.rs

use std::collections::HashMap;
use regex::Regex;

use crate::domain::dto::{RuleOutput, ExtractedEntities};
use crate::domain::enums::{Industry, SmsType};
use crate::rules::extractor::extract_entities;
use crate::rules::signals::*;

pub fn apply_rules(content: &str) -> RuleOutput {
    let mut signals: HashMap<String, serde_json::Value> = HashMap::new();
    let mut reasons: Vec<String> = vec![];
    let entities: ExtractedEntities = extract_entities(content);

    // 强模式:验证码
    let has_otp_kw = content.contains("验证码") || content.contains("校验码") || content.contains("动态码") || content.to_uppercase().contains("OTP");
    if has_otp_kw && entities.verification_code.is_some() {
        signal_bool(&mut signals, "rule_otp", true);
        reasons.push("命中强规则:验证码关键词 + 4-8位验证码".to_string());
        return RuleOutput {
            hit: true,
            industry: Some(Industry::General),
            sms_type: Some(SmsType::Otp),
            entities,
            confidence: 0.98,
            reasons,
            signals,
        };
    }

    // 强模式:物流取件(含取件码/驿站/快递到了)
    let re_pick = Regex::new(r"(取件|取货|驿站|快递已到|提货码|取件码)").unwrap();
    if re_pick.is_match(content) {
        signal_bool(&mut signals, "rule_logistics_pickup", true);
        reasons.push("命中强规则:物流取件关键词".to_string());
        return RuleOutput {
            hit: true,
            industry: Some(Industry::Channel),
            sms_type: Some(SmsType::LogisticsPickup),
            entities,
            confidence: 0.95,
            reasons,
            signals,
        };
    }

    // 强模式:显式政务机构(12345/公安/税务/社保/政务服务)
    let re_gov = Regex::new(r"(12345|公安|税务|社保|政务|政府|人民法院|检察院|交警)").unwrap();
    if re_gov.is_match(content) {
        signal_bool(&mut signals, "rule_gov", true);
        reasons.push("命中强规则:政务机构关键词".to_string());
        return RuleOutput {
            hit: true,
            industry: Some(Industry::Gov),
            sms_type: Some(SmsType::GovNotice),
            entities,
            confidence: 0.94,
            reasons,
            signals,
        };
    }

    // 强模式:银行/证券/保险 交易提醒(扣款/入账/转账/消费/余额)
    let re_fin_org = Regex::new(r"(银行|证券|信用卡|借记卡|保险|保单)").unwrap();
    let re_tx = Regex::new(r"(扣款|入账|转账|消费|交易|支付|还款|余额|可用余额)").unwrap();
    if re_fin_org.is_match(content) && re_tx.is_match(content) {
        signal_bool(&mut signals, "rule_fin_transaction", true);
        reasons.push("命中强规则:金融机构关键词 + 交易/余额关键词".to_string());
        return RuleOutput {
            hit: true,
            industry: Some(Industry::Finance),
            sms_type: Some(SmsType::Transaction),
            entities,
            confidence: 0.93,
            reasons,
            signals,
        };
    }

    // 强模式:账单催缴(欠费/逾期/应还/催缴)
    let re_bill = Regex::new(r"(欠费|逾期|应还|催缴|缴费|账单|最低还款)").unwrap();
    if re_bill.is_match(content) {
        signal_bool(&mut signals, "rule_bill_collect", true);
        reasons.push("命中强规则:账单催缴关键词".to_string());
        return RuleOutput {
            hit: true,
            industry: Some(Industry::Finance),
            sms_type: Some(SmsType::BillCollect),
            entities,
            confidence: 0.90,
            reasons,
            signals,
        };
    }

    // 未命中强规则:返回 signals + entities,交给模型补全
    signal_bool(&mut signals, "rule_hit", false);
    if entities.url.is_some() {
        signal_bool(&mut signals, "has_url", true);
        reasons.push("弱信号:包含URL".to_string());
    }
    if entities.amount.is_some() {
        signal_bool(&mut signals, "has_amount", true);
        reasons.push("弱信号:包含金额".to_string());
    }

    RuleOutput {
        hit: false,
        industry: None,
        sms_type: None,
        entities,
        confidence: 0.0,
        reasons,
        signals,
    }
}

规则测试样例(≥10 条,覆盖场景)

assets/sample_messages.csv
id,received_at,sender,phone,source,content
m1,2026-02-10 10:01:00,中国银行,95566,import,"【中国银行】您尾号1234卡于2026-02-10 09:58消费58.20元,余额1020.55元。"
m2,2026-02-10 10:02:00,支付宝,95188,import,"【支付宝】验证码 493821,用于登录验证,5分钟内有效。"
m3,2026-02-10 10:03:00,顺丰速运,95338,import,"【顺丰】快件已到达XX驿站,取件码 662913,请于18:00前取走。"
m4,2026-02-10 10:04:00,12345,12345,import,"【12345政务】您反映的问题已受理,查询进度请访问 https://gov.example.cn/track"
m5,2026-02-10 10:05:00,某运营商,10086,import,"您本月话费账单已出,应缴 89.50 元,逾期将影响服务。"
m6,2026-02-10 10:06:00,平安保险,95511,import,"【平安】您的保单将于2026-03-01到期,请及时续保,详询4008000000。"
m7,2026-02-10 10:07:00,某电商,1069xxxx,import,"【京东】会员账号绑定手机号变更成功,如非本人操作请致电950618。"
m8,2026-02-10 10:08:00,某平台,1069xxxx,import,"【美团】本店新客立减券已到账,点击 http://promo.example.com 立即使用。"
m9,2026-02-10 10:09:00,公安反诈,12110,import,"【反诈中心】警惕冒充客服退款诈骗,任何验证码均不要透露。"
m10,2026-02-10 10:10:00,未知,unknown,import,"您有一笔订单待处理,请联系 13800138000 获取详情。"

4) llama.cpp 推理 Provider 与分类 Prompt(严格 JSON、枚举约束)

src-tauri/src/providers/provider.rs

use async_trait::async_trait;
use crate::domain::dto::{ModelOutput, RuleOutput};

#[derive(Debug, Clone)]
pub struct ClassifyPayload {
    pub message_id: String,
    pub content: String,
    pub rule: RuleOutput,
    pub schema_version: String,
    pub rules_version: String,
}

#[async_trait]
pub trait Provider: Send + Sync {
    async fn classify(&self, payload: ClassifyPayload) -> anyhow::Result<ModelOutput>;
    fn name(&self) -> &'static str;
    fn model_version(&self) -> String;
}

src-tauri/src/providers/prompt.rs

use crate::domain::schema::SCHEMA_VERSION;
use serde_json::json;

pub fn build_prompt(content: &str, entities_json: &serde_json::Value, signals_json: &serde_json::Value) -> String {
    // 强约束:只允许输出严格 JSON,不要额外文本
    // 要求枚举必须从给定集合中选
    let schema = json!({
      "schema_version": SCHEMA_VERSION,
      "industry_enum": ["金融","通用","政务","渠道","互联网","其他"],
      "type_enum": ["验证码","交易提醒","账单催缴","保险续保","物流取件","会员账号变更","政务通知","风险提示","营销推广","其他"],
      "entities": {
        "brand": "string|null",
        "verification_code": "string|null",
        "amount": "number|null",
        "balance": "number|null",
        "account_suffix": "string|null",
        "time_text": "string|null",
        "url": "string|null",
        "phone_in_text": "string|null"
      }
    });

    format!(
r#"你是一个离线短信分类与结构化抽取引擎。你的任务:对短信做行业大类与类型判定,并补全实体字段。
要求:
1) 仅输出一个严格 JSON 对象,禁止输出任何多余文本。
2) industry 与 type 必须从枚举中选择,禁止出现新值。
3) entities 必须包含所有字段,缺失填 null4) confidence 为 0~1 小数。
5) reasons 为字符串数组,解释你为何做出判断,必须引用 signals / entities / content 中的信息。
6) 不要臆造链接/电话/金额;无法确定填 null 或降低 confidence。

【约束Schema】
{schema}

【短信content】
{content}

【规则层提取entities(可能不全)】
{entities}

【规则层signals(可解释性线索)】
{signals}

输出 JSON 结构如下(字段名固定):
{{
  "industry": "...",
  "type": "...",
  "entities": {{
    "brand": null,
    "verification_code": null,
    "amount": null,
    "balance": null,
    "account_suffix": null,
    "time_text": null,
    "url": null,
    "phone_in_text": null
  }},
  "confidence": 0.0,
  "reasons": ["..."]
}}"#,
        schema = schema.to_string(),
        content = content,
        entities = entities_json.to_string(),
        signals = signals_json.to_string(),
    )
}

src-tauri/src/providers/llama_cpp.rs

use std::{path::PathBuf, sync::Arc, time::Duration};
use tokio::{process::Command, sync::Semaphore, time::timeout};
use async_trait::async_trait;
use serde_json::Value;

use crate::providers::provider::{Provider, ClassifyPayload};
use crate::domain::dto::{ModelOutput, ExtractedEntities};
use crate::infra::log::append_error_log;
use crate::providers::prompt::build_prompt;

#[derive(Clone)]
pub struct LlamaCppProvider {
    pub sidecar_path: PathBuf, // llama-cli 或 llama 可执行文件
    pub model_path: PathBuf,   // GGUF
    pub threads: u32,
    pub max_concurrency: usize,
    pub timeout_ms: u64,
    pub semaphore: Arc<Semaphore>,
}

impl LlamaCppProvider {
    pub fn new(sidecar_path: PathBuf, model_path: PathBuf, threads: u32, max_concurrency: usize, timeout_ms: u64) -> Self {
        Self {
            sidecar_path,
            model_path,
            threads,
            max_concurrency,
            timeout_ms,
            semaphore: Arc::new(Semaphore::new(max_concurrency)),
        }
    }

    fn parse_model_output(&self, s: &str) -> anyhow::Result<ModelOutput> {
        // llama.cpp 可能带前后空白或多行,尽量截取第一个 JSON 对象
        let trimmed = s.trim();
        let start = trimmed.find('{').ok_or_else(|| anyhow::anyhow!("no json start"))?;
        let end = trimmed.rfind('}').ok_or_else(|| anyhow::anyhow!("no json end"))?;
        let json_str = &trimmed[start..=end];

        let v: Value = serde_json::from_str(json_str)?;
        let industry = serde_json::from_value(v.get("industry").cloned().ok_or_else(|| anyhow::anyhow!("missing industry"))?)?;
        let sms_type = serde_json::from_value(v.get("type").cloned().ok_or_else(|| anyhow::anyhow!("missing type"))?)?;
        let entities: ExtractedEntities = serde_json::from_value(v.get("entities").cloned().ok_or_else(|| anyhow::anyhow!("missing entities"))?)?;
        let confidence: f64 = v.get("confidence").and_then(|x| x.as_f64()).unwrap_or(0.5);
        let reasons: Vec<String> = v.get("reasons").and_then(|x| x.as_array())
            .map(|arr| arr.iter().filter_map(|i| i.as_str().map(|s| s.to_string())).collect())
            .unwrap_or_else(|| vec![]);

        Ok(ModelOutput {
            industry,
            sms_type,
            entities,
            confidence: confidence.clamp(0.0, 1.0),
            reasons,
            model_version: self.model_version(),
        })
    }
}

#[async_trait]
impl Provider for LlamaCppProvider {
    async fn classify(&self, payload: ClassifyPayload) -> anyhow::Result<ModelOutput> {
        let _permit = self.semaphore.acquire().await?;

        let entities_json = serde_json::to_value(&payload.rule.entities)?;
        let signals_json = serde_json::to_value(&payload.rule.signals)?;
        let prompt = build_prompt(&payload.content, &entities_json, &signals_json);

        // llama.cpp 命令行参数:根据你下载的版本可能是 llama-cli 或 llama
        // 这里采用常见参数:-m 模型 -p prompt -t threads --temp 0.2 --top-p 0.9 --ctx-size 2048
        let mut cmd = Command::new(&self.sidecar_path);
        cmd.arg("-m").arg(&self.model_path)
            .arg("-p").arg(prompt)
            .arg("-t").arg(self.threads.to_string())
            .arg("--temp").arg("0.2")
            .arg("--top-p").arg("0.9")
            .arg("--ctx-size").arg("2048");

        let dur = Duration::from_millis(self.timeout_ms);
        let out = timeout(dur, cmd.output()).await;

        match out {
            Ok(Ok(output)) => {
                let stdout = String::from_utf8_lossy(&output.stdout).to_string();
                let stderr = String::from_utf8_lossy(&output.stderr).to_string();
                if !output.status.success() {
                    append_error_log(format!("llama.cpp exit != 0: {}\nstderr={}", output.status, stderr)).ok();
                    return Err(anyhow::anyhow!("llama.cpp failed"));
                }
                // stderr 也可能有日志
                if !stderr.trim().is_empty() {
                    append_error_log(format!("llama.cpp stderr: {}", stderr)).ok();
                }
                self.parse_model_output(&stdout)
            }
            Ok(Err(e)) => {
                append_error_log(format!("llama.cpp spawn error: {}", e)).ok();
                Err(anyhow::anyhow!(e))
            }
            Err(_) => {
                append_error_log("llama.cpp timeout".to_string()).ok();
                Err(anyhow::anyhow!("timeout"))
            }
        }
    }

    fn name(&self) -> &'static str { "llama.cpp" }

    fn model_version(&self) -> String {
        // 简化:用模型文件名当版本
        self.model_path.file_name().unwrap_or_default().to_string_lossy().to_string()
    }
}

src-tauri/src/infra/log.rs

use std::{fs, io::Write, path::PathBuf};

pub fn app_log_path() -> anyhow::Result<PathBuf> {
    let base = tauri::api::path::app_log_dir(&tauri::Config::default())?;
    Ok(base.join("sms-tagging-officer.log"))
}

pub fn append_error_log(line: String) -> anyhow::Result<()> {
    let p = app_log_path()?;
    if let Some(parent) = p.parent() { fs::create_dir_all(parent)?; }
    let mut f = fs::OpenOptions::new().create(true).append(true).open(p)?;
    writeln!(f, "{}", line)?;
    Ok(())
}

5) 融合决策器(冲突处理、needs_review、置信度调节)

src-tauri/src/fusion/decision.rs

use crate::domain::dto::{FinalLabel, RuleOutput, ModelOutput, ExtractedEntities};
use crate::domain::schema::{RULES_VERSION, SCHEMA_VERSION};

fn merge_entities(rule_e: &ExtractedEntities, model_e: &ExtractedEntities) -> ExtractedEntities {
    // 规则优先:强模式常常更准;模型补全空缺字段
    ExtractedEntities {
        brand: rule_e.brand.clone().or(model_e.brand.clone()),
        verification_code: rule_e.verification_code.clone().or(model_e.verification_code.clone()),
        amount: rule_e.amount.or(model_e.amount),
        balance: rule_e.balance.or(model_e.balance),
        account_suffix: rule_e.account_suffix.clone().or(model_e.account_suffix.clone()),
        time_text: rule_e.time_text.clone().or(model_e.time_text.clone()),
        url: rule_e.url.clone().or(model_e.url.clone()),
        phone_in_text: rule_e.phone_in_text.clone().or(model_e.phone_in_text.clone()),
    }
}

pub fn fuse(message_id: &str, rule: &RuleOutput, model: Option<&ModelOutput>) -> FinalLabel {
    // 1) 规则强命中:直接用规则输出(无需模型)
    if rule.hit && rule.industry.is_some() && rule.sms_type.is_some() {
        return FinalLabel {
            message_id: message_id.to_string(),
            industry: rule.industry.clone().unwrap(),
            sms_type: rule.sms_type.clone().unwrap(),
            entities: rule.entities.clone(),
            confidence: rule.confidence.clamp(0.0, 1.0),
            reasons: rule.reasons.clone(),
            signals: rule.signals.clone(),
            needs_review: false,
            rules_version: RULES_VERSION.to_string(),
            model_version: "rule_only".to_string(),
            schema_version: SCHEMA_VERSION.to_string(),
        };
    }

    // 2) 规则未命中强模式:必须依赖模型
    let m = model.expect("model required when rule not hit");
    let mut needs_review = false;
    let mut confidence = m.confidence.clamp(0.0, 1.0);
    let mut reasons = vec![];
    reasons.extend(rule.reasons.clone());
    reasons.extend(m.reasons.clone());

    // 冲突:如果规则给了弱倾向(signals)但模型判断非常不同,可触发复核
    // 这里用简单启发:若规则提取到 otp 码/金额/链接,而模型给到类型“其他”,降低置信并进复核
    let has_otp = rule.entities.verification_code.is_some();
    let has_amount = rule.entities.amount.is_some();
    let has_url = rule.entities.url.is_some();

    if (has_otp || has_amount || has_url) && matches!(m.sms_type, crate::domain::enums::SmsType::Other) {
        needs_review = true;
        confidence = (confidence * 0.75).min(0.75);
        reasons.push("冲突:规则抽取到关键实体,但模型类型为“其他”,进入复核".to_string());
    }

    // 低置信:进入复核
    if confidence < 0.70 {
        needs_review = true;
        reasons.push("置信度低于阈值0.70,进入复核".to_string());
    }

    let entities = merge_entities(&rule.entities, &m.entities);

    FinalLabel {
        message_id: message_id.to_string(),
        industry: m.industry.clone(),
        sms_type: m.sms_type.clone(),
        entities,
        confidence,
        reasons,
        signals: rule.signals.clone(),
        needs_review,
        rules_version: RULES_VERSION.to_string(),
        model_version: m.model_version.clone(),
        schema_version: SCHEMA_VERSION.to_string(),
    }
}

6) SQLite 初始化脚本 + DAO 层(messages / labels / audit_logs)

src-tauri/src/db/migrations.sql

PRAGMA journal_mode=WAL;

CREATE TABLE IF NOT EXISTS messages (
  id TEXT PRIMARY KEY,
  content TEXT NOT NULL,
  received_at TEXT NULL,
  sender TEXT NULL,
  phone TEXT NULL,
  source TEXT NULL,
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE IF NOT EXISTS labels (
  message_id TEXT PRIMARY KEY,
  industry TEXT NOT NULL,
  type TEXT NOT NULL,
  brand TEXT NULL,
  verification_code TEXT NULL,
  amount REAL NULL,
  balance REAL NULL,
  account_suffix TEXT NULL,
  time_text TEXT NULL,
  url TEXT NULL,
  phone_in_text TEXT NULL,
  confidence REAL NOT NULL,
  reasons_json TEXT NOT NULL,
  signals_json TEXT NOT NULL,
  needs_review INTEGER NOT NULL DEFAULT 0,
  rules_version TEXT NOT NULL,
  model_version TEXT NOT NULL,
  schema_version TEXT NOT NULL,
  updated_at TEXT NOT NULL DEFAULT (datetime('now')),
  FOREIGN KEY(message_id) REFERENCES messages(id)
);

CREATE TABLE IF NOT EXISTS audit_logs (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  message_id TEXT NOT NULL,
  operator TEXT NOT NULL,
  before_json TEXT NOT NULL,
  after_json TEXT NOT NULL,
  changed_at TEXT NOT NULL DEFAULT (datetime('now')),
  FOREIGN KEY(message_id) REFERENCES messages(id)
);

CREATE INDEX IF NOT EXISTS idx_labels_industry ON labels(industry);
CREATE INDEX IF NOT EXISTS idx_labels_type ON labels(type);
CREATE INDEX IF NOT EXISTS idx_labels_needs_review ON labels(needs_review);
CREATE INDEX IF NOT EXISTS idx_labels_confidence ON labels(confidence);

src-tauri/src/db/mod.rs

use rusqlite::Connection;
use std::path::PathBuf;

pub fn db_path() -> anyhow::Result<PathBuf> {
    let dir = tauri::api::path::app_data_dir(&tauri::Config::default())?;
    std::fs::create_dir_all(&dir)?;
    Ok(dir.join("sms-tagging-officer.sqlite"))
}

pub fn connect() -> anyhow::Result<Connection> {
    let p = db_path()?;
    Ok(Connection::open(p)?)
}

pub fn migrate(conn: &Connection) -> anyhow::Result<()> {
    let sql = include_str!("migrations.sql");
    conn.execute_batch(sql)?;
    Ok(())
}

src-tauri/src/db/dao.rs

use rusqlite::{params, Connection};
use serde_json::Value;

use crate::domain::dto::{FinalLabel};
use crate::domain::enums::{Industry, SmsType};

#[derive(Debug, Clone)]
pub struct MessageRow {
    pub id: String,
    pub content: String,
    pub received_at: Option<String>,
    pub sender: Option<String>,
    pub phone: Option<String>,
    pub source: Option<String>,
}

pub fn upsert_messages(conn: &Connection, rows: &[MessageRow]) -> anyhow::Result<usize> {
    let tx = conn.transaction()?;
    let mut count = 0usize;
    for r in rows {
        tx.execute(
            r#"INSERT INTO messages (id, content, received_at, sender, phone, source)
               VALUES (?1, ?2, ?3, ?4, ?5, ?6)
               ON CONFLICT(id) DO UPDATE SET
                 content=excluded.content,
                 received_at=excluded.received_at,
                 sender=excluded.sender,
                 phone=excluded.phone,
                 source=excluded.source"#,
            params![r.id, r.content, r.received_at, r.sender, r.phone, r.source],
        )?;
        count += 1;
    }
    tx.commit()?;
    Ok(count)
}

pub fn upsert_label(conn: &Connection, label: &FinalLabel) -> anyhow::Result<()> {
    let reasons_json = serde_json::to_string(&label.reasons)?;
    let signals_json = serde_json::to_string(&label.signals)?;
    conn.execute(
        r#"INSERT INTO labels (
            message_id, industry, type,
            brand, verification_code, amount, balance, account_suffix, time_text, url, phone_in_text,
            confidence, reasons_json, signals_json, needs_review,
            rules_version, model_version, schema_version, updated_at
        ) VALUES (
            ?1, ?2, ?3,
            ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11,
            ?12, ?13, ?14, ?15,
            ?16, ?17, ?18, datetime('now')
        )
        ON CONFLICT(message_id) DO UPDATE SET
            industry=excluded.industry,
            type=excluded.type,
            brand=excluded.brand,
            verification_code=excluded.verification_code,
            amount=excluded.amount,
            balance=excluded.balance,
            account_suffix=excluded.account_suffix,
            time_text=excluded.time_text,
            url=excluded.url,
            phone_in_text=excluded.phone_in_text,
            confidence=excluded.confidence,
            reasons_json=excluded.reasons_json,
            signals_json=excluded.signals_json,
            needs_review=excluded.needs_review,
            rules_version=excluded.rules_version,
            model_version=excluded.model_version,
            schema_version=excluded.schema_version,
            updated_at=datetime('now')"#,
        params![
            label.message_id,
            industry_to_str(&label.industry),
            type_to_str(&label.sms_type),
            label.entities.brand,
            label.entities.verification_code,
            label.entities.amount,
            label.entities.balance,
            label.entities.account_suffix,
            label.entities.time_text,
            label.entities.url,
            label.entities.phone_in_text,
            label.confidence,
            reasons_json,
            signals_json,
            if label.needs_review { 1 } else { 0 },
            label.rules_version,
            label.model_version,
            label.schema_version,
        ],
    )?;
    Ok(())
}

pub fn get_label_json(conn: &Connection, message_id: &str) -> anyhow::Result<Option<Value>> {
    let mut stmt = conn.prepare(r#"SELECT
        industry, type, brand, verification_code, amount, balance, account_suffix, time_text, url, phone_in_text,
        confidence, reasons_json, signals_json, needs_review, rules_version, model_version, schema_version
      FROM labels WHERE message_id=?1"#)?;
    let mut rows = stmt.query(params![message_id])?;
    if let Some(r) = rows.next()? {
        let reasons_json: String = r.get(11)?;
        let signals_json: String = r.get(12)?;
        let v = serde_json::json!({
          "message_id": message_id,
          "industry": r.get::<_, String>(0)?,
          "type": r.get::<_, String>(1)?,
          "entities": {
            "brand": r.get::<_, Option<String>>(2)?,
            "verification_code": r.get::<_, Option<String>>(3)?,
            "amount": r.get::<_, Option<f64>>(4)?,
            "balance": r.get::<_, Option<f64>>(5)?,
            "account_suffix": r.get::<_, Option<String>>(6)?,
            "time_text": r.get::<_, Option<String>>(7)?,
            "url": r.get::<_, Option<String>>(8)?,
            "phone_in_text": r.get::<_, Option<String>>(9)?,
          },
          "confidence": r.get::<_, f64>(10)?,
          "reasons": serde_json::from_str::<Value>(&reasons_json).unwrap_or(Value::Array(vec![])),
          "signals": serde_json::from_str::<Value>(&signals_json).unwrap_or(Value::Object(Default::default())),
          "needs_review": r.get::<_, i64>(13)? == 1,
          "rules_version": r.get::<_, String>(14)?,
          "model_version": r.get::<_, String>(15)?,
          "schema_version": r.get::<_, String>(16)?,
        });
        return Ok(Some(v));
    }
    Ok(None)
}

pub fn insert_audit_log(conn: &Connection, message_id: &str, operator: &str, before_json: &Value, after_json: &Value) -> anyhow::Result<()> {
    conn.execute(
        r#"INSERT INTO audit_logs (message_id, operator, before_json, after_json)
           VALUES (?1, ?2, ?3, ?4)"#,
        params![
            message_id,
            operator,
            before_json.to_string(),
            after_json.to_string()
        ],
    )?;
    Ok(())
}

fn industry_to_str(i: &Industry) -> &'static str {
    match i {
        Industry::Finance => "金融",
        Industry::General => "通用",
        Industry::Gov => "政务",
        Industry::Channel => "渠道",
        Industry::Internet => "互联网",
        Industry::Other => "其他",
    }
}

fn type_to_str(t: &SmsType) -> &'static str {
    match t {
        SmsType::Otp => "验证码",
        SmsType::Transaction => "交易提醒",
        SmsType::BillCollect => "账单催缴",
        SmsType::InsuranceRenew => "保险续保",
        SmsType::LogisticsPickup => "物流取件",
        SmsType::AccountChange => "会员账号变更",
        SmsType::GovNotice => "政务通知",
        SmsType::RiskAlert => "风险提示",
        SmsType::Marketing => "营销推广",
        SmsType::Other => "其他",
    }
}

7) 批处理队列(并发/超时/重试/不卡 UI)+ Tauri Commands

src-tauri/src/batch/worker.rs

use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
use serde_json::Value;

use crate::{db, rules, providers::provider::{Provider, ClassifyPayload}, fusion};
use crate::infra::log::append_error_log;

#[derive(Debug, Clone)]
pub struct BatchOptions {
    pub only_unlabeled: bool,
    pub only_needs_review: bool,
    pub max_retries: u8,
}

#[derive(Debug, Clone)]
pub struct BatchProgress {
    pub total: usize,
    pub done: usize,
    pub failed: usize,
    pub current_id: Option<String>,
}

pub struct BatchState {
    pub running: bool,
    pub progress: BatchProgress,
}

pub type SharedBatchState = Arc<Mutex<BatchState>>;

pub async fn run_batch(
    app: tauri::AppHandle,
    provider: Arc<dyn Provider>,
    message_ids: Vec<String>,
    options: BatchOptions,
    state: SharedBatchState,
) -> anyhow::Result<()> {
    {
        let mut s = state.lock().unwrap();
        s.running = true;
        s.progress = BatchProgress { total: message_ids.len(), done: 0, failed: 0, current_id: None };
    }

    let (tx, mut rx) = mpsc::channel::<(String, anyhow::Result<Value>)>(64);

    // worker producer:并发投递,每条短信独立重试
    for id in message_ids.clone() {
        let txc = tx.clone();
        let prov = provider.clone();
        let appc = app.clone();
        tokio::spawn(async move {
            let res = process_one(appc, prov, &id, &options).await;
            let _ = txc.send((id, res)).await;
        });
    }
    drop(tx);

    while let Some((id, res)) = rx.recv().await {
        let mut emit_payload = serde_json::json!({"id": id, "ok": true});
        match res {
            Ok(label_json) => {
                emit_payload["label"] = label_json;
                let mut s = state.lock().unwrap();
                s.progress.done += 1;
                s.progress.current_id = None;
            }
            Err(e) => {
                append_error_log(format!("batch item failed id={} err={}", id, e)).ok();
                emit_payload["ok"] = serde_json::json!(false);
                emit_payload["error"] = serde_json::json!(e.to_string());
                let mut s = state.lock().unwrap();
                s.progress.failed += 1;
                s.progress.done += 1;
                s.progress.current_id = None;
            }
        }

        // 推送进度到前端
        let s = state.lock().unwrap().progress.clone();
        let _ = app.emit_all("batch_progress", serde_json::json!({
            "total": s.total,
            "done": s.done,
            "failed": s.failed,
            "current_id": s.current_id,
            "event": emit_payload
        }));
    }

    {
        let mut s = state.lock().unwrap();
        s.running = false;
    }
    Ok(())
}

async fn process_one(
    _app: tauri::AppHandle,
    provider: Arc<dyn Provider>,
    message_id: &str,
    options: &BatchOptions,
) -> anyhow::Result<Value> {
    let conn = db::connect()?;
    db::migrate(&conn)?;

    // 查询 content
    let mut stmt = conn.prepare("SELECT content FROM messages WHERE id=?1")?;
    let content: String = stmt.query_row([message_id], |r| r.get(0))?;

    // 过滤:only_unlabeled / only_needs_review
    if options.only_unlabeled {
        let mut s2 = conn.prepare("SELECT COUNT(1) FROM labels WHERE message_id=?1")?;
        let cnt: i64 = s2.query_row([message_id], |r| r.get(0))?;
        if cnt > 0 { return Ok(serde_json::json!({"skipped": true})); }
    }
    if options.only_needs_review {
        let mut s3 = conn.prepare("SELECT needs_review FROM labels WHERE message_id=?1")?;
        let v = s3.query_row([message_id], |r| r.get::<_, i64>(0)).ok();
        if v != Some(1) { return Ok(serde_json::json!({"skipped": true})); }
    }

    let rule = rules::rule_engine::apply_rules(&content);

    // 规则强命中:直接融合(rule_only)
    if rule.hit && rule.industry.is_some() && rule.sms_type.is_some() {
        let final_label = fusion::decision::fuse(message_id, &rule, None);
        crate::db::dao::upsert_label(&conn, &final_label)?;
        return Ok(crate::db::dao::get_label_json(&conn, message_id)?.unwrap());
    }

    // 模型层:重试
    let mut last_err: Option<anyhow::Error> = None;
    for _ in 0..=options.max_retries {
        let payload = ClassifyPayload {
            message_id: message_id.to_string(),
            content: content.clone(),
            rule: rule.clone(),
            schema_version: crate::domain::schema::SCHEMA_VERSION.to_string(),
            rules_version: crate::domain::schema::RULES_VERSION.to_string(),
        };
        match provider.classify(payload).await {
            Ok(mo) => {
                let final_label = fusion::decision::fuse(message_id, &rule, Some(&mo));
                crate::db::dao::upsert_label(&conn, &final_label)?;
                return Ok(crate::db::dao::get_label_json(&conn, message_id)?.unwrap());
            }
            Err(e) => last_err = Some(e),
        }
    }
    Err(last_err.unwrap_or_else(|| anyhow::anyhow!("unknown classify error")))
}

src-tauri/src/commands.rs

use std::{path::PathBuf, sync::{Arc, Mutex}};
use serde::{Deserialize, Serialize};
use serde_json::Value;

use crate::{db, db::dao::{MessageRow, upsert_messages, get_label_json, insert_audit_log}, providers::llama_cpp::LlamaCppProvider, providers::provider::Provider, batch::{worker, worker::{SharedBatchState, BatchState, BatchOptions}}};

#[derive(Debug, Deserialize)]
pub struct ImportRequest {
    pub rows: Vec<MessageRowReq>,
}

#[derive(Debug, Deserialize)]
pub struct MessageRowReq {
    pub id: String,
    pub content: String,
    pub received_at: Option<String>,
    pub sender: Option<String>,
    pub phone: Option<String>,
    pub source: Option<String>,
}

#[derive(Debug, Serialize)]
pub struct ImportResponse {
    pub inserted: usize,
}

#[tauri::command]
pub fn db_init() -> Result<(), String> {
    let conn = db::connect().map_err(|e| e.to_string())?;
    db::migrate(&conn).map_err(|e| e.to_string())?;
    Ok(())
}

#[tauri::command]
pub fn import_messages(req: ImportRequest) -> Result<ImportResponse, String> {
    let conn = db::connect().map_err(|e| e.to_string())?;
    db::migrate(&conn).map_err(|e| e.to_string())?;

    let rows: Vec<MessageRow> = req.rows.into_iter().map(|r| MessageRow {
        id: r.id,
        content: r.content,
        received_at: r.received_at,
        sender: r.sender,
        phone: r.phone,
        source: r.source,
    }).collect();

    let inserted = upsert_messages(&conn, &rows).map_err(|e| e.to_string())?;
    Ok(ImportResponse { inserted })
}

#[tauri::command]
pub fn get_label(message_id: String) -> Result<Option<Value>, String> {
    let conn = db::connect().map_err(|e| e.to_string())?;
    db::migrate(&conn).map_err(|e| e.to_string())?;
    get_label_json(&conn, &message_id).map_err(|e| e.to_string())
}

#[derive(Debug, Deserialize)]
pub struct SaveReviewRequest {
    pub message_id: String,
    pub operator: String,
    pub after: Value,
}

#[tauri::command]
pub fn save_review(req: SaveReviewRequest) -> Result<(), String> {
    let conn = db::connect().map_err(|e| e.to_string())?;
    db::migrate(&conn).map_err(|e| e.to_string())?;
    let before = get_label_json(&conn, &req.message_id).map_err(|e| e.to_string())?
        .unwrap_or(Value::Null);

    // 直接写 labels:这里复用 JSON 写入策略(简化:前端传字段齐全)
    // 生产版可改为结构体反序列化,进一步强校验
    let a = &req.after;
    conn.execute(
        r#"INSERT INTO labels (
            message_id, industry, type,
            brand, verification_code, amount, balance, account_suffix, time_text, url, phone_in_text,
            confidence, reasons_json, signals_json, needs_review,
            rules_version, model_version, schema_version, updated_at
        ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, datetime('now'))
        ON CONFLICT(message_id) DO UPDATE SET
            industry=excluded.industry,
            type=excluded.type,
            brand=excluded.brand,
            verification_code=excluded.verification_code,
            amount=excluded.amount,
            balance=excluded.balance,
            account_suffix=excluded.account_suffix,
            time_text=excluded.time_text,
            url=excluded.url,
            phone_in_text=excluded.phone_in_text,
            confidence=excluded.confidence,
            reasons_json=excluded.reasons_json,
            signals_json=excluded.signals_json,
            needs_review=excluded.needs_review,
            rules_version=excluded.rules_version,
            model_version=excluded.model_version,
            schema_version=excluded.schema_version,
            updated_at=datetime('now')"#,
        rusqlite::params![
            req.message_id,
            a["industry"].as_str().unwrap_or("其他"),
            a["type"].as_str().unwrap_or("其他"),
            a["entities"]["brand"].as_str(),
            a["entities"]["verification_code"].as_str(),
            a["entities"]["amount"].as_f64(),
            a["entities"]["balance"].as_f64(),
            a["entities"]["account_suffix"].as_str(),
            a["entities"]["time_text"].as_str(),
            a["entities"]["url"].as_str(),
            a["entities"]["phone_in_text"].as_str(),
            a["confidence"].as_f64().unwrap_or(0.5),
            a["reasons"].to_string(),
            a["signals"].to_string(),
            if a["needs_review"].as_bool().unwrap_or(false) { 1 } else { 0 },
            a["rules_version"].as_str().unwrap_or("1.0.0"),
            a["model_version"].as_str().unwrap_or("manual"),
            a["schema_version"].as_str().unwrap_or("1.0.0"),
        ],
    ).map_err(|e| e.to_string())?;

    insert_audit_log(&conn, &req.message_id, &req.operator, &before, &req.after).map_err(|e| e.to_string())?;
    Ok(())
}

#[derive(Debug, Deserialize)]
pub struct ProviderConfig {
    pub sidecar_path: String,
    pub model_path: String,
    pub threads: u32,
    pub max_concurrency: usize,
    pub timeout_ms: u64,
}

#[tauri::command]
pub async fn start_batch(
    app: tauri::AppHandle,
    provider_cfg: ProviderConfig,
    message_ids: Vec<String>,
    only_unlabeled: bool,
    only_needs_review: bool,
) -> Result<(), String> {
    let provider = LlamaCppProvider::new(
        PathBuf::from(provider_cfg.sidecar_path),
        PathBuf::from(provider_cfg.model_path),
        provider_cfg.threads,
        provider_cfg.max_concurrency,
        provider_cfg.timeout_ms,
    );
    let provider_arc: Arc<dyn Provider> = Arc::new(provider);

    let state: SharedBatchState = app.state::<SharedBatchState>().inner().clone();

    let opts = BatchOptions {
        only_unlabeled,
        only_needs_review,
        max_retries: 1,
    };

    worker::run_batch(app, provider_arc, message_ids, opts, state)
        .await
        .map_err(|e| e.to_string())
}

src-tauri/src/main.rs

#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

mod commands;
mod domain;
mod infra;
mod db;
mod rules;
mod providers;
mod fusion;
mod batch;

use std::sync::{Arc, Mutex};
use batch::worker::{SharedBatchState, BatchState, BatchProgress};

fn main() {
    let initial_state: SharedBatchState = Arc::new(Mutex::new(BatchState {
        running: false,
        progress: BatchProgress { total: 0, done: 0, failed: 0, current_id: None },
    }));

    tauri::Builder::default()
        .manage(initial_state)
        .invoke_handler(tauri::generate_handler![
            commands::db_init,
            commands::import_messages,
            commands::get_label,
            commands::save_review,
            commands::start_batch,
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

src-tauri/Cargo.toml

[package]
name = "sms-tagging-officer"
version = "0.1.0"
edition = "2021"

[build-dependencies]
tauri-build = { version = "1.5", features = [] }

[dependencies]
tauri = { version = "1.5", features = ["path-all", "fs-all", "shell-open"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
rusqlite = { version = "0.31", features = ["bundled"] }
tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "time", "process", "sync"] }
anyhow = "1.0"
regex = "1.10"
async-trait = "0.1"

8) 前端页面与核心组件(导入 / 批处理 / 列表过滤 / 复核抽屉 / 导出 / 设置)

apps/desktop/src/api/tauri.ts

import { invoke } from "@tauri-apps/api/tauri";
import type { LabelResult } from "./schema";

export async function dbInit() {
  return invoke<void>("db_init");
}

export async function importMessages(rows: any[]) {
  return invoke<{ inserted: number }>("import_messages", { req: { rows } });
}

export async function getLabel(messageId: string) {
  return invoke<LabelResult | null>("get_label", { messageId });
}

export async function saveReview(payload: { message_id: string; operator: string; after: any }) {
  return invoke<void>("save_review", { req: payload });
}

export async function startBatch(args: {
  provider_cfg: any;
  message_ids: string[];
  only_unlabeled: boolean;
  only_needs_review: boolean;
}) {
  return invoke<void>("start_batch", args);
}

apps/desktop/src/api/types.ts

export type MessageRow = {
  id: string;
  content: string;
  received_at?: string | null;
  sender?: string | null;
  phone?: string | null;
  source?: string | null;
};

apps/desktop/src/stores/settings.ts

import { defineStore } from "pinia";

export const useSettingsStore = defineStore("settings", {
  state: () => ({
    operator: "default",
    llamaSidecarPath: "",
    modelPath: "",
    threads: 4,
    maxConcurrency: 2,
    timeoutMs: 15000,
  }),
  actions: {
    load() {
      const raw = localStorage.getItem("sms_officer_settings");
      if (raw) Object.assign(this.$state, JSON.parse(raw));
    },
    save() {
      localStorage.setItem("sms_officer_settings", JSON.stringify(this.$state));
    },
  },
});

apps/desktop/src/stores/batch.ts

import { defineStore } from "pinia";

export const useBatchStore = defineStore("batch", {
  state: () => ({
    total: 0,
    done: 0,
    failed: 0,
    lastEvent: null as any,
    running: false,
  }),
  actions: {
    reset() {
      this.total = 0; this.done = 0; this.failed = 0; this.lastEvent = null; this.running = false;
    },
  },
});

apps/desktop/src/router.ts

import { createRouter, createWebHashHistory } from "vue-router";
import ImportPage from "./pages/ImportPage.vue";
import BatchPage from "./pages/BatchPage.vue";
import ListPage from "./pages/ListPage.vue";
import ExportPage from "./pages/ExportPage.vue";
import SettingsPage from "./pages/SettingsPage.vue";

export const router = createRouter({
  history: createWebHashHistory(),
  routes: [
    { path: "/", redirect: "/import" },
    { path: "/import", component: ImportPage },
    { path: "/batch", component: BatchPage },
    { path: "/list", component: ListPage },
    { path: "/export", component: ExportPage },
    { path: "/settings", component: SettingsPage },
  ],
});

apps/desktop/src/main.ts

import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import { router } from "./router";

createApp(App).use(createPinia()).use(router).mount("#app");

apps/desktop/src/App.vue

<template>
  <div class="app">
    <aside class="nav">
      <h2>短信智标官</h2>
      <nav>
        <RouterLink to="/import">导入</RouterLink>
        <RouterLink to="/batch">批处理</RouterLink>
        <RouterLink to="/list">列表复核</RouterLink>
        <RouterLink to="/export">导出</RouterLink>
        <RouterLink to="/settings">设置</RouterLink>
      </nav>
    </aside>
    <main class="main">
      <RouterView />
    </main>
  </div>
</template>

<style scoped>
.app { display: grid; grid-template-columns: 220px 1fr; height: 100vh; }
.nav { border-right: 1px solid #eee; padding: 16px; }
.nav nav { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; }
.main { padding: 16px; overflow: auto; }
a.router-link-active { font-weight: 700; }
</style>

导入页:CSV/Excel 列映射 + 写入 messages

apps/desktop/src/pages/ImportPage.vue
<template>
  <section>
    <h3>导入数据</h3>
    <p>支持 CSV / Excel。先选择文件,再进行列映射,然后导入到本地 SQLite。</p>

    <div class="row">
      <input type="file" @change="onFile" />
      <button @click="loadSample">加载内置样例</button>
      <button @click="doImport" :disabled="rows.length===0">导入({{ rows.length }}条)</button>
    </div>

    <ColumnMapper
      v-if="headers.length"
      :headers="headers"
      v-model:mapping="mapping"
    />

    <pre class="preview" v-if="rows.length">{{ rows.slice(0,3) }}</pre>
    <div v-if="msg" class="msg">{{ msg }}</div>
  </section>
</template>

<script setup lang="ts">
import * as Papa from "papaparse";
import * as XLSX from "xlsx";
import { ref } from "vue";
import ColumnMapper from "../components/ColumnMapper.vue";
import { dbInit, importMessages } from "../api/tauri";
import { buildSampleRows } from "../utils/sample";
import type { MessageRow } from "../api/types";

const headers = ref<string[]>([]);
const rows = ref<any[]>([]);
const msg = ref("");

const mapping = ref<Record<string, string>>({
  id: "id",
  content: "content",
  received_at: "received_at",
  sender: "sender",
  phone: "phone",
  source: "source",
});

async function onFile(e: Event) {
  msg.value = "";
  const file = (e.target as HTMLInputElement).files?.[0];
  if (!file) return;

  const name = file.name.toLowerCase();
  if (name.endsWith(".csv")) {
    const text = await file.text();
    const parsed = Papa.parse(text, { header: true, skipEmptyLines: true });
    headers.value = (parsed.meta.fields || []) as string[];
    rows.value = parsed.data as any[];
  } else if (name.endsWith(".xlsx") || name.endsWith(".xls")) {
    const buf = await file.arrayBuffer();
    const wb = XLSX.read(buf);
    const sheet = wb.Sheets[wb.SheetNames[0]];
    const json = XLSX.utils.sheet_to_json(sheet, { defval: "" }) as any[];
    headers.value = Object.keys(json[0] || {});
    rows.value = json;
  } else {
    msg.value = "仅支持 CSV / Excel";
  }
}

function loadSample() {
  const s = buildSampleRows();
  headers.value = Object.keys(s[0]);
  rows.value = s;
}

async function doImport() {
  await dbInit();

  const mapped: MessageRow[] = rows.value.map((r) => ({
    id: String(r[mapping.value.id] ?? "").trim(),
    content: String(r[mapping.value.content] ?? "").trim(),
    received_at: r[mapping.value.received_at] ? String(r[mapping.value.received_at]) : null,
    sender: r[mapping.value.sender] ? String(r[mapping.value.sender]) : null,
    phone: r[mapping.value.phone] ? String(r[mapping.value.phone]) : null,
    source: r[mapping.value.source] ? String(r[mapping.value.source]) : "import",
  })).filter(x => x.id && x.content);

  const res = await importMessages(mapped);
  msg.value = `导入完成:${res.inserted} 条`;
}
</script>

<style scoped>
.row { display: flex; gap: 10px; align-items: center; margin: 10px 0; }
.preview { background: #fafafa; border: 1px solid #eee; padding: 10px; }
.msg { margin-top: 10px; color: #0a7; }
</style>
apps/desktop/src/components/ColumnMapper.vue
<template>
  <div class="mapper">
    <h4>列映射</h4>
    <div class="grid">
      <label>id</label>
      <select v-model="local.id"><option v-for="h in headers" :key="h" :value="h">{{ h }}</option></select>

      <label>content</label>
      <select v-model="local.content"><option v-for="h in headers" :key="h" :value="h">{{ h }}</option></select>

      <label>received_at</label>
      <select v-model="local.received_at"><option value="">(空)</option><option v-for="h in headers" :key="h" :value="h">{{ h }}</option></select>

      <label>sender</label>
      <select v-model="local.sender"><option value="">(空)</option><option v-for="h in headers" :key="h" :value="h">{{ h }}</option></select>

      <label>phone</label>
      <select v-model="local.phone"><option value="">(空)</option><option v-for="h in headers" :key="h" :value="h">{{ h }}</option></select>

      <label>source</label>
      <select v-model="local.source"><option value="">(空)</option><option v-for="h in headers" :key="h" :value="h">{{ h }}</option></select>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed } from "vue";

const props = defineProps<{ headers: string[]; mapping: Record<string,string> }>();
const emit = defineEmits<{ (e:"update:mapping", v: Record<string,string>): void }>();

const local = computed({
  get: () => props.mapping,
  set: (v) => emit("update:mapping", v),
});
</script>

<style scoped>
.mapper { border: 1px solid #eee; padding: 12px; border-radius: 8px; margin: 12px 0; }
.grid { display: grid; grid-template-columns: 140px 1fr; gap: 8px; align-items: center; }
select { width: 100%; }
</style>

批处理页:进度条、失败计数、重试、增量选项

apps/desktop/src/pages/BatchPage.vue
<template>
  <section>
    <h3>批处理</h3>

    <div class="panel">
      <label><input type="checkbox" v-model="onlyUnlabeled" /> 只跑未标注</label>
      <label><input type="checkbox" v-model="onlyNeedsReview" /> 只跑 needs_review</label>
      <button @click="start" :disabled="running">开始</button>
    </div>

    <ProgressPanel
      :total="total"
      :done="done"
      :failed="failed"
      :running="running"
      :lastEvent="lastEvent"
    />
  </section>
</template>

<script setup lang="ts">
import { onMounted, ref } from "vue";
import { listen } from "@tauri-apps/api/event";
import ProgressPanel from "../components/ProgressPanel.vue";
import { useSettingsStore } from "../stores/settings";
import { startBatch, dbInit } from "../api/tauri";

const settings = useSettingsStore();
settings.load();

const onlyUnlabeled = ref(true);
const onlyNeedsReview = ref(false);

const total = ref(0);
const done = ref(0);
const failed = ref(0);
const running = ref(false);
const lastEvent = ref<any>(null);

onMounted(async () => {
  await dbInit();
  await listen("batch_progress", (e) => {
    const p: any = e.payload;
    total.value = p.total;
    done.value = p.done;
    failed.value = p.failed;
    lastEvent.value = p.event;
    if (done.value >= total.value) running.value = false;
  });
});

async function start() {
  running.value = true;
  total.value = 0; done.value = 0; failed.value = 0; lastEvent.value = null;

  // 这里简化:前端传一个 message_ids 列表
  // 生产版:增加后端接口 query_message_ids(filters)
  // 先用样例:m1..m10
  const ids = Array.from({ length: 10 }).map((_, i) => `m${i + 1}`);

  await startBatch({
    provider_cfg: {
      sidecar_path: settings.llamaSidecarPath,
      model_path: settings.modelPath,
      threads: settings.threads,
      max_concurrency: settings.maxConcurrency,
      timeout_ms: settings.timeoutMs,
    },
    message_ids: ids,
    only_unlabeled: onlyUnlabeled.value,
    only_needs_review: onlyNeedsReview.value,
  });
}
</script>

<style scoped>
.panel { display: flex; gap: 16px; align-items: center; padding: 12px; border: 1px solid #eee; border-radius: 8px; }
</style>
apps/desktop/src/components/ProgressPanel.vue
<template>
  <div class="box">
    <div class="bar">
      <div class="fill" :style="{ width: pct + '%' }"></div>
    </div>
    <div class="meta">
      <div>总数:{{ total }},完成:{{ done }},失败:{{ failed }}</div>
      <div v-if="running">处理中…</div>
      <div v-else>空闲</div>
    </div>

    <pre v-if="lastEvent" class="event">{{ lastEvent }}</pre>
  </div>
</template>

<script setup lang="ts">
import { computed } from "vue";
const props = defineProps<{ total: number; done: number; failed: number; running: boolean; lastEvent: any }>();
const pct = computed(() => props.total ? Math.floor((props.done / props.total) * 100) : 0);
</script>

<style scoped>
.box { margin-top: 12px; border: 1px solid #eee; border-radius: 8px; padding: 12px; }
.bar { height: 10px; background: #f0f0f0; border-radius: 999px; overflow: hidden; }
.fill { height: 10px; background: #3b82f6; }
.meta { display: flex; justify-content: space-between; margin-top: 10px; color: #333; }
.event { background: #fafafa; border: 1px solid #eee; padding: 10px; margin-top: 10px; }
</style>

列表页 + 复核抽屉(这里给“可跑通的核心闭环”)

列表过滤/导出全量查询接口较长,我在 README 里给你扩展点;这版先把“导入→批处理→单条复核保存→审计落库→导出”跑通。

apps/desktop/src/pages/ListPage.vue
<template>
  <section>
    <h3>列表复核</h3>
    <p>输入 message_id 直接打开复核抽屉(演示闭环)。生产版在此页接入后端分页查询与过滤。</p>

    <div class="row">
      <input v-model="id" placeholder="例如 m1" />
      <button @click="open">打开</button>
    </div>

    <ReviewDrawer v-if="label" :label="label" @close="label=null" @save="save" />
    <div v-if="err" class="err">{{ err }}</div>
  </section>
</template>

<script setup lang="ts">
import { ref } from "vue";
import ReviewDrawer from "../components/ReviewDrawer.vue";
import { getLabel, saveReview } from "../api/tauri";
import { useSettingsStore } from "../stores/settings";

const settings = useSettingsStore(); settings.load();

const id = ref("m1");
const label = ref<any>(null);
const err = ref("");

async function open() {
  err.value = "";
  const v = await getLabel(id.value);
  if (!v) {
    err.value = "未找到标签(先去批处理页跑一遍)";
    return;
  }
  label.value = v;
}

async function save(after: any) {
  await saveReview({ message_id: after.message_id, operator: settings.operator, after });
  label.value = await getLabel(after.message_id);
}
</script>

<style scoped>
.row { display:flex; gap:10px; align-items:center; }
.err { color:#c00; margin-top:10px; }
</style>
apps/desktop/src/components/ReviewDrawer.vue
<template>
  <div class="mask">
    <div class="drawer">
      <header>
        <h4>复核:{{ local.message_id }}</h4>
        <button @click="$emit('close')">关闭</button>
      </header>

      <div class="field">
        <label>industry</label>
        <select v-model="local.industry">
          <option v-for="x in industryEnum" :key="x" :value="x">{{ x }}</option>
        </select>
      </div>

      <div class="field">
        <label>type</label>
        <select v-model="local.type">
          <option v-for="x in typeEnum" :key="x" :value="x">{{ x }}</option>
        </select>
      </div>

      <div class="field">
        <label>confidence</label>
        <input type="number" step="0.01" v-model.number="local.confidence" />
      </div>

      <h5>entities</h5>
      <div class="grid">
        <label>brand</label><input v-model="local.entities.brand" placeholder="null 或字符串" />
        <label>verification_code</label><input v-model="local.entities.verification_code" />
        <label>amount</label><input v-model="amountText" />
        <label>balance</label><input v-model="balanceText" />
        <label>account_suffix</label><input v-model="local.entities.account_suffix" />
        <label>time_text</label><input v-model="local.entities.time_text" />
        <label>url</label><input v-model="local.entities.url" />
        <label>phone_in_text</label><input v-model="local.entities.phone_in_text" />
      </div>

      <div class="field">
        <label>needs_review</label>
        <input type="checkbox" v-model="local.needs_review" />
      </div>

      <h5>reasons</h5>
      <textarea v-model="reasonsText" rows="4"></textarea>

      <footer>
        <button class="primary" @click="doSave">保存</button>
      </footer>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, reactive } from "vue";
import { INDUSTRY_ENUM, TYPE_ENUM } from "../api/schema";

const props = defineProps<{ label: any }>();
const emit = defineEmits<{ (e:"close"): void; (e:"save", after: any): void }>();

const local = reactive(JSON.parse(JSON.stringify(props.label)));

const industryEnum = INDUSTRY_ENUM as unknown as string[];
const typeEnum = TYPE_ENUM as unknown as string[];

const amountText = computed({
  get: () => local.entities.amount == null ? "" : String(local.entities.amount),
  set: (v) => local.entities.amount = v.trim() ? Number(v) : null,
});
const balanceText = computed({
  get: () => local.entities.balance == null ? "" : String(local.entities.balance),
  set: (v) => local.entities.balance = v.trim() ? Number(v) : null,
});
const reasonsText = computed({
  get: () => (local.reasons || []).join("\n"),
  set: (v) => local.reasons = v.split("\n").map(s => s.trim()).filter(Boolean),
});

function doSave() {
  // 维持字段齐全
  emit("save", local);
}
</script>

<style scoped>
.mask { position: fixed; inset: 0; background: rgba(0,0,0,0.25); display:flex; justify-content:flex-end; }
.drawer { width: 520px; height: 100%; background: #fff; padding: 14px; overflow:auto; }
header { display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid #eee; padding-bottom:8px; }
.field { display:grid; grid-template-columns: 140px 1fr; gap: 8px; margin: 10px 0; align-items:center; }
.grid { display:grid; grid-template-columns: 140px 1fr; gap: 8px; }
footer { margin-top: 12px; display:flex; justify-content:flex-end; }
.primary { background:#3b82f6; color:#fff; border:none; padding: 8px 12px; border-radius: 6px; }
</style>

设置页:模型路径选择 + 健康检查(这里实现为“前端填写路径 + 批处理时由后端报错”)

apps/desktop/src/pages/SettingsPage.vue
<template>
  <section>
    <h3>设置</h3>
    <p>本机离线推理:填写 llama.cpp 可执行文件路径 与 GGUF 模型路径。</p>

    <div class="grid">
      <label>operator</label>
      <input v-model="s.operator" />

      <label>llama sidecar path</label>
      <input v-model="s.llamaSidecarPath" placeholder="例如 C:...\llama-cli.exe 或 /usr/local/bin/llama" />

      <label>gguf model path</label>
      <input v-model="s.modelPath" placeholder="例如 D:\models\qwen2.5-1.5b-instruct-q4.gguf" />

      <label>threads</label>
      <input type="number" v-model.number="s.threads" />

      <label>max concurrency</label>
      <input type="number" v-model.number="s.maxConcurrency" />

      <label>timeout(ms)</label>
      <input type="number" v-model.number="s.timeoutMs" />
    </div>

    <div class="row">
      <button @click="save">保存</button>
      <span v-if="msg" class="msg">{{ msg }}</span>
    </div>
  </section>
</template>

<script setup lang="ts">
import { useSettingsStore } from "../stores/settings";
import { ref } from "vue";

const s = useSettingsStore(); s.load();
const msg = ref("");

function save() {
  s.save();
  msg.value = "已保存";
  setTimeout(() => msg.value = "", 1200);
}
</script>

<style scoped>
.grid { display:grid; grid-template-columns: 180px 1fr; gap: 10px; align-items:center; max-width: 900px; }
.row { margin-top: 12px; display:flex; gap: 10px; align-items:center; }
.msg { color:#0a7; }
</style>

导出页:导出 JSONL / CSV(演示版:导出 sample,生产版接入后端查询并写文件)

apps/desktop/src/pages/ExportPage.vue
<template>
  <section>
    <h3>导出</h3>
    <p>演示版:导出样例 JSONL。生产版:增加后端 export_labels(filters, format) 并写入用户选择路径。</p>
    <button @click="download">下载样例 JSONL</button>
  </section>
</template>

<script setup lang="ts">
function download() {
  const text = [
    JSON.stringify({ message_id: "m1", industry: "金融", type: "交易提醒" }),
    JSON.stringify({ message_id: "m2", industry: "通用", type: "验证码" }),
  ].join("\n");
  const blob = new Blob([text], { type: "application/jsonl" });
  const a = document.createElement("a");
  a.href = URL.createObjectURL(blob);
  a.download = "labels.sample.jsonl";
  a.click();
  URL.revokeObjectURL(a.href);
}
</script>

前端依赖:package.json(最小可跑)

{
  "name": "sms-tagging-officer",
  "private": true,
  "packageManager": "pnpm@9.0.0",
  "scripts": {
    "dev": "pnpm -C apps/desktop dev",
    "build": "pnpm -C apps/desktop build",
    "tauri:dev": "pnpm -C apps/desktop tauri dev",
    "tauri:build": "pnpm -C apps/desktop tauri build"
  },
  "devDependencies": {
    "pnpm": "^9.0.0"
  }
}
apps/desktop/package.json
{
  "name": "sms-tagging-officer-desktop",
  "private": true,
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "tauri": "tauri"
  },
  "dependencies": {
    "@tauri-apps/api": "^1.5.0",
    "pinia": "^2.1.7",
    "vue": "^3.4.0",
    "vue-router": "^4.2.5",
    "papaparse": "^5.4.1",
    "xlsx": "^0.18.5"
  },
  "devDependencies": {
    "@tauri-apps/cli": "^1.5.0",
    "@vitejs/plugin-vue": "^5.0.0",
    "typescript": "^5.3.3",
    "vite": "^5.0.0"
  }
}
apps/desktop/src/utils/sample.ts
export function buildSampleRows() {
  return [
    { id:"m1", received_at:"2026-02-10 10:01:00", sender:"中国银行", phone:"95566", source:"sample", content:"【中国银行】您尾号1234卡于2026-02-10 09:58消费58.20元,余额1020.55元。" },
    { id:"m2", received_at:"2026-02-10 10:02:00", sender:"支付宝", phone:"95188", source:"sample", content:"【支付宝】验证码 493821,用于登录验证,5分钟内有效。" },
    { id:"m3", received_at:"2026-02-10 10:03:00", sender:"顺丰速运", phone:"95338", source:"sample", content:"【顺丰】快件已到达XX驿站,取件码 662913,请于18:00前取走。" },
    { id:"m4", received_at:"2026-02-10 10:04:00", sender:"12345", phone:"12345", source:"sample", content:"【12345政务】您反映的问题已受理,查询进度请访问 https://gov.example.cn/track" },
    { id:"m5", received_at:"2026-02-10 10:05:00", sender:"某运营商", phone:"10086", source:"sample", content:"您本月话费账单已出,应缴 89.50 元,逾期将影响服务。" },
    { id:"m6", received_at:"2026-02-10 10:06:00", sender:"平安保险", phone:"95511", source:"sample", content:"【平安】您的保单将于2026-03-01到期,请及时续保,详询4008000000。" },
    { id:"m7", received_at:"2026-02-10 10:07:00", sender:"某电商", phone:"1069xxxx", source:"sample", content:"【京东】会员账号绑定手机号变更成功,如非本人操作请致电950618。" },
    { id:"m8", received_at:"2026-02-10 10:08:00", sender:"某平台", phone:"1069xxxx", source:"sample", content:"【美团】本店新客立减券已到账,点击 http://promo.example.com 立即使用。" },
    { id:"m9", received_at:"2026-02-10 10:09:00", sender:"公安反诈", phone:"12110", source:"sample", content:"【反诈中心】警惕冒充客服退款诈骗,任何验证码均不要透露。" },
    { id:"m10", received_at:"2026-02-10 10:10:00", sender:"未知", phone:"unknown", source:"sample", content:"您有一笔订单待处理,请联系 13800138000 获取详情。" }
  ];
}

9) README:运行、打包、离线分发、自测与验收点

README.md

# 短信智标官(SMS Tagging Officer)

离线桌面软件:对几千条短信进行“行业大类 + 类型”两层标签、实体抽取与可解释 reasons 输出。
推理完全离线:llama.cpp + GGUF 模型文件(用户在设置页选择路径)。
数据落地:SQLite(messages / labels / audit_logs),支持导入、批处理、复核、导出。

## 1. 功能边界(固定枚举)
一级行业:金融、通用、政务、渠道、互联网、其他  
二级类型:验证码、交易提醒、账单催缴、保险续保、物流取件、会员账号变更、政务通知、风险提示、营销推广、其他  
实体字段:brand、verification_code、amount、balance、account_suffix、time_text、url、phone_in_text(缺失填 null

每条输出稳定 JSON,必须包含:
confidence、reasons、rules_version、model_version、schema_version、needs_review

## 2. 本地推理集成方式
默认 Provider:llama.cpp sidecar(可执行文件随应用打包/或由用户指定路径)
后续可扩展 Provider:比如其他本地推理、甚至远端(如果你未来允许联网)

Provider 抽象:classify(payload) -> ModelOutput

## 3. 环境准备(开发)
- Node.js 18+
- pnpm 9+
- Rust stable
- Tauri CLI

```bash
pnpm i
pnpm tauri:dev


## 4. llama.cpp 与模型文件准备(运行期离线)

你需要准备:

1.  llama.cpp 可执行文件:llama-cli (Windows: llama-cli.exe)  llama
1.  GGUF 模型文件(推荐小模型 q4/q5 量化)

把二进制放在本机任意目录,模型放在本机任意目录。  
在应用「设置页」填写路径后,批处理页即可运行。

## 5. 一键自测步骤(验收闭环)

1.  打开应用 -> 导入页 -> 点击「加载内置样例」-> 导入(10条)

1.  设置页:填写 sidecar_path  model_path

1.  批处理页:勾选「只跑未标注」-> 开始

1.  列表复核页:输入 m1/m2... 打开抽屉,修改字段 -> 保存

1.  验证点:

    -   labels 表存在 message_id 对应记录
    -   audit_logs 表新增一条记录(before/after 有差异)
    -   输出 JSON 字段齐全(entities 所有字段都有,缺失为 null
    -   冲突样本进入 needs_review(例如包含金额/链接却被模型判“其他”时)

## 6. 如何验证输出 JSON 约束

仓库 assets/json_schema_output.json 提供 JSON Schema。  
你可以用任意 JSON Schema 校验工具对导出的 JSONL 逐行验证。

## 7. 打包与分发

```
pnpm tauri:build
```

产物在:

-   Windows: src-tauri/target/release/bundle/msi  nsis
-   macOS: .app / dmg
-   Linux: AppImage / deb

### 分发注意

-   运行期完全离线:SQLite 文件在应用数据目录,模型与 sidecar 在本机路径。

-   若希望随包附带 llama.cpp 二进制:

    -    sidecar 放入 src-tauri/bin/ 并在 tauri.conf.json  sidecar
    -   设置页默认填入 sidecar  resolved 路径(扩展点)

## 8. 扩展点(生产完善)

-   列表页接入分页查询与过滤:按 industry/type/needs_review/confidence 区间/含链接/含验证码/含金额等
-   导出接口:按筛选条件导出 CSV/JSONL,支持仅导出已复核或全量
-   health check:在设置页调用后端 command 执行一次 `llama -m ... -p "ping"` 并校验 JSON
-   模型输出更严格:加入 JSON schema 约束解码(可在 llama.cpp 参数或 prompt 中增强)

# ````

---

## 你要的“评价”结论(基于可交付性)

- 这套框架在离线桌面交付里属于 **能稳定跑通、能验收、能扩展** 的形态:规则层先把高确定性短信打穿,模型层只收灰区,融合层把冲突聚焦到 needs_review 队列,复核动作全审计,最终输出稳定 JSON,满足报表与治理的回放要求。
- 关键风险点集中在两处:**模型输出稳定性**(严格 JSON/枚举约束)与 **批处理吞吐**(并发、超时、重试、不卡 UI)。这版工程把两者都“工程化地钉住”了:sidecar 调用 + timeout + semaphore + 事件推送;prompt 强约束 + 输出截取 + 失败落盘。
- 后续要上生产强度,你主要补三块:列表查询/过滤与导出接口、设置页 health check、规则字典与企业自定义(品牌/机构词典、金融/政务更细的 signals),这些都已经留好了扩展点。

---

如果你希望我把“列表页的分页查询 + 过滤条件(行业/类型/needs_review/置信度区间/含链接/含验证码/含金额)+ 导出 CSV/JSONL(仅复核/全量)”也补成完整可跑代码,我可以在现有骨架上继续把 `query_messages(filters)`、`export_labels(filters, format, reviewed_only)` 两个后端 command 和前端 FilterBar + MessageTable 的整套链路补齐。
```

CLI → TUI → GUI → Web,交互设计 4 次跃迁

2026年2月10日 09:09

新一代技术出现,上一代技术自然被淘汰。可在真实的工程世界里,事情往往更复杂。

从 CLI 到 TUI,再到 GUI,最终进入 Web 时代,这四次变化既是界面形式的变化,也是软件工程哲学的变化。

我们现在与大模型的交互如此, vibe coding 如何,Agent 也将如此!

一、CLI:把计算机当作语言机器

image.png

CLI(命令行界面):用户通过文本命令与系统沟通,命令既是操作入口,也是抽象接口。每一条命令都像一个小型 API,具备清晰的输入、输出与组合规则。

这种模式天然适合自动化。命令可以被脚本调用,可以被管道连接,可以被版本化管理。

CLI 的世界强调可组合性与可预测性,界面几乎没有视觉负担,所有复杂性都集中在语义层。

CLI 的局限同样明显。它要求用户记忆大量语法与约定,对新手不友好。计算机的能力在快速增长,人们希望通过更直观的方式驾驭这些能力,于是 TUI 出现了。

二、TUI:在文本中引入结构

image.png

TUI(文本用户界面)可以看作是 CLI 的结构化升级。它仍然运行在字符终端里,却通过窗口布局、颜色和控件模拟出更丰富的界面结构。文件管理器、文本编辑器、系统监控工具在 TUI 时代获得了更高的可用性。

TUI 的关键贡献在于把“状态”引入界面。用户不再只面对一条命令,而是面对一个持续存在的交互空间。界面成为状态的可视化载体,操作从“发出指令”转变为“操控环境”。

这种转变降低了使用门槛,同时保留了高效的键盘驱动模式。TUI 在资源受限环境中依然具有极强生命力,因为它在表达力与性能之间找到了平衡。

三、GUI:视觉直觉的胜利

image.png

GUI(图形用户界面)把交互彻底交给视觉系统。窗口、图标、菜单和指针构成了现代桌面计算的基础隐喻。用户通过点击和拖拽完成操作,界面成为一个可视化的工作台。

GUI 的成功来自两个方面。一方面,它极大降低了学习成本,让更多人能够使用计算机。另一方面,它为复杂应用提供了更丰富的表达空间,图像、动画和排版成为信息组织的重要工具。

代价同样存在。GUI 系统需要更强的硬件支持,软件栈变得更厚,状态管理更加复杂。随着应用规模扩大,桌面软件开始面临分发、更新和跨平台兼容的挑战。这些问题为 Web 的崛起创造了条件。

四、Web:统一平台的承诺

image.png

Web 的性能问题与兼容问题,很大程度上源于我们对 Web 的使用方式,而不是 Web 本身。

Web 把浏览器变成通用运行时,把 HTML、CSS 和 JavaScript 变成跨平台语言。开发者只需面向一个标准化环境,就能触达几乎所有设备。分发成本骤降,更新变得即时,应用形态从“安装的软件”转向“访问的服务”。

Web 的早期形态极其朴素。静态 HTML 页面加载迅速,兼容性问题很少,渲染模型简单透明。随着前端框架与富交互需求的爆发,Web 应用逐渐演变为复杂的客户端系统。虚拟 DOM、构建工具链、动画库和状态管理框架叠加在一起,浏览器开始承担接近操作系统级别的职责。

正是在这个阶段,性能焦虑与兼容焦虑集中爆发。页面加载变慢,设备差异放大,调试成本上升。一部分开发者开始质疑 Web 的方向,甚至回头寻找桌面或原生方案。

五、问题的真正来源

把性能问题归咎于 Web 平台本身,容易忽略一个关键事实:Web 依然可以运行极其高效的界面。一个结构清晰、样式克制的 HTML 页面,在现代浏览器中的渲染成本非常低。很多兼容性问题源于对浏览器特性的过度依赖,以及对复杂动画和特效的滥用。

当界面设计回归朴素,浏览器的优势会重新显现。HTML 的语义标签提供稳定的结构基础,CSS 的基础布局能力足以覆盖大多数需求。减少 JavaScript 运行时负担,意味着更少的阻塞与更可预测的性能曲线。

这种做法更像是一种工程取舍:在表达力与复杂度之间寻找合理区间。Web 的强大之处在于可伸缩性,同一套技术既能构建极简页面,也能支撑复杂应用。选择权始终掌握在开发者手中。

六、朴素 Web 的工程价值

采用朴素 HTML 的一个直接收益是可维护性提升。语义化结构更容易被理解与修改,样式层与行为层的边界更加清晰。团队协作时,新成员可以快速定位问题,而无需穿透厚重的抽象层。

性能稳定性同样随之改善。更少的脚本意味着更低的解析与执行开销,更简单的渲染路径意味着更少的浏览器差异触发点。在移动设备和低端硬件上,这种优势尤为明显。

兼容性问题往往出现在边缘特性上。坚持使用成熟标准,可以显著降低跨浏览器差异。历史经验表明,HTML 与 CSS 的核心子集在多年时间里保持高度稳定,这种稳定性本身就是一种长期资产。

简单的系统更容易测试,更容易推理,也更容易扩展。它为未来的演进留下空间,而不是在一开始就耗尽复杂度预算。Web 作为一个高度通用的平台,允许这种简单存在,也允许逐步增加复杂度。

真正的挑战在于建立判断力:

何时引入抽象,何时保持直接;何时追求炫目的效果,何时坚持克制的表达。这种判断力来自经验,也来自对技术演化历史的理解。

结语:向前,同时向内

交互设计的历史展示了一条清晰轨迹:

人类不断寻找更自然、更高效的方式与机器沟通。在这条轨迹上,每一次前进都伴随着对复杂性的再认识。Web 时代的我们,既拥有前所未有的能力,也面临前所未有的选择。

当界面回归朴素,HTML 的基础能力重新成为主角,很多看似棘手的问题会自然消解。这种现象提醒我们,技术进步并不总是依赖更复杂的工具,有时依赖更清醒的取舍。

CLI、TUI、GUI 与 Web 共同构成了现代计算的交互谱系。理解它们的关系,意味着在设计系统时拥有更多自由。我们可以向前探索新的可能,也可以向内收敛到简单而稳固的核心。在这种张力之中,软件工程持续演化,而简单始终是一种值得珍视的力量。

⏰前端周刊第 452 期(2026年2月2日-2月8日)

2026年2月9日 09:49

📢 宣言每周更新国外论坛的前端热门文章,推荐大家阅读/翻译,紧跟时事,掌握前端技术动态,也为写作或突破新领域提供灵感~

欢迎大家访问:github.com/TUARAN/fron… 顺手点个 ⭐ star 支持,是我们持续输出的续航电池🔋✨!

在线网址:frontendweekly.cn/

前端周刊封面


💬 推荐语

本期聚焦“交互组件选择 + 浏览器行为细节 + 生态工具更新”。Web 开发部分从组合框/多选/列表框的选型指南、浏览器对“意外”变更的敏感反应,到“不要把单词拆成字母”的可访问性提醒;工具与性能板块涵盖 Deno 生态新进展、ESLint 10 发布、ViteLand 月报、以及 SVG/视频与 Node.js 版本演进的性能分析。CSS 方面关注 @scope、@container scroll-state()、bar chart 与 clamp() 等现代特性;JavaScript 则有 Temporal 提案、显式资源管理、框架选型与 React/Angular 的新范式探讨。


🗂 本期精选目录

🧭 Web 开发

🛠 工具

⚡️ 性能

🎨 CSS

💡 JavaScript

《TanStack Start 深入解析:Single Flight Mutations 机制(第二篇)》

2026年2月6日 15:34

原文:Single Flight Mutations in TanStack Start: Part 2

作者:Adam Rackis

日期:2026年1月28日

翻译:TUARAN

欢迎关注 前端周刊,每周更新国外论坛的前端热门文章,紧跟时事,掌握前端技术动态。


TL;DR

这篇文章延续 Part 1 的思路:把一次 mutation 所需的 UI 更新数据,在同一次网络往返里一起带回来(避免 mutation 后再额外发请求 refetch)。

Part 2 的重点是把“要 refetch 哪些查询”抽成可复用的 middleware:调用 server function 时传入 react-query 的 QueryKey[],middleware 会在客户端从 Query Cache 找到每个 query 对应的 serverFn 和参数,把这些信息通过 sendContext 送到服务端统一执行,然后把结果回传给客户端并用 setQueryData 写回缓存。


Part 1 里,我们聊过 single flight mutations:它让你在更新数据时,同时把 UI 需要的所有相关“已更新数据”重新获取回来,并且整个过程只需要一次跨网络的往返。

我们当时做了个很朴素的实现:在“更新数据”的 server function 里直接把需要的东西 refetch 一遍。它确实能用,但可扩展性和灵活性都一般(耦合也偏重)。

这篇文章我们会实现同样的效果,但方式更通用:定义一个“refetch middleware”,把它挂到任意 server function 上。这个 middleware 允许我们通过 react-query 的 key 指定要 refetch 的数据,剩下的事情它会自动完成。

我们会先做一个最简单版本,然后不断加能力、加灵活性。到最后会稍微复杂一些,但请别误会:你不需要把文中讲的全部都用上。事实上,对绝大多数应用来说,single flight mutations 可能完全无关紧要。更别被“高级做法”迷惑了:对很多小应用而言,直接在 server function 里 refetch 一点数据可能就足够了。

不过,跟着做一遍,我们会看到一些很酷的 TanStack(甚至 TypeScript)特性。即便你永远不用 single flight mutations,这些内容也很可能在别的场景派上用场。

我们的第一个 Middleware

TanStack Query(我们有时也会称它为 react-query,这是它的包名)已经有一套非常好用的层级 key 系统。如果我们的 middleware 能直接接收“要 refetch 的 query keys”,然后就……自动搞定,那该多好?

问题在于:middleware 要怎么知道“怎么 refetch”呢?第一眼看确实有点难。我们的 queries(刻意保持简单)本质上都是对 server functions 的调用。但我们没法把一个普通函数引用传到服务端;函数不可序列化,这很合理。你能把字符串/数字/布尔值序列化成 JSON 在线上传输,但一个函数可能带状态、闭包、上下文……传过去根本说不清。

除非——它是 TanStack Start 的 server function。

这个项目背后的工程师们为序列化引擎做了定制,使其支持 server functions。也就是说:你可以从客户端把一个 server function “发到”服务端,它能正常工作。底层原理是:server functions 有一个内部 ID。TanStack 会捕捉到它、发送 ID,然后在另一端把 ID 反序列化成对应的 server function。

为了让事情更简单,我们不妨把 server function(以及它需要的参数)直接放到我们已经定义好的 query options 上。这样 middleware 只要拿到 query keys,就能从 TanStack Query 的 cache 里找到对应的 query options,拿到“如何 refetch”的信息,然后把整个流程串起来。

开始吧

首先引入一些好用的东西:

import { createMiddleware, getRouterInstance } from "@tanstack/react-start";
import { QueryClient, QueryKey } from "@tanstack/react-query";

接着更新我们的 epics 列表查询(主要的 epics 列表)的 query options:

export const epicsQueryOptions = (page: number) => {
  return queryOptions({
    queryKey: ["epics", "list", page],
    queryFn: async () => {
      const result = await getEpicsList({ data: page });
      return result;
    },
    staleTime: 1000 * 60 * 5,
    gcTime: 1000 * 60 * 5,
    meta: {
      __revalidate: {
        serverFn: getEpicsList,
        arg: page,
      },
    },
  });
};

注意这个新增的 meta 区块。它允许我们往 query 上塞任何我们需要的元数据。这里我们放了 getEpicsList 这个 server function 的引用以及它需要的参数。这样写确实会有“重复”(queryFn 写了一次调用方式,meta 又写了一次),如果你觉得别扭,先别急,后面会处理。summary 查询(用于统计数量)我们也会同样更新,不过这里没贴代码。

接下来我们把 middleware 一点点拼出来:

// the server function and args are all `any`, for now, 
// to keep things simple we'll see how to type them in a bit
type RevalidationPayload = {
  refetch: {
    key: QueryKey;
    fn: any;
    arg: any;
  }[];
};

type RefetchMiddlewareConfig = {
  refetch: QueryKey[];
};

export const refetchMiddleware = createMiddleware({ type: "function" })
  .inputValidator((config?: RefetchMiddlewareConfig) => config)
  .client(async ({ next, data }) => {
    const { refetch = [] } = data ?? {};

我们为 middleware 定义了一个输入。这个输入会自动与“挂载该 middleware 的 server function 的输入”合并。

我们把输入写成可选的(config?),因为完全可能出现这种情况:你只想调用 server function,但并不想 refetch 任何东西。

然后开始写 .client 回调(在浏览器中运行):先拿到要 refetch 的 keys:

const { refetch = [] } = data ?? {};

接着我们拿到 queryClient 和它的 cache,并创建一个 payload,之后会通过 sendContext 发到 .server 回调,让它执行真正的 refetch。

如果你对 TanStack middleware 不熟,我之前写的 middleware 文章 可能会更适合作为入门。

const router = await getRouterInstance();
const queryClient: QueryClient = router.options.context.queryClient;
const cache = queryClient.getQueryCache();

const revalidate: RevalidationPayload = {
  refetch: [],
};

我们的 queryClient 已经挂在 TanStack router 的 context 上,所以只要拿到 router 再取出来即可。

还记得我们把 __revalidate 塞到 query options 的 meta 里吗?现在我们针对每个 key 去 cache 里找对应 query,并把 serverFn/arg 抽出来组装成要发给服务端的 payload。

refetch.forEach((key: QueryKey) => {
  const entry = cache.find({ queryKey: key, exact: true });
  if (!entry) return;

  const revalidatePayload: any = entry?.meta?.__revalidate ?? null;

  if (revalidatePayload) {
    revalidate.refetch.push({
      key,
      fn: revalidatePayload.serverFn,
      arg: revalidatePayload.arg,
    });
  }
});

if (!entry) return; 是为了防止请求里包含了“当前缓存里根本不存在”的 query(也就是说,它可能从未在 UI 里被请求过)。这种情况下我们拿不到 serverFn,也就无法 refetch。

你也可以把 middleware 输入扩展得更丰富:比如对那些“无论是否在缓存里都必须执行”的 refetch,直接把 serverFn + arg 一起传上去。比如你打算 mutation 后 redirect,并希望新页面的数据能预取。本文不实现这个变体,但它只是同一主题的另一种组合。

接着我们调用 next,触发真正的 server function(以及其它 middleware)。通过 sendContext 我们把 revalidate 发到服务端:

const result = await next({
  sendContext: {
    revalidate,
  },
});

result 是 server function 调用的返回值。它的 context 上会有一个 payloads 数组(由下方 .server 回调返回),其中每一项都包含 key(query key)和 result(对应数据)。我们遍历并写回 query cache。

我们稍后会修复这里用 // @ts-expect-error 遮掉的 TS 错误:

// @ts-expect-error
for (const entry of result.context?.payloads ?? []) {
  queryClient.setQueryData(entry.key, entry.result);
}

return result;

服务端回调

服务端回调完整代码如下:

.server(async ({ next, context }) => {
  const result = await next({
    sendContext: {
      payloads: [] as any[]
    }
  });

  const allPayloads = context.revalidate.refetch.map(refetchPayload => {
    return {
      key: refetchPayload.key,
      result: refetchPayload.fn({ data: refetchPayload.arg })
    };
  });

  for (const refetchPayload of allPayloads) {
    result.sendContext.payloads.push({
      key: refetchPayload.key,
      result: await refetchPayload.result
    });
  }

  return result;
});

我们会立刻调用 next(),它会执行这个 middleware 所挂载的 server function。我们在 sendContext 里传入一个 payloads 数组:这个数组决定了“服务端最终会发回给客户端回调的数据结构”(也就是 .client 里循环的那份 payloads)。

然后我们遍历客户端通过 sendContext 传上来的 revalidate payload,并从 context 上读出来(是的:send context,发上来再从 context 读出来)。接着调用所有 server functions,并把结果 push 到 payloads 数组里。

把前后拼起来,这就是完整 middleware:

export const refetchMiddleware = createMiddleware({ type: "function" })
  .inputValidator((config?: RefetchMiddlewareConfig) => config)
  .client(async ({ next, data }) => {
    const { refetch = [] } = data ?? {};

    const router = await getRouterInstance();
    const queryClient: QueryClient = router.options.context.queryClient;
    const cache = queryClient.getQueryCache();

    const revalidate: RevalidationPayload = {
      refetch: [],
    };

    refetch.forEach((key: QueryKey) => {
      const entry = cache.find({ queryKey: key, exact: true });
      if (!entry) return;

      const revalidatePayload: any = entry?.meta?.__revalidate ?? null;

      if (revalidatePayload) {
        revalidate.refetch.push({
          key,
          fn: revalidatePayload.serverFn,
          arg: revalidatePayload.arg,
        });
      }
    });

    const result = await next({
      sendContext: {
        revalidate,
      },
    });

    // @ts-expect-error
    for (const entry of result.context?.payloads ?? []) {
      queryClient.setQueryData(entry.key, entry.result);
    }

    return result;
  })
  .server(async ({ next, context }) => {
    const result = await next({
      sendContext: {
        payloads: [] as any[],
      },
    });

    const allPayloads = context.revalidate.refetch.map(refetchPayload => {
      return {
        key: refetchPayload.key,
        result: refetchPayload.fn({ data: refetchPayload.arg }),
      };
    });

    for (const refetchPayload of allPayloads) {
      result.sendContext.payloads.push({
        key: refetchPayload.key,
        result: await refetchPayload.result,
      });
    }

    return result;
  });

修复 TypeScript 报错

为什么下面这一行是无效的?

// @ts-expect-error
for (const entry of result.context?.payloads ?? []) {

这段代码运行在 .client 回调里,并且是在我们调用 next() 之后运行的。本质上,我们是在服务端读取“发送回客户端的数据”(通过 sendContext 传回来的 payload)。这段代码在运行时确实能工作,那为什么类型对不上?

我在上面提到的 middleware 文章里解释过:服务端回调能“看见”客户端发给它的内容,但反过来不成立。这种信息天生就不是双向可见的;类型推断也没法倒着跑。

解决方式很简单:把 middleware 拆成两段,让后一段 middleware 依赖前一段。

const prelimRefetchMiddleware = createMiddleware({ type: "function" })
  .inputValidator((config?: RefetchMiddlewareConfig) => config)
  .client(async ({ next, data }) => {
    const { refetch = [] } = data ?? {};

    const router = await getRouterInstance();
    const queryClient: QueryClient = router.options.context.queryClient;

    // same
    // as
    // before

    return await next({
      sendContext: {
        revalidate,
      },
    });

    // those last few lines are removed
  })
  .server(async ({ next, context }) => {
    const result = await next({
      sendContext: {
        payloads: [] as any[],
      },
    });

    // exactly the same as before

    return result;
  });

export const refetchMiddleware = createMiddleware({ type: "function" })
  .middleware([prelimRefetchMiddleware]) // <-------- connect them!
  .client(async ({ next }) => {
    const result = await next();

    const router = await getRouterInstance();
    const queryClient: QueryClient = router.options.context.queryClient;

    // and here's those last few lines we removed from above
    for (const entry of result.context?.payloads ?? []) {
      queryClient.setQueryData(entry.key, entry.result);
    }

    return result;
  });

整体逻辑不变,只是把 .client 回调里 next() 之后那部分移到了单独的 middleware 里。其余部分留在另一个 middleware 中,并作为输入传给新的这个 middleware。这样当我们在 refetchMiddleware 里调用 next 时,TypeScript 就能看到“从服务端发下来的 context 数据”,因为这些数据是在 prelimRefetchMiddleware 里发送的,而它又是本 middleware 的输入,因此 TS 可以完整看清类型流动。

接起来

现在我们回到“更新 epic”的 server function:把之前的手动 refetch 移除,改为使用 refetch middleware。

export const updateEpic = createServerFn({ method: "POST" })
  .middleware([refetchMiddleware])
  .inputValidator((obj: { id: number; name: string }) => obj)
  .handler(async ({ data }) => {
    await new Promise(resolve => setTimeout(resolve, 1000 * Math.random()));
    await db.update(epicsTable).set({ name: data.name }).where(eq(epicsTable.id, data.id));
  });

在 React 组件中通过 useServerFn 来调用它;这个 hook 会自动处理错误、重定向等。

const runSave = useServerFn(updateEpic);

还记得我说过:middleware 的输入会自动与底层 server function 的输入合并吗?当我们调用这个 server function 时就能看到:

图 1:一个 handleSaveFinal 函数的代码片段,保存输入值并调用 runSave,参数对象包含 id 和 name。转存失败,建议直接上传图片文件

unknown[] 对 react-query 的 query key 来说就是正确类型)

现在我们可以这样调用它,并指定要 refetch 的查询:

await runSave({
  data: {
    id: epic.id,
    name: newValue,
    refetch: [
      ["epics", "list", 1],
      ["epics", "list", "summary"],
    ],
  },
});

运行后,一切正常:epics 列表和 summary 都会在没有任何新网络请求的情况下更新。测试 single flight mutations 时,你其实不是在找“发生了什么”,而是在找“什么都没发生”——也就是 Network 面板里缺少那些本该出现的额外请求。

再改进

react-query 的 query keys 是层级结构的,你可能很熟悉这种写法:

queryClient.invalidateQueries({ queryKey: ["epics", "list"] });

它会 refetch 任何 key 以 ["epics", "list"] 开头的 queries。我们的 middleware 能不能也支持这种“key 前缀”呢?也就是只传一个 key prefix,让它找出所有匹配项并 refetch。

可以,开干。

匹配 key 会稍复杂一点:每个传入的 key 可能是 prefix,会匹配多条 cache entry,所以我们用 flatMap 来找出所有匹配项,再利用 cache.findAll(很好用)。

const allQueriesFound = refetch.flatMap(
  k => cache.findAll({ queryKey: k, exact: false })
);

然后循环并做和之前一样的事:

const allQueriesFound = refetch.flatMap(
  k => cache.findAll({ queryKey: k, exact: false })
);

allQueriesFound.forEach(entry => {
  const revalidatePayload: any = entry?.meta?.__revalidate ?? null;

  if (revalidatePayload) {
    revalidate.refetch.push({
      key: entry.queryKey,
      fn: revalidatePayload.serverFn,
      arg: revalidatePayload.arg,
    });
  }
});

这就能用了。

更进一步

不过我们的方案仍然不理想。假设用户在 epics 页面翻页:到第 2 页、到第 3 页、再回到第 1 页。我们的逻辑会找到第 1 页和 summary query,但也会把第 2、3 页一并找到(因为它们现在也在 cache 里)。然而第 2、3 页并不活跃,也不在屏幕上展示,我们不应该 refetch 它们。

我们可以只 refetch active queries:只要给 findAll 加上 type 参数即可。

cache.findAll({ queryKey: key, exact: false, type: "active" });

于是代码就变成这样:

const allQueriesFound = refetch.flatMap(key => cache.findAll({ queryKey: key, exact: false, type: "active" }));

allQueriesFound.forEach(entry => {
  const revalidatePayload: any = entry?.meta?.__revalidate ?? null;

  if (revalidatePayload) {
    revalidate.refetch.push({
      key: entry.queryKey,
      fn: revalidatePayload.serverFn,
      arg: revalidatePayload.arg,
    });
  }
});

更更进一步

这样就能工作了。但你仔细想想,那些 inactive 的 queries 其实应该被 invalidated。我们不希望立刻 refetch 它们(浪费资源,而且用户没在看),但如果用户又翻回那些页面,我们希望触发一次重新获取。TanStack Query 通过 invalidateQueries 很容易做到。

我们把这段加到“被依赖的那个 middleware”的 client 回调里:

data?.refetch.forEach(key => {
  queryClient.invalidateQueries({ queryKey: key, exact: false, type: "inactive", refetchType: "none" });
});

遍历传入的 query keys,把所有匹配的 inactive queries 标记为无效,但不立刻 refetch(refetchType: "none")。

下面是更新后的完整 middleware:

const prelimRefetchMiddleware = createMiddleware({ type: "function" })
  .inputValidator((config?: RefetchMiddlewareConfig) => config)
  .client(async ({ next, data }) => {
    const { refetch = [] } = data ?? {};

    const router = await getRouterInstance();
    const queryClient: QueryClient = router.options.context.queryClient;
    const cache = queryClient.getQueryCache();

    const revalidate: RevalidationPayload = {
      refetch: [],
    };

    const allQueriesFound = refetch.flatMap(key => cache.findAll({ queryKey: key, exact: false, type: "active" }));

    allQueriesFound.forEach(entry => {
      const revalidatePayload: any = entry?.meta?.__revalidate ?? null;

      if (revalidatePayload) {
        revalidate.refetch.push({
          key: entry.queryKey,
          fn: revalidatePayload.serverFn,
          arg: revalidatePayload.arg,
        });
      }
    });

    return await next({
      sendContext: {
        revalidate,
      },
    });
  })
  .server(async ({ next, context }) => {
    const result = await next({
      sendContext: {
        payloads: [] as any[],
      },
    });

    const allPayloads = context.revalidate.refetch.map(refetchPayload => {
      return {
        key: refetchPayload.key,
        result: refetchPayload.fn({ data: refetchPayload.arg }),
      };
    });

    for (const refetchPayload of allPayloads) {
      result.sendContext.payloads.push({
        key: refetchPayload.key,
        result: await refetchPayload.result,
      });
    }

    return result;
  });

export const refetchMiddleware = createMiddleware({ type: "function" })
  .middleware([prelimRefetchMiddleware])
  .client(async ({ data, next }) => {
    const result = await next();

    const router = await getRouterInstance();
    const queryClient: QueryClient = router.options.context.queryClient;

    for (const entry of result.context?.payloads ?? []) {
      queryClient.setQueryData(entry.key, entry.result, { updatedAt: Date.now() });
    }

    data?.refetch.forEach(key => {
      queryClient.invalidateQueries({ queryKey: key, exact: false, type: "inactive", refetchType: "none" });
    });

    return result;
  });

我们告诉 TanStack Query:把匹配 key 的 inactive queries 置为 invalid(但不 refetch)。

这个方案非常好用:如果你浏览到第 2、3 页,然后回到第 1 页,再编辑一个 todo,你会看到第 1 页列表和 summary 立刻更新。之后如果你再翻回第 2、3 页,你会看到网络请求触发,从而拿到新数据。

锦上添花

还记得我们把 server function 和参数塞进 query options 时的写法吗?

export const epicsQueryOptions = (page: number) => {
  return queryOptions({
    queryKey: ["epics", "list", page],
    queryFn: async () => {
      const result = await getEpicsList({ data: page });
      return result;
    },
    staleTime: 1000 * 60 * 5,
    gcTime: 1000 * 60 * 5,
    meta: {
      __revalidate: {
        serverFn: getEpicsList,
        arg: page,
      },
    },
  });
};

我之前提过:在 metaqueryFn 里重复写 serverFn/arg 有点“脏”。我们来修一下。

先从最简单的 helper 开始:

export function refetchedQueryOptions(queryKey: QueryKey, serverFn: any, arg?: any) {
  const queryKeyToUse = [...queryKey];
  if (arg != null) {
    queryKeyToUse.push(arg);
  }
  return queryOptions({
    queryKey: queryKeyToUse,
    queryFn: async () => {
      return serverFn({ data: arg });
    },
    meta: {
      __revalidate: {
        serverFn,
        arg,
      },
    },
  });
}

这个 helper 会接收 query key、server function 和参数,然后返回 query options:

  • 拼好的 queryKey(必要时把 arg 追加进去)
  • queryFn(直接调用 server function)
  • meta.__revalidate(同样记录 server function 和参数)

于是 epics 列表 query 就可以写成:

export const epicsQueryOptions = (page: number) => {
  return queryOptions({
    ...refetchedQueryOptions(["epics", "list"], getEpicsList, page),
    staleTime: 1000 * 60 * 5,
    gcTime: 1000 * 60 * 5,
  });
};

它能工作,但类型不好:到处都是 any,意味着传给 server function 的参数不做类型检查;更糟的是,queryFn 的返回值也不会被检查,于是你的 query(比如这个 epics 列表)会变成返回 any

我们来加点类型。

server functions 本质上是函数:接收一个对象参数;如果 server function 定义了输入,那么这个对象会包含一个 data 属性,里面就是输入。说一堆大白话不如看调用例子:

const result = await runSaveSimple({
  data: {
    id: epic.id,
    name: newValue,
  },
});

第二版 helper 可以这样写:

export function refetchedQueryOptions<T extends (arg: { data: any }) => Promise<any>>(
  queryKey: QueryKey,
  serverFn: T,
  arg: Parameters<T>[0]["data"],
) {
  const queryKeyToUse = [...queryKey];
  if (arg != null) {
    queryKeyToUse.push(arg);
  }
  return queryOptions({
    queryKey: queryKeyToUse,
    queryFn: async (): Promise<Awaited<ReturnType<T>>> => {
      return serverFn({ data: arg });
    },
    meta: {
      __revalidate: {
        serverFn,
        arg,
      },
    },
  });
}

我们把 server function 约束为一个 async 函数,且它的参数对象上有 data;然后用它来静态推断 arg 的类型。这已经不错了,但当你把它用在“没有参数”的 server function 上时会报错:

...refetchedQueryOptions(["epics", "list", "summary"], getEpicsSummary)
// Expected 3 arguments, but got 2.

你传 undefined 可以解决,功能也正常:

...refetchedQueryOptions(["epics", "list", "summary"], getEpicsSummary, undefined),

如果你是个正常人,你大概会觉得这已经很好了,而且确实如此。但如果你像我一样有点“怪”,你可能会想能不能做到更完美:

  • 当 server function 有参数时:必须传入且类型要正确
  • 当 server function 没参数时:允许省略 arg

TypeScript 有一个特性正好适合:函数重载(overloaded functions)

这篇文章已经够长了,所以我直接贴代码,解读留作读者练习(以及可能的未来文章)。

import { QueryKey, queryOptions } from "@tanstack/react-query";

type AnyAsyncFn = (...args: any[]) => Promise<any>;

type ServerFnArgs<TFn extends AnyAsyncFn> = Parameters<TFn>[0] extends infer TRootArgs
  ? TRootArgs extends { data: infer TResult }
    ? TResult
    : undefined
  : never;

type ServerFnHasArgs<TFn extends AnyAsyncFn> = ServerFnArgs<TFn> extends infer U ? (U extends undefined ? false : true) : false;

type ServerFnWithArgs<TFn extends AnyAsyncFn> = ServerFnHasArgs<TFn> extends true ? TFn : never;
type ServerFnWithoutArgs<TFn extends AnyAsyncFn> = ServerFnHasArgs<TFn> extends false ? TFn : never;

type RefetchQueryOptions<T> = {
  queryKey: QueryKey;
  queryFn?: (_: any) => Promise<T>;
  meta?: any;
};

type ValidateServerFunction<Provided, Expected> = Provided extends Expected ? Provided : "This server function requires an argument!";

export function refetchedQueryOptions<TFn extends AnyAsyncFn>(
  queryKey: QueryKey,
  serverFn: ServerFnWithArgs<TFn>,
  arg: Parameters<TFn>[0]["data"],
): RefetchQueryOptions<Awaited<ReturnType<TFn>>>;
export function refetchedQueryOptions<TFn extends AnyAsyncFn>(
  queryKey: QueryKey,
  serverFn: ValidateServerFunction<TFn, ServerFnWithoutArgs<TFn>>,
): RefetchQueryOptions<Awaited<ReturnType<TFn>>>;
export function refetchedQueryOptions<TFn extends AnyAsyncFn>(
  queryKey: QueryKey,
  serverFn: ServerFnWithoutArgs<TFn> | ServerFnWithArgs<TFn>,
  arg?: Parameters<TFn>[0]["data"],
): RefetchQueryOptions<Awaited<ReturnType<TFn>>> {
  const queryKeyToUse = [...queryKey];
  if (arg != null) {
    queryKeyToUse.push(arg);
  }
  return queryOptions({
    queryKey: queryKeyToUse,
    queryFn: async () => {
      return serverFn({ data: arg });
    },
    meta: {
      __revalidate: {
        serverFn,
        arg,
      },
    },
  });
}

有了它之后,当 server function 需要参数时,你可以这样调用:

export const epicsQueryOptions = (page: number) => {
  return queryOptions({
    ...refetchedQueryOptions(["epics", "list"], getEpicsList, page),
    staleTime: 1000 * 60 * 5,
    gcTime: 1000 * 60 * 5,
  });
};

参数类型会被正确检查:

...refetchedQueryOptions(["epics", "list"], getEpicsList, "")
// Argument of type 'string' is not assignable to parameter of type 'number'.

如果你忘了传参数,它也会报错:

...refetchedQueryOptions(["epics", "list"], getEpicsList)
// Argument of type 'RequiredFetcher<undefined, (page: number) => number, Promise<{ id: number; name: string; }[]>>' is not assignable to parameter of type '"This server function requires an argument!"'.

最后这个报错信息不算特别直观,但如果你把代码读到最后,会发现它已经在尽力提示你哪里错了,靠的就是这个小工具类型:

type ValidateServerFunction<Provided, Expected> = Provided extends Expected ? Provided : "This server function requires an argument!";

而对于“没有参数”的 server function,它也能正常工作。完整解释留给未来文章。

总结

single flight mutations 是一个很不错的优化工具:当你做一次 mutation 后,UI 需要的更新数据不必再额外发请求获取,而是可以在同一次往返里顺便带回来。

希望这篇文章把各个拼图都讲清楚了:如何用 middleware 收集要 refetch 的查询、如何借助 TanStack Start 的 server function 序列化能力把“要执行的 refetch”发送到服务端、以及如何在客户端用 setQueryData 把数据写回缓存。

一个月手搓 JavaScript runtime

2026年2月6日 15:08

原文:building a javascript runtime in one month

作者:themackabu

日期:2026年1月2日

翻译:TUARAN

欢迎关注 前端周刊,每周更新国外论坛的前端热门文章,紧跟时事,掌握前端技术动态。


TL;DR

我做了一个叫 Ant 的小型 JavaScript runtime(大概 2MB)。源码、测试和文档都在 GitHub:github.com/themackabu/…


我在 11 月初开始做这个项目时,脑子里只有一个简单念头:

如果能做一个足够小、能嵌进 C 程序里,但又足够完整、能跑真实代码的 JavaScript 引擎,会怎么样?

一个你可以发布出去、却不用捆上几百 MB 的 V8 或 Node 的东西。我以前也试过做“极简版 Deno”的路子,但始终不够。

我没想到这会花一个月;更没想到一个月真的做得出来。但不设 deadline 的项目有个特点:你会一直往前推,推着推着就做出来了。

第一周:纯纯生存模式

我是一边做一边学——说白了就是不断试错,然后把每一个错误也一起“发布”出去。最开始的工作只围绕最基本的东西:

  • 数值运算
  • 字符串内建函数
  • 一个非常粗糙的 CommonJS 模块系统

每一次提交都像是在虚无里抢回一点点地盘。

最核心的问题是 解析(parsing)。在其它东西能工作之前,你必须先有 parser。而 parser 往往比看起来复杂得多。JavaScript 这种语言尤其“诡异”:

  • 自动分号插入(ASI)是规范的一部分,你得处理
  • this 的绑定会随上下文变化
  • var 的提升(hoisting)意味着变量会在赋值前就“存在”
  • 甚至 window.window.window 这种写法都是合法的……

我前几天做的主要是把基本流程跑通,类似一个“能算数、也能调用函数”的计算器。由于动量已经起来了,我就一直继续。

runtime 的核心数据表示大概长这样:

typedef uint64_t jsval_t;

在这个 runtime 里,每一个 JavaScript 值都用一个 64 位整数表示:NaN-boxing

IEEE 754 浮点规范有个“洞”:理论上存在 2532^{53} 种 NaN,其中绝大多数从来不会被用到。所以我把它们“偷”来用了。

如果把一个 64 位值按 double 解释时它看起来像 NaN,同时 exponent 与 mantissa 又满足你定义的模式,那么你就可以在这些 bit 里塞一个 tag。你有足够空间同时存一个指针和一个类型标签:把对象引用和类型 tag 一起塞进 64 bit,瞬间所有 JS 值都能塞进一个 machine word。

编译期断言也证明了前提:

_Static_assert(sizeof(double) == 8, "NaN-boxing requires 64-bit IEEE 754 doubles");
_Static_assert(sizeof(uint64_t) == 8, "NaN-boxing requires 64-bit integers");
_Static_assert(sizeof(double) == sizeof(uint64_t), "double and uint64_t must have same size");

这就成了 runtime 表示“一切”的心脏:每个数字、对象、字符串、函数、Promise、参数、作用域……全部都是一个 jsval_t

没有“带标签联合体”、没有 vtable、也不需要额外分配元数据——只有 bits。为了把它调顺,我迭代了好几天;但一旦跑通,其它东西就会更快更顺。NaN 和 Infinity 当然也有坑,不过通过微调 boxing 布局也能解决。

大约第 4 天我让变量能用了,第 5 天函数能用了,第 6 天循环能跑了。早期提交非常散:箭头函数、IIFE、可选链、空值合并……我就是一边翻 MDN 一边想起啥加啥。

垃圾回收(GC)灾难

然后就撞上了真正的硬骨头:内存管理

一个 JavaScript runtime 必须有 GC,你不可能要求用户手动 free 对象。所以到第二周左右,我开始尝试自己实现 GC。

结果是一场噩梦:

  • 我加新特性会把 GC 搞崩
  • 我修 GC 又会把性能搞崩
  • 我试着接入别人写的 GC,又发现集成复杂到不可控

这段时间我非常痛苦。手写的 free-list GC 被我开开关关上百次,每次都能把另一个核心模块弄坏。有些日子我明显已经快崩了:凌晨三点 debug,试图弄清为什么协程栈没被保护好、为什么内存泄漏、为什么加了 JSON 支持之后一切都坏了。

转折点是:放弃手写 GC,改用 bdwgc

这是一个生产级 GC(很多语言都在用)。我把它和自己手写的“带前向引用跟踪的内存压缩”结合起来:它能做 mark、能做 forwarding 的哈希表、能做生产 GC 会做的所有事。

一旦集成上去,内存问题大部分就消失了。我写代码的“语气”也变了:东西开始更稳定地工作起来,我加了 process 模块、把错误信息做得更友好——速度从这里开始明显加快。

Promise / async:另一个野兽

你以为 async/await 很简单,直到你尝试自己实现它。

要实现 async/await,你需要 Promise;Promise 需要 microtask 与定时器;microtask 与定时器又需要事件循环;事件循环还要有地方存异步操作的状态。

我为这件事折腾了好几天:

  • 想让 async 工作,你需要协程
  • 协程需要调度
  • 调度需要事件循环
  • 事件循环还要知道协程什么时候结束

如果协程在等 I/O,你不能阻塞;如果某个协程死了,它也不该把整个系统拖死。

你看提交历史就能感受到痛苦:"async promise pushback""segfault when event loop empty""prevent dead task from blocking"……这些坑都是做到一半才会冒出来的。

更要命的是:JS Promise 不能“简化”。它必须支持 .then() 链式调用,必须正确 reject,还要能与 async function 配合——而 async function 本质上是 generator 的语法糖,而 generator 又是 Promise 与回调的语法糖……

大约第 10 天,我引入了 minicoro 作为协程支持。这个决定大概救了整个项目。minicoro 很优雅:你定义基于栈的协程,然后让系统在它们之间切换。有了协程,我终于能让 async 真正跑起来。

typedef struct coroutine {
struct js *js;
coroutine_type_t type;
jsval_t scope;
jsval_t this_val;
jsval_t awaited_promise;
jsval_t result;
jsval_t async_func;
jsval_t *args;
int nargs;
bool is_settled;
bool is_error;
bool is_done;
jsoff_t resume_point;
jsval_t yield_value;
struct coroutine *prev;
struct coroutine *next;
mco_coro* mco;
bool mco_started;
bool is_ready;
} coroutine_t;

所有 async 执行相关的信息都塞进了这个结构:scope、this、正在等待哪个 promise、是否出错……接着我只需要调度这些东西并管理事件循环。

有了协程以后,Promise 才“成真”:.then() 链能跑,await 会真正暂停并在之后恢复执行。runtime 的 async 侧开始成形。后面我再补齐 Promise 内建时就快很多了,因为最难的那部分已经解决。

JavaScript 的“诡异边缘案例”

中间两周基本就是:不停发现 JavaScript 比我预想中更诡异。

不可配置属性、freeze/seal、可选链的边缘语义、严格模式……听起来都不难,但每一个背后都是几十年的规范细节,真实世界的代码会依赖这些行为。

我一个个啃过去:

  • 处理冻结/密封对象
  • 支持不可配置属性
  • 第 10 次修解构
  • 给属性查找加 getter/setter 的访问器支持

每天都在撞一个新边缘案例。有时候一天修好几个:我实现一个功能、跑一致性测试、发现三个 bug、修完之后又冒出五个新 bug。

你知道 JavaScript 有多少种方式访问原型链吗?

  • __proto__
  • Object.getPrototypeOf()
  • Object.setPrototypeOf()
  • [[Prototype]] 内部槽

你得把它们全部做对,而且还要彼此一致。一个看起来很短的提交信息,比如 “use descriptor tables for getters/setters/properties”,背后可能就是几周的工作。

解构看起来也很简单:const [a, b] = arr

但稀疏数组怎么办?对象的可枚举属性怎么办?嵌套解构、默认值、...rest 参数怎么办?每次修一个点都像打地鼠:修好这里,那里又坏。

一致性测试在“最好的意义上”非常残酷:每次跑都会失败在一个我根本不知道存在的语义上。然后我修掉它,继续失败在下一个。这个循环发生了几十次。

后半程:开始变得“能用”

第二周时,我已经有了一个能执行代码的 JavaScript runtime。它不完整,但它是真的。

然后我开始加那些让它变得“有用”的东西:文件系统、路径工具、URL 模块、以及那个因为 Bun 而变得很有名的内建 HTTP server。突然之间,真实程序开始能在 Ant 上跑了。

比如一个 Web 服务器只要写:

import { join } from 'ant:path';
import { readFile } from 'ant:fs';
import { createRouter, addRoute, findRoute } from 'rou3';

const router = createRouter();

addRoute(router, 'GET', '/status/:id', async c => {
await new Promise(resolve => setTimeout(resolve, 1000));

const result = await Promise.resolve('Hello');
const name = await readFile(join(import.meta.dirname, 'name.txt'));

const base = '{{name}} {{version}} server is responding with';
const data = { name, version: Ant.version() };

return c.res.body(`${base.template(data)} ${result} ${c.params.id}!`);
});

async function handleRequest(c) {
console.log('request:', c.req.method, c.req.uri);
const result = findRoute(router, c.req.method, c.req.uri);

if (result?.data) {
c.params = result.params;
return await result.data(c);
}

c.res.body('not found: ' + c.req.uri, 404);
}

console.log('started on http://localhost:8000');
Ant.serve(8000, handleRequest);

运行起来就是:

$ ant examples/server/server.js
started on http://localhost:8000

$ curl http://localhost:8000/status/world
Ant 0.3.2.6 server is responding with Hello world!

这就是“真 JavaScript”跑在 Ant 里:async/await、文件 I/O、HTTP、带参数路由、网络、字符串操作。

之后节奏更快:每天更自信,修更多 bug,加更多特性。然后到了“冷门但必须”的阶段:Proxy、Reflection、Symbol,甚至 class 私有字段/方法。它们也许很少人用,但规范里写了就得支持。

我最喜欢的一类能力之一是 Atomics

const sharedBuffer = new SharedArrayBuffer(256);

const int32View = new Int32Array(sharedBuffer);
Atomics.store(int32View, 0, 42);
const value = Atomics.load(int32View, 0);
console.log('stored 42, loaded:', value);

Atomics.store(int32View, 1, 10);
const oldValue = Atomics.add(int32View, 1, 5);
console.log('old value:', oldValue);

Atomics.store(int32View, 2, 100);
const result = Atomics.compareExchange(int32View, 2, 100, 200);
console.log('exchanged, new value:', Atomics.load(int32View, 2));
$ ant examples/atomics.js
stored 42, loaded: 42
old value: 10
exchanged, new value: 200

最后一周:多米诺骨牌一样倒下

当 Ant 的核心 runtime 能跑、GC 稳了、Promise 也通了之后,其它东西就像多米诺骨牌一样:小问题被修掉、缺的方法补齐、边缘语义逐个处理。

我重新加回了数组 length 校验,修了对象的属性缓存失效逻辑;为了优化 hash 性能又掉进“复杂算法 + 安全影响”的兔子洞——因为我已经在打磨一个“能工作的东西”。

到第 28 天,我给一个真的能用的 runtime 收尾:支持 async/await、靠谱的内存管理、网络、文件 I/O、并通过 ES1–ES5 的一致性测试,还混搭了一堆更现代的特性。

我甚至在别人提醒之后才“想起来”打开 LTO 和一些编译器 flag 😅

uzaaft

最终结果

一个月后,Ant 作为 JavaScript runtime:

  • 通过 javascript-zoo 测试套件中 ES1 到 ES5 的每一个一致性测试(25 年规范跨度的完整兼容)
  • 实现 async/await,并具备正确的 Promise 与 microtask 行为
  • 拥有一个真的能用、且不漏内存的 GC
  • 基于 libuv 运行 Web 服务器(和 Node 类似的网络底座)
  • 支持通过 FFI 调用系统库,例如:
import { dlopen, suffix, FFIType } from 'ant:ffi';

const sqlite3 = dlopen(`libsqlite3.${suffix}`);

sqlite3.define('sqlite3_libversion', {
args: [],
returns: FFIType.string
});

console.log(`version: ${sqlite3.sqlite3_libversion()}`);
$ ant examples/ffi/basic/sqlite.js
version: 3.43.2
  • 支持读写文件与异步 I/O
  • 支持正确的作用域、提升、变量遮蔽
  • 支持 class、箭头函数、解构、展开、模板字符串、可选链
  • 覆盖一些多数人根本不会想到的“怪边缘”:__proto__ 赋值、属性描述符、不可配置属性、冻结/密封对象(可参考测试:tests/__proto__.js
  • 实现 ES Module(import / export)
  • 支持 Symbol、Proxy、Reflect、WeakMap/WeakSet、Map/Set
  • 支持共享内存与 Atomics 并发原语

把这些串起来,你会发现你面对的已经几乎是一个“完整的 JavaScript runtime”,不太像玩具。

代价

我不知道代价是什么。

可能是睡眠,可能是健康,可能是本来可以拿去做任何其它事情的大把时间。

有些日子我连续工作 10+ 小时;有些日子一天 20+ commits。项目不会减速,只会加速:每天更自信、更快、修更多 bug、加更多特性。

到最后,我开始撞上那些必须去读 ECMAScript 规范、去理解 V8 行为、去对比其它引擎怎么处理某个怪角落的工作。改符号计数、优化 class、把内部属性迁移到 slots(像 V8 那样)……这类优化正常应该等代码稳定后再做,但因为地基已经稳了,我在最后一周反而有了余力去做。

发布后:优化阶段

首个 release 是 11 月 26 日。之后是一段沉默——那种“发完版本之后就没声了”的沉默。直到 12 月 20 日左右,开发又恢复。

这一次不同:runtime 能跑、能过测试,但总有更多优化空间。xctrace 让我看清什么才是真正的瓶颈。12 月下旬和 1 月初的提交呈现一种模式:找到瓶颈 → 修复 → 测量提升。

fast

我先为 typed array 加了 arena allocator。之前 typed array 散落在 heap 的各处;我把它们集中起来,加速分配并改善 cache locality。

然后我把 getter/setter/property 从“每个 descriptor 单独分配”改成“descriptor table 批处理”:更少的分配、更少的指针追逐。

. 运算符支持 property reference 也很烦:每次查属性都要全量解析;于是我加了 reference table 跳过重复工作。

我很喜欢 dispatch table。我把 FFI、JSON 等路径改为 computed goto,让 CPU 直接跳到正确的 handler:少一次分支、少一次查找。

把 properties 迁到 slots 是最侵入的一次重构。对象之前用的是灵活但慢的属性系统;slots 则是按对象类型固定结构,让 runtime 能做更多假设,减少 indirection。

某个时刻我开始拿它对比 Node:跑同样 benchmark,Ant 表现如何?结果开始变得很好——好到你会想:我是不是能在某些点上赢 Node?

bunnerfly wow

优化 Ant 的过程中我会保留一些可工作的 snapshot:如果某次优化把东西搞坏了,我还能退回到一个稳定点。于是就能持续小步推进:每次提交都比上一次快一点。有些优化有效,有些没用,但整体模式始终成立:profile → optimize → measure → commit。

然后是 GC 的改进。在最初那一个月里 bdwgc 集成得挺好,但在优化阶段的某个时刻它被禁掉了,runtime 就开始漏内存。我重新加回“可延迟 GC”的机制,并把旧 GC 的大部分代码取消注释。

但这次不是老办法:我做的是一个 mark-copy + compact 的 GC,能真正做内存碎片整理。旧 GC 的问题是它在错误的时机运行,导致热路径卡顿。所以我让它“可延迟”:在逻辑工作单元之间再收集;同时用前向引用跟踪保证对象移动后指针不坏。GC 回来了,但更聪明:它会等到合适的点暂停,并在运行时压缩堆。

为什么会做这件事

老实说,我也不知道。

也许是赌气?也许是想证明点什么?也许是纯粹的执念。

那种“进入心流”的状态:你写着写着,八小时就过去了,已经凌晨四点,然后你把代码 commit 掉,第二天又继续。

这个项目之所以存在,是因为我脑子里某个东西决定“它必须存在”,并且直到它真的存在之前都不会停。

它并不完美。代码里可能还有没发现的 bug;可能还有没做的性能优化;可能还有漏掉的规范角落。

但它能跑:你可以写真实 JavaScript,它会执行;你可以用 async/await;你可以写服务器;你可以拿它去做真实事情。

如果你曾经好奇:一个人如果足够执着、又不睡觉,能做到什么?答案就是:做出一个规范兼容的 JavaScript 引擎。

源码、测试与文档都在:github.com/themackabu/…

一次 WebGPU 流体之旅

2026年2月4日 11:42

原文:Particles, Progress, and Perseverance: A Journey into WebGPU Fluids

作者:Hector Arellano

日期:2025年1月29日

翻译:TUARAN

欢迎关注 前端周刊,每周更新国外论坛的前端热门文章,紧跟时事,掌握前端技术动态。

image.png

本文是一段回顾式的长旅程:作者用十多年的时间不断尝试“浏览器里做流体”,从 WebGL 时代的各种 Hack,一路走到 WebGPU 让许多“现代图形 API 能力”变得可用。

编者按:如果你关注过 Web 图形圈子,可能知道 Hector Arellano(又名 Hat)。这篇文章不仅是技术拆解,更是一段关于坚持、试错、与 Web 图形演进的故事。

注意:Demo 依赖 WebGPU,并非所有浏览器都支持。请使用支持 WebGPU 的浏览器(例如最新版 Chrome / Edge,并确保 WebGPU 已启用)。

在继续阅读之前……先去拿杯喝的——这篇很长

13 年前……

我正盯着电脑屏幕发呆(无聊得很),一个很要好的朋友 Felix 打电话给我,非常认真又兴奋地说:Gathering Party 刚发布了一个新 Demo。它有流体模拟、粒子动画、惊艳的着色方案——最重要的是,它真的很美。

那时候 WebGL 还算“新东西”,把硬件加速的 3D 图形带进浏览器,看起来会打开很多门。我天真地以为:WebGL 也许能做出 Felix 给我看的那种东西。

但我开始研究那个 Demo 的实现方式时,就撞上了残酷现实:里面用到了一堆我从没听过的 API/特性——“Atomics(原子操作)”“Indirect Draw Calls(间接绘制调用)”“Indirect Dispatch(间接派发)”“Storage Buffers(存储缓冲区)”“Compute Shaders(计算着色器)”“3D Textures(三维纹理)”。

它们属于现代图形 API 的能力,但在当时的 WebGL 里基本不存在。

更别提它还用了很多听起来就很复杂的算法/技术:用 SPH(Smoothed Particle Hydrodynamics,平滑粒子流体动力学) 驱动粒子动画、用 histopyramids 做流压缩(我当时还想:我为什么需要这个?)、用 GPU 上的 marching cubes(从粒子生成三角形???)等等。

我完全不知道从哪里开始。更糟的是,Felix 跟我打赌:这种流体效果不可能在浏览器里“可用于生产”。

10 年前……

又过了三年,Felix 说他还有一个更炸裂的 Demo一定要我看。除了流体模拟,它还用实时光线追踪渲染了几何体——材质很震撼、画面很惊人。

这下挑战更大了:我不仅想模拟流体,我还想用光追去渲染它,得到漂亮的反射与折射。

我花了大概 3 年才把这些东西理解到能在 WebGL 里“硬凿”出来:

  • 我用 SPH 让粒子行为像流体;
  • 我用 marching cubes 从粒子生成网格(见图 1 的描述)。

当时没有 atomics,我就用多次 draw call 把数据塞进纹理的 RGBA 通道来“分层”;没有 storage buffer 和 3D 纹理,我就用纹理存数据,并用二维层来“模拟” 3D 纹理;没有 indirect draw,我就干脆按预期数量发起 draw call;没有 compute shader,我就用顶点着色器做 GPGPU 的数据重排……虽然也做不出那种“在 buffer 里随意写多个内存位置”的事,但至少我能在 GPU 里生成一个加速结构。

实现是能跑,但离“美”差得很远(Felix 直接评价:丑。确实丑,你可以想象图 2)。我那时也不太懂距离场,也不知道怎么把 shading 做得更有趣,基本就是老派 phong。

性能也限制了很多高级效果:环境光遮蔽、更复杂的反射折射……但至少我能渲染出点东西。

7 年前……

再过三年,我又做了一些进展:实现了一个混合式光追。思路是:marching cubes 先生成三角形,然后用光追去算二次射线做反射/折射;同一个光追还能遍历加速结构去做焦散。这些基本都沿用了 Matt Swoboda 的想法(那些 Demo 的原作者)。我的工作大部分就是:把他的点子尽量在 WebGL 里跑起来(祝你好运)。

效果在视觉上还不错(类似图 3),但需要非常强的 GPU。当时我用的是 NVidia 1080GTX。也就是说:即使 WebGL 可行,也不可能拿去做“生产”。手机不行,普通笔记本也扛不住。

看得到“结果”,却用不到真实项目里,这种挫败感很强。我花了太多时间,最后也没有达到期望。至少,这套代码库还能继续帮我学习。

于是我停了。

Felix 赢了赌局。

这段铺垫对一篇“教程”来说太长了,但我想把背景交代清楚:有些 Demo 看起来像“几天搞定”,实际可能是多年积累;你要花时间学很多技术,也经常要借鉴别人的想法——最后也可能仍然失败。

WebGPU 登场

还记得那些“现代图形 API 的关键词”吗?WebGPU 基于现代 API 标准,这意味着我不必再靠 Hack:

  • 我可以用 compute shader 直接操作 storage buffer;
  • 我可以用 atomics 做邻域搜索、流压缩时的索引写入;
  • 我可以用 dispatch indirect 来只生成必要数量的三角形,并用同样的方式绘制它们。

我想学习 WebGPU,于是决定把之前的流体工作迁移过来,顺便理解新范式:怎么组织 pipeline 和 binding、怎么管理 GPU 内存与资源……做一个小 Demo 很适合练手。

需要先讲清楚:本文的 Demo 并不适合生产。在 M3 Max 这类比较强的 MacBook Pro 上它可能能跑到 120fps;M1 Pro 上大概 60fps;其它不错的机器也许 50fps……但如果你拿去跑在 MacBook Air 上,“浏览器流体梦”会很快破碎。

那它为什么仍然有价值?

因为它其实是一组可拆解的技术集合。你可能对其中某个部分感兴趣:粒子动画、从势场生成表面(避免 ray marching)、间接光、世界空间 AO……你可以把仓库里的代码拿出来,只取你需要的部分来构建自己的想法。

这个 Demo 大致可以拆成 4 个主要阶段:

  • 流体模拟:用粒子模拟(基于 Position Based Dynamics 思路)驱动流体的运动。
  • 几何生成:用 GPU 上的 marching cubes,从粒子生成渲染用三角形。
  • 几何渲染:使用距离场估算几何厚度以做次表面散射(SSS),并用体素锥追踪(Voxel Cone Tracing)计算 AO。
  • 合成:地面反射模糊、调色与 Bloom 等后期。

流体模拟

很多年前,如果你想在图形圈子里“显得很酷”,你得证明你能自己做流体模拟:做 2D 就很强,做 3D 就是“封神”(当然这是我脑内的中二设定)。为了“封神”(也为了赢赌局),我开始疯狂读 3D 模拟相关的资料。

做流体的方法很多,其中一种叫 SPH。理性做法应该是先评估哪个方法更适合 Web,但我当时选它就因为名字听起来很酷。SPH 是粒子法,这一点长期来看很有好处,因为后来我把 SPH 换成了 position based 的方法。

如果你做过“群体行为(steering behaviors)”或 flocking,会更容易理解 SPH。

Three.js 有很多 flocking 示例,它基于吸引、对齐、排斥等 steering 行为。用不同的权重/函数,根据粒子之间的距离决定粒子受哪些行为影响。

SPH 的做法也有点类似:你先算每个粒子的密度,再用密度算压力;压力就像 flocking 里的吸引/排斥,使粒子靠近或远离。密度又是邻域粒子距离的函数,所以压力本质上也是“由距离间接决定的”。

SPH 的粘性项(viscosity)也类似 flocking 的对齐项(alignment):让粒子速度趋向邻域的平均速度场。

为了(过度)简化,你可以把 SPH 理解成:给 flocking 套上一组更“物理正确”的参数,让粒子更像流体。当然 SPH 还会涉及表面张力等更多步骤,且其核函数/权重远比这里描述复杂,但如果你能把 flocking 做好,理解 SPH 会更轻松。

SPH/群体行为都有一个共同难点:朴素实现是 O(n2)O(n^2),粒子多就会爆炸。你需要一个加速结构只查询附近粒子,让复杂度从 O(n2)O(n^2) 降到 O(kn)O(k\cdot n)kk 是每个粒子要检查的邻居数)。常见做法是体素网格:每个体素格子存最多 4 个粒子索引。

在这个示例里,算法会检查粒子周围 27 个体素,每个体素最多 4 个粒子,所以最多 108 次邻域检查。听起来也不少,但比检查 8 万个粒子要好太多。

但邻域遍历仍然昂贵。SPH 还要求多次 pass:密度、压力/位移、粘性、表面张力……当你意识到 GPU 绝大部分算力都在“驱动粒子”时,性能就会变得非常重要。

而且 SPH 很难调参,你得理解很多工程/物理参数才能做得好看。

后来 NVidia 提出了一套粒子动力学方法:Position Based Dynamics(PBD),其中包含刚体、软体、流体、碰撞等。课程笔记在这里

PBD 通过“约束(constraints)”直接修正粒子位置,结果稳定、调参相对容易。这让我从 SPH 转向 PBF(Position Based Fluids)。核心差别在于:PBF 用约束来定义位移,而不是像 SPH 那样先算密度。

PBF 的参数更“无量纲”,更好理解。

但它也有代价:PBD 往往要迭代多次才能得到更好结果(计算约束、应用位移、计算粘性……反复执行),稳定但更慢。

而我不想只渲染粒子,我要渲染网格:GPU 还要算三角形、做渲染。我没有足够预算做多轮迭代,所以我必须“砍角”。

幸运的是,PBD 有一种很便宜的碰撞计算方式:在施加力(forces)后做一次 pass 即可。我选择:

  • 用重力作为主力;
  • 用 curl noise 作为辅助力,增加流体感;
  • 用鼠标驱动一个很强的斥力(repulsion);
  • 让碰撞负责避免粒子聚成奇怪的团。

curl + 重力提供“像流体”的整体趋势,碰撞避免粒子聚团。它不如 PBF 那么真实,但更快。

实现上只需要一次 pass 应用所有力,同时在 storage buffer 里生成网格加速结构;atomics 写索引只需要几行代码。你可以在仓库的 PBF_applyForces.wgsl 里读到力与网格构建的实现。

粒子位置更新在 PBF_calculateDisplacements.wgsl:负责遍历邻域做碰撞,也负责和环境(不可见包围盒)碰撞。

pipeline 与绑定在 PBF.js:模拟只用三个 shader——施力、位移更新、速度积分。位置更新后,速度通过“新位置 - 旧位置”的差值得到。

最后一个 shader PBF_integrateVelocity.wgsl 还会设置一个包含粒子信息的 3D 纹理,后续会用于 marching cubes 生成势场。

Marching Cubes(几何生成)

当年我第一次用 SPH 把粒子跑起来时兴奋得不行,在办公室到处吹(基本到处都是)。Felix 当然知道怎么治我:他把我赶回去继续做“表面生成”,因为只有把流体渲染成液体表面而不是点,才算“像样”。

从粒子场渲染表面常见有三种思路:

  • Point Splatting
  • Raymarching
  • Marching Cubes

Point splatting 是最简单也最快的一种:屏幕空间效果,渲染粒子后结合可分离模糊与深度来生成法线。效果很不错,还能做焦散,实时性能也好。

image.png

Raymarching 很有趣,能做多次反射折射等复杂效果,但非常慢:你需要从粒子生成距离场,再在距离场里做步进采样(过去还没有 3D 纹理,只能软插值)。即使硬件支持三线性插值,性能也依然不太理想。画面很美,但不适合实时。

Marching cubes 听起来很吸引人:从粒子生成的势场(potential field)里提取等值面生成网格。优点是网格可直接栅格化,在高分辨率下也能稳定渲染;并且有了网格,很多“反射”就能更便宜地实现。与前两种方案相比,它更容易作为世界空间几何体融入场景。

Three.js 有 marching cubes 的例子,但那些是 CPU 上生成表面;而我的粒子数据在 GPU。我去读 Matt Swoboda 的分享,了解他如何在 GPU 上做 marching cubes,但里面有很多我当时还不懂的问题:

  • 如何从粒子场生成势场?
  • 间接 dispatch 是怎么回事?
  • 如何在 GPU 上生成三角形?

先把路线图讲清楚。Marching cubes 本质是从势场提取等值面(iso-surface)。关键步骤有:

  1. 从粒子生成势场(potential)。
  2. 在体素网格上评估势值,决定每个体素对应 256 种“case”里哪一种(每个体素会生成 0 到 5 个三角形)。
  3. GPU 上把满足条件的体素写入连续 buffer(用 atomics 追加)。
  4. 根据体素信息生成三角形。

势场生成(Potential Generation)

如果你了解 point splatting,会发现“模糊”很关键:它能把点云平滑成近似表面。同样思路也适用于 3D 纹理:对 3D 纹理做 blur,就能得到一种“穷人版距离场”。

你也可以用 Jump Flood 算法生成更精确的距离场(粒子也可以),看起来还可能比 3D blur 更快——但它有个致命缺点:它太精确了。

Jump Flood 的结果更像是一组球体的距离场,等值面阈值不同会把球“连起来”,但不会以一种“好看”的方式平滑。你得有非常多的粒子才会像连续表面,而那种情况下你倒不如直接用 point splatting。

3D blur 反而会把粒子“抹开”,去掉高频的颗粒感,让它更像表面。blur 次数越多,表面越平滑;你也能尝试不同 blur 方式混合出不同的表面效果。奇妙的是:这个简单办法在这里反而更快、更实用。

实现上,blur 用 compute shader Blur3D.wgsl,沿三个轴各 dispatch 一次;绑定与 dispatch 在 Blur3D.js

体素筛选(Checking voxels)

势场生成后,我用另一个 compute shader 扫描体素网格,找出会生成三角形的体素。仓库里的 MarchCase.wgsl 会遍历整个体素网格,为需要生成三角形的体素计算 marching cubes case,并用 atomics 把该体素的 3D 坐标与 case 连续写入 storage buffer。

然后 EncodeBuffer.wgsl 读取上一步得到的体素数量,编码出用于“间接 dispatch”的参数(三角形生成需要多少顶点)以及“间接 draw”的参数(需要绘制多少三角形)。

三角形生成(Triangles Generation)

负责生成顶点/法线的 shader 是 GenerateTriangles.wgsl。它根据每个线程的全局索引定位到对应体素和要生成的顶点,并通过 EncodeBuffer.wgsl 产生的间接 dispatch 来运行。

体素信息用于在边的两个角点之间做线性插值,得到顶点位置;法线则来自边两端角点梯度(gradient)的线性插值。

势场生成、体素收集、三角形生成这些步骤在 TrianglesGenerator.jsgenerateTriangles 函数里串起来,每次粒子位置更新后都会调用。

渲染

这些年我最大的错误之一,是把“模拟/GPGPU 技术”看得比“视觉美感”更重要。我太执着于证明自己能做复杂东西,而忽略了最终画面。

Felix 经常在我准备发布 Demo 之前拦住我:花更多时间把画面打磨得更舒服,别只做成那种“只有四个人会觉得很酷”的技术展示。

相信我:你可以做很强的物理模拟、很复杂的材质——但如果看起来很糟糕,那它就是糟糕。

流体的难点在于:你已经把大量 GPU 时间花在粒子动力学和表面生成上了,留给渲染效果的预算不多;还要给场景里其它东西留时间。所以实时流体一般很难做到“极致画质”。

实时渲染液体的最优解通常是 point splatting:反射、折射、阴影、焦散都能做,而且很“便宜”。不信可以看看这个很棒的 Demo:webgpu-ocean.netlify.app/

如果你要的是不透明/半透明但不需要“真正透明”的液体(比如颜料),marching cubes 是不错选择:你可以用 PBR 得到很好看的视觉,而且它是世界空间几何,和场景整合更简单。

在这个 Demo 的范围里,我想利用现成的体素结构(用于三角形生成)以及用于生成三角形的势场(可视作距离场),做一些“相对便宜但有效”的视觉提升。

我先做了基于体素锥追踪(Voxel Cone Tracing,VCT)的 AO。VCT 通常要求先把三角形体素化,但这个 Demo 反过来:我们本来就是从体素生成三角形。所以 VCT 所需的一大块工作已经在流程里。

我只需要稍微改一下 MarchCase.wgsl:用离散化方式更新体素网格——有三角形的体素标记为 1,没有的标记为 0;同时把地面以下一定高度的体素标 0.5 来模拟地面 AO。只多加两行代码就能准备好 VCT 的信息。

体素网格更新后,再对 3D 纹理做 mipmap(MipMapCompute.wgsl,绑定在 CalculateMipMap.js)。

你会注意到我也做了地面反射:marching cubes 生成的是网格,做反射很直接——算反射矩阵,把网格绘制两遍即可。若用 ray marching 做同样效果会贵很多。

这时我还有一点 GPU 预算,于是继续问朋友:还有什么特性值得加?有人建议做次表面散射(Subsurface Scattering,SSS),像下面这种效果。

image.png

SSS 做得好非常加分,难点在于要知道几何体的厚度(thickness),才能决定光在内部散射的程度。

很多 SSS demo 用“厚度贴图”,但流体表面无法烘焙厚度,必须实时计算。

幸运的是,我们在生成三角形之前已经有势场,它可以当距离场用来实时估算表面厚度。概念上类似 Iñigo Quilez 做 AO 的方式:在距离场里 ray marching,看表面距离如何影响遮蔽。

我采用类似思路,但把光线沿“进入几何内部”的方向发射,从而估算内部光传播被遮挡的程度——厚的地方散射弱,薄的地方散射强。效果出乎意料地好。

几何材质在 RenderMC.wgsl:顶点着色器使用存储在 storage buffer 的顶点位置与法线。因为 CPU 不知道 marching cubes 实际生成了多少三角形,所以用 EncodeBuffer.wgsl 编码出的间接 draw 来绘制。

绑定里我用了两套矩阵:一套用于正常视角,另一套用于反射网格;这些在 Main.js 完成。

到这一步,模拟、表面生成、材质都有了,接下来该谈合成(composition)。

合成(Composition)

你可能觉得自己是很厉害的图形开发者:会用 Three.js / Babylon.js / PlayCanvas 做酷炫效果……也可能你更强,很多东西自己写。

我想说的是:我不是。

我为什么知道?

因为我曾在 Active Theory(activetheory.net/)工作,身边是非常优秀的图形开发和 3D 艺术家。他们让我看清自己的短板,也帮助我把交付物推到更好的状态。

如果你能争取和他们共事,对你的职业发展非常有帮助——你会学到很多。

其中最关键的一点是:合成决定一切。

我请曾在 Active Theory 共事的 Paul-guilhem Repaux(x.com/arpeegee)帮我做合成建议。他指出了几个关键问题:

  • 地面反射过于清晰,应该更粗糙(更模糊)。
  • 黑色背景无法体现光从哪里来;背景应该营造更“有情绪”的氛围。
  • 缺少把几何体与环境融合的光效。
  • 字母之间的过渡缺乏合理性。
  • 需要调色。

(当然还有很多能改的地方,他只是很友好地挑了最关键的。)

反射

第一点可以用后期解决:根据几何体到地面的距离决定反射的模糊程度——离地越远越模糊,从而得到“粗糙度”效果。

但如果只在“有几何体高度信息”的区域模糊,周围空白区域会不模糊,结果会很怪。

为了解决这个问题,我先做了一个预处理 pass:把反射几何体做一个偏移,并把“最近的高度”写入一张纹理,用来在空白区域也决定模糊强度。

深红色是未反射几何体,绿色是反射几何体(包含偏移),你会看到绿色更“厚”。高度编码在红色通道里,可视为从地面到上方的渐变。

背景与光

SSS 的实现假设光源始终从几何体背后打过来——即便镜头移动,这个方向也得成立,否则 SSS 不明显。

这对背景设计反而是好事:背景可以做一个“背光渐变”,自然地解释光来自背后;同时背景色也可以和材质更接近,让整体更融合。

光效融合

最后,为了让背景与几何体的光融合得更好,我加了 Bloom:在几何体更薄的区域,Bloom 更强,从而强化 SSS 的视觉。

(顺带一提:我还尝试过把字母动画和 Codrops 的 Logo 对齐,但看起来像儿童识字应用,于是放弃。)

调色与氛围

最后我加了亮度、对比度、gamma 校正,选择偏暖的配色让氛围更柔和。后期由多个 compute shader 完成,在 Main.js 里调用。

完整代码库:github.com/HectorArell…

简化版仓库:github.com/HectorArell…

你可以通过在 Demo URL 末尾加 /?word=something 来更改展示的单词。

结语

本文没有深入性能优化,我觉得也没必要:这个 Demo 本来就面向强 GPU,不是移动端。

image.png

WebGPU 的 timestamp query 让定位瓶颈变得更容易(例如 Blur3D.js 里就有注释掉的查询)。

这并不意味着“这套方案能直接上生产”。Felix 做过一个用 SPH 做字母的探索,非常快也很酷,你可以看看:fluid.felixmartinez.dev/

总之,这么多年过去,Felix 还是赢着赌局,而我还在努力扭转结果……希望你也能遇到一个让你说出 “hold my beer” 的人。

小结

WebGPU 流体长文真正有价值的点在于:它把“酷炫 Demo”拆成了可复用的工程模块链路——粒子模拟、势场生成、marching cubes、间接 dispatch/draw、再到合成与调色。落地时最容易卡住的往往是性能预算与调参成本:算力都花在模拟与几何生成上,留给画面打磨的空间很小,所以工程上需要把可控的阶段拆开验证、逐步迭代。实践中可以用 RollCode 低代码平台私有化部署自定义组件静态页面发布(SSG + SEO) 来把 Demo 展示、参数面板、素材与发布流程做成可复现的交付闭环。

React 的 ViewTransition 元素

2026年2月5日 08:58

原文:React’s ViewTransition Element

作者:Chris Coyier

日期:2026年1月30日

翻译:TUARAN

欢迎关注 前端周刊,每周更新国外论坛的前端热门文章,紧跟时事,掌握前端技术动态。

作为一个 View Transitions 的爱好者,同时又在用 React,我自然会关注 React 现在直接提供了一个 <ViewTransition> 元素(目前在 “Canary” 预发布版本中)。

image.png

我想看看它到底怎么用,但在开始之前,我们先……用它。View Transitions 是 Web 平台本身的特性,不属于任何框架。所以 React 也无法阻止我们使用它。直接用其实也不算奇怪。

在 React 中使用 View Transitions(经典方式?)

同页 View Transitions API(对 React 更相关,而不是多页切换的那种)基本是这样:

document.startViewTransition(() => {
  // 在这里改 DOM
});

但改 DOM 这种事……是 React 的工作。它并不喜欢你自己去动它。所以与其直接操作 DOM,我们不如做些“更 React 的事”,比如更新 state。

import React, { useState } from "react";

export default function DemoOne() {
  const [buttonExpanded, setButtonExpanded] = useState(false);

  const toggleButton = () => {
    document.startViewTransition(() => {
      setButtonExpanded(!buttonExpanded);
    });
  };

  return (
    <button
      className={`button ${buttonExpanded ? "expanded" : ""}`}
      onClick={toggleButton}
    >
      Button
    </button>
  );
}

视觉效果由 CSS 完成。状态变化触发 class 变化,而 class 改变按钮的样式。

.button {
  /* button styles */

  &.expanded {
    scale: 1.4;
    rotate: -6deg;
  }
}

准备使用 <ViewTransition>

写这篇文章时,这个元素还只存在于 React 的 “Canary” 版本,所以你得显式安装:

npm install react@canary

你的 package.json 会把版本写成 canary

{
  "dependencies": {
    "react": "canary",
    "react-dom": "canary"
  }
}

如果你在客户端用 React,也可以用 CDN 的 import map:

<script type="importmap">
{
  "imports": {
    "react": "https://esm.sh/react@canary",
    "react-dom": "https://esm.sh/react-dom@canary"
  }
}
</script>

在 React 中使用 <ViewTransition>

现在我们可以导入 ViewTransition,并把它作为 JSX 元素使用,同时配合它的搭档 startTransition

import React, { startTransition, ViewTransition } from "react";

function App() {
  const [buttonExpanded, setButtonExpanded] = useState(false);

  const toggleButton = () => {
    startTransition(() => {
      // 以更“React”的方式改变 DOM
      setButtonExpanded(!buttonExpanded);
    });
  };

  return (
    <main>
      <ViewTransition>
        <button
          className={`button ${buttonExpanded ? "expanded" : ""}`}
          onClick={toggleButton}
        >
          Button
        </button>
      </ViewTransition>
    </main>
  );
}

CSS 和上面一样,因为本质还是切换 class。注意我们没有用 .classList.toggle("expanded") 这种直接 DOM 操作,而是让 React 走自己的渲染流程。

所以……两种方式都能用?

是的,至少在这些简单 demo 里都没问题。甚至同页混用也可以。

一个小差异是:如果你直接用 document.startViewTransition,需要自己加 view-transition-name;而 <ViewTransition> 会自动帮你加。这算是 <ViewTransition> 的一个小加分点。

我“讨厌”的那部分

有一部分我并不喜欢这个方案。React 并没有给出太多额外价值,它只是要求你用一种不破坏框架运行方式的写法。如果你花了很多时间去学它(而且确实有不少内容),这些知识并不太能迁移到其他地方。

我“勉强接受”的那部分

React 一直以来就希望自己掌控 DOM,这是它的核心卖点。也正因为如此,你必须让它来做一些协调工作。这意味着使用 <ViewTransition> 可以“自动与渲染生命周期、Suspense 边界、并发特性协调”,完成批量更新、防冲突、嵌套管理等你我不想操心的事情。

此外,<ViewTransition> 有一点点更“声明式”:你明确包裹了要过渡的区域,更符合很多人的心智模型。但你仍然需要调用 startTransition,所以仍然是偏命令式的。在更复杂的嵌套 UI 中,如何组织它可能会有些困惑。

我倒是挺喜欢 <ViewTransition> 上像 enterexit 这样的明确属性,它对应“自带的” CSS view transition class,比起自己通过 :only-child 技巧推断要直观一些。

总之,以上就是我的看法。更多示例请参见原文。

推荐

React 的 <ViewTransition> 把 Web 原生 View Transitions 纳入 React 的渲染与并发体系中。

直接使用 document.startViewTransition 已经足够灵活,而 <ViewTransition> 的价值更多体现在与 React 生命周期、Suspense、并发更新的自动协同上,代价是多了一层框架语义,学习成本也更偏 React 私有。它更像是一种“安全封装”,而不是必选能力。

如果你需要把这类新特性快速做成 示例页、技术文章或 Demo 站点,可以用 RollCode 低代码平台 快速搭建展示页面,把实验、讲解和转化路径一次性跑通,而不用在工程脚手架上消耗精力。

❌
❌