Skip to content

Commit 6d75460

Browse files
refactor(dashboard): 四行常驻说明收进「图谱详情」popover
## 我把仪表盘堆坏了 这一轮往「它认识的人与事」板块顶部堆了四行常驻文字:建图失败原因、 降级原因黄条、调度倒计时、上一轮产出。后两行是我刚加/刚改长的 —— CDP 量过:加起来把图挤下去大半屏。 而它们回答的都是**"不是现在要做什么"**:调度是预告、产出是回顾、 "正在建图"是旁边那颗按钮已经在说的事(按钮上就写着「建图中…」)。 ## 判据:常驻 vs 收起 = 「用户现在需要做什么吗」 `graph.reason` 有四种来源,新增纯函数 `classifyGraphReason` 分档: · 正在建图 ×2 / 还没建过图 → progress(收进 popover)—— 前者与按钮重复,后者那颗按钮本身就是入口; · facts=0 抽取没成功 / 读图谱失败 → actionable(**仍然常驻**)—— 要用户重试或换网关,收起来等于把待办藏了; · 建图**失败**那条也照旧常驻。 ★ 判据用结构化事实(`building` / `available`)而不是 `reason.includes(...)`: 后者会在改文案的那天静默失效,而失效的表现是"黄条又常驻了",没有报错。 反证时验过:写成匹配文案 → 那条"换措辞也不失效"的断言变红。 ## 复用现成件,不新增 UI 原语 · popover 骨架**逐行照抄** `persona/chat-header.tsx` 的「历史处理结果」—— 那里的注释记着两个坑:滚动容器必须挂在有 `max-h` 的那层(`min-h-0` 缺了会顶破而不是滚动)、高度别写死 288px; · `Tooltip` / `IconButton` 用 design 包现成的; · `describeBuildVolume` / `describeBuildSchedule` 只搬渲染位置, 文案与算法一行不动(那 15 条断言 + 反证是围着它们写的)。 ## 去重 `status-panel.tsx` 的 `status.kl.buildDone` 显示的是 entities/facts/edges **绝对值**,与 popover 第一段重复且信息量更少(增量下总数几乎不变)。 改成读同一个 `volume` 的净增,`volume` 缺席时退回绝对值。 **不删**——运行状态页是"建完立刻看回执"的地方。 ## 端到端(CDP,运行中的应用) 常驻说明行:**4 行 → 0 行**(DOM 扫描确认)。 点开 popover 三段都在(实测一轮真实建图之后): 上一轮建了多少 新增 +155 实体 · +273 事实 · +4,080 关系 处理语料 105 条(切 252 块) 增量省下 36,654 条已抽过,跳过 下次什么时候建 增量 194 / 500 条(还差 306 条)· 或 约 22 小时后按时间触发 攒够条数 500 条 / 最小间隔 1 小时 hover 出摘要、再点收起、点外面收起都验过;截图确认图恢复了全高。 ★ 截图还抓到一处:340px 宽会盖住右侧「直接关联」那一列,改成 300。 反证:加回一行常驻 → 主判据红;去掉点外面收起的捕获层 → 出口那条红; 全判 actionable → building 优先那条红;不认"还没建过"那档 → 1 红。 全量 3872 条通过;typecheck / lint 干净。 ★ 数字分身那个「这句话是怎么来的」弹窗**一行没动**:实测确认 `EventStream` 已经会画 thought / tool_call / message,那次只看到 JSON 是因为 `dh_run_trace` 里真的只有 1 行(`tool_calls_json=[]`,agent 没调工具), 不是渲染缺失。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent de86971 commit 6d75460

8 files changed

Lines changed: 596 additions & 57 deletions

File tree

‎apps/desktop/src/renderer/features/dashboard/dashboard-data.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,60 @@ export function formatEta(ms: number): string {
370370
return `约 ${String(Math.round(ms / 86_400_000))} 天`
371371
}
372372

373+
/**
374+
* `graph.reason` 那句话**要不要常驻在版面上**。
375+
*
376+
* ## ★★ 判据是「用户现在需要做什么吗」,不是「信息重不重要」
377+
*
378+
* 那个字段现在有四种来源(`kl-server.service.ts` 的 `graphOverview`):
379+
*
380+
* | 形态 | 例子 | 该常驻吗 |
381+
* |---|---|---|
382+
* | 建图中(缺库窗口) | `正在建图 —— 这一轮完成后就会有内容` | ✗ 板块头按钮已经写着「建图中…」 |
383+
* | 建图中(有库) | `正在建图 —— 数字会随进度增长` | ✗ 同上 |
384+
* | 还没建过 | `还没建过图(点「重新建图」开始…)` | ✗ 那颗按钮本身就是入口 |
385+
* | 半成品 / 读失败 | `事实一条都没抽出来 —— Phase B 的 LLM 抽取没成功` | **✓ 要动手**(重试或换网关) |
386+
*
387+
* 前三种是**进度或入口的复述** —— 它们与旁边那颗按钮说的是同一件事,
388+
* 常驻等于把同一句话说两遍,而版面被挤掉一行。第四种才有下一步动作。
389+
*
390+
* ## ★★★ 为什么用结构化输入而不是匹配文案
391+
*
392+
* 最直接的写法是 `reason.includes("正在建图")`。那会在**改文案的那一天**
393+
* 静默失效 —— 而失效的表现是"黄条又常驻了",没有任何报错,
394+
* 而且没人会想到去改这个判据(本仓库刚因为
395+
* `formatEta`/`formatDuration` 那类文案耦合栽过)。
396+
*
397+
* `building` 与 `available` 是主进程给的**事实**,与措辞无关:
398+
* · `building` = 我们自己的状态机(`rebuildGraph` 进出时置位);
399+
* · `available` = 图里有没有东西(`entities > 0 || facts > 0`)。
400+
*
401+
* 「还没建过」那一档的判据是 `available===false && building===false`
402+
* —— 而"半成品"恰恰是 `available===true`(有实体但 facts=0),
403+
* 两者因此可分。
404+
*/
405+
export function classifyGraphReason(input: {
406+
reason: string | null
407+
/** 我们的建图状态机(`klServerStatus.building`) */
408+
building: boolean
409+
/** 图里有没有东西(`graphOverview.available`) */
410+
available: boolean
411+
}): "none" | "progress" | "actionable" {
412+
if (input.reason === null || input.reason.trim() === "") return "none"
413+
// 正在建 → 进度。板块头那颗按钮已经在说这件事。
414+
if (input.building) return "progress"
415+
/**
416+
* 图里空着且没在建 = 「还没建过」。那是**入口**而不是问题 ——
417+
* 旁边那颗「开始建图」就是下一步,再说一遍没有信息量。
418+
*/
419+
if (!input.available) return "progress"
420+
/**
421+
* 有内容却仍有话说 = 半成品(facts=0 / 读失败)→ 要用户动手。
422+
* ★ 这一档必须常驻:收起来等于把一个待办藏进 popover。
423+
*/
424+
return "actionable"
425+
}
426+
373427
/**
374428
* 最近一轮建图**产出了多少** → 一句人能读的话。`null` = 还没建过 / 没测到。
375429
*

‎apps/desktop/src/renderer/features/dashboard/dashboard-module.tsx‎

Lines changed: 47 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
*/
4242
import { useEffect, useRef, useState } from "react"
4343
import { useQueryClient } from "@tanstack/react-query"
44-
import { Avatar, Button, cn } from "@mycontext/design"
44+
import { Avatar, Button } from "@mycontext/design"
4545
import { resolveDisplayName } from "@mycontext/ipc-contract"
4646
import {
4747
useAdoptableSession,
@@ -65,12 +65,12 @@ import { FactsExplorer } from "../graph/facts-explorer.js"
6565
import { entityColor } from "../graph/palette.js"
6666
import { personaIdentityFromSteps } from "../persona/persona-identity.js"
6767
import { FocusBridge } from "./focus-bridge.js"
68+
import { GraphDetailPopover } from "./graph-detail-popover.js"
6869
import { GreetingRow, pickChannelNick, resolveGreetingName } from "./greeting-row.js"
6970
import { PersonaCard } from "./identity.js"
7071
import { Distribution, Section } from "./primitives.js"
7172
import {
72-
describeBuildSchedule,
73-
describeBuildVolume,
73+
classifyGraphReason,
7474
describeKl,
7575
formatCount,
7676
readIdentityBar,
@@ -222,8 +222,19 @@ export function DashboardModule() {
222222
* 上游百分比是两回事(后者 Phase B 恒 40%、停 server 时卡在 stale 值上,
223223
* 见契约里那段注释与 kl-panel-build-state 的门禁)。
224224
*/
225-
const buildSchedule = describeBuildSchedule(graph?.buildSchedule ?? null)
226-
const buildVolume = describeBuildVolume(graph?.lastBuild ?? null)
225+
/**
226+
* ★ `graph.reason` 该常驻还是收进 popover —— 见 `classifyGraphReason`。
227+
*
228+
* `building` 取的是 kl 的状态机而不是"文案里有没有'正在建图'":
229+
* 后者会在改文案的那天静默失效,而失效的表现是"黄条又常驻了"。
230+
*/
231+
const graphReasonKind = classifyGraphReason({
232+
reason: graph?.reason ?? null,
233+
building,
234+
available: graph?.available ?? false,
235+
})
236+
/** 进度那一档的说明文字 —— 交给 popover 的第三段。 */
237+
const graphProgressNote = graphReasonKind === "progress" ? (graph?.reason ?? null) : null
227238

228239
/**
229240
* ★ 本人身份**未确认**:一条必须被看见的警示。
@@ -572,13 +583,22 @@ export function DashboardModule() {
572583
: `kl · ${klView.text}`
573584
}
574585
action={
575-
<Button
576-
size="sm"
577-
variant="secondary"
578-
disabled={kl === null || building || buildGraph.isPending}
579-
onClick={() => buildGraph.mutate(false)}
580-
>
581-
{/*
586+
/*
587+
★ 两颗放一行:ⓘ 在左、动作在右。
588+
589+
左信息右动作是这一页其余地方的既有顺序(数字分身右上角那排
590+
也是"看"在前、"设"在后)。而 ⓘ 只在真有内容时渲染
591+
(见 `GraphDetailPopover`:点开什么都没有的入口比没有更糟)。
592+
*/
593+
<div className="flex items-center gap-1.5">
594+
<GraphDetailPopover overview={graph ?? null} progressNote={graphProgressNote} />
595+
<Button
596+
size="sm"
597+
variant="secondary"
598+
disabled={kl === null || building || buildGraph.isPending}
599+
onClick={() => buildGraph.mutate(false)}
600+
>
601+
{/*
582602
★★ 文案必须说清这是**增量**,不能叫「重新建图」。
583603
584604
叫「重新」而做增量是一次真实的语义 bug:图谱侧的写入全部
@@ -594,8 +614,9 @@ export function DashboardModule() {
594614
真正会清空重来的入口是状态页那个「重建」(`fresh=true`,
595615
它会删掉 knowledge.db + qdrant + 抽取缓存)。
596616
*/}
597-
{building ? "建图中…" : graph?.available === true ? "继续建图(增量)" : "开始建图"}
598-
</Button>
617+
{building ? "建图中…" : graph?.available === true ? "继续建图(增量)" : "开始建图"}
618+
</Button>
619+
</div>
599620
}
600621
>
601622
{/*
@@ -605,48 +626,22 @@ export function DashboardModule() {
605626
{buildGraph.data?.ok === false && buildGraph.data.reason !== null ? (
606627
<ProblemLine text={buildGraph.data.reason} tone="bad" />
607628
) : null}
608-
{graph?.reason === undefined || graph.reason === null ? null : (
609-
<ProblemLine text={graph.reason} tone="warn" />
610-
)}
611-
612629
{/*
613-
★ 自动构建的调度状态 —— 回答「它下次什么时候更新」。
630+
★★ `graph.reason` **只在"要用户动手"时常驻**。
614631
615-
放在图**上方**、与那两条 ProblemLine 同列:它们回答的是同一类
616-
问题("现在这张图处于什么状态")。而放在板块外面时读者不知道
617-
那句话在说哪一块(那正是上面两条被移进来的理由)。
632+
那个字段有四种来源,前三种(正在建图 ×2、还没建过图)是**进度或
633+
入口的复述** —— 它们与旁边那颗按钮说的是同一件事(按钮上写着
634+
「建图中…」/「开始建图」),常驻等于把同一句话说两遍,
635+
而版面被挤掉一行。第四种(facts=0 抽取没成功)才有下一步动作。
618636
619-
`null` = 没接自动构建(未登录 / 未配置)→ 不占位。
620-
显示一行"—"比不显示更糟:它看起来像"坏了"。
637+
判据在 `classifyGraphReason`(纯函数、按结构化事实分档,不匹配文案)。
638+
进度那两档进右上角那颗 ⓘ 的 popover。
621639
*/}
622-
{buildSchedule === null ? null : (
623-
<p
624-
className={cn(
625-
"typography-caption-400",
626-
buildSchedule.tone === "warn"
627-
? "text-[var(--status-warning)]"
628-
: "text-[var(--text-base-tertiary)]",
629-
)}
630-
>
631-
自动构建 · {buildSchedule.text}
632-
</p>
633-
)}
634-
635-
{/*
636-
★★ 「上一轮建了多少」——与上面那些绝对值是两件事。
637-
638-
绝对值(实体 618 / 事实 814)回答"图里有多少",而用户问的是
639-
"刚才那一轮干了什么"。增量建图下一轮可能只新增几十个实体、
640-
总数几乎不变,于是**每轮看起来都像没跑** —— 而那恰恰让人以为
641-
增量没生效。
642-
643-
`null` = 这次启动还没建过 → 不占位(显示一行"—"看起来像坏了)。
644-
*/}
645-
{buildVolume === null ? null : (
646-
<p className="typography-caption-400 text-[var(--text-base-tertiary)]">
647-
上一轮 · {buildVolume}
648-
</p>
649-
)}
640+
{graphReasonKind === "actionable" &&
641+
graph?.reason !== undefined &&
642+
graph.reason !== null ? (
643+
<ProblemLine text={graph.reason} tone="warn" />
644+
) : null}
650645

651646
<EgoGraphPanel
652647
data={ego.data}

0 commit comments

Comments
 (0)