Skip to content

Commit b5cc07b

Browse files
feat(数据平面): 路由下沉修监听范围漏网 + 拓扑可见 + 文档覆盖面(v29)
四件事,围绕"渠道数据平面:生产者/消费者/路由"的重构: ## A 正确性:路由下沉到 deliverMessage(唯一真实缺陷) 改动前路由只挂在快通道(inbound.message 回调),慢兜底(persona-inbox 消费者)整条绕过监听范围 —— inbox-consumer 全文零引用 attention_scope。 而慢兜底恰是真机上主要生效的那条(快通道要 changed.length>0,本机历史早 已采完,实测 62 页全 changed:0)。于是用户勾的监听范围在真机上几乎不生效。 新增 store/attention-router.ts(AttentionRouter),把路由 + 记账打包, 下沉进 deliverMessage —— 快慢两条投递路唯一的交汇点。任何新增第三条 路径也必然过闸。路由与 admit 仍分开(范围问题 vs 时机问题)。 名单为空→放行(迁移期正确一侧:存量用户空表判成"什么都不关心"会让分身 整个静默)。 ## B 可见性:每个消费者状态进快照 + 状态页拓扑卡 runCycle 一直返回 waitingForUpstream,但只进了日志。新增 topology-view.ts (纯函数)把它接到快照与状态页。区分三对"同形但出路相反"的状态: 在等上游 vs 自己卡住、没注册 vs 已追平、需重建 vs 只是落后。 ## B 文档覆盖面 v29:补齐"消息/听记/文档"三类 抽 CoverageRepositoryBase:chat_coverage(v27) 与 document_coverage(v29) 形状同构,五条判据(累加/COALESCE保留/drained覆盖/MIN聚合/不凭空造行) 只有一份实现,同一组用例对两张表都跑。 ## D 收敛:域声明带 producedBy + 补 doc-ingest + 一致性自检 contact 域无生产者(PII 不进白名单)标 absent+原因;PRODUCERS 补漏掉的 doc-ingest;checkTopologyConsistency 让"漏一行声明"变可检查。 ## 顺带修的既存问题 · IngestSnapshot 有两份并行声明(手写接口 + 契约 schema)→ 主进程改成 从契约派生,加字段只改一处; · 契约缺 scope 字段(主进程一直填、渲染层类型看不见)→ 补进契约; · 仪表盘 running 过期:useIngestSnapshot 内建 useIngestProgress 订阅 (CDP 实测:主进程 running=true 而 DOM 显示"采集未运行",转页回来消失)。 设计文档见 docs/design/channel-data-plane.md。
1 parent 63f4349 commit b5cc07b

23 files changed

Lines changed: 3374 additions & 607 deletions

File tree

‎apps/desktop/src/main/services/data-plane.service.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
type SelfIdentityView,
3434
} from "@mycontext/ipc-contract"
3535
import { AUTO_BUILD_MIN_INTERVAL_MS } from "@mycontext/knowledge-feed"
36+
import { buildDomainStatuses } from "@mycontext/ingest"
3637
import { IngestService } from "./ingest.service.js"
3738
import type { FeedDirs, FeedService } from "./feed.service.js"
3839

@@ -617,6 +618,33 @@ export class DataPlaneService {
617618
minutesCoverage: null,
618619
storage: { mainBytes: 0, walBytes: 0, rawRecords: 0, rawPruned: 0, vectors: 0 },
619620
staleConsumers: [],
621+
/**
622+
* ★★ 未登录时消费者列表**为空**,而不是"四个消费者、全部 absent"。
623+
*
624+
* 判据是这一层要表达的事实:**还没有库**,所以"这套部署里有哪些
625+
* 消费者"这个问题现在没有答案。给一份"全部 absent"会让状态页显示
626+
* 四行红色的"不存在" —— 而用户此刻要做的只是登录。
627+
*
628+
* ★ 与 `minutesCoverage: null` 同一个取舍:"不知道"不能显示成
629+
* "有问题",也不能显示成"没问题"。
630+
*/
631+
consumers: [],
632+
/**
633+
* ★ 域的**声明**与登录无关(`DOMAINS` 是静态的),所以这里给全量、
634+
* 水位全 0 —— 那是诚实的:"这些是我们支持的域,现在还没有数据"。
635+
*
636+
* 与 `consumers: []` 的区别:消费者的"存在与否"是**运行时**事实
637+
* (注册过才算),而域是**设计**事实。前者未登录时未知,后者已知。
638+
*/
639+
domains: buildDomainStatuses({ domainHeads: {} }).map((status) => ({ ...status })),
640+
/**
641+
* ★ 未登录时范围闸「没配、没丢过」。
642+
*
643+
* `restricted: false` 是**诚实**的:那时压根没有库,也就没有任何
644+
* 白名单在生效。给 `true` + `allowed: 0` 会让界面说"许可 0 个会话"
645+
* —— 而那是"一个都不采"这个**已配置**状态,与"还没登录"完全不同。
646+
*/
647+
scope: { restricted: false, allowed: null, droppedOutOfScope: 0, lastDroppedAt: null },
620648
eventStream: null,
621649
}
622650
}

‎apps/desktop/src/main/services/ingest.service.ts‎

Lines changed: 190 additions & 178 deletions
Large diffs are not rendered by default.

‎apps/desktop/src/renderer/features/shell/data-plane-panel.tsx‎

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,9 @@ import { SelfIdentityPanel } from "../settings/self-identity-panel.js"
1919
* 主渠道 id。本人身份、数字分身这些只在它上面成立 —— 其余渠道是只读接入。
2020
*/
2121
const PRIMARY_CHANNEL_ID = "dingtalk"
22-
import {
23-
useClearIngestBlocked,
24-
useIngestProgress,
25-
useIngestSnapshot,
26-
useRunIngestOnce,
27-
} from "../../lib/queries.js"
22+
import { useClearIngestBlocked, useIngestSnapshot, useRunIngestOnce } from "../../lib/queries.js"
2823
import { ChannelPicker } from "./channel-picker.js"
24+
import { DataPlaneTopologyPanel } from "./data-plane-topology-panel.js"
2925
import { useDynamicTranslation } from "../../lib/use-dynamic-translation.js"
3026

3127
function formatBytes(bytes: number): string {
@@ -52,8 +48,7 @@ export function DataPlanePanel({
5248
const snapshot = useIngestSnapshot(enabled)
5349
const runOnce = useRunIngestOnce()
5450
const clearBlocked = useClearIngestBlocked()
55-
// 订阅主进程推来的快照:入库后立刻更新,不轮询
56-
useIngestProgress()
51+
// ★ 订阅已并进 useIngestSnapshot(见其注释)—— 这里不再单独调,避免重复挂 handler。
5752

5853
/**
5954
* 当前在看哪个渠道。`null` = 没选过 → 第一个(主渠道排在前)。
@@ -196,11 +191,18 @@ export function DataPlanePanel({
196191
/>
197192
)}
198193

199-
{data.staleConsumers.length > 0 && (
200-
<p className="typography-body-small-400 rounded-[var(--radius-md)] bg-[var(--status-fill-warning-container)] px-3 py-2 text-[var(--status-warning)]">
201-
{t("status.dataPlane.staleConsumers", { list: data.staleConsumers.join(", ") })}
202-
</p>
203-
)}
194+
{/*
195+
★★★ 数据平面拓扑(消费者逐个 + 域水位)。
196+
197+
它**取代**了原来那行「离线消费者:distill, graph-export」——
198+
那行说不出落后多少、在等谁、存不存在,而这三件事的出路完全不同
199+
(见 `DataPlaneTopologyPanel` 的文件头)。
200+
201+
★ `staleConsumers` 那个字段仍在契约里(别的地方可能读),
202+
但这里不再单独渲染它:stale 已经作为每个消费者的一个状态显示了,
203+
两处都画会让同一件事说两遍,而它们措辞不同就会互相矛盾。
204+
*/}
205+
<DataPlaneTopologyPanel consumers={data.consumers} domains={data.domains} />
204206

205207
{/* ★ 归属判定同样只对主渠道成立(它依赖身份行)—— 见下面 SelfIdentityPanel */}
206208
{channel === PRIMARY_CHANNEL_ID && data.unjudged > 0 && (
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
/**
2+
* 数据平面**拓扑**面板 —— 谁在生产、谁在消费、谁落后、谁在等谁。
3+
*
4+
* ## ★★★ 为什么必须有这一块(它替换的那行文案说不出任何有用的话)
5+
*
6+
* 改动前状态页对消费者只有一行:
7+
*
8+
* ```
9+
* 离线消费者:distill, graph-export
10+
* ```
11+
*
12+
* 三件事读不出来,而它们的出路完全不同:
13+
*
14+
* · **落后多少** —— `distill` 落后 8 条与落后 80000 条是两种状况;
15+
* · **在等谁** —— `distill` 被 `graph-export` 夹住(依赖闸)时,它与
16+
* "蒸馏卡住了"在数字上完全同形(lag 都在涨、processed 都是 0),
17+
* 但前者要去看图谱为什么慢,后者要去看蒸馏本身;
18+
* · **存不存在** —— `graph-export` 由 kl 服务侧推进,没起服务时它压根
19+
* 不注册。那时它既不 stale 也没有 lag,"追平了"与"不存在"同形。
20+
*
21+
* ## ★★ 为什么显示 `required`
22+
*
23+
* 它决定用户**该不该着急**:`required: true` 的消费者落后时**历史不能裁**
24+
* (丢了补不回来,如蒸馏语料);`false` 的可以裁(三天前没回的消息现在回
25+
* 也没意义)。同样的 "落后 8000 条",一个是待办、一个是无所谓。
26+
*
27+
* ## ★ 域那一列为什么要显示 `absent`
28+
*
29+
* `contact` 域在声明里存在,但**没有生产者**(通讯录属 PII,相关渠道命令
30+
* 不在白名单内)。不区分的话界面会显示"通讯录 0 条" —— 读起来像坏了,
31+
* 而事实是我们不采。「没做」与「做了没数据」必须能区分。
32+
*
33+
* ## 这里**不画进度条**
34+
*
35+
* 与覆盖面同一条理由:`lag` 有分子没分母("还要多久追上"取决于消费速度,
36+
* 而那是变量)。给两个诚实的数字(已确认到 seq N,落后 M 条)比给一个
37+
* 编出来的百分比有用。
38+
*/
39+
import type { IngestSnapshot } from "@mycontext/ipc-contract"
40+
import { useDynamicTranslation } from "../../lib/use-dynamic-translation.js"
41+
42+
type ConsumerStatusView = IngestSnapshot["consumers"][number]
43+
type DomainStatusView = IngestSnapshot["domains"][number]
44+
45+
/**
46+
* 一个消费者当前**最该说的那一句**。
47+
*
48+
* ★ 顺序即优先级,而且刻意让"结构性问题"压过"落后多少":
49+
* 一个 `absent` 的消费者报"落后 8000 条"是误导(它压根不该追)。
50+
*/
51+
function consumerState(
52+
consumer: ConsumerStatusView,
53+
): "absent" | "rebuild" | "stale" | "waiting" | "lagging" | "ok" {
54+
if (consumer.absent) return "absent"
55+
// 需要全量重建 = 历史已被裁剪,增量补不回来 —— 比 stale 更严重
56+
if (consumer.needsFullRebuild) return "rebuild"
57+
if (consumer.stale) return "stale"
58+
if (consumer.waitingForUpstream !== null) return "waiting"
59+
if (consumer.lag > 0) return "lagging"
60+
return "ok"
61+
}
62+
63+
/** 状态 → 颜色。★ `waiting` 是**中性**的:按依赖顺序干活不是故障。 */
64+
const STATE_TONE: Record<ReturnType<typeof consumerState>, string> = {
65+
absent: "text-[var(--text-base-tertiary)]",
66+
rebuild: "text-[var(--status-error)]",
67+
stale: "text-[var(--status-warning)]",
68+
waiting: "text-[var(--text-base-secondary)]",
69+
lagging: "text-[var(--status-warning)]",
70+
ok: "text-[var(--status-success)]",
71+
}
72+
73+
export function DataPlaneTopologyPanel({
74+
consumers,
75+
domains,
76+
}: {
77+
consumers: readonly ConsumerStatusView[]
78+
domains: readonly DomainStatusView[]
79+
}) {
80+
const { t } = useDynamicTranslation("settings")
81+
82+
/**
83+
* 未登录时 `consumers` 是空数组("还没有库,这个问题现在没有答案"),
84+
* 而 `domains` 仍是全量声明。两者分别判空 —— 合起来判会让未登录时
85+
* 连"我们支持哪些域"都不显示。
86+
*/
87+
if (consumers.length === 0 && domains.length === 0) return null
88+
89+
return (
90+
<div className="flex flex-col gap-3 border-t border-[var(--border-divider-light)] pt-3">
91+
<div className="flex items-baseline gap-2">
92+
{/*
93+
★ 用 `typography-title-small-500` 而不是 `body-small-500`:
94+
后者**不在**排版表里(`check:typography` 抓到了)—— 那种 className
95+
不生成任何样式,文字会静默退回浏览器默认字号且不报错。
96+
这一块是卡内的小标题,与状态页其余 section 标题同档。
97+
*/}
98+
<p className="typography-title-small-500 text-[var(--text-base-primary)]">
99+
{t("status.topology.title", { defaultValue: "数据平面" })}
100+
</p>
101+
<span className="typography-caption-400 text-[var(--text-base-tertiary)]">
102+
{t("status.topology.subtitle", {
103+
defaultValue: "生产者投变更、消费者按依赖序消费",
104+
})}
105+
</span>
106+
</div>
107+
108+
{consumers.length > 0 && (
109+
<div className="flex flex-col gap-1.5">
110+
{consumers.map((consumer) => (
111+
<ConsumerRow key={consumer.id} consumer={consumer} />
112+
))}
113+
</div>
114+
)}
115+
116+
{domains.length > 0 && (
117+
<div className="flex flex-wrap gap-x-4 gap-y-1">
118+
{domains.map((domain) => (
119+
<DomainChip key={domain.id} domain={domain} />
120+
))}
121+
</div>
122+
)}
123+
</div>
124+
)
125+
}
126+
127+
function ConsumerRow({ consumer }: { consumer: ConsumerStatusView }) {
128+
const { t } = useDynamicTranslation("settings")
129+
const state = consumerState(consumer)
130+
131+
/**
132+
* 右侧那句话。★ 每种状态说**不同的**话,而不是统一说"落后 N 条" ——
133+
* 那是这一整块存在的理由(见文件头)。
134+
*/
135+
const detail =
136+
state === "absent"
137+
? t("status.topology.absent", { defaultValue: "未注册(这套部署里没有它)" })
138+
: state === "rebuild"
139+
? t("status.topology.rebuild", { defaultValue: "需要全量重建(历史已裁剪)" })
140+
: state === "waiting"
141+
? t("status.topology.waiting", {
142+
defaultValue: "在等 {{upstream}}",
143+
upstream: consumer.waitingForUpstream ?? "",
144+
})
145+
: state === "ok"
146+
? t("status.topology.caughtUp", { defaultValue: "已追平" })
147+
: t("status.topology.lag", { defaultValue: "落后 {{lag}} 条", lag: consumer.lag })
148+
149+
return (
150+
<div className="flex items-baseline justify-between gap-3">
151+
<div className="flex min-w-0 items-baseline gap-2">
152+
<span className="typography-body-small-400 shrink-0 text-[var(--text-base-primary)]">
153+
{consumer.id}
154+
</span>
155+
{/* ★ purpose 来自声明(`ConsumerSpec.purpose`)——界面不再自己编文案 */}
156+
<span className="typography-caption-400 truncate text-[var(--text-base-tertiary)]">
157+
{consumer.purpose}
158+
</span>
159+
</div>
160+
<div className="flex shrink-0 items-baseline gap-2">
161+
{/*
162+
★★ `required` 决定"落后了要不要紧":true = 历史不能裁(丢了补不回来)。
163+
只在落后/等待时显示 —— 追平时它是一句废话。
164+
*/}
165+
{consumer.required && state !== "ok" && state !== "absent" && (
166+
<span className="typography-caption-400 text-[var(--text-base-tertiary)]">
167+
{t("status.topology.required", { defaultValue: "不可裁剪" })}
168+
</span>
169+
)}
170+
<span className={`typography-caption-400 ${STATE_TONE[state]}`}>{detail}</span>
171+
</div>
172+
</div>
173+
)
174+
}
175+
176+
function DomainChip({ domain }: { domain: DomainStatusView }) {
177+
const { t } = useDynamicTranslation("settings")
178+
const absent = domain.producedBy === "absent"
179+
return (
180+
<span className="typography-caption-400 flex items-baseline gap-1">
181+
<span
182+
className={
183+
absent ? "text-[var(--text-base-tertiary)]" : "text-[var(--text-base-secondary)]"
184+
}
185+
>
186+
{domain.id}
187+
</span>
188+
{/*
189+
★★★ `absent` 说清**为什么没有**,而不是显示一个 0。
190+
显示 0 会让"我们不采通讯录"看起来像"通讯录采失败了"。
191+
*/}
192+
<span className="text-[var(--text-base-tertiary)]">
193+
{absent
194+
? (domain.absentReason ?? t("status.topology.noProducer", { defaultValue: "无生产者" }))
195+
: t("status.topology.head", { defaultValue: "水位 {{head}}", head: domain.head })}
196+
</span>
197+
</span>
198+
)
199+
}

‎apps/desktop/src/renderer/lib/queries.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1369,8 +1369,37 @@ export function useUpdateProfile() {
13691369
*
13701370
* 不轮询:主进程在入库后主动推快照(见下面的 useIngestProgress)。
13711371
* 轮询会在空闲时白跑,而在忙时又跟不上 —— 两头都不对。
1372+
*
1373+
* ## ★★★ 订阅**内建**在这个 hook 里(而不是让调用方另调 useIngestProgress)
1374+
*
1375+
* ### 实测过的故障(CDP 确证)
1376+
*
1377+
* 仪表盘显示「采集未运行」,而**同一时刻**主进程 `running: true`、
1378+
* 两个渠道都在跑、无错误无 blocked。去别的页转一圈回来那句话就消失了。
1379+
*
1380+
* 根因:`useIngestSnapshot` 只在挂载时取一次(无 `refetchInterval`),
1381+
* 刷新全靠 `useIngestProgress` 的推送写回同一个 cache key。而**仪表盘
1382+
* 只调了 `useIngestSnapshot`、没调 `useIngestProgress`** —— 运行状态页调了。
1383+
* 于是仪表盘卡在挂载那一刻的快照,而冷启动时序恰好不利:
1384+
*
1385+
* ```
1386+
* vault opened ← 渲染层挂载,此刻 running=false
1387+
* ingest started ← 2 秒后定时器才起,running=true(但没人推给仪表盘)
1388+
* ```
1389+
*
1390+
* ### 为什么把订阅并进来,而不是给仪表盘补一行
1391+
*
1392+
* `useIngestSnapshot` 每多一个调用方,就多一个"忘了配对订阅"的机会,
1393+
* 而忘了的表现就是这次这样:数字静默过期、没有任何报错(CLAUDE.md §4)。
1394+
* 把订阅收进 hook 内部,这个错在结构上不可能再犯 —— 取快照与保持新鲜
1395+
* 变成同一件事,不是两件要记得配对的事。
1396+
*
1397+
* ★ `useIngestProgress` 仍单独导出:`refresh-status-button` 等只想"订阅、
1398+
* 不自己发起查询"的地方还用它。订阅是逐实例的独立 `ipcRenderer.on`,
1399+
* 多个订阅者并存安全(见 preload 的 `onProgress`)。
13721400
*/
13731401
export function useIngestSnapshot(enabled: boolean) {
1402+
useIngestProgress()
13741403
return useQuery({
13751404
queryKey: QUERY_KEYS.ingest,
13761405
queryFn: async () => unwrap(await window.mycontext.ingest.snapshot()),

0 commit comments

Comments
 (0)