面向 Geek 的 AI 原生高置信度情报终端:搜索返回一个数据面板,而不是一串链接。
差异化不是"更聪明的分析",而是"不撒谎":页面上的每个数字都能点回原文, 没有来源就不显示,缺失就留空而不是填 0。默认只呈数据,追问才做分析。
超大锁标 + 一个输入行,热榜推到下一屏。理由写在「首页:入口一屏,热榜下一屏」—— 一屏里堆两件事,人会先读榜、再决定问什么,产品就从「你说问题我给证据」退化成「看我们抓到了什么」。
右上角是这一页唯一的两个工具:「运行指标」与齿轮(设置)。它们用 absolute 钉在角上,
不参与首屏的垂直居中 —— 但必须在首屏里:这两个入口一个曾经藏在页脚、一个曾经只在
结果页顶栏出现,等于默认用户会先随便问一句再去找设置。「运行指标」带文字而不是纯图标,
因为一个没人认识的自定义图标比一个偏僻的链接更难找。窄屏(<640px)顶栏一行放不下四件东西,
指标入口退回结果页页脚,齿轮留在角上。
锁标下面一行:Micro in, Macro out | 见微,知著。两句在同一个行盒里,但字距不是同一个值 ——
拉丁 0.12em 就到顶,再拉开字母之间就散了;汉字要 0.26em,否则四个字挤成一团。
汉字末尾会多留一个字距的空白(letter-spacing 加在每个字后面,包括最后一个),
所以补一个等量负边距 -mr-[0.26em] 才真正视觉居中。实测 1440 视口下:拉丁 140px、
分隔线 1px、汉字 69.3px、两处间隔各 12px,整体中心落在 720。
色阶是反过来的:汉字 neutral-500、拉丁 neutral-600。11px 下汉字本来就比拉丁字母压得住,
而这里的主读者是中文,拉丁半句是呼应 —— 让字母更亮会让一句话读成两句话。
排位是扫读入口,数字是细节 —— 两者不该抢字号。中间那条线是近 14 天每日提及条数, 它回答的是「这个话题平时多大」,同排的数字回答的是「今天比平时大多少」。
提问之后锁标与搜索框上推成顶栏(同一个元素在移动,不是换页),摘要逐段流出,
面板按 module.plan 先落骨架、module.ready 再填内容 —— 不会盯着空白等。
图表可拖拽改位置、可一键收起(收起即卸载 ECharts 实例)。图下方的裸数字都带一句 口径说明,降级与「没测到」也如实写在同一屏里,不藏着。
「苹果」这句话有两种常见读法,两种会检索到完全不同的信源。卡上只写「有哪几种读法、每种会 检索到什么」,不写「你大概想问 X」;关掉只藏这一轮,检索从来不等它。
key 存 localStorage,随请求以 x-probe-* 头发给服务端,服务端不落盘。
没配也完全能用:检索与图表真实,摘要走本地模板,断言抽取关闭(不是用假数据填空)。
「API 配置」这一节不只接受 OpenAI 官方:填上接口地址(Base URL)与指定模型就能接 DeepSeek、Kimi、智谱 GLM、阿里百炼、硅基流动、OpenRouter、自建网关或本地 Ollama (面板里有一排预设,点一下把地址与模型一起填好)。两个 key 输入框下面各有一份 「如何配置」——写着去哪注册、点哪里拿 key、以及填错时的典型症状:
窄屏不是把桌面版缩一缩:卡片回到单列、图表高度降到 300px、设置齿轮与工具按钮撑到 40px(拇指够得着),
输入框字号提到 16px —— iOS 上低于 16px 聚焦会自动放大整页,一放大就再也回不到原样。
刘海与底部横条用 env(safe-area-inset-*) 让开,横向不留半截被裁的浮层。
npm install
cp .env.example .env.local # 可选,不配 key 也能跑
npm run dev # http://localhost:3210不配 OPENAI_API_KEY 时:检索与图表全部真实,摘要走本地模板,断言抽取关闭(不是用假数据填空)。
配了之后可额外得到:
| 变量 | 作用 |
|---|---|
OPENAI_API_KEY |
意图精修、断言抽取、流式摘要 |
OPENAI_BASE_URL |
OpenAI 兼容接口地址(要带 /v1);空 = 官方。DeepSeek / 本地 Ollama 走这个 |
PROBE_MODEL |
指定模型,默认 gpt-6-luna(面板里的「指定模型」优先于它) |
TAVILY_API_KEY |
补一路通用搜索,显著提升信源多样性 |
PROBE_RATE_LIMIT_PER_MIN |
单用户每分钟检索上限,默认 3(PRD 附录 B) |
这个实例现在是邀请制的(docs/AUTH.md):不登录会被重定向到 /login,
API 也一律 401,所以 npm run dev 之后第一件事是给自己开个账号 —— 三选一:
# 1) 直接在 .env.local 里种一个管理员(只在库为空时生效),然后 npm run dev,登录 admin
PROBE_ADMIN_USER=admin
PROBE_ADMIN_PASSWORD='dev horse battery'
# 2) 命令行建(然后去 /login)
npm run auth -- create-admin admin
# 3) 只想调界面、不想要登录
PROBE_AUTH_MODE=off登录后在右下角身份条进后台(路径由 src/lib/admin-path.ts 的 ADMIN_PATH 决定,当前 /ayuanjc,刻意不叫 /admin)发邀请码或直接建用户。数据在 .data/auth/store.json
(已在 .gitignore 里,别提交)。
route → intent(规则先行,模型精修,模型失败自动回落)
retrieve → 4 路并发检索,逐源隔离,任何一路挂掉只记 degraded
analyze → 去重 → 置信度 → 断言抽取 → 数据集
compose → 数据集映射成 ModuleSpec,过 compile() 白名单校验
前端通过 SSE 收 intent / text.delta / module.plan / module.ready / module.error / sources / done,
module.plan 先落骨架卡、module.ready 再换内容,所以不会盯着空白等。
收敛度高只能证明各家的说法一致——十家媒体转载同一篇通稿即可满分,那是假共识。 这里改成三个可测量的分量:
score = 0.40 × 一手性 + 0.35 × 独立性 + 0.25 × 一致性
一手性 P = 1 - 转载数 / 总数
独立性 I = 1 - e^(-独立稿源数 / 3) // 3 家 0.63,6 家 0.86,之后饱和
一致性 C = 1 - 冲突数 / 可比对数 // 无可比断言时取中性 0.5,不是 1
一致性按 attr + unit 分组:只按 attr 分组时,"营收 $3.2B" 与 "营收 3.2 million"
都会被归一成 3.2,三个数量级的差距被判为"完全一致"。单位不同即不同组,
组内不足两条就不参与比对。
分档 ≥0.75 高、0.50–0.75 中、<0.50 低;低档直接抑制聚合数字与分布图,只留原文列表。
三条硬约束写死在代码里:
- 独立稿源 < 2 时封顶 0.45。
- 整批都是本地演示样本时封顶 0.34,并在正文里明说"这不是实时抓取的结果";样本不进模型, 由代码写死的事实说明替代——只靠 prompt 叮嘱"记得说这不是实时结果"是不可靠的。
- 没有模型时断言数为空——不用模板编造 claim,否则界面上会出现几句谁也没说过的"可核验断言"。
- 一条断言都没交叉核对过(
nChecked === 0)时封顶 0.64。 一致性给中性 0.5、乘 0.25 权重后,只要一手性和独立性接近满分就会越过 0.75—— 可那个分数里没有任何一条事实被两个来源对上过。独立性靠域名数量堆得动, 一手性靠"没识别出转载"就能满分,只有一致性真正代表"核验过"。 封 0.64 而不是 0.74:0.74 落在 medium 的上沿,看起来就像"马上要高置信", 而它实际上只是"我数了 40 篇文章、来自 26 个域名"。nChecked = 0的原因 (没配模型、或抽到的断言没有两条独立来源对得上)会作为一行口径说明出现在页面上。 - 一致性按「独立来源说了什么」算,不按断言条数算。 同一篇稿子拆出三条相同数值,
以前能顶到
nChecked = 3、conflicts = 0、一致性满分;现在组内先按sourceId收敛,来源数 < 2 的组直接不参与比对。 - 同一属性的数值先折算数量级再比:
3.2 billion→ 3200000000、1.5万→ 15000。 中文刻度与英文刻度不能混用(亿 = 1e8,billion = 1e9),否则「3.2 亿」与 「3.2 billion」会被当成同一个数。
ownerOf 的口径必须全链路一致(confidence.ts 与 analyze.ts 同源),
否则 KPI 的"独立信源"数和信源分布柱的分组数会对不上。二级公共后缀要单独成表,
bbc.co.uk / ft.co.uk 若按 slice(-2) 切成 co.uk,十几家无关英国媒体会被并成一个稿源。
用户的"最近三个月"是显式约束。每个 provider 都在服务端带窗口参数
(GDELT startdatetime、HN Algolia numericFilters=created_at_i、Tavily start_date),
返回后本地再兜一层。宁可结果稀疏,也不拿六年前的旧闻凑数量。
无 query 时首屏给热榜,数据来自 Hacker News 近 24 小时的 story 聚合(src/lib/trending.ts):
- 抓过去 24 小时全部 story(实测约 994 条),只保留
points >= 2的 553 条。 另外 442 条 points 为 0 —— 全是刚发布、无人评分的,把它们算进"提及数"等于让冷门词冒充热点。 - 按词边界匹配实体词典,同一个话题被 两条以上 提到才上榜(一条不足以叫热点,只能叫新闻)。
- 名次按热度分(score)排,既不按条数也不按 points 之和:条数说不出「今天是不是异常、 还新不新鲜」,points 之和对单篇爆款过敏(三条 TLS 稿 1068 分盖过十三家在提的实体)。 热度分怎么算、状态标签怎么来,见下一节。
- 同一话题内按 URL 去重:HN 上同一篇稿子被重复提交是常态(多条 story、不同 objectID、 外链一模一样)。不收敛的后果是列表里同一链接出现两次(React 列表 key 冲突)加上 「N 条在提」把同一篇稿子数两遍 —— 那是提交次数,不是报道数。去重保留 points 最高的那条, 去重后不足两条的话题不上榜。
- 缓存 10 分钟;话题名与检索词分开(展示
人工智能,检索artificial intelligence), 否则中文话题丢进英文索引必然 0 命中。 - 「N/M 条进入统计」的分母是上游原始命中数(
rawStories),不是过滤后的条数。 差这一层会闹出很具体的笑话:上游给 300 条、297 条被标题长度筛掉,页面上写着「3/3 条进入统计」—— 每个数字都"对",但读者看到的是一个健康的分母。标题太短被丢时记stories:short_title。
榜单标题写死「Hacker News 前沿」而不是「实时热点」:GDELT 缺席时这里只有单一来源, 「多少家媒体在报」这个维度并不存在,写成全网热度就是编。
原来的卡片是「标题 + 一排逗号分隔的数字 + 三条带下划线的新闻」,读起来像后台日志。 一屏 12 张这样的卡,信息密度不低,但没有一处告诉你该先看什么。重排成四层:
- 排位 + 名称 + 条数同一行。序号
01~12用 10px 等宽,前三名才是蓝的 —— 名次是扫读入口,不该和指标抢字号;条数去掉了边框,只用 11px 蓝等宽数字。 - 标签行去边框:
技术/升温期都是rounded-sm的纯色块(bg-neutral-800)。 原来每个标签都套一圈灰实线,一排放三四个就像一组禁用按钮。蓝色只留给「升温 / 爆发」 这两个真趋势(bg-blue-500/10 text-blue-400),平稳、回落一律中性灰。 - 指标组是一个浅底框的三格表,不是一行字。标签 9px 大写灰色在上、值 14px 等宽白字在下,
格与格用
divide-x分隔。热度分 0.88 热度 53 r7 1.55连成一串时人是不会读的 —— 它们量纲不同、含义不同,唯一共同点是都属于这个话题。 - 新闻列表去线化:删掉每条下面的
border-b,改用gap-3控制间距;标题 14pxtext-neutral-300,hover 转纯白;分数退到右侧 10px 灰等宽。横线本来是在说「这是表格」, 而这里是一组并列的条目,靠留白分组比靠线更安静。
卡片本身用 bg-[#0c0c0c] + 透明边框,hover 才亮成 neutral-700:让 12 张卡先融成一片背景,
鼠标停在哪儿才浮出哪儿。
火花线(近 14 天每日提及量)用内联 SVG polyline,没有上 ECharts:首页 12 张卡各起一个
canvas + zrender 实例,为了一条 14 个点的折线不值得。preserveAspectRatio="none" 让线随卡片
宽度拉伸,所以描边必须 vector-effect: non-scaling-stroke,否则窄卡上线条会变粗。
顺带修了一个连带缺陷:kindOf 原来按 length < 5 判 "需要澄清",于是 英伟达、人工智能、
Rust 这类主体明确的短查询全被判死,热榜点进来每一张卡都会撞墙。含糊不等于短 ——
现在只拦真正的寒暄(你好 / hi / test)。
只排名次的热榜有个死结:一个常年被提 80 条的话题永远压在今天突然冒到 30 条的话题上面, 那就不是热榜,是活跃度榜。PRD 2.3 要的是「今天相对它自己平时是否异常」,所以先攒基线。
r7 = 近 7 日均值 / 前 30 日均值 // 1.0 = 和平时一样
score = (0.6·pct(log1p(今日条数)) + 0.4·pct(z-score)) × exp(-最近一条至今小时数 / 36)
两项先各自按 30 天日序列取经验分位、变成同一量纲再加权 —— PRD 原式 Mag + Dev × decay
把无上界的文章量和 0~3 量级的 z 值直接相加,量纲不同,而且衰减只作用在 Dev 上,
等于量级永不衰减,历史热点会永久霸榜。
| 状态 | 判据 | 含义 |
|---|---|---|
| 爆发期 | r7 > 2 且 今日条数 ≥ 历史 P95 | 今天确实异常 |
| 升温期 | r7 > 1.3 | 正在往上,还没到爆发 |
| 平稳期 | 0.8 ≤ r7 ≤ 1.3 | 常态 |
| 回落期 | r7 < 0.8 | 比平时冷 |
| 长尾期 | 历史爆发过 且 r7 ∈ [1,2] 且 连续 ≥5 天 | 「事情过去了但还在被讨论」 |
顺序即优先级:长尾必须最先判,否则一个刚回落的爆发话题会被「升温」吃掉, 而它恰恰是最需要被读成「余波」的那种。序列不足 14 天一律不出标签 —— 两周不到算不出「平时是什么样」,硬给一个就是这个项目最不该做的事。
基线怎么攒(src/lib/heat-history.ts):
- 日界用 UTC,不跟浏览器时区走。混用两套日界会让「昨天」变成 32 或 16 小时,r7 会莫名跳动。
- 窗口 12 小时。实测 12h 内最多约 850 条 story,压在 Algolia 单页 1000 条以内; 再宽就被截断,而偏小的基线会把普通一天误判成爆发。命中数贴近 1000 时还会对半再拆一次。
- 落盘到
.cache/hn-history.json(临时文件 + rename 原子替换):首次要跑 60 次请求, 进程重启不该重来。首页调用ensureHistory()不等构建 —— 返回pending并把构建丢到后台, 基线好了下一分钟自己就带标签;基线未就绪时那份快照只缓存 1 分钟(正常是 10 分钟), 否则第一次访问会把「没有标签」的榜单钉住十分钟。
这里踩到一个口径 bug,值得记下来:基线最初只滤 title.length < 8,而实时快照还要求
points >= 2 并按 URL 去重 —— 宽口径的每日计数比实时口径大近一倍(人工智能从 97.3 条/天
变成 138~170 条/天),r7 被系统性压低,每个话题都像在回落。现在两处共用同一个 MIN_POINTS,
HISTORY_VERSION 升到 2 让旧缓存自动作废 —— 不升版本,旧宽口径数据会一直生效。
实测:30 天 / 60 个 12h 窗口 / 并发 4,19.2 秒建完;首页 12 个话题拿到
升温 2 / 平稳 7 / 回落 3;五档判据用构造序列单测过一遍(含长尾:21 天 10 条、2 天 60 条、
近 7 天 20 条 → r7 1.28、hotDays 7、wasBurst true → 长尾期)。
Phase 2 的验收是「拖拽保存刷新仍在」。PRD 写的是 Supabase,但那个 key 至今空着——
(当时)没有鉴权、没有表结构,写进去只能先造一张谁都能读的表。所以这一版把快照存在
localStorage,键是 probe:layout:<layoutVersion>:<query>,结构仍按 PRD:
{ schemaVersion, layoutVersion, query, savedAt, items: [{ i, x, y, w, h }] }
换存储层只改 src/lib/layout.ts 的读写,形状不变;界面文案如实写着"按查询词存在本机"。
几个不显眼但会咬人的点:
- 流式期间锁拖拽,不是靠 CSS 假装。
module.plan一路到达就重排一次, 用户在拖到一半的卡片会跳走;锁是真的把dragConfig.enabled关掉,且不渲染拖拽把手。 - 只在
onDragStop/onResizeStop提交。跟onLayoutChange提交的区别不是性能洁癖: 每帧 setState 会重渲染整棵树,ECharts 实例跟着重建,拖起来像在拖一张图片。 - 快照不复活已消失的卡片。
reconcile以本轮默认布局为准,只把能对上的 id 的位置搬过来; 上一轮有的模块这一轮没有,就直接不出现,不硬渲染一个没有数据的空壳。 - 版本不匹配整份丢弃并给一句中文提示,而不是逐项迁移——布局数据不值钱, 读错的布局会让人以为卡片丢了。
- 卡片高度交给网格单元(行高 28、间距 12),图表容器
height:100%: 拖高卡片图跟着长,拖矮则保住min-h不压成一条缝。 - 窗口 < 720px 退回单列并说明拖拽已停用。12 列在这个宽度下每列不到 40px, 拖出来的位置没有任何意义。
「一手性」「独立性」「歧义」这些词是产品的核心口径,也是用户最容易误读的地方——
把「置信度 0.74」看成「74% 正确」的人不止一个。所以每个术语都是一个可展开的
Term(src/components/Glossary.tsx,词条在 src/lib/glossary.ts):
- 三通道打开:hover(鼠标)、focus(Tab 到)、点击(触屏)。只做 hover 等于 把触屏和平板用户挡在外面。
- 不用原生
title:原生提示延迟一秒、不可选中、不能换行,塞不下"公式 + 口径 + 已知盲点"三段。 - 弹层用
createPortal挂到document.body,position: fixed。挂在触发词里面会被 卡片的overflow: hidden裁掉;网格卡片还带transform,而transform会把position: fixed的包含块从视口改成卡片本身——气泡于是又飞出去、又被裁。挂到 body 才同时躲开这两条。 - 位置是量出来的,不是按 align 猜的:
getBoundingClientRect()取锚点、offsetWidth/offsetHeight取气泡,先按align算出一侧,再夹进[8, 视口宽 − 8 − 气泡宽];下方放不下翻到上方, 上方也放不下就贴底。缩放、滚动时跟着重算,锚点滚出视口直接关掉。 只按align选左右等于赌「这个词永远不在屏幕边上」——页头右侧的置信度徽章专门证伪这一点。 - 从触发词移进气泡不会关:140ms 延迟关闭 + 气泡自己
onMouseEnter取消。 否则鼠标一离开词条气泡就消失,"公式 + 口径 + 盲点"三段文案根本读不完。 role="tooltip"+ 内层aria-label,Escape 与点外部关闭。- 词条正文写清它不是什么("置信度是证据强度,不是正确概率"),比只写定义有用。
点任一条信源的「对照原文」,服务端抓正文、分段、翻译,逐段左边原文右边译文
(src/lib/translate.ts + src/components/BilingualCompare.tsx)。
歧义度由机械比对算出来,不问模型。 让模型给自己的译文打分歧分,它永远给 0.9—— 那是自评,不是证据。实际用的是可核对、可复现的五类信号:
| 信号 | 触发条件 |
|---|---|
| 数字丢失 | 原文里的数字在译文里找不到(正则排除 Q3 / H200 这类编号里的数字) |
| 专名未保留 | 公司/产品/人名被意译掉,读者搜不到原始出处 |
| 未译残留 | 译文里还留着成串英文(单句首大写词如 Rust 不算——那本来就该保留) |
| 篇幅比例异常 | 英译中落在 0.22–1.05 之外,通常意味着漏译或自我发挥 |
| 原文本身含糊 | may / could / 据称 / 消息源模糊 —— 翻得再准,读者也会读成确定结论 |
risk = 1 - Π(1 - w),分档 <0.30 低(青)、<0.60 中(琥珀)、否则高(玫红)。
图例里写死了"不是质量评分":青色只代表数字和专名都对得上,不代表这段话可信。
几条必须守住的边界:
-
内网地址不抓——这个接口是「让服务器替我访问任意 URL」,不收口就是 SSRF 跳板。 判据不是域名字符串,而是解析出来的 IP:
hostAllowed()用dns.lookup拿到全部 A/AAAA 地址,任一落在私网段(含169.254.169.254这类云元数据)就拒。原因是按域名启发式守不住:127.0.0.1.nip.io、localhost.、[::ffff:127.0.0.1]都不像内网,但都指向本机。 更关键的是这道闸门在getDirect()入口,而每一跳重定向都递归回getDirect()——http://X/302 到http://169.254.169.254/…会在第二跳被拦,而不是只看首跳。 畸形Location(//、坏 IPv6 括号)返回invalid_redirect,不会把请求挂死成 500。 -
抓不到正文就退回标题与片段,并在面板里写明"只对照了标题与片段", 而不是默默拿标题当全文翻。
-
原文本来就是中文时不翻(
targetFor恒为zh):把中文报告"翻译"成英文 对读中文的人没有价值,只会多一层机器改写,于是单栏出原文、不给颜色。 -
没译成不记 0 分:0 分在界面上等于"没问题",而这一段恰恰最该被怀疑。
-
引擎分级写清楚:有
OPENAI_API_KEY走模型,否则用 MyMemory 公共接口, 都不行就只给原文分段。面板里永远标注谁翻的。 -
公共接口有每日配额,长文只翻得成前一部分(实测 41 段里翻成 20 段): 剩下的如实标成"未翻成,保留原文",并在降级条里写 X/Y。
-
平均歧义的分母是全部段落(
overallRisk(scores))。原来只对"有风险"的段求平均, 48 段里错 1 段和全部译错会得出同一个数,而面板上写的是"平均"。 -
认不出语言就直说:极短标题、中英混排会让
detectLang返回unknown。 这时面板写「没认出原文语言:只做分段,不翻译、不上色」,而不是替用户下结论说"原文已经是中文"—— 后者听起来像我们检查过,其实什么都不知道。 -
正文按 charset 解码:请求头显式
accept-encoding: identity,拿回来再按content-encoding(gzip/deflate/br)解压、按content-type的charset解码。 一律toString("utf8")的后果不是"偶尔几个方块",而是 GBK/Big5 站点整篇乱码, 而下游还在乱码上切句、比数字、比专名 —— 那比直接失败更糟。
一个非写不可的坑:本机 NODE_USE_ENV_PROXY=1 时,Node 的 fetch 会走
HTTPS_PROXY,而那条链路对 api.mymemory.translated.net 会 ECONNRESET
(实测 6/6 失败;同一 URL 直连 100% 成功,curl 走代理也正常)。所以抓正文与调翻译
都不走 fetch,改用 node:https 的 getDirect(src/lib/http.ts)——
它默认不读代理环境变量。附带好处是重定向、超时、字节上限都握在自己手里。
第二批 6 种图形全部由 analyze() 里真算出来的 dataset 驱动,没有一种是装饰性占位。
共同原则:凑不出就整张图不出——不画空图,也不补 0。
| 模块 | 形状 | 数据从哪来 | 出不来的时候 |
|---|---|---|---|
m-hours |
heatmap 星期×小时 | hourParts() 拆 publishedAt(Asia/Hong_Kong;hour12:false 下午夜给 24,要再 %24) |
只有 1 个格子时不出 |
m-weekday |
radar 7 维 | 同一份解析结果按星期计数 | 有数据不满 3 个星期时不出(三维以下没有形状可言) |
m-terms |
graph 共现网络 | titleTerms() 抽标题里的专名,两两共现计数 |
高频专名 <4 个或边 <3 条时不出:几根散线不叫网络 |
m-flow |
funnel 检索收敛 | 命中条目 → 去重后 → 有发布时间 → 24h 内 | 一条都没有发布时间时整张不出 |
m-fresh |
gauge 24h 新鲜度 | fresh / dated |
同上 |
m-tone-scatter |
scatter 时距×情感 | GDELT 的 tone 与距今天数(0–7 天) | 不足 3 个点、或本轮没有 tone 时不出 |
漏斗那四级是刻意选的:fresh ⊆ dated ⊆ sources ⊆ raw 是集合包含关系,
所以这张图不可能自相矛盾。断言数没有放进来——claim 可以来自没有发布时间的条目,
放进去会破坏包含关系,画出来就会出现"后一级比前一级多"。
compile.ts 的字段校验这批改成了全量:以前只查 x/y/value/series 四个键,
graph 的 from/to 与 funnel 的 stage 正好绕过去,字段写错的结果是一块空白而不是报错。
现在 encode 里任何值不在 fields 中就直接降级成表格,并在卡片上写原因。
层级只靠两件事:底色差与边框亮度。页面是纯黑 #000(不是近黑 #0a0a0a——Logo 的节点蓝
#0055FF 压在近黑上只有 3.5:1,会「陷」进底色,退到纯黑才站得住)、卡片 #111、边框 #262626、hover
#404040、圆角 6px。没有 backdrop-filter、没有发光阴影、没有渐变背景、没有扫描线——
这些在意式咖啡上很好看,在数据终端里每一个都在跟数字抢注意力。
颜色只有三个意思,多一个都要解释:
| 颜色 | 含义 | 出现位置 |
|---|---|---|
蓝 #3b82f6 |
强调 / 可操作 / 正常 | 主按钮、链接 hover、焦点环、图表主色、置信度 high |
黄 #eab308 |
降级 / 需要留意 | 检索降级条、疑似转载、置信度 medium、布局没存下、翻译歧义 mid |
红 #ef4444 |
出错 / 高危 | 查询失败、图表编译错误、置信度 low、翻译歧义 high |
其余一律灰阶。同一套语义用到三处:置信度三档(high 蓝 / medium 黄 / low 红)、双语对照的 歧义度(low 蓝 / mid 黄 / high 红)、请求状态(降级黄 / 失败红)——不另造第四种编码, 用户学一次就够用三处。
数字必须能竖着比:所有指标、ID、时间都是 font-mono + tabular-nums(等宽数字),
不然列表里 1 和 7 宽度不同,上下两行的小数点就对不齐。正文用 Inter。
以前有三套主题(青 / 紫 / 琥珀),靠覆盖 Tailwind 的 --color-cyan-* 变量换色。现在连
THEMES 常量、data-theme、data-fx 一起删了:一套配色只要说清「这个颜色什么意思」,
三套就得说清三遍,而第二遍开始就没人看说明了。图表色板也收成一个蓝的明度阶梯 + 一个中性灰
(compile.ts 的 PALETTE)——颜色在图表里不该用来「好看」,只该用来区分。
品牌只有一处:src/components/ProbeLogo.tsx(白轴线 + 三个蓝节点,vectorEffect="non-scaling-stroke"
保证放大缩小时描边不跟着变粗),favicon src/app/icon.svg 用同一套语言重画。节点色就是强调色
#3b82f6(纯黑上 5.7:1);品牌蓝原先用更深的 #0055FF,在纯黑上只有 3.7:1,蓝点会陷进背景。
验收方式是扫 DOM 而不是看截图:全站 backdrop-filter !== none 的元素 0 个、box-shadow !== none
0 个、旧色值(青/琥珀/玫红)0 处;.panel 的 computed 值依次为 rgb(17,17,17) / rgb(38,38,38) /
6px / layout style,hover 后边框 rgb(64,64,64)。
三件事共用一个面板,全部只在本机:
| 存在哪 | 走哪条路 | |
|---|---|---|
| OpenAI / Tavily key、模型名 | localStorage 的 probe:settings:v1 |
每个请求带 x-probe-openai-key / x-probe-tavily-key / x-probe-model |
| 字号、密度、动画 | 同上 | 写到 <html> 的 data-density / data-anim,字号直接改根 font-size |
| 布局快照 | probe:layout:* |
见上一章 |
key 走请求头而不是查询串:查询串会进服务端访问日志,也会留在浏览器历史里。
服务端用 AsyncLocalStorage 把它绑在本次请求的调用链上(src/lib/runtime-config.ts),
llmEnabled() / resolvedModelId() / resolvedTavilyKey() 都是"先读这次请求的、再回落 env"。
这样不用给每个函数加参数,也不会串到下一个请求里:不落库、不写日志、不跨请求复用。
模型名不是标识符:openai/gpt-4o(网关写法)、@scope/model 都收,长度 2–80。
早先只允许 [A-Za-z0-9._:-],于是这两种写法被静默当成"没填",用户看到的是"模型没换",
却没有任何提示 —— 一个校验过严比不校验更坏,因为它把失败伪装成默认值。
填了错的 key 会怎样:llmEnabled() 变 true,模型调用失败,摘要一句都写不出来。
这种情况会在摘要区明说"摘要没有生成",而不是留一片空白让人以为数据不全——
面板里的数字与信源不依赖模型,照常可读。
这里没有「主题切换」,只有一套配色。之前的青/紫/琥珀三套皮肤连 THEMES 常量一起删掉了:
一套界面配色要能说清「这个颜色是什么意思」,三套就得说清三遍。图表色板同理,写在
compile.ts 的 PALETTE 里,是蓝的明度阶梯加一个中性灰——canvas 读不到 CSS 变量,
所以这一份必须和界面手对齐。
字号改的是根字号(15/16/17px):Tailwind 的间距与字号都是 rem,改根字号等于整站等比缩放。 密度改的是网格间距(紧凑 16 / 标准 24),不是给卡片加一层更小的 padding—— 后者会让图表在窄卡里挤成一团。
"一键还原"清掉所有 probe: 前缀的本机键(设置 + 每个查询词的布局快照),然后整页重载。
按钮要按两次,第二次 4 秒内有效:这个操作没有撤销。
面板最后多了一节「配置文档」(src/components/ConfigDocs.tsx)。它解决的不是"没文档",
而是配置分散在三层、每层都可能失效:浏览器设置 → 请求头 → 服务端 env。四个可折叠小节:
- 三层配置:每一项的三层落点并排写(存哪 / 哪个头 / 哪个 env / 都没有时怎样), 并写明优先级是「请求头 > env > 内置默认」—— 填了面板里的 key,这一轮就完全不看服务端 env。
- 服务端环境变量:8 个真正被读取的变量 + 一句默认值,外加一个可复制的
.env.local示例。 顺带把.env.example里写了但代码没读的两个(PROBE_DAILY_TOKEN_BUDGET、PROBE_CACHE_TTL_SECONDS)照实标出来:配了不生效比没写更伤人。(OPENAI_BASE_URL已经从这份名单里摘掉——它现在真的被读。) - 本机存储键:
probe:settings:v1、probe:layout:<LAYOUT_VERSION>:<查询词>、probe:grid-hint:v1, 版本号从layout.ts直接 import——文档写死版本号,改代码那天就过期了。 - 接口与鉴权:五个路由的限流默认值、
/api/metrics默认脱敏查询词、/api/cron/heat未配口令直接 503。
还有一条容易被当成 bug 的规则写在第一节里:脏值一律当没填(key 8–400 字符且不含空白,
模型要匹配 [A-Za-z0-9._:-]{2,64})。填错不报错、只是静默回落 —— 所以"我填了 key 但没生效"
先回来对格式,而不是去怀疑缓存。
平均值骗人。240Hz 屏上一秒 240 帧,只要有 3 帧掉了 60ms,手感就是「卡一下」,而平均帧率仍然是 230fps。所以这里的验收口径是 LoAF(long-animation-frame):浏览器原生告诉你「这一帧超过 50ms 了吗、渲染开始得多晚、里面脚本占多少」。
测量方式(PerformanceObserver({ type: "long-animation-frame" }) + rAF 帧间隔采样),1440×1000、next start 生产构建、每档跑 3 次看区间:
| 场景 | 改前 最长帧 (ms) | 改后 最长帧 (ms) | 说明 |
|---|---|---|---|
| 流式输出期间 | 413 / 262 / 229 | 246 / 229 / 213 | 中位 262 → 229(−13%)。单次测量本身就能差 ±80ms,所以只敢说中位,不敢说峰值 |
| 流式 jank 帧(>33.4ms) | 5 / 6 / 6 | 5 / 5 / 6 | 每 2000 帧里 5 帧,0.3%,没变 |
| 空闲 | 8.3 / 62.5 / 4.4 | 8.4 / 62.3 / 8.4 | 那 62ms 是偶发的,改前改后都会撞上一次 |
| 滚动 | 16.7 / 8.4 / 12.6 | 8.4 / 8.3 / 8.5 | 三次都一样:首次滚动不再掉一帧 |
| 拖拽 | 50.1 / 45.9 / 66.6 | 45.8 / 74.7 / 62.4 | 没有实质改善,见下 |
真正稳定的改善是滚动:三次测量全部 8.3–8.5ms(= 120Hz 的一帧,本机屏 240Hz 所以是四分之一帧),改前有一半的跑法会掉到 12–17ms。空闲与拖拽的帧率都在 200fps 以上。拖拽没变好:react-grid-layout 拖动时是 transform 位移,本来就该是合成层的事,但每次松手前它都要测一遍所有卡片的占位——这段成本它的 API 不给钩子,我们改不动。
流式那 5 个 jank 帧也别指望消掉:那是 8.6 秒里 SSE 事件把三张卡依次塞进网格造成的,属于「内容真的变多了」,不是渲染浪费。
- 流式合帧(
src/hooks/useProbe.ts):原来每个text.delta都setState,摘要每多一个字,整棵树(四张卡、两张图)就重渲染一次。现在攒进 ref、按requestAnimationFrame一帧只刷一次,并且遇到module.ready/done这类结构事件先强制 flush,保证顺序不乱。 - ECharts 动画 1000ms → 240ms(
src/lib/compile.ts):默认入场动画会在这 1 秒里每帧重绘 canvas;情报工具不需要「图表长出来」的仪式感。 backdrop-filter的旧账(src/app/globals.css的.panel):旧皮肤上十几张卡各带一层blur(24px),我们做过 A/B(首页 12 张卡,运行时注入/撤掉 blur)——去掉 blur 滚动 231fps、最长帧 8.4ms;带着 blur 234fps、最长帧还是 8.4ms,4 倍降频下也一样;只测流式段(带 blur 112.3–112.6ms vs 去掉 104–108ms)差 4–8ms,落在噪声里。所以它当年不算性能问题。现在整套backdrop-filter随新设计一起删了:干净不是为了省帧,省帧只是顺带。留着的是contain: layout style——卡片内部布局不外溢,拖拽时少一次全局布局计算。- hover 只改边框颜色:旧版是让
box-shadow从无过渡到一片大范围模糊阴影,指针扫过一排卡片时每张都要重新光栅化(后来改成常驻层切opacity)。新版干脆不用阴影——hover 只把 1px 边框从#262626提到#404040,transition只有border-color 160ms,颜色插值不新建任何绘制层。 - 背景只剩一个颜色:
body上只有background-color: #000。中间那一版把辉光、扫描线、64px 网格铺在两个fixed伪元素上(滚动只移动内容层,确实是优化),但新设计里这三层一个都不该存在——最省的重绘是不画。 ResizeObserver让 canvas 跟着卡片走(ModuleCard里的ChartBox):ECharts 只知道窗口尺寸,不知道卡片被拖大了——原来把卡从 4×8 拖到 6×11,图会被拉花。现在按帧去抖后resize()。- 拖拽期间降级(
ModuleGrid在<body>上挂.grid-interacting):拖起来的那张卡与邻居本来都要重画,期间把.panel的border-color过渡关掉,只搬动已光栅化的图层,松手恢复。 memo挡在网格外(ModuleGrid/ModuleCard/SourceList):流式期间摘要每帧变,但modules/sources的引用没变,网格不必跟着重跑react-grid-layout的布局计算。- 尊重
prefers-reduced-motion:关掉光标闪烁与骨架流动。这不是性能,是礼貌。
npm run dev 下的数字不能用来判断渲染性能。Turbopack 的按需编译、React 开发版的双次渲染,都会在首次交互时塞进几百毫秒的长任务;同一个页面在 next start 下这些数字会凭空少一半。要复现上表,先 npm run build 再 npm start,且不要和 next dev 同时跑(两者共用 .next)。
这一版的交互原则来自 Nielsen Norman Group 的三条经典结论,不是凭手感调的:
- 渐进披露(Progressive Disclosure, Nielsen 2006):先只露最重要的选项,专业选项按需请求 才出现(原文举的例子就是打印对话框)。→ 生成器不进主视图,收在侧栏入口背后,只在结果 跑完之后才出现,检索过程中连占位都没有。
- 模态 vs 非模态(Modal & Nonmodal Dialogs, Fessenden 2017):模态会打断并强制一个动作, 只在必须把注意力导向重要信息时才用。→ 生成器是非模态浮层(portal + fixed):不锁滚动、 不遮背景、点外面就收。为了一个可选动作拦下整页,成本远大于收益。
- 10 Usability Heuristics:#1 系统状态可见(跑完才给工具,跑的时候只说"正在检索")、
#2 贴近真实世界(入口文案是"说想看什么",不是
ModuleSpec)、#7 灵活高效(/一键聚焦 搜索框)、#8 极简(操作提示只留一次,之后可关)。
落成七件小事:
- DIY 只在结果页:
Workspace用state.status === "done" && datasets.length > 0门控侧栏, 不满足时整列都不渲染,同时把xl的两列栅格收成一列,不留 340px 空槽。 - 侧栏只有一个键:DIY(38px 高的一行,左边字标、右边「N 份数据 · 一句话生成」)。
原来是一张 64px 圆钮 + 三行说明的大卡 —— 侧栏是工具区不是引导页,常驻的大卡会一直
和结果抢注意力。一句话生成是 DIY 的下级内容,点开浮层才看到。
aria-expanded跟着变。 - 生成后带路:「去看看 →」滚到新卡并给 1.4s 的
.card-flash高亮,而不是弹 toast。 - 提示可关:网格顶部那行操作说明点
×收起,写进probe:grid-hint:v1(probe:前缀会被 设置面板的「一键还原」一起清掉)。 /聚焦搜索框:GitHub / Linear 那套。只在焦点不在输入框里时抢——正在别处打字不动它。- 浮层定位只剩一份算术:
src/lib/placement.ts的placeFloating()被术语气泡与 DIY 面板共用 (贴边夹回视口、下方放不下翻上方、贴底兜底),anchorVisible()负责"锚点滚走就收起"。 prefers-reduced-motion与设置里的「动画」开关同时作用于新增的.rise与.card-flash。- 「收起全部」只收看不收存:收起把每张卡压到 2 行高(80px),卡片的 body 整个不挂载 ——
ECharts 实例跟着卸载,收起才真的省下渲染。布局里始终保留展开时的真实
h,所以展开原样 复原、快照也不会被收起状态污染(下次打开仍是展开的)。拖拽落盘前要把收起中的卡的高 换回原值,否则一次拖动就会把 80px 写进快照。实体关系图不在网格里,靠onCollapseAll这个信号同步收起 —— 一排标题栏中间留一张还亮着的图,像没听指令。 - 后退键回到「上一次结果」,不是「上一个页面」:每次从界面发起的检索(输入框回车、
点热榜话题)都会写一条
?q=历史,popstate再按地址栏那个词重跑;「新建搜索」用replaceState清掉 —— 它要的是回到起点,不该在历史里留一条空记录。这里踩过一次:onRun曾直接绑run,只有pickTopic写 URL,于是结果页里换词回车之后地址栏还是旧词, 按后退直接离开整站(q=null、0 张卡)。现在两条入口都走同一个startSearch(): 先跑、再写历史 —— 顺序反了后退会读到还没开始跑的那一轮。
PRD 2.1 要求 ambiguity > 0.35 时先出一张澄清卡。之前这一条只是摘要里的一句免责声明
("这条查询存在多种解读…")—— 说了等于没说,用户既不知道有几种读法,也没法选。现在换成
一张能点的卡:src/lib/polysemy.ts 出判据,src/components/ClarifyCard.tsx 出交互。
四条决定:
- 判据是词典,不是模型自评。
ambiguity由规则算出:命中多义词表且还剩 ≥2 种读法 →min(0.9, 0.55 + 0.1×(命中数−1));认不出任何主体 → 0.4;其余 0.12。模型对ambiguity的判断会为了显得谨慎而全线偏高(实测对「英伟达 财报」也给 0.4),那样卡就成了噪声。 - 只收「两种读法会检索到完全不同信源」的词,12 条:苹果(Apple Inc. / 水果)、亚马逊(电商 / 亚马孙雨林)、小米(集团 / 谷物)、特斯拉(Tesla Inc. / 磁场单位)、Rust(语言 / 锈蚀)、Java(语言 / 爪哇)、Python(语言 / 蟒蛇)、Swift(语言 / SWIFT 结算)、Windows(系统 / 窗户)、Meta(公司 / 元分析)、 长城(长城汽车 / 文物保护)、银河(产品线 / 银河系)。英伟达不值得问一句 —— 它只有一种读法。
- 每条读法带语境锚点。
anchor正则命中就说明用户已经讲清了:苹果 财报、Rust 的所有权、Java 虚拟机都不出卡。剩下的开放读法不足两种时整条不算命中 —— 一张只有一个选项的卡是噪声。 英文按词边界匹配,pineapple prices不会命中apple。 - 非模态、不阻塞、只藏本轮。事件
intent.clarify紧跟intent、在检索开始前发出,流水线 从不等它;卡排在意图条下面,用户可以直接无视往下读(面板照常出)。关掉只藏这一轮,换查询 重新给机会。整句认不出主体时不出空卡,改写一句「补一个具体主体再问」。
实测(npx esbuild 打包 src/lib/polysemy.ts 后 node 跑,dev 3210 上 Playwright 复验):
| 查询 | 歧义 | 出卡 | 说明 |
|---|---|---|---|
| 苹果 | 0.55 | 两种读法 | 只剩「公司 / 水果」两读 |
| 苹果 2025 Q3 财报 | 0.12 | 不出 | 财报 锚点已排除水果 |
| apple earnings | 0.55 | 两种读法 | 英文命中,earnings 不在锚点里 |
| pineapple prices | 0.12 | 不出 | 词边界不命中 |
| Rust 的所有权 | 0.12 | 不出 | 所有权 锚点已排除锈蚀 |
| Windows 补丁 | 0.12 | 不出 | 补丁 锚点已排除窗户 |
| 英伟达 财报 | 0.12 | 不出 | 词典里没有,不硬凑 |
| 固态电池前景 | 0.40 | 建议卡 | 认不出主体,不编候选 |
Playwright 复验:卡出现 → 点「苹果(水果 / 农业)」→ 卡消失且换成新查询重跑;「苹果 财报」不出卡; 「按现在的理解继续」关掉后同词不再弹、换词重新弹;console 零 error/warning。
没有 query 时不再是「工作台 + 侧边热榜」的同屏布局,而是搜索引擎式的封面:
- 一屏只放一件事:竖排锁标(
size用clamp(30px, 4vw, 56px),1440 下图形 162px)居中,正下方是 720px 宽的 搜索框,再往下才是「不看提问,先看今天的 24 小时热点 ↓」。热榜放进同一页的#trending区块(min-h-[calc(100svh-5.5rem)]把 hero 撑满首屏),一屏之下,不是另一个路由 —— 另开路由会让「点进去看看」多一次跳转和一次返回,而这里的热榜本来就是同一份数据。 - 为什么不把热榜并排放:同屏时眼睛先落在榜首,用户会先读榜再决定问什么。产品的第一句 话应该是「你说问题,我给证据」,而不是「看我们抓到了什么」。热榜是留客的入口,不是主页。
- 发起检索就换布局:
state.status !== "idle"时切回小锁标 header + 搜索行 + 结果。 手里有结果的人不需要再看巨大的 Logo;把首页那套排版留在结果页只会挤掉数据。 - 回得去:结果页的锁标本身就是「回到首页」按钮,右侧另有
新建搜索(清空输入、 中止流、去掉?q=、滚回顶部)。两者都不刷新页面 —— 状态归零靠 React,不靠一次重载。 - 入场先画线,再落点:轴线用
stroke-dasharray/dashoffset从 45.3 画到 0(880ms),三个节点 按 240 / 380 / 500ms 依次缩放落位;中间那颗原本就靠animate-pulse呼吸,入场动画会顶掉它, 所以把「落位 + 呼吸」两段叠在同一条animation里。触发类是.logo-anim,封面与结果页 顶栏共用同一个 —— 进页面播一次,之后由用户决定要不要再看(见下一条)。 关动效时必须显式给终态:prefers-reduced-motion下若只是animation: none,元素会停在dashoffset: 45.3/opacity: 0的初始样式上,画面里什么都没有;另外中间那颗的「落位 + 呼吸」 选择器多一个.animate-pulse,特异度比通用那条高,得单独再关一次。 - 点一下锁标,重播一遍:类名没变时 CSS 动画不会自己重来 —— 必须
remove("logo-anim") → void host.offsetWidth → add("logo-anim")三步放在同一个任务里 (跨帧摘类会闪一帧「线全满、点全亮」的终态)。附带一次 320ms 的.logo-pop轻微回弹(scale 1 → 0.94 → 1),挂在<button>上而不是外层motion.div—— 那条 div 的 transform 归 framer-motion 的 layout 动画写内联样式,两套动画抢同一个属性会互相打断。 首页点它只重播不重置;结果页点它先重播、再走原来的「回到首页」。键盘Enter走同一条路径 (按钮本来就可聚焦),aria-label在首页是「重播 Logo 动画」、结果页是「回到首页」。
实测(prod,1440×1000):锁标 svg 162×162 在 x=639(画布中线 (1440−162)/2),搜索框
720×49 在 (360, 528),#trending 顶端落在首屏之下;390×844 下锁标居中、搜索框铺满
350、热榜顶端 912 仍在下一屏。点热榜话题后 7 个模块正常出图,console 零 error / 零 warning。
回车之后没有跳转、没有重载、也没有两套 DOM 互相淡入淡出。锁标和输入行是同一批元素,
className 换了、位置和尺寸由 framer-motion 的 layout 插值:
<motion.div layout transition={SPRING} /* 容器:竖排居中 ⇄ 顶部一行 */
className={searched ? "flex flex-wrap items-center gap-x-4 border-b … py-4"
: "flex min-h-[calc(100svh-5.5rem)] flex-col items-center justify-center gap-7"}>
<motion.div layout transition={SPRING} …> /* 锁标 162px 竖排 → 41px 横排 */
<motion.div layout transition={SPRING} …> /* 输入行 720px 居中 → flex-1 顶栏 */- 为什么不是两个组件:两套 DOM 各自动画时,中间那 300ms 会出现两个锁标、两个输入框
(一个在飞、一个在原位等),而且输入框换实例会丢焦点、丢已输入的内容。元素只有一个,
状态也只有一个(
state.status !== "idle"就是isSearched,不再另存一个布尔, 否则两处各记一次必然漂移)。 - 只给容器和两个子元素
layout:容器管换行与 padding,子元素管自己的宽高。SPRING = { stiffness: 260, damping: 30, mass: 0.9 }落在 ~480ms 收住 —— 再软就慢得像加载,再硬就是瞬移。 - 卡片另走一条:网格项外层是 react-grid-layout 的定位盒,在它上面做
transform会和拖拽位移打架。所以入场只动内层<motion.section>(opacity 0→1、y 20→0, 0.4s ease-out)。落定后实测transform: none、opacity: 1,拖拽与快照写入照旧。 - 顶栏在窄屏会换行:输入行有
basis-[240px],放不下就整行落下(390 宽时 header 变成 三行 171px 高:锁标 / 输入行铺满 350 / 工具 242),scrollWidth仍是 390。 首页示例 chip 只在size="lg"出现 —— 顶栏那一行是常驻控件,摆一排快捷词会把行高撑到 185px。 - 顺带修掉一个窄屏旧 bug:
ChartBox的画布原来是height: 100%,要求父元素有确定高度; 网格里卡片高度是定值所以看不出来,窄屏退回单列后卡片是auto,100% 解析成 auto → ECharts 量到 0,报Can't get DOM width or height,然后按 100px 画、底下空一截。 改成容器relative+ 画布position: absolute; inset: 0,实测移动端 canvas 从308×100变成308×180,那条 warning 消失。
过渡实测(1440,每 ~53ms 采一次几何):锁标宽 162 → 67 → 51 → 44 → 42 → 41、y 220 → 33,
输入行宽 720 → 836 → 993 → 1047 → 1064、y 528 → 210,~480ms 收住;结果页 header 高 75,
搜索框 940 宽,工具组(置信度徽章 + 新建搜索 + 齿轮)靠右到 x=1166。点「新建搜索」后
锁标回到 162 @ (639, 220)、地址栏的 ?q= 被清掉、热榜回到下一页。
首屏原来有五个各自带边框的元素(锁标、副标题、输入行、两个 kbd、三个 chip、一个按钮), 每个边框都在喊「我也是一块」。收紧后只剩输入行一条线:
- 锁标图形与 PROBE 的间距从
0.85em收到0.3em(56px 字号下 17px)。间距一旦大于字距, 读起来就是「一个图标 + 一行字」,不像一个锁标。 - 输入行压扁:
py-4→py-3,行高 66 → 49px;放大镜换成常规形状、描边1.25(再细一档在 2x 屏上发虚);右侧「检索 →」改成幽灵按钮,无底色,hover 只把字提到纯白。 - 示例 chip 去边框:
rounded-full bg-neutral-900 text-neutral-400,hover 提亮到白。 原来三个灰边框胶囊排在一起像禁用按钮。 - 设置入口只留齿轮:
button变成 8px padding + 15px 的stroke-width: 1.25齿轮,text-neutral-600,无边框无底色 —— 它是逃生口,不该和主行动争重量。 - 底部提示
text-xs,箭头animate-bounce(motion-reduce:animate-none)。
输入「用散点图看情感分布」,网格里多一张卡。这句话里的模型能决定的只有三件事:
用哪一份数据集、画哪种图、标题叫什么。字段怎么映射到 x / y / value 不归它管——
compile.ts 是安全边界,模型直出的 encode 的失败方式不是语法错,而是「语法合法、语义胡说」,
那种错没有人能一眼看出来。所以 autoEncode() 按字段类型硬性匹配:数值字段才配 value 轴,
时间字段优先配 x 轴,一张都匹配不上就返回 null,让调用方换图。
没有 key 的时候走 src/lib/module-plan.ts 的本机关键词规则,两个细节是有意的:
KIND_WORDS的顺序参与语义:「散点图」必须排在「图」前面,否则任何带「图」的句子都会退化成长条。DS_WORDS给每份数据集配中文说法(信源/稿源/来源、时段/小时/几点、星期/周几/周末……), 按命中词 × 3 + 数据集名字串 × 4 + 字段名 × 1打分。一个都没命中时不猜, 直接回一份「这一轮有这些数据」的清单让人自己挑。
数据只从这一轮已经算出来的 dataset 里挑(datasetsFromModules 遍历现有模块的 spec.dataset),
不重新检索、不碰全网。代价是自定义面板跟着这一轮走:刷新页面或换一个问题,它就没了——
这句话直接印在面板上,不让人误以为它被存下来了。
入口是侧栏里的「DIY」卡片(src/components/ModuleDock.tsx),面板本身开在浮层里。
它只在 done 且真有数据可出图时才出现:检索中、失败、空闲都不留占位——进度已经在左列
写着,一个还不能用的入口只是噪音(依据见上一节)。整块卡片都是按钮,点说明文字也能开,
不是只有那个圆钮能点。点开是 portal + position: fixed,理由和术语气泡一样(侧栏
overflow-y-auto 会裁、卡片 transform 会改 fixed 的包含块)。位置对齐整块入口而不是
圆钮:右对齐 64px 的圆钮会让 340px 的面板往左偏出侧栏、盖住结果区——面板左边缘和侧栏左边缘
重合才像"从这里展开"。下方放不下上翻、两侧都放不下夹进视口;入口滚出视口直接收起来;
Esc、点外部、再点一次都能关。
生成成功后浮层里多一个**「去看看 →」**:收起浮层、滚到新卡片、闪 1.4 秒。不弹 toast—— 要说的话("已加入网格")面板里已经写着了,再弹一次只是打断。
侧栏从 xl(1280px) 起才常驻(更窄时落到结果下方):1024–1279px 撑两列,左列会窄过
ModuleGrid 的 720px 底线、网格直接退回单列,那不如让它先不分栏。
画不出来时说清楚。前端先拿 compile() 试一遍,失败就把 compileErrorText 的原话贴出来
(「雷达图至少要三个维度」),不悄悄降级成表格——一张表能装下任何数据,但用户要的是图,
换成表格等于没回答。字段真的不合适时,规则会在选图阶段就换一种(「用散点图看信源分布」
只有一列数值,改条形图),并在 reason 里写「已改用条形图」。
一个坑记在这:compile() 对 table / kpi 返回 unknown_kind(它是给图表用的),
所以这两种图在插入前跳过编译,否则「用指标卡看概况」永远失败。
实测(POST /api/module,无 key,本机规则路径):
| 句子 | 结果 |
|---|---|
| 用条形图看每天的文章数 | bar + ds-timeline,encode {x: day, y: articles} |
| 看发布时段 | heatmap + ds-hours,encode {x: weekday, y: hour, value: articles} |
| 用散点图看趋势 | 回落 line,reason 写「按日文章数」的字段画不出散点图 |
| 用散点图看信源分布 | 回落 bar,同一条路径的第二层兜底 |
| 你好 | {ok: false, error: "no_dataset"},附这一轮可用数据集清单 |
路由限流 12 次/分钟(PROBE_MODULE_RATE_LIMIT_PER_MIN),和搜索分开计数:
本机规则是廉价调用,但走到模型路径要花钱,别让它变成无限次试用。
PRD 里这一项写的是 Postgres 邻接表 + pgvector 的「向量近邻 2 跳」。这个仓库没有数据库、 也没有 embedding,所以落地的是本轮信源的共现快照:节点是实体,边是两个名字在 同一条标题里同时出现过,权重是共同出现的信源条数。它跟着这一轮走,刷新或换问题就重建—— 这件事同时写在图和术语气泡里,不让人误以为底下有个知识库。
三个决定值得单独说:
- 词边界匹配。词典里
meta/eu这类短别名用includes会命中metadata/europe, 凭空造出一个假枢纽。所以英文一律走(?<![A-Za-z0-9])term(?![A-Za-z0-9]),中文才用includes。 - 边权下限是 1,不是 2。第一版卡 2,结果四条完全不同的查询给出同一张 4 节点图—— 本轮信源常常只有二十几条,卡 2 等于让多数真实查询出不了图。所以 weight 1 也画,但画成 虚线灰色,语义是「只有一条信源这么写,当候选看」。
- 一次性实体要么不进,要么必须连着主干。只出现一次的词典实体(Linux、WebAssembly) 只有在与主干节点共现时才补进来;否则图里全是孤立的单点,那不是结构,是毛球。
出不了图就整张卡不出现(主干 <4 个实体的函数返回 null 并给原因),而不是画一张全是散点的图
假装有信息。真实查询 Rust 的实测:8 个节点 / 8 条边 / 41 条信源,SSE payload 1.4 KB,
枢纽是 Rust(40 条信源),外圈是 TypeScript、Adobe、微软、Linux、WebAssembly、Python、大语言模型。
交互上只做一件事:点一个节点,只看它 2 跳之内的邻域(1 跳 = 直接同现,2 跳 = 那些 实体的邻居),右侧列出该实体自己的证据信源。2 跳是共现边的 2 跳,不是向量近邻—— 没有 embedding 就不假装有。
画布上的圆点键盘到不了、读屏也念不出来,所以图下面并排放着同一批入口(按被提及次数 排序的前 6 个实体)。这是无障碍兜底,不是第二套导航。
一个踩到的坑:ECharts 的 canvas 用 height: 100%,只对「高度已定」的父元素生效。
卡片里之所以没问题,是因为父级是 flex 列;这里父级是普通块,只给 min-height 会让
百分比失效、canvas 退到默认 100px。套一层定高 flex 列解决(实测从 100px 变 360px)。
/metrics 是给运维看的一页,不是第二个首页。它读 GET /api/metrics,数据全部来自
进程内的环形缓冲(src/lib/metrics.ts,容量 200 轮,重启即清空、不落盘)。
为什么不落库:现在虽然有账号体系了(docs/AUTH.md),这一条仍然不变 ——
落盘等于凭空造一份「谁在搜什么」的历史,它对我们调试的价值远低于泄露它的代价。
/metrics 页本身要登录,但环形缓冲只活在进程内存里,重启即清空。
每轮记什么:五个阶段的耗时(意图 / 检索 / 分析 / 编排 / 摘要)、端到端耗时、 条目数与独立稿源数、置信度与档位、模块数与失败模块数、降级码、口径条数、 是否演示样本、是否被客户端中断、模型用量(调用次数 / 输入输出字数 / token)。
三条刻意的边界:
- 查询词默认脱敏:显示成
«已脱敏 · 14 字»,保留长度(排查长查询有用), 不保留内容。要解除得配PROBE_METRICS_KEY,并且请求时把同一个值放进x-probe-metrics-key头 —— 「谁知道密钥谁才看得到词」,而不是「同网段都能看」。 - token 数拿不到就不显示:
tokensOf()只认 SDK 真给出的有限数字字段。 拿不到时面板写「token 数未由 SDK 提供,因此不显示估算值冒充测量值」, 不用「字数 ÷ 4」这种编出来的数当指标 —— 指标一旦是估的,优化就会跟着估的走。 - 中断也要记:
runPipeline把打点放在finally里(run.totalMs、run.aborted = signal?.aborted),客户端断开、超时、异常都会落一条。 只在成功时记,就永远看不见「为什么用户觉得慢」。
面板本身也遵守同样的口径:没有数据时写「这一进程还没有跑过检索」,不画 0 值图表; 阶段样本数不足时那一行就是 1 条,不合并成「其他」。
实测(本机,无模型 key):三轮检索后 p50 4823ms / p95 4832ms,五个阶段的
n/p50/p95 = route 3/1/8、retrieve 3/4803/4825、analyze 3/12/40、compose 3/1/2、
summary 2/0/1 —— 检索占了 99% 的时间,其余全是毫秒级。这一行数字就是它的用处:
要优化先优化检索,别去动图表。
页脚右侧一个「反馈」按钮(左边是「运行指标 →」,两个都属于"想找的人才会往下翻"的东西)。 点开是一段话的输入框 + 可选的联系方式,提交后当场回一个编号。
四个刻意的选择:
- 不做五星评分 / 分类下拉:真正值钱的是「你在看什么、哪句话看不懂」,而上下文
(当前查询词、所在路径)本来就该自动带上,不该让用户填表。所以提交体里
query与page由前端填好,输入框只有一个。 - 落在磁盘,不进数据库:这台机器没有 DB,反馈的价值是「别丢」,不是「能查」。
POST /api/feedback追加一行到.cache/feedback.jsonl,tail -n 20就能读完。.cache在自动更新覆盖源码时被跳过,所以发版不会清掉旧反馈。 - 写失败要说出来:落盘抛错就回 500 并说「这条反馈没有保存」,界面上是红色告警条 —— 与「已收到」用两套文案,绝不让用户以为存下了。空内容(<2 字)在前端就拦。
- 不做「已读」承诺:回执只给编号,不写"我们会尽快回复"。预览站是一个人维护的, 承诺做不到的事比沉默更糟。
限流按 IP:PROBE_FEEDBACK_RATE_LIMIT_PER_MIN 默认 5 次/分钟,超出回 429 + retry-after。
记录里带 IP 与 UA(只为定位问题),面板与原页都不展示它们。
/api/cron/heat(GET 与 POST 等价)做一件事:把历史基线补齐,再算一次热榜快照。
它的存在理由是热度状态机的基线 —— 30 天 × 12 小时窗口 = 60 次外部请求,
不能挂在用户的第一次访问上(那会让首屏等 20 秒)。
它默认是关的:没配 PROBE_CRON_SECRET 时直接回 503 cron_disabled。
理由很直白:一个能触发 60 次外部请求的端点被扫到,就是免费的放大器。它走 secret 而不是
用户会话 —— 定时任务没有浏览器,也不该依赖某个人的账号还在不在。
配好之后支持两种带法:Authorization: Bearer <secret> 或 x-probe-cron-secret,
不匹配回 401。
返回体只报事实:history.days / from / to / pending 与
trending.topics / considered / fetched / degraded / withStatus。基线已经新鲜时
整趟 2.8 秒(读 .cache/hn-history.json + 一次 24 小时抓取);需要重建时约 19 秒。
调用示例:
PROBE_CRON_SECRET=... npx next start -p 3211
curl -s -H "Authorization: Bearer $PROBE_CRON_SECRET" http://127.0.0.1:3211/api/cron/heat部署时把它接到平台的 cron(Vercel Cron / systemd timer / crontab 都行), 一小时一次足够:基线是日粒度的,太勤只是重复抓同一批数据。
端点是端点,定时器是定时器。预览站上两者都装好了,所以热榜在没人访问时也在更新:
/opt/probe/heat-cron.sh # 从 probe.env 读 secret,curl 打 127.0.0.1:3215/api/cron/heat
/etc/systemd/system/probe-heat.service # oneshot,失败重试 3 次(间隔 10s)
/etc/systemd/system/probe-heat.timer # OnBootSec=2min、OnUnitActiveSec=30min
实测返回:
{"ok":true,"elapsedMs":4456,"history":{"days":30,"from":"2026-09-24","to":"2026-10-23","pending":false},
"trending":{"topics":12,"considered":625,"fetched":994,"degraded":["gdelt:not_configured"],"withStatus":12}}三件当时必须弄明白的事:
- 先证伪"口令没生效":不带密钥打
POST /api/cron/heat必须回 401, 而不是 200 带一份空数据。401 出来才说明这条路是通的、只是没带钥匙。 - 定时器跑在谁的时间上:
OnUnitActiveSec=30min从「上次跑完」算起,不是墙钟整点 —— 一次重建 19 秒不会让下一次被挤掉。list-timers看下次触发时间就够了。 - 一次瞬时失败不判死刑:22 秒后紧接着的第二次运行拿到的是
topics:0+hn:failed,同一分钟里上一次还是 12 个话题。journal 里是hn failed: The operation was aborted due to timeout—— HN 的单次抖动。 所以抓取给了一次重试(间隔 800ms),两次都空才记hn:failed; 而失败快照只缓存 60 秒(正常是 10 分钟),别让一次坏运气把空榜钉十分钟。 这条同样适用于页面首访:抓到 0 条时下一次刷新就会重新试,不是继续端旧的空榜。
http://106.52.116.164/probe 跑的是 main 的最新构建。整台机器 80 端口本来归另一个项目,
所以这套预览不是新开端口、也不是新域名,而是在现有 nginx 站点里加一个 location /probe。
浏览器 → nginx :80 /probe → 127.0.0.1:3215 (next start, basePath=/probe)
↑ systemd probe-web.service
↑ /opt/probe/update.sh(systemd timer,5 分钟一次)
basePath 靠环境变量,不写死。 next.config.ts 读 PROBE_BASE_PATH,本地不设就照常在根路径跑。
但 Next 的 basePath 只改写路由和 /_next/* —— 客户端手写的 fetch("/api/…") 它一个字都不动,
所以那些地方统一走 src/lib/base-path.ts 的 appPath()。漏掉一处的表现是:页面能开、检索永远失败。
自动更新用 tarball,不用 git。 这台机器连 github.com 会 GnuTLS recv error (-110)(api.github.com
与 codeload.github.com 反而正常),git clone 从来没成功过。update.sh 于是走:
问 api.github.com 要 main 的 sha → sha 变了才 curl codeload…/tar.gz/<sha> → 覆盖源码
(tar 管道,机器上没有 rsync)→ 构建 → 换目录 → 重启 → 自检。
构建进 .next-staging,成功了才换。 直接往 .next 上构建,一次失败的构建会把正在服务的产物弄成半截,
整站白屏 —— 预览站没人看着,这种事故要走一次才知道。换完重启,curl /probe/api/version 不通就
把 .next-old 换回来再重启,线上继续跑旧版本。
版本横幅按构建号比对,不按时间猜。 /api/version 在进程启动时读一次 .next/BUILD_ID(不是每个请求读),
否则更新一落地,旧进程会立刻报出新版本号、横幅在没人更新过的时候就开始弹。前端每分钟比一次,
只在自己手里的号与线上不同时提示「刷新」,并且允许「稍后」。
失败要自己再来一次:更新时进程会短暂不可用,如果那一分钟的轮询刚好撞上重启窗口, 按「下一分钟再问」就得让人多等一分钟。所以问不到有效构建号时 10 秒后重试一次 (实测:一次正常的更新在 130 秒内提示到位,把一次轮询故意打断后第 13 秒补上)。
手机端的安全区与 16px 是硬要求,不是样式偏好。 输入框低于 16px,iOS 聚焦会整页放大;
env(safe-area-inset-*) 不处理,刘海机底部按钮会压在横条上。
# 现在线上是哪个 commit
cat /opt/probe/deployed-sha
# 手动触发一次更新(等价于 timer 到点)
systemctl start probe-update.service
tail -40 /var/log/probe-update.log
# 服务本身
systemctl status probe-web --no-pager -l
journalctl -u probe-web -n 50 --no-pager配置在 /opt/probe/probe.env(PROBE_BASE_PATH、限流阈值、以及想接的话 OPENAI_API_KEY)。
改完 systemctl restart probe-web 即可,不需要重新构建 —— 除非改的是 NEXT_PUBLIC_*,那个会被打进前端。
完整的落盘路径、回滚步骤与 nginx 片段见 docs/DEPLOY.md。
src/app/opengraph-image.png(1200×630)与src/app/apple-icon.png(180×180)走 Next 的元数据文件约定: 构建过程负责路由、内容哈希与<meta>,og:image/twitter:image/apple-touch-icon自动生成。 手写images: [{ url: "/og.png" }]就要自己拼 basePath 与哈希,换部署路径必然错一次。src/app/manifest.ts:主屏幕安装(display: standalone、纯黑底、theme 与 background 都#000)。metadataBase来自PROBE_SITE_URL—— 分享卡要的是绝对地址,而它只能部署时确定。 用不带NEXT_PUBLIC_前缀的变量:带前缀的值会在构建期被内联,换域名就得重新构建。
三个真踩到的坑:
- Next 只给打包出来的图片资源加 basePath,手写的字符串路径不会。子路径部署(
/probe)时url: "/"生成的是裸域名,得写成BASE_PATH + "/"。实测修正前后:http://106.52.116.164→http://106.52.116.164/probe/。 - manifest 里的
start_url/scope/icons全是字符串,同理必须自己带前缀,否则装到主屏后打开的是 404。 - 分享卡上的 og:image 是静态 PNG(不是动态生成):一张固定构图 22 KB,比每次请求跑一次
ImageResponse便宜, 也不必担心字体在服务端不存在 —— 卡片上那句中文在部分 Linux 镜像里会渲染成方框。
验证方式(子路径构建):
curl -s localhost:3300/probe | grep -o '<meta property="og:image"[^>]*>'
curl -s localhost:3300/probe/manifest.webmanifest
页脚左下角是 探针 V0.1.0,GET /api/version 里同时给 version 与 buildId。
它们不是一回事,混用会误导人:
| 谁改 | 用来干什么 | |
|---|---|---|
version(V0.1.0) |
人改 package.json |
说明这是产品第几版:对外沟通、issue 里对版本 |
buildId |
每次构建自动生成 | 判断「这个页面是不是最新构建」,更新横幅就靠它 |
单一来源:next.config.ts 在构建时读 package.json 的 version,写成
NEXT_PUBLIC_PROBE_VERSION,客户端与服务端都从 src/lib/version.ts 取,
所以不存在「页脚写 0.1.0、接口写 0.0.9」这种对不上的情况。发版只改一处:
npm version 0.1.1 --no-git-tag-version # 或直接编辑 package.json 的 version
npm run build # NEXT_PUBLIC_* 编译进前端,必须重新构建改完要重新构建,只 systemctl restart 不生效 —— 版本号是编译期常量。
src/
agents/ route · retrieve · analyze · compose · pipeline
lib/ schemas · confidence · heat · heat-history · topic-terms · entity-graph · compile · layout · module-catalog · module-plan · sse · llm
fixtures · entities · text · trending · glossary · lang · translate · translate-risk
translate-types · http · settings · runtime-config · config-headers · placement · metrics · polysemy
base-path
app/ layout · page · globals.css · manifest.ts · api/search/route.ts · api/translate/route.ts · api/module/route.ts
api/metrics/route.ts · api/cron/heat/route.ts · api/version/route.ts · api/feedback/route.ts · metrics/page.tsx
icon.svg · apple-icon.png · opengraph-image.png(元数据文件约定,不手写 URL)
components/ Workspace(首页与结果页同一棵树) · ModuleGrid · ModuleCard · SearchBar · ConfidenceBadge · SourceList · TrendingBoard · ChartBox
Glossary · BilingualCompare · SettingsPanel · ConfigDocs · ModuleDock · ModulePrompt
EntityGraphCard · MetricsPanel · ClarifyCard · UpdateBanner
hooks/ useProbe.ts
src/lib/compile.ts 是图表的安全边界:模型只能产出受限 ModuleSpec,
ECharts option 由前端编译器按白名单生成。让模型直出 option 的失败方式不是语法错,
而是"语法合法、语义胡说"——那种错没有人能一眼看出来。
| 源 | 状态 | 说明 |
|---|---|---|
| Hacker News (Algolia) | ✅ 可用 | 发现层:每条 story 指向各自原站,owner 从真实域名取,否则独立性永远是 1 |
| Wikipedia pageviews | ✅ 可用 | 单条目,做热度旁证 |
| GDELT DOC 2.0 | 按 IP 限流 1 req/5s,超限时拖 12–14 秒才回纯文本。2.5s 软超时 + 300s 冷却 | |
| Tavily | 🔑 需 key | 未配置时静默跳过,不算降级 |
| Google News RSS / Bing News RSS | ❌ 不可达 | 本机网络环境下连接超时 |
- HN 不是新闻媒体:它是聚合入口,返回的条目里含博客、GitHub、个人站。 信源多样性是真实的,但"媒体机构"这个语义不成立。
- GDELT 长期被限流:这台机器的出口 IP 基本拿不到数据,实时面板实际上只由 HN + Wiki 支撑;同一 IP 短时间反复查同一实体,结果会高度重合。
- 没有向量检索与缓存:Phase 1 每次查询都重新抓取,TTL 缓存留给下一阶段。
- 单用户假设:限流与预算按进程内变量计,多实例部署会失效。
- 布局只在本机:快照在
localStorage,换设备、换浏览器、清站点数据都会回到默认排布; 换查询词也不共享(按 query 做键)。跨设备同步等 Supabase 鉴权就绪。 - 限流会拦到你:默认 3 次/分钟。本地反复调试时用
PROBE_RATE_LIMIT_PER_MIN=50启动。 - GDELT 冷却只对限流与超时生效:一条查询语法写错不该让后面所有查询一起停摆 5 分钟。
- 热榜是单源:只有 Hacker News,它偏技术圈;GDELT 恢复后「多少家媒体在报」才成立。
热度公式(median_30d / MAD_30d / 五档状态机)已接线,基线靠
.cache/hn-history.json攒, 首次访问只等 1 分钟内的快照缓存,不等 20 秒的基线构建。 - 争议态没做:PRD 2.3 的「争议态」要 GDELT GKG 的 V2Tone,而
api.gdeltproject.org从这台机器恒回 429(data.gdeltproject.org倒是通)。没有真情感数据就不上这一档, 宁可少一个标签,也不要一个编出来的标签。 - 指标不跨进程:
/metrics只统计当前进程(重启清零、多实例各算各的)。 它是排障镜,不是监控系统。
- 数字点击 ≤ 2 次到原文 ✅ 每个模块头部有「溯源 N 条」抽屉(列出该模块实际引用的条目,
不是全量信源),
SourceList另有全量直链 - 无源数字出现率 0 ✅ 低置信档禁止聚合数字;KPI 只数已抓到的条目
- 徽章可展开且与数字一一对应 ✅
ConfidenceBadge用<details>展开三分量 - 单源失败仍渲染 ≥ 1 模块 ✅ 四路
Promise.allSettled隔离 + 全空落样本 - 拖拽保存刷新仍在 ✅
done后解锁拖拽,位置/尺寸落localStorage,重查同一个词原样恢复; 流式期间拖不动也存不下(已用 Playwright 断言把手不渲染、位置不变、无快照写入)
npm run typecheck # tsc --noEmit
npm run build






