diff --git a/README.md b/README.md index 9e5ca440c..d8d88fae8 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ cd web && pnpm install && pnpm dev - [ ] **Execution gate**: every downstream call an agent makes passes ontology rules and symbolic logic first; what fails does not land - [ ] **Lakehouse**: Iceberg / Delta Lake, plus Databricks, Snowflake and MaxCompute - [ ] **More sources**: MySQL, ClickHouse and Doris drivers; S3, WebDAV, Notion and Feishu connectors +- [ ] **Time to the moment**: an `instant` precision beside year / month / day, for sources that carry a real timestamp — today a connector rounds it to a UTC day, which can shift an event across midnight by one day - [ ] **Agent memory over MCP**: episode writes, the retrieve endpoint, and the MCP server - [ ] **Enterprise**: OIDC SSO, backup and restore commands, benchmarks at 100k documents diff --git a/README.zh-CN.md b/README.zh-CN.md index 0229d6aa6..d4ba9e713 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -98,6 +98,7 @@ cd web && pnpm install && pnpm dev - [ ] **执行校验层**:Agent 的每一次调用先过本体规则与符号逻辑,通不过不落地 - [ ] **数据湖仓**:Iceberg / Delta Lake,以及 Databricks、Snowflake、MaxCompute - [ ] **更多数据源**:MySQL、ClickHouse、Doris 驱动,S3、WebDAV、Notion、飞书连接器 +- [ ] **精确到时刻**:在年 / 月 / 日之外加一档 `instant` 精度,给那些本来就带时间戳的来源——现在连接器按 UTC 截到天,跨午夜的事件会差一天 - [ ] **MCP 上的 Agent 记忆**:补齐 episodes 写入、retrieve 端点与 MCP 服务器 - [ ] **企业化**:OIDC SSO、备份恢复命令、10 万文档级别的性能基准 diff --git a/crates/utopia-server/src/api/graph_routes.rs b/crates/utopia-server/src/api/graph_routes.rs index c28a8648a..48081132e 100644 --- a/crates/utopia-server/src/api/graph_routes.rs +++ b/crates/utopia-server/src/api/graph_routes.rs @@ -32,14 +32,22 @@ fn parse_at(raw: Option<&str>) -> Result>, .map_err(|_| AppError::Validation("Invalid `at` (expected YYYY-MM-DD or RFC3339)".into())) } -/// 总览一次画多少个节点。**上限本身是合理的**——画一万个点没人看得懂; +/// 总览一次画多少个节点的**默认值**。上限本身是合理的——画一万个点没人看得懂; /// 骗人的是把它当成规模显示,所以接口同时回总数 const GRAPH_NODE_CAP: i64 = 150; +/// 调得再高也得有个天花板。**这个数不是拍的**:节点按度数降序取,越往后越是 +/// 边缘节点,而力导布局是 O(n²) 量级的——超过这个数,先垮的是「拖得动」 +/// 而不是「看得清」。要真看上万个点,那是另一种视图,不是把这个调大 +const GRAPH_NODE_CAP_MAX: i64 = 1000; #[derive(Deserialize)] pub struct OverviewQuery { #[serde(default)] pub at: Option, + /// 画多少个。不给就用默认值;给了也钳在 [10, GRAPH_NODE_CAP_MAX]—— + /// 界面上的按钮只给几档,但接口是公开的,别让一个 `limit=999999` 把库拖垮 + #[serde(default)] + pub limit: Option, } pub async fn overview( @@ -52,8 +60,12 @@ pub async fn overview( let at = parse_at(q.at.as_deref())?; // 画多少个是渲染的事,库里有多少是知识库的事——两个数都回,界面才说得出 // 「画了 150 个,共 325 个」而不是把上限说成规模 + let limit = q + .limit + .unwrap_or(GRAPH_NODE_CAP) + .clamp(10, GRAPH_NODE_CAP_MAX); let (nodes, edges, total_nodes, total_edges) = - utopia_store::graph::overview(&state.pool, kb_id, GRAPH_NODE_CAP, at).await?; + utopia_store::graph::overview(&state.pool, kb_id, limit, at).await?; Ok(Json(json!({ "nodes": nodes, "edges": edges, "total_nodes": total_nodes, "total_edges": total_edges, diff --git a/docs/decisions/0013-a-source-should-hand-over-its-history.md b/docs/decisions/0013-a-source-should-hand-over-its-history.md index 46a250744..6e6be5a20 100644 --- a/docs/decisions/0013-a-source-should-hand-over-its-history.md +++ b/docs/decisions/0013-a-source-should-hand-over-its-history.md @@ -101,6 +101,32 @@ Kafka 项目有 **14506** 张工单,一轮翻页上限取 500。不报的话 与 #108「部分抽取报告成完成」、#127「一条坏记录不该毁掉一整块」同一条原则: **限了覆盖面就要说出来**。 +## 已知取舍:时刻被截成了日期 + +连接器把时间戳写进正文时是 `created_at.format("%Y-%m-%d")`——**按 UTC 截到天**。 +抽取器从 "Opened by X on 2026-08-18." 这句话里读出的 `valid_from` 因此是 +day 精度,时刻在入库前就没了。 + +**代价说清楚**:跨 UTC 午夜的事件会差一天。一个 UTC+8 的人在 8 月 19 日 +早上七点开的 issue,GitHub 界面上写着 8-19,我们的文档里写的是 8-18。 + +**为什么仍然接受**: + +- 它没有真的丢。源头原样还在,而两个连接器都是**幂等可重同步**的—— + 真要精确到时刻,重同步一次就能拿回来。这与"不可逆丢失"差一个量级。 +- 精度模型本来就在说实话。`precision = day` 的含义正是"知道哪天、不知哪刻", + day 精度的日期存成 UTC 午夜、按 UTC 渲染,是自洽的。**真正的错是假装知道**。 +- 这产品目前不问"几点"。它回答"某个时刻世界是什么样",粒度到天。 + +**什么时候该回头做**:有人要按"当地营业日"统计,或者接了一个事件天然聚在 +午夜附近的源(交接班、开盘收盘)。那时 day 精度不够,才值得加一档 `instant` +精度——连带 CHECK 约束、抽取提示词、渲染分支一起改。 + +顺带记一条同源的规矩,它已经落在代码里:**世界时间与记录时间的时区待遇相反**。 +`valid_from`/`valid_to` 来自文档里的陈述,是日历日期不是时刻,一律按 UTC 渲染 +(转本地会让 UTC-5 的读者看到前一天);`recorded_at` 这类"我们何时这么认为" +是真实时刻,按看的人的时区渲染。这两处**故意不一样,别统一**。 + ## 为什么没有抽象成 `issues` + provider 接第二个供应商正是为了看清该不该抽象。答案是**暂时不该**:取法根本不同 diff --git a/web/src/api.ts b/web/src/api.ts index 7c3c9ae6a..e714e1847 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1174,7 +1174,7 @@ export const api = { method: "PUT", body: JSON.stringify(body), }), - graphOverview: (kbId: string) => + graphOverview: (kbId: string, limit?: number) => request<{ nodes: GraphNode[]; edges: GraphEdge[]; @@ -1182,7 +1182,9 @@ export const api = { * 那一批,把上限当成规模显示是这个接口从前最误导人的地方 */ total_nodes?: number; total_edges?: number; - }>(`/api/v1/kbs/${kbId}/graph/overview`), + }>( + `/api/v1/kbs/${kbId}/graph/overview${limit ? `?limit=${limit}` : ""}`, + ), /** 邻域视图**没有总数**:它本来就只是一小片,说「共 325 个」没有意义。 * 两个字段声明成可选,好让调用方与总览共用一个类型 */ graphNeighborhood: (kbId: string, entityId: string) => diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 4660cfed2..8c05a764f 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -163,6 +163,15 @@ export const en = { unknownKind: (kind: string) => kind, }, + kbScope: { + deniedTitle: "You don't have access to this knowledge base", + deniedBody: + "The link points at a base you can't open. Ask whoever shared it to grant you access, or pick one of your own.", + missingTitle: "This knowledge base is gone", + missingBody: + "It was deleted, or the link was mistyped. Your own bases are listed below.", + myKbs: "My knowledge bases", + }, nav: { workspaceLabel: "Workspace", kbLabel: "Knowledge base", @@ -547,6 +556,16 @@ export const en = { graph: { // 还没判出类型的实体(0009)。不是一个类,是"这一格还空着" untyped: "Untyped", + legendMore: (n: number) => `All ${n} classes`, + nodeBudget: "How many entities to draw", + nodeBudgetMore: "Draw more", + nodeBudgetLess: "Draw fewer", + legendSearch: "Filter classes", + legendNone: "No class matches", + legendOnly: "Only", + legendShowAll: (n: number) => `Show all (${n} hidden)`, + legendAllHint: + "Every class on screen, most common first. Click to show or hide.", searchMore: (n: number) => `${n} more — load 20`, zoomIn: "Zoom in", zoomOut: "Zoom out", @@ -615,6 +634,9 @@ export const en = { "Edges no one asserted — the engine worked them out from axioms your ontology declares. Each one shows the premises it came from.", derivedNoProof: "The premises are gone.", derivedPanel: "Inference", + derivedRunAsk: "Re-run inference for the whole base?", + derivedRunGo: "Run", + derivedRunCancel: "Cancel", derivedCountLabel: "Edges derived", derivedStateLabel: "Schedule", derivedLastLabel: "Last run", @@ -665,15 +687,26 @@ export const en = { /* 必须跟 ongoing 看得出区别:混淆这两个正是迁移 0046 要修的东西—— 原文说 "former CEO",界面却显示 now */ endedUnknown: "ended, date unknown", - stats: (n: number, e: number, active: number) => - `${n} entities · ${e} facts · ${active} active`, + stats: (n: number, e: number, active: number | null) => + `${n} entities · ${e} facts${active === null ? "" : ` · ${active} active`}`, /** 画布只画度数最高的一批。**说清楚画了多少、共多少**——从前这里写的是 * 上限,一个上万实体的库右上角永远是 150 */ - statsCapped: (shown: number, total: number, e: number, active: number) => - `showing ${shown} of ${total} entities · ${e} facts · ${active} active`, + statsCapped: ( + shown: number, + total: number, + shownE: number, + totalE: number, + active: number | null, + ) => + `showing ${shown} of ${total} entities · ${shownE} of ${totalE} facts${active === null ? "" : ` · ${active} active`}`, cappedHint: (shown: number, total: number) => `The canvas draws the ${shown} best-connected entities of ${total}. Search to reach the rest.`, stabilizing: "Stabilizing layout", + scrubUnitHint: "Step size for playback and for each bar", + scrubUnitYear: "Yr", + scrubUnitMonth: "Mo", + scrubUnitDay: "Dy", + scrubBarMerged: (n: number) => `each bar covers ${n} steps`, allTime: "All time", nowBtn: "Now", play: "Play timeline", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 769ffeeac..f1c058c45 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -144,6 +144,14 @@ export const zh: Strings = { unknownKind: (kind: string) => kind, }, + kbScope: { + deniedTitle: "你没有这个知识库的权限", + deniedBody: + "链接指向的库你打不开。找分享给你的人开通权限,或者回到自己的库。", + missingTitle: "这个知识库不在了", + missingBody: "它已被删除,或者链接抄错了。下面是你自己的库。", + myKbs: "我的知识库", + }, nav: { workspaceLabel: "工作区", kbLabel: "知识库", @@ -512,6 +520,15 @@ export const zh: Strings = { }, graph: { untyped: "未分类", + legendMore: (n: number) => `全部 ${n} 个类`, + nodeBudget: "画多少个实体", + nodeBudgetMore: "多画一些", + nodeBudgetLess: "少画一些", + legendSearch: "筛选类", + legendNone: "没有匹配的类", + legendOnly: "只看", + legendShowAll: (n: number) => `显示全部(隐藏了 ${n} 个)`, + legendAllHint: "画面上的全部类,按出现次数排。点一下显示或隐藏。", searchMore: (n: number) => `还有 ${n} 个——再加载 20`, zoomIn: "放大", zoomOut: "缩小", @@ -566,6 +583,9 @@ export const zh: Strings = { "没有人断言过的边——引擎按本体声明的公理推出来的。每一条都附着它用到的前提。", derivedNoProof: "前提已经不在了。", derivedPanel: "推理", + derivedRunAsk: "对整个库重跑一遍推理?", + derivedRunGo: "跑", + derivedRunCancel: "取消", derivedCountLabel: "推出来的边", derivedStateLabel: "定时", derivedLastLabel: "上次推理", @@ -610,13 +630,24 @@ export const zh: Strings = { "被取代的那条断言仍留在台账里。", ongoing: "至今", endedUnknown: "已结束(时间不详)", - stats: (n: number, e: number, active: number) => - `${n} 个实体 · ${e} 条事实 · ${active} 条现行`, - statsCapped: (shown: number, total: number, e: number, active: number) => - `已画 ${shown} / 共 ${total} 个实体 · ${e} 条事实 · ${active} 条现行`, + stats: (n: number, e: number, active: number | null) => + `${n} 个实体 · ${e} 条事实${active === null ? "" : ` · ${active} 条现行`}`, + statsCapped: ( + shown: number, + total: number, + shownE: number, + totalE: number, + active: number | null, + ) => + `已画 ${shown} / 共 ${total} 个实体 · ${shownE} / 共 ${totalE} 条事实${active === null ? "" : ` · ${active} 条现行`}`, cappedHint: (shown: number, total: number) => `画布只画连接最密的 ${shown} 个,库里共 ${total} 个。其余的用搜索找。`, stabilizing: "布局收敛中", + scrubUnitHint: "播放的步长,也是每根柱子的跨度", + scrubUnitYear: "年", + scrubUnitMonth: "月", + scrubUnitDay: "日", + scrubBarMerged: (n: number) => `每根柱子含 ${n} 步`, allTime: "全部时间", nowBtn: "现在", play: "播放时间线", diff --git a/web/src/kb.tsx b/web/src/kb.tsx index 41c5492cf..f52bbbe63 100644 --- a/web/src/kb.tsx +++ b/web/src/kb.tsx @@ -1,9 +1,19 @@ // 当前工作区/知识库上下文:均可切换且 localStorage 记忆;工作区无 KB 时自动创建 "General"。 import { useSyncExternalStore } from "react"; +import { useParams } from "@tanstack/react-router"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { api, type Kb, type Workspace } from "./api"; import { kbStore, wsStore } from "./wsStore"; +/** 当前路径里的知识库 id。**页面都在 /kb/$kbId 之下,所以直接从路径取**—— + * 不必等库列表加载完,链接里写的是谁就是谁。不在作用域内(账户页等)时 + * 回落到记忆里的那个。 */ +export function useKbId(): string { + const params = useParams({ strict: false }) as { kbId?: string }; + const { kb } = useKb(); + return params.kbId ?? kb?.id ?? ""; +} + export function useKb(): { kb: Kb | null; kbs: Kb[]; @@ -39,7 +49,13 @@ export function useKb(): { }); const kbList = kbs.data ?? []; - const kb = kbList.find((k) => k.id === selectedKbId) ?? kbList[0] ?? null; + /* **URL 里有就以 URL 为准**:两者回答的不是同一个问题——地址栏说的是 + "这个链接指向什么",localStorage 说的是"我上次在看什么"。 + 别人分享的链接必须赢过我自己的记忆,否则打开看到的是我的库、 + 数据不同而界面一模一样 */ + const routeParams = useParams({ strict: false }) as { kbId?: string }; + const wantedKbId = routeParams.kbId ?? selectedKbId; + const kb = kbList.find((k) => k.id === wantedKbId) ?? kbList[0] ?? null; return { kb, diff --git a/web/src/pages/AccountShell.tsx b/web/src/pages/AccountShell.tsx index fc6de5412..0828ac722 100644 --- a/web/src/pages/AccountShell.tsx +++ b/web/src/pages/AccountShell.tsx @@ -43,7 +43,7 @@ export function AccountShell() {
{S.account.backToApp} diff --git a/web/src/pages/Chat.tsx b/web/src/pages/Chat.tsx index 27c15749a..b1df800f9 100644 --- a/web/src/pages/Chat.tsx +++ b/web/src/pages/Chat.tsx @@ -33,7 +33,7 @@ import { } from "../api"; import { S } from "../i18n"; import { toast } from "../toast"; -import { useKb } from "../kb"; +import { useKb, useKbId } from "../kb"; import { DangerConfirm, RAIL_CLS } from "../ui"; interface Turn { @@ -49,6 +49,7 @@ const lastKey = (kbId: string) => `chat:last:${kbId}`; const DRAFT_KEY = "chat:draft"; export function Chat() { + const kbId = useKbId(); const { kb, kbs, setKb } = useKb(); const queryClient = useQueryClient(); const navigate = useNavigate(); @@ -126,7 +127,7 @@ export function Chat() { activeIdRef.current = null; setActiveId(null); setTurns([]); - navigate({ to: "/chat", replace: true }); + navigate({ to: "/kb/$kbId/chat", params: { kbId }, replace: true }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [kb?.id]); @@ -138,8 +139,8 @@ export function Chat() { const last = sessionStorage.getItem(lastKey(kb.id)); if (last) { navigate({ - to: "/chat/$conversationId", - params: { conversationId: last }, + to: "/kb/$kbId/chat/$conversationId", + params: { kbId, conversationId: last }, replace: true, }); } @@ -164,7 +165,10 @@ export function Chat() { /** 列表点击只改 URL,装载由路由同步 effect 负责 */ const openConversation = (id: string) => - navigate({ to: "/chat/$conversationId", params: { conversationId: id } }); + navigate({ + to: "/kb/$kbId/chat/$conversationId", + params: { kbId, conversationId: id }, + }); const loadConversation = async (id: string) => { if (streaming) abortRef.current?.(); @@ -188,7 +192,7 @@ export function Chat() { activeIdRef.current = null; setActiveId(null); setTurns([]); - navigate({ to: "/chat", replace: true }); + navigate({ to: "/kb/$kbId/chat", params: { kbId }, replace: true }); } }; @@ -199,7 +203,7 @@ export function Chat() { activeIdRef.current = null; setActiveId(null); setTurns([]); - navigate({ to: "/chat" }); + navigate({ to: "/kb/$kbId/chat", params: { kbId } }); inputRef.current?.focus(); }; @@ -231,8 +235,8 @@ export function Chat() { setActiveId(id); sessionStorage.setItem(lastKey(kb.id), id); navigate({ - to: "/chat/$conversationId", - params: { conversationId: id }, + to: "/kb/$kbId/chat/$conversationId", + params: { kbId, conversationId: id }, replace: true, }); invalidateList(); @@ -580,6 +584,7 @@ function Thinking({ step }: { step?: ChatStep }) { } function TurnView({ turn, live }: { turn: Turn; live?: boolean }) { + const kbId = useKbId(); if (turn.role === "user") { return (
@@ -646,8 +651,8 @@ function TurnView({ turn, live }: { turn: Turn; live?: boolean }) { ) : ( navigate({ to: "/library", search: { src: sel } })} + onSelect={(sel) => navigate({ to: "/kb/$kbId/library", params: { kbId }, search: { src: sel } })} />
@@ -87,7 +89,7 @@ export function DocViewer() { {new Date(doc.created_at).toLocaleDateString()}

- + {S.doc.backToLibrary}
@@ -131,7 +133,8 @@ export function DocViewer() {
@@ -154,7 +157,8 @@ export function DocViewer() { {f.object_id ? ( diff --git a/web/src/pages/Docs.tsx b/web/src/pages/Docs.tsx index beaa706e2..a0e927e9d 100644 --- a/web/src/pages/Docs.tsx +++ b/web/src/pages/Docs.tsx @@ -225,7 +225,7 @@ export function DocsPage() { {/* 右侧:显式返回(与字标双路回城)+ GitHub·版本胶囊 + 登录态 */}
{S.account.backToApp} diff --git a/web/src/pages/EntityHistory.tsx b/web/src/pages/EntityHistory.tsx index 725f329ae..1955b4dfb 100644 --- a/web/src/pages/EntityHistory.tsx +++ b/web/src/pages/EntityHistory.tsx @@ -8,6 +8,7 @@ import { Link } from "@tanstack/react-router"; import { FileText, Merge, PencilLine, Tag, Undo2 } from "lucide-react"; import { api, type EntityHistoryEvent } from "../api"; import { S } from "../i18n"; +import { useKbId } from "../kb"; import { Pager } from "../ui"; const PER = 20; @@ -33,7 +34,22 @@ const KIND_TONE: Record = { retype_reverted: "text-[var(--u-warn)]", }; -const ymd = (iso: string) => iso.slice(0, 10); +/* 这两个函数**故意不一样**,别"统一一下"——它们渲染的是两种时间。 + + `ymd` 给的是**记录时刻**(我们何时这么认为):那是一个真实时刻, + 该按看的人所在的时区显示。从前这里也是切 ISO 字符串,等于按 UTC 显示—— + UTC+8 的人在早上八点前做的修订,历史里会显示成前一天。 + + `ym` 给的是**世界时间**(这件事何时成立):它来自文档里的陈述 + ("2019 年 5 月就任"),是**日历日期不是时刻**,本来就没有时区。 + 切 ISO 字符串正是按 UTC 读回存进去的那一天;转成本地反而会让 + UTC-5 的读者看到前一个月。 */ +const ymd = (iso: string) => { + const d = new Date(iso); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${d.getFullYear()}-${m}-${day}`; +}; const ym = (iso: string | null) => (iso ? iso.slice(0, 7) : null); /** 宾语:实体名优先,其次字面值(属性事实)的摘要/值 */ @@ -57,6 +73,7 @@ function intervalNote(e: EntityHistoryEvent): string | null { } function EventRow({ e }: { e: EntityHistoryEvent }) { + const kbId = useKbId(); const Icon = KIND_ICON[e.kind] ?? FileText; const note = intervalNote(e); return ( @@ -99,8 +116,8 @@ function EventRow({ e }: { e: EntityHistoryEvent }) { <> · ( - entityParam ?? null, + search.focus ?? entityParam ?? null, ); const [selected, setSelected] = useState(entityParam ?? null); const [searchInput, setSearchInput] = useState(""); @@ -283,27 +334,115 @@ export function Graph() { // 推出来的边显不显示。默认显示——推理默认关着,有派生就意味着用户开过开关 const [showDerived, setShowDerived] = useState(true); // 信息窗默认收起:它答的是「什么时候推的」,那是偶尔才问的问题 - const [derivedPanel, setDerivedPanel] = useState(false); + /* Inference 也用原地展开,与「+N 个类」、通知、用户菜单同一套。 + **贴左下角**:塔在画布左下,面板要从那个 ⋯ 按钮往右上长开 */ + const derivedPop = usePopoverFlip( + "bottom left", + ); + /* 「+N 个类」用与通知/用户卡片同一套原地展开:面板压到 chip 的真实边界 + (圆角 999px)再长成卡片。**贴左边,所以锚点角是 top left** */ + const legendPop = usePopoverFlip( + "top left", + ); + const [legendQ, setLegendQ] = useState(""); + /* 正在退场的实体。**面板不能一取消选中就卸载**——那样它是瞬间消失的。 + 先留在原地演完退场,再真的移除。用 selectedRef 取当前值而不是把 + setState 写成带副作用的 updater:那种写法在 StrictMode 下会跑两遍 */ + const [exiting, setExiting] = useState(null); + const deselect = useCallback(() => { + const cur = selectedRef.current; + if (!cur) return; + setExiting(cur); + setSelected(null); + window.setTimeout(() => setExiting(null), 170); + }, []); /** null = 全时段;数值 = as-of 时刻(ms)。 默认 as-of 今天:时态平台的图谱默认呈现"现在的世界", 已闭合的事实不该与现行事实无差别并列(All time 是显式选择) */ - const [timeT, setTimeT] = useState(() => Date.now()); + /* 时间轴。URL 里带了就用它:`all` = 全时段,否则按 YYYY-MM-DD 解析 + (与数据的 day 级精度一致,也比一串毫秒好读) */ + const [timeT, setTimeT] = useState(() => { + if (search.at === "all") return null; + if (search.at) { + const t = Date.parse(search.at); + if (!Number.isNaN(t)) return t; + } + return Date.now(); + }); const [activeCount, setActiveCount] = useState(0); const [stabilizing, setStabilizing] = useState(false); /* 播放态提升到此层:reducer 需区分"播放推进"(淡入)与"手动拖动"(瞬切) */ const [playing, setPlaying] = useState(false); + + /* 画面 → 地址栏。**replace 不是 push**:点节点是浏览不是导航, + 堆进历史会把「后退」变成逐个撤销点击。 + 播放中整段跳过——每帧写一次 URL 是灾难 */ + useEffect(() => { + if (playing) return; + const at = + timeT === null + ? "all" + : // 停在「现在」就不写。否则每次打开都在地址栏拖一串今天的日期, + // 而那本来就是默认值 + Math.abs(timeT - Date.now()) < DAY_MS + ? undefined + : new Date(timeT).toISOString().slice(0, 10); + const next = { + entity: selected ?? undefined, + // **与 entity 相同就不写**:点搜索结果会同时设这两个, + // 照直写出来地址栏里就是同一串 UUID 出现两遍。 + // 只有"聚焦在 A 的邻域、却选中了 B"时它才带信息 + focus: + focusEntity && focusEntity !== selected ? focusEntity : undefined, + at, + }; + if ( + next.entity === search.entity && + next.focus === search.focus && + next.at === search.at + ) + return; + navigate({ + to: "/kb/$kbId/graph", + params: { kbId }, + search: next, + replace: true, + }); + }, [ + selected, + focusEntity, + timeT, + playing, + search.entity, + search.focus, + search.at, + navigate, + ]); + + /* 地址栏 → 画面。**这一半是给后退/前进用的**:没有它,浏览器回退 + 只改地址不改画面,看起来像后退失灵。两个方向都先比较再动手,所以不会打架 */ + useEffect(() => { + const e = search.entity ?? null; + const f = search.focus ?? null; + setSelected((cur) => (cur === e ? cur : e)); + setFocusEntity((cur) => (cur === f ? cur : f)); + }, [search.entity, search.focus]); /* 布局模式:force = FA2 斥力;circular = 圆环;pack = 按类型圆填充聚簇 */ type LayoutMode = "force" | "circular" | "pack"; const [layoutMode, setLayoutMode] = useState("force"); const layoutModeRef = useRef("force"); const layoutCtlRef = useRef<{ apply: (m: LayoutMode) => void } | null>(null); + /* 画多少个。**进 queryKey**——不进的话调了档位不会重新取数, + 界面看着变了实际还是老数据 */ + const [nodeBudget, setNodeBudget] = useState(NODE_BUDGETS[0]); + const data = useQuery({ - queryKey: ["graph", kb?.id, focusEntity], + queryKey: ["graph", kb?.id, focusEntity, nodeBudget], queryFn: () => focusEntity ? api.graphNeighborhood(kb!.id, focusEntity) - : api.graphOverview(kb!.id), + : api.graphOverview(kb!.id, nodeBudget), enabled: !!kb, }); @@ -386,22 +525,37 @@ export function Graph() { const types = useMemo(() => { const map = new Map< string, - { label: string; color: string; shape: string } + { label: string; color: string; shape: string; count: number } >(); for (const n of data.data?.nodes ?? []) { // 没判出类型的归到空 key 一档(0009)。真实 key 由 IRI 派生,不可能为空, // 所以它撞不着任何一个类;标签走 i18n,别把 null 画到图例上 const key = n.type_key ?? ""; - if (!map.has(key)) + const cur = map.get(key); + if (cur) cur.count++; + else map.set(key, { label: n.type_label ?? S.graph.untyped, color: n.color, shape: n.shape, + count: 1, }); } - return [...map.entries()]; + // **按出现次数排,不是按遇到的先后**。图例只摆得下几个,那几个位置该给 + // 画面上最多的类;从前是节点到达顺序,等于随机。次数相同按标签排—— + // 否则同样的数据每次刷新顺序都在抖 + return [...map.entries()].sort( + (a, b) => b[1].count - a[1].count || a[1].label.localeCompare(b[1].label), + ); }, [data.data]); + /* 摆得下的 / 收起来的。收起来的那些仍然可以在「+N」里搜到并切换 */ + const legendShown = types.slice(0, LEGEND_MAX); + const legendRest = types.slice(LEGEND_MAX); + // 被收起来的类里有没有正被隐藏的。**没有这个标记就是无声过滤**—— + // 在面板里关掉一个类、把面板一收,界面上再没有任何东西说它被关了 + const hiddenInRest = legendRest.filter(([k]) => hiddenTypes.has(k)).length; + // 有几条推出来的边。**为零时那个开关整个不出现**——一个没开推理的库不该 // 看到一个永远切换不出任何变化的按钮 const derivedCount = useMemo( @@ -463,6 +617,96 @@ export function Graph() { sigmaRef.current?.refresh(); }, [hiddenTypes, showDerived]); + const deriveRafRef = useRef(0); + /* 演完之前派生边不出现。**开关是"要不要显示",这个是"演到了没有"**—— + 两件事,混成一个会让关掉再打开时少演一遍 */ + const [derivedRevealed, setDerivedRevealed] = useState(false); + /* reducer 是每帧跑的闭包,读 state 会读到旧值——它只认 ref */ + const derivedRevealedRef = useRef(false); + useEffect(() => { + derivedRevealedRef.current = derivedRevealed; + sigmaRef.current?.refresh(); + }, [derivedRevealed]); + + const revealDerived = useCallback(() => { + setDerivedRevealed(true); + // 复用开关那套淡入:方向为"开",从近背景色亮到常态 + derivedToggleRef.current = { at: performance.now(), on: true }; + const step = () => { + const tr = derivedToggleRef.current; + const done = !tr || performance.now() - tr.at >= DERIVE_FADE_MS; + if (done) derivedToggleRef.current = null; + sigmaRef.current?.refresh(); + deriveRafRef.current = done ? 0 : requestAnimationFrame(step); + }; + cancelAnimationFrame(deriveRafRef.current); + deriveRafRef.current = requestAnimationFrame(step); + }, []); + + useEffect(() => () => cancelAnimationFrame(deriveRafRef.current), []); + + /* 开关的淡入淡出:{ 起始时刻, 朝哪个方向 };null = 没有过渡在飞 */ + const derivedToggleRef = useRef<{ at: number; on: boolean } | null>(null); + const derivedRafRef = useRef(0); + /* 上一次的开关值。**判「是不是真的切换了」只能靠它**——effect 的依赖里 + 还有 derivedCount,而「Run now 推出新边」会改 count 却没碰开关; + 只看 effect 触发就淡一次,那是一次没人要求的动画 */ + const prevShowDerived = useRef(showDerived); + + // 切换时走一段渐变,而不是瞬间消失。**得自己驱动重绘**——关掉时下面那个 + // 呼吸定时器不转了,没人推 sigma 重画,淡出就会卡在第一帧 + useEffect(() => { + const changed = prevShowDerived.current !== showDerived; + prevShowDerived.current = showDerived; + // 首次挂载与「只有 count 变了」都不是切换: + // 进页面时、以及推理跑完刷新计数时,都不该看到一段莫名其妙的淡入 + if (!changed) return; + // 数量太多时不淡:与呼吸同一条线——每帧重算几千条边的颜色换来的是卡顿。 + // **写出来而不是悄悄降级** + if (derivedCount > DERIVED_ANIMATE_MAX) return; + + const now = performance.now(); + const prev = derivedToggleRef.current; + // 半途反向(用户连点两下):从当前进度接着走,而不是从头开始—— + // 否则会看见一次亮度的跳变 + const at = + prev && prev.on !== showDerived + ? now - Math.max(0, DERIVED_TOGGLE_MS - (now - prev.at)) + : now; + derivedToggleRef.current = { at, on: showDerived }; + + const step = () => { + const tr = derivedToggleRef.current; + const done = !tr || performance.now() - tr.at >= DERIVED_TOGGLE_MS; + if (done) derivedToggleRef.current = null; + sigmaRef.current?.refresh(); + derivedRafRef.current = done ? 0 : requestAnimationFrame(step); + }; + cancelAnimationFrame(derivedRafRef.current); + derivedRafRef.current = requestAnimationFrame(step); + // **不在这里挂清理**:清理会在依赖变化时也跑一遍,而依赖里有 derivedCount + // ——推理恰好在这 420ms 中途跑完,动画就被掐在半路(画面停在一半亮度, + // 要等下一次任意重绘才归位)。循环自己会终止;取消只该发生在卸载时 + }, [showDerived, derivedCount]); + + // 卸载时收掉可能在飞的那一帧 + useEffect(() => () => cancelAnimationFrame(derivedRafRef.current), []); + + /* 什么时候进场。两个入口共用一段延时:进页面、以及手动打开开关。 + **不等布局收敛**——收敛要 2.5 秒,等完人早就在看别处了。 + + **顺序本身是内容**:先落位的是人写下的边,然后才轮到推出来的。 + 一起出现就分不清谁在前 */ + useEffect(() => { + if (!showDerived || !data.data) { + if (!showDerived) setDerivedRevealed(false); + return; + } + if (derivedRevealed) return; + const t = window.setTimeout(revealDerived, DERIVE_SETTLE_MS); + return () => window.clearTimeout(t); + }, [showDerived, data.data, derivedRevealed, revealDerived]); + // 派生边的呼吸。**只在有派生边、且开着显示、且数量不多时才转**—— // 一个没开推理的库不该为这件事每两秒重画一次 useEffect(() => { @@ -527,6 +771,10 @@ export function Graph() { let fa2Settings: ReturnType | null = null; if (g.order > 0) { circular.assign(g, { scale: 300 }); + /* 试过按规模缩放(gravity 0.12–0.22 / scalingRatio 11–16 + 加大阻尼), + 拿真实的图一看就否了:散是散开了,但那种"被推开"的张力没了, + 整张图显得瘫。**这一组是既有的、刻意偏大的**——要的是节点之间 + 互相顶着的感觉,不是最省力的排布 */ const settings = { ...forceAtlas2.inferSettings(g), gravity: 0.35, @@ -648,6 +896,8 @@ export function Graph() { const f = filterRef.current; const res = { ...attrs }; const base = attrs.size as number; + // 状态环取节点自己的类型色(见 RING_*_MIX 处的理由) + const ownColor = (attrs.typeColor as string) ?? NODE_CORE_BASE; if (f.hiddenTypes.has(attrs.typeKey as string)) { res.hidden = true; return res; @@ -662,15 +912,34 @@ export function Graph() { res.label = ""; res.zIndex = 0; }; - // hover 只提亮自身(不压暗全图);压暗聚焦只属于点击选中 + /* 悬停时其余的按 HOVER_MUTE 压一档(选中是压到底)。 + 邻居不压——悬停要回答的是"它连着谁",把邻居也压掉就等于没回答 */ + const softMute = () => { + res.size = base * (1 - 0.48 * HOVER_MUTE); + res.color = lerpColor( + String(attrs.color ?? NODE_CORE_BASE), + mix(MUTED_SHELL, NODE_CORE_BASE, 0.3), + HOVER_MUTE, + ); + res.shellColor = lerpColor( + String(attrs.shellColor ?? NODE_SHELL_BASE), + MUTED_SHELL, + HOVER_MUTE, + ); + res.borderColor = TRANSPARENT; + res.ringColor = TRANSPARENT; + res.label = ""; + res.zIndex = 0; + }; if (hoverRef.current === node) { res.size = Math.max(base * 1.08, 10.4); - res.ringColor = RING_HOVERED; + res.ringColor = mix(ownColor, "#ffffff", RING_HOVER_MIX); // 悬浮卡接管标签展示;label 本身保留(悬浮卡靠它渲染标题) res.hideBaseLabel = true; res.zIndex = 4; return res; } + const hov = hoverRef.current; // 选中实体可能不在当前画布(侧栏跳转/邻域重载间隙)——不在则跳过聚焦压暗逻辑 const sel = selectedRef.current && g.hasNode(selectedRef.current) @@ -679,7 +948,7 @@ export function Graph() { if (sel) { if (node === sel) { res.size = Math.max(base * 1.02, 9.2); - res.ringColor = RING_SELECTED; + res.ringColor = mix(ownColor, "#ffffff", RING_SELECT_MIX); res.forceLabel = true; res.zIndex = 3; return res; @@ -692,6 +961,14 @@ export function Graph() { muteNode(); return res; } + } else if (hov && hov !== node && !g.areNeighbors(hov, node)) { + // **悬停也压暗其余**,只是比选中轻一档(见 HOVER_MUTE)。 + // 邻居留着:悬停要回答的正是"它连着谁"。 + // **此刻还不存在的节点直接压到底**:这个分支会提前 return, + // 绕过下面那道时间过滤,只压一半的话它反而比不 hover 时更亮 + if (f.activeNodes && !f.activeNodes.has(node)) muteNode(); + else softMute(); + return res; } else { // default {×0.7} res.size = base * 0.7; @@ -737,12 +1014,47 @@ export function Graph() { // 推出来的边:先看藏不藏,再决定呼吸到哪一档。 // **放在最前面**——藏起来的边不必再算后面那些提亮/压暗 const isDerived = attrs.derived === true; + if (isDerived) { - if (!f.showDerived) { + // 还没轮到它进场:先不画。**事实先落位,推出来的后到** + if (!derivedRevealedRef.current) { res.hidden = true; return res; } - res.color = lerpColor( + const tr = derivedToggleRef.current; + const k = tr + ? Math.min(1, (performance.now() - tr.at) / DERIVED_TOGGLE_MS) + : 1; + // 关掉了:只有「淡出尚未走完」这一种情况还留着不藏 + if (!f.showDerived) { + if (!tr || tr.on || k >= 1) { + res.hidden = true; + return res; + } + /* 由当前颜色渐灭到近背景色。**暗度必须编码进 RGB** + (见 EDGE_DIM 处的注释:预乘混合下 alpha 压不暗边), + 所以是往 EDGE_DIM 混而不是降 alpha。 + + **起点不能一律写死成满亮的金**:这个分支在悬停/选中的压暗逻辑 + 之前就 return 了,于是一条本来被压成暗色的无关派生边, + 会先跳回满亮再淡出——那一跳就是"关派生时无关的边闪一下"。 + 起点得取它此刻本来的样子 */ + const selNow = + selectedRef.current && g.hasNode(selectedRef.current) + ? selectedRef.current + : null; + const hovNow = hoverRef.current; + const focused = selNow ?? hovNow; + const from = !focused + ? EDGE_COLOR_DERIVED + : s === focused || t === focused + ? EDGE_FOCUS_DERIVED + : EDGE_DIM; + res.color = lerpColor(from, EDGE_DIM, k); + res.label = ""; + return res; + } + const pulse = lerpColor( EDGE_COLOR_DERIVED_DIM, EDGE_COLOR_DERIVED, // 三角波而不是正弦:两端各停一瞬,看起来是「呼吸」不是「闪」 @@ -751,6 +1063,9 @@ export function Graph() { 1, ), ); + // 打开:从近背景色亮起来,接上呼吸 + res.color = + tr && tr.on && k < 1 ? lerpColor(EDGE_DIM, pulse, k) : pulse; } // hover: 只提亮关联边;selected: 提亮关联边 + 压暗其余 const hov = hoverRef.current; @@ -759,12 +1074,26 @@ export function Graph() { ? selectedRef.current : null; const boost = () => { - res.color = EDGE_FOCUS; + res.color = isDerived ? EDGE_FOCUS_DERIVED : EDGE_FOCUS; res.size = Math.max((attrs.size as number) * 1.42, 1.85); res.zIndex = 5; }; - if (hov && (s === hov || t === hov)) { + /* **时间轴停在某一刻时,这条边此刻存不存在**。 + 悬停的两条分支都会提前 return,绕过下面那道时间过滤—— + 不带上它的话,一 hover,所有"还没长出来"的边会从近背景色 + 跳到常态色的 45%,看起来是被点亮了。实测就是这么亮的 */ + const liveNow = !f.activeEdges || f.activeEdges.has(edge); + if (hov && (s === hov || t === hov) && liveNow) { boost(); + } else if (hov && !sel) { + // 悬停时其余的边也退下去,但**只退一半**——与节点那边同一个 HOVER_MUTE。 + // 压到底是选中才有的待遇。此刻不存在的边**本来就该是暗的**, + // 从 EDGE_DIM 起混等于原地不动 + const from = liveNow ? String(res.color) : EDGE_DIM; + res.color = lerpColor(from, EDGE_DIM, HOVER_MUTE); + res.size = (attrs.size as number) * (1 - 0.4 * HOVER_MUTE); + res.label = ""; + return res; } else if (sel) { if (s === sel || t === sel) { boost(); @@ -796,7 +1125,7 @@ export function Graph() { setFocusEntity(node); setSelected(node); }); - sigma.on("clickStage", () => setSelected(null)); + sigma.on("clickStage", () => deselect()); sigma.on("enterNode", ({ node }) => { hoverRef.current = node; sigma.refresh(); @@ -963,9 +1292,11 @@ export function Graph() { )} - {/* 图例(点击切换类型显隐) */} + {/* 图例(点击切换类型显隐)。**只摆前 LEGEND_MAX 个**,其余收进 + 「+N 个类」——那一排横着长,类一多就换行把画布顶下去;而且十几个 + 一模一样的胶囊排开,谁重要也读不出来 */}
- {types.map(([key, t]) => ( + {legendShown.map(([key, t]) => ( ))} + + {/* chip 上的数是**全部类**,不是被收起来的那几个—— + 点开看到的就是全部(搜得到任何一个),写「+3」等于承诺了另一件事 */} + {/* 复位。**只要存在隐藏就给一步到位的出口**——「只看」很容易把 + 画面收得很窄,没有这个就得挨个点回来 */} + {hiddenTypes.size > 0 && ( + + )} + + {legendRest.length > 0 && ( +
+ + {legendPop.open && ( +
+ {/* 面板盖在 chip 原位,所以**第一行就长成那个 chip 的样子**, + 点它收回去——「哪儿展开的就从哪儿收回去」, + 与通知/用户卡片的关闭键跟触发键原位重合是同一个道理 */} + + setLegendQ(e.target.value)} + placeholder={S.graph.legendSearch} + className="input-dark mb-1.5 w-full px-2 py-1 text-[12px]" + /> + {/* **列的是全部类,不只是收起来的那些**:想找一个类的时候, + 没人记得它是不是恰好排进了前几个 */} +
+ {types + .filter(([, t]) => + t.label.toLowerCase().includes(legendQ.toLowerCase()), + ) + .map(([key, t]) => ( + /* **一行两个按钮,不是一个按钮循环三态。** + 单键循环的代价是:不看当前状态就不知道下一次点击 + 会发生什么,而且从「只看」回到正常必须路过「排除」 + ——想清空却得先让画面变成另一个错的样子。 + 拆开之后每个手势含义固定 */ +
+ + {/* 「只看这个」:类一多时最想要的动作。**给显式按钮而不是 + 修饰键**——alt+点击没人猜得到,这里横向有地方 */} + + + {t.count} + +
+ ))} + {types.every( + ([, t]) => + !t.label.toLowerCase().includes(legendQ.toLowerCase()), + ) && ( +
+ {S.graph.legendNone} +
+ )} +
+
+ )} +
+ )}
- {/* 推出来的边:**自成一组,不进类型图例。** - 图例回答「显示哪些类」,一排全是本体里的类;这个回答的是 - 「显不显示推出来的边」——不是同一个问题,混进那一排它就像是 - 多出来的一个类。为零时整组不出现 */} - {derivedCount > 0 && ( -
+ {/* 右上:能调「画多少个」+ 统计。**统计说的正是这个数** + (「画了 150 个,共 548 个」),把调节放在它旁边,改的是谁一目了然。 + 外壳保持中性——这一片是 chrome,彩色只属于数据 */} +
+
+
- {/* 展开成一个小窗:这批边是什么时候推的、现在还推不推、手动再跑一次。 - **与开关分成两个按钮**——「藏起来」是每天要点的,「什么时候推的」 - 是偶尔才问的,合成一个会让常用动作多一步 */} + {/* **画满了就别再给「多画」**:库里一共就这么多,再调高什么也不会变, + 而一个点了没反应的按钮比没有这个按钮更糟 */} - {derivedPanel && kb && ( - setDerivedPanel(false)} - /> - )}
- )} - -
- {stabilizing && ( - {S.graph.stabilizing} · - )} +
{/* 画满上限时说清「画了多少 / 共多少」。**这个数从前是上限冒充规模**—— 一个上万实体的库右上角永远写着 150 */} {capped ? ( + {/* **事实也用「已画 / 共」的口径**:从前这里给的是库里的总数, + 而实体给的是「画了多少 / 共多少」——同一句话里两套口径, + 于是调档位时实体数在变、事实数纹丝不动,看着像坏了。 + 没有时间筛选时 active 恒等于已画条数,那就不说 */} {S.graph.statsCapped( nodeCount, totalNodes, + edgeCount, totalEdges, - timeT === null ? edgeCount : activeCount, + timeT === null ? null : activeCount, )} ) : ( S.graph.stats( nodeCount, edgeCount, - timeT === null ? edgeCount : activeCount, + timeT === null ? null : activeCount, ) )} +
+
+ {/* **单独一行,不做统计文字的前缀。** + 当前缀时它一出现就把整块撑宽,而这一块是靠右的—— + 于是每次重新布局,左边的档位按钮都会被挤着跳一下。 + 自己占一行,第一行的宽度就不再随它变 */} + {stabilizing && ( +
+ + {S.graph.stabilizing} +
+ )}
@@ -1064,9 +1536,80 @@ export function Graph() {
- {/* 左下控件塔:布局切换 + 相机(右下归实体侧栏,底部中央归时间岛) */} -
-
+ {/* 左下控件塔:推出来的边 + 布局切换 + 相机(右下归实体侧栏,底部中央归时间岛) */} + {/* **items-start**:列内项目默认 stretch,一组展开就会把其余几组 + 一起拉到同宽——那几组的字还收着,于是看着是几个莫名其妙的空白长条。 + 各自按内容收放,才是「一组一组展开,不牵连别人」 */} +
+ {/* 推出来的边:**自成一组,也不进类型图例。** + 图例回答「显示哪些类」,一排全是本体里的类;这个回答的是 + 「显不显示推出来的边」——不是同一个问题。为零时整组不出现。 + + **摆到这座塔上,是绕开一对矛盾走的**:放在顶栏图例旁边,它长得 + 像第 10 个类;想靠颜色把它区分开,又撞上这文件开头那条既定原则 + ——「chrome 零色偏、彩色只属于数据」(见调色板那段注释)。 + 往框架里塞一块高饱和金底,是整个界面唯一的彩色色块,扎眼且不成体系。 + + 这座塔本来就是「视图怎么看」的地盘(布局、缩放), + 「显不显示推出来的边」正是同一族问题。外壳保持中性, + 金色只出现在图标本身——与色点用在类胶囊上是同一个做法。 */} + {derivedCount > 0 && ( + /* **两层**:外层只负责定位,内层才有 overflow-hidden。 + 那个类是给按钮堆裁圆角的,可面板是同一个盒子的子元素—— + 合成一层的话面板会被一起裁掉,实测只剩塔本身那 32px 宽 */ +
+
+ +
+ {/* 展开成一个小窗:这批边是什么时候推的、现在还推不推、手动再跑一次。 + **与开关分成两个按钮**——「藏起来」是每天要点的,「什么时候推的」 + 是偶尔才问的,合成一个会让常用动作多一步 */} + +
+ {derivedPop.open && kb && ( + derivedPop.close()} + /> + )} +
+ )} +
{( [ { key: "force", Icon: Orbit, label: S.graph.layoutForce }, @@ -1086,34 +1629,37 @@ export function Graph() { layoutModeRef.current = key; layoutCtlRef.current?.apply(key); }} - className={`p-2 transition-colors ${ + className={`flex items-center p-2 transition-colors ${ layoutMode === key ? "text-white bg-white/[0.1]" : "text-neutral-400 hover:text-white hover:bg-white/[0.06]" }`} > + {label} ))}
-
+
@@ -1149,12 +1696,13 @@ export function Graph() { /> )} - {/* 实体侧栏 */} - {selected && kb && ( + {/* 实体侧栏。**取消选中之后还要多留 170ms**:那段时间它在演退场 */} + {(selected || exiting) && kb && ( setSelected(null)} + entityId={(selected ?? exiting)!} + exiting={!selected} + onClose={deselect} onNavigate={(id) => { // 跳转目标可能不在当前画布:同时把图 refocus 到它的邻域(与搜索选择一致) setFocusEntity(id); @@ -1184,6 +1732,33 @@ function scrubValueAt( return Math.min(maxTs, minTs + Math.round((raw - minTs) / DAY_MS) * DAY_MS); } +/** 播放/柱子的步长。**这两件事本来就该是同一个单位**——从前柱子按年、 + * 播放按天,界面上没有任何地方说得出「一格是多久」。 */ +type ScrubUnit = "year" | "month" | "day"; + +/** 一根柱子最多画多少根。超过就把相邻的桶并起来画——**只影响画,不影响 + * 播放步长**:日单位下 15 年有五千多个桶,一根一像素也画不下, + * 但播放仍然是一天一步。并了几个会在提示里说出来,不闷着 */ +const SCRUB_MAX_BARS = 220; +/** 整条轨走完的目标时长。**与单位无关**——单位换的是颗粒度与密度, + * 不该顺带把「等多久」也换掉:日单位若按「一天一拍」走,15 年要放二十分钟 */ +const SCRUB_PLAY_MS = 18000; + +function bucketStart(ts: number, unit: ScrubUnit): number { + const d = new Date(ts); + if (unit === "year") return Date.UTC(d.getUTCFullYear(), 0, 1); + if (unit === "month") + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1); + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); +} +function bucketNext(ts: number, unit: ScrubUnit): number { + const d = new Date(ts); + if (unit === "year") return Date.UTC(d.getUTCFullYear() + 1, 0, 1); + if (unit === "month") + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1); + return ts + DAY_MS; +} + function TimeScrubber({ edges, value, @@ -1199,10 +1774,27 @@ function TimeScrubber({ onPlayingChange: (v: boolean) => void; }) { const setPlaying = onPlayingChange; + /* 默认年:**大多数库跨度都以年计**,一进来先给能一眼看全的那一档 */ + const [unit, setUnit] = useState("year"); + /* 走完整条的次数。**拿它当 key**——同一个元素上重复触发同一个动画不会重播, + 换 key 让它重新挂载才会 */ + const [sweep, setSweep] = useState(0); + /* 指针在轨道上时,已走过的那段提亮。**它回答的是"我走到哪了"**—— + 不播的时候整条都是同一档灰,看不出进度停在哪;而这正是人把指针 + 移上来想知道的事 */ + const [trackHover, setTrackHover] = useState(false); const trackRef = useRef(null); const draggingRef = useRef(false); + /* 拖动落点。**播放循环有自己的浮点累加器**,不读 value——否则每帧的取整 + 误差会积起来。所以光改 value 是没用的,下一帧就被原样覆盖回去。 + 拖动把落点放进这里,循环下一帧接手,从新位置继续走 */ + const seekRef = useRef(null); + const seek = (v: number) => { + seekRef.current = v; + onChange(v); + }; - const { minTs, maxTs, bars } = useMemo(() => { + const { minTs, maxTs, bars, merged, trackW } = useMemo(() => { const now = Date.now(); const froms = edges .map((e) => (e.valid_from ? Date.parse(e.valid_from) : NaN)) @@ -1210,42 +1802,83 @@ function TimeScrubber({ const min = froms.length ? Math.min(...froms) : now - 5 * 365 * 24 * 3600 * 1000; - const minYear = new Date(min).getUTCFullYear(); - const maxYear = new Date(now).getUTCFullYear(); + // 起点对齐到单位边界:否则第一根柱子是半格,读起来像数据缺了一块 + const start = bucketStart(min, unit); + const counts = new Map(); for (const t of froms) { - const y = new Date(t).getUTCFullYear(); - counts.set(y, (counts.get(y) ?? 0) + 1); + const k = bucketStart(t, unit); + counts.set(k, (counts.get(k) ?? 0) + 1); } - const peak = Math.max(1, ...counts.values()); - const bars: { year: number; h: number }[] = []; - for (let y = minYear; y <= maxYear; y++) { - bars.push({ year: y, h: (counts.get(y) ?? 0) / peak }); + const raw: { ts: number; n: number }[] = []; + for (let t = start; t <= now; t = bucketNext(t, unit)) + raw.push({ ts: t, n: counts.get(t) ?? 0 }); + + // 画不下就并桶。**并的是画,不是步长** + const group = Math.max(1, Math.ceil(raw.length / SCRUB_MAX_BARS)); + const cells: { ts: number; n: number }[] = []; + for (let i = 0; i < raw.length; i += group) { + const slice = raw.slice(i, i + group); + cells.push({ + ts: slice[0].ts, + n: slice.reduce((a, b) => a + b.n, 0), + }); } - return { minTs: Date.UTC(minYear, 0, 1), maxTs: now, bars }; - }, [edges]); + const peak = Math.max(1, ...cells.map((c) => c.n)); + + // 单位越大 → 桶越少 → 岛越短;越小 → 越长。**但下限要抬得够高**: + // 岛里那排固定控件(播放键 + 单位选择器 + 两个年份 + 日期 + All time/Now) + // 本身就要四百多像素,岛只有 320 时 flex-1 的轨道被压成 0—— + // 实测柱子一根都看不见,整条是空的。 + // + // 抬高之后单位主要改变的是**每根柱子的粗细**:同一条轨道, + // 年是十几根粗块,日是两百多根细线。这比整条伸缩更说明问题 + const w = Math.min(780, Math.max(660, 380 + cells.length * 2)); + + return { + minTs: start, + maxTs: now, + bars: cells.map((c) => ({ ts: c.ts, h: c.n / peak, n: c.n })), + merged: group, + trackW: w, + }; + }, [edges, unit]); // 播放按日推进(数据即 day 精度),日子快速翻过;整体节奏仍 ≈ 一个月/260ms。 // rAF 时间驱动:帧率无关,内部浮点累加避免取整漂移,值只在跨天时才下发 useEffect(() => { if (!playing) return; - const SPEED = MONTH_MS / 260; // 每毫秒真实时间推进的时间线毫秒数 + // 整条走完约 SCRUB_PLAY_MS,与单位无关;单位只决定落点取整到哪一格 + const SPEED = (maxTs - minTs) / SCRUB_PLAY_MS; let raf = 0; let last = performance.now(); let acc = value ?? minTs; - let lastSnapped = Number.NaN; + let lastPushed = 0; const step = (now: number) => { + // 有人拖过了:从落点接着走,而不是沿原来的轨迹 + if (seekRef.current !== null) { + acc = seekRef.current; + seekRef.current = null; + } acc += (now - last) * SPEED; last = now; if (acc >= maxTs) { setPlaying(false); onChange(null); + // 走到头了扫一道光。**这是个收尾**——播放停下、时间跳回全时段, + // 没有交代的话看着像中途断了;一道光扫过说明"这条走完了" + setSweep((n) => n + 1); return; } - const snapped = minTs + Math.round((acc - minTs) / DAY_MS) * DAY_MS; - if (snapped !== lastSnapped) { - lastSnapped = snapped; - onChange(snapped); + // **连续推进,不按桶跳。** 从前按 `bucketStart` 取整下发,年单位下 + // 一次就是一年——播放头一格一格蹦,看着像卡顿而不是在走。 + // 单位现在只管**显示**(标签精度、柱子跨度),不再管推进的步长。 + // + // 代价是下发变密(每帧一次),而每次下发都要重算全图的现行边, + // 所以限到 ~30fps:肉眼看不出与 60fps 的差别,重算量减半 + if (now - lastPushed >= 33) { + lastPushed = now; + onChange(Math.round(acc)); } raf = requestAnimationFrame(step); }; @@ -1253,7 +1886,7 @@ function TimeScrubber({ return () => cancelAnimationFrame(raf); // 只随播放开关重启:acc 在循环内自持,value 帧帧变不应重建循环 // eslint-disable-next-line react-hooks/exhaustive-deps - }, [playing, minTs, maxTs]); + }, [playing, minTs, maxTs, unit]); // 展示到日:与数据的 day 级 valid_precision 对齐 const label = (() => { @@ -1261,17 +1894,36 @@ function TimeScrubber({ const d = new Date(value); const mm = String(d.getUTCMonth() + 1).padStart(2, "0"); const dd = String(d.getUTCDate()).padStart(2, "0"); + // 精度跟着单位:年单位下写出「2019-01-01」是假精确 + if (unit === "year") return `${d.getUTCFullYear()}`; + if (unit === "month") return `${d.getUTCFullYear()}-${mm}`; return `${d.getUTCFullYear()}-${mm}-${dd}`; })(); - const minYear = bars[0]?.year; - const maxYear = bars[bars.length - 1]?.year; + const minYear = bars.length + ? new Date(bars[0].ts).getUTCFullYear() + : undefined; + const maxYear = bars.length + ? new Date(bars[bars.length - 1].ts).getUTCFullYear() + : undefined; return ( -
+ /* 宽度随单位变:单位大 → 桶少 → 短;单位小 → 桶多 → 长而密。 + 仍夹在视口内(calc 那一项),窄屏不会顶出去。 + 实测宽度:年 320 / 月 648 / 日 760。 */ +
+ {/* 步长。**播放与柱子共用它**——从前柱子按年、播放按天, + 界面上没有一处说得出「一格是多久」 */} +
+ {(["year", "month", "day"] as const).map((u) => ( + + ))} +
+ {minYear} @@ -1287,30 +1967,61 @@ function TimeScrubber({ {/* 密度带轨道:内嵌浅色井 + 每年事实量柱 */}
setTrackHover(true)} + onMouseLeave={() => setTrackHover(false)} + className="relative h-9 min-w-[150px] flex-1 overflow-hidden rounded-lg bg-white/[0.04]" > -
+ {/* 演完由 **React** 卸载,**别自己 `remove()`**。 + 从前是 `onAnimationEnd={(e) => e.currentTarget.remove()}`—— + 把 React 管着的节点从 DOM 里抠走,它自己并不知道。下一次扫光时 + key 变了,React 去移除"旧节点",而那个节点已经不在父节点里, + removeChild 抛 NotFoundError,未捕获的错误让整棵树卸载重挂: + 现象就是**连播两轮之后界面像刷新了一次** */} + {sweep > 0 && ( + setSweep(0)} + /> + )} + {/* **间隙必须随密度收**:写死 2px 时,日单位下 216 根柱子有 215 个间隙 + ≈ 430px,而轨道内宽才 ~455px——柱子被挤成 0.1px,整条看起来是空的。 + 实测就是这么丢的。柱子稀疏时留 2px 好数,密了就贴在一起当密度带看 */} +
120 ? 0 : bars.length > 40 ? 1 : 2 }} + > {bars.map((b) => { - // 进入即亮(年初为判据):播放头脚下的柱子即已覆盖——进度条通用语义 - const barTs = Date.UTC(b.year, 0, 1); - const past = value !== null && barTs <= value; + // 进入即亮(桶起点为判据):播放头脚下的柱子即已覆盖——进度条通用语义 + const past = value !== null && b.ts <= value; + const d = new Date(b.ts); + const stamp = + unit === "year" + ? `${d.getUTCFullYear()}` + : unit === "month" + ? `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}` + : d.toISOString().slice(0, 10); return (
1 ? ` · ${S.graph.scrubBarMerged(merged)}` : ""}`} >
@@ -1324,25 +2035,24 @@ function TimeScrubber({ max={maxTs} step={DAY_MS} value={value ?? maxTs} - onChange={(e) => { - setPlaying(false); - onChange(Number(e.target.value)); - }} + /* **拖动不停播**:拖是"我要看那一段",不是"我要停下"—— + 松手之后应该从新位置继续走到底。 + (`All time` / `Now` 那两个按钮仍然停:那是明确的跳转,不是擦洗) */ + onChange={(e) => seek(Number(e.target.value))} // 原生 range 的拖拽手势会被页面级鼠标监听(如图上拖节点)干扰—— // 自己用 pointer capture 驱动拖动,点击与拖拽都走同一条计算路径 onPointerDown={(e) => { - setPlaying(false); draggingRef.current = true; try { e.currentTarget.setPointerCapture(e.pointerId); } catch { /* 合成事件的 pointerId 可能无效,忽略 */ } - onChange(scrubValueAt(e.clientX, trackRef.current, minTs, maxTs)); + seek(scrubValueAt(e.clientX, trackRef.current, minTs, maxTs)); }} onPointerMove={(e) => { if (draggingRef.current) - onChange(scrubValueAt(e.clientX, trackRef.current, minTs, maxTs)); + seek(scrubValueAt(e.clientX, trackRef.current, minTs, maxTs)); }} onPointerUp={() => { draggingRef.current = false; @@ -1403,6 +2113,13 @@ function TimeScrubber({ /* ============ 实体侧栏 ============ */ +/** 世界时间(这件事何时成立)→ 文本。**一律按 UTC 读,不转本地。** + * + * `valid_from` / `valid_to` 来自文档里的陈述("2019 年 5 月 2 日就任"), + * 是**日历日期不是时刻**,本来就没有时区;存的是那一天的 UTC 午夜。 + * 按本地渲染会让 UTC-5 的读者看到 2019-05-01——凭空差一天,而且差的方向 + * 还随读者所在地变。记录时间(我们何时这么认为)是另一回事,那个该按本地, + * 见 EntityHistory 里 ymd 的注释。 */ function fmtTime(iso: string | null, precision: string | null): string | null { if (!iso) return null; const d = new Date(iso); @@ -1439,10 +2156,12 @@ function fmtInterval(f: EntityFact): string { * 手动按钮留在这里而不是别处:想重推的人正是刚看完这三行、觉得数字太旧的那个人。 */ function DerivedPanel({ + panelRef, kbId, count, onClose, }: { + panelRef: React.Ref; kbId: string; count: number; onClose: () => void; @@ -1452,6 +2171,15 @@ function DerivedPanel({ queryKey: ["kbOne", kbId], queryFn: () => api.kbDetail(kbId), }); + /* 重跑要确认,但**确认的第二下必须落在另一个按钮上**。 + 这产品的手势约定是「同一个控件连点两下 = 收回去」——开关、⋯、图例胶囊 + 都是这么用的。把「再点一次就执行」压在同一个按钮上,等于让同一个手势 + 在这里意外地变成了「执行」,而别处它一直是「取消」。 + 所以点一下只是**问一句**,问句下面给 取消 / 跑 两个目标。 + + 也没有用全站的 DangerConfirm:那是红标题、可要求逐字输入的危险级, + 留给删库那类不可逆操作。重跑推理重但可重复,够不上那一档 */ + const [armed, setArmed] = useState(false); const run = useMutation({ mutationFn: () => api.runInference(kbId), onSuccess: () => { @@ -1468,14 +2196,36 @@ function DerivedPanel({ ? Math.round((Date.now() - new Date(last).getTime()) / 60000) : null; + // **盖在触发器原位往右上长开**(bottom-0 left-0),而不是在旁边挂一扇窗。 + // 面与圆角跟通知/用户卡片对齐:u-menu-glass + rounded-xl return ( -
-
+
+ {/* items-center 而不是 baseline:标题旁边站着一个按钮和一个关闭键, + 按基线对齐会让那两个看着往上飘 */} +
{S.graph.derivedPanel} + {!armed && ( + + )} + {/* 固定 18px 方格:**别让关闭键撑起标题行的高**——一撑高, + 行里最矮的标题就被居中挤出上下空当,看着像上边距过大 */}
+ {/* 问句 + 两个目标。**取消排在前面**:从「跑」那一下移过来最先碰到的 + 是取消,误触的代价小的那个该更近 */} + {armed && ( +
+

+ {S.graph.derivedRunAsk} +

+
+ + +
+
+ )} +
{S.graph.derivedCountLabel}
@@ -1516,61 +2294,81 @@ function DerivedPanel({

)} - {/* **不是实心白。** `u-btn-primary` 是全站主操作那一档,用在一个悬浮小窗里 - 的次要动作上,整块面板会被这一个按钮压住 */} -
); } +/** 推出来的一条边。**行式样与 FactRow 对齐**:同样的圆角行、同样的 + * chevron 展开、同样的 role="link" 跳转(避免按钮套按钮)。 + * + * 从前这里是一张 `glass rounded-xl p-3` 卡片、证明常驻展开——在一列 + * Relations/Timeline/History 的紧凑行里显得是另一个产品的东西,而且十几条 + * 推导堆起来是一面墙。证明是「问了才看」的东西,收进展开区正合适。 */ function DerivedRow({ d, + otherId, + otherName, + open, + onToggle, onNavigate, }: { d: DerivedFact; + otherId: string; + otherName: string; + open: boolean; + onToggle: () => void; onNavigate: (entityId: string) => void; }) { return ( -
-
- - {d.predicate} - - - {d.rule === "transitive" - ? S.graph.ruleTransitive - : S.graph.ruleSymmetric} + {otherName} -
- {/* 证明:前提按推导顺序,缩进一格。看得出链是怎么走的 */} -
    - {d.premises.map((p, i) => ( -
  1. - {p} -
  2. - ))} -
- {d.premises.length === 0 && ( -

- {S.graph.derivedNoProof} -

+ + {d.premises.length} + + + {/* 证明:前提按推导顺序。**边框与 EvidenceList 同一档**—— + 两者是同一件事的两种形态:一个给出处,一个给推理链 */} + {open && ( +
+
    + {d.premises.map((p, i) => ( +
  1. + {p} +
  2. + ))} +
+ {d.premises.length === 0 && ( +

+ {S.graph.derivedNoProof} +

+ )} +
)}
); @@ -1579,11 +2377,14 @@ function DerivedRow({ function EntityPanel({ kbId, entityId, + exiting, onClose, onNavigate, }: { kbId: string; entityId: string; + /** 正在演退场:还挂在 DOM 上,但已经不接受点击 */ + exiting: boolean; onClose: () => void; onNavigate: (entityId: string) => void; }) { @@ -1595,6 +2396,33 @@ function EntityPanel({ // 推出来的那些。**单独一个键,不掺进 facts**——混在一个列表里,用户看不出 // 「文档里写的」和「引擎推的」的区别 const derived = detail.data?.derived ?? []; + /* 按「方向 + 谓词 + 规则」分组,骨架与 Relations 的 groups 一致。 + 规则挂在组上而不是每一行:它对整组都成立,逐行重复既冗余, + 那个琥珀色小字还会跟派生边抢色相 */ + const derivedGroups = useMemo(() => { + const map = new Map< + string, + { + key: string; + direction: "in" | "out"; + predicate: string; + rule: string; + rows: DerivedFact[]; + } + >(); + for (const d of derived) { + const direction = d.subject_id === entityId ? "out" : "in"; + const rule = + d.rule === "transitive" + ? S.graph.ruleTransitive + : S.graph.ruleSymmetric; + const key = `${direction}|${d.predicate}|${d.rule}`; + const cur = map.get(key); + if (cur) cur.rows.push(d); + else map.set(key, { key, direction, predicate: d.predicate, rule, rows: [d] }); + } + return [...map.values()]; + }, [derived, entityId]); // Relations = 按关系分组(查关系);Timeline = 有效时间轴(事情何时成立); // History = 记录时间轴(我们何时这么认为、又何时改了主意) const [view, setView] = useState< @@ -1723,7 +2551,9 @@ function EntityPanel({ }, [detail.data]); return ( -
+
{e && ( @@ -1968,15 +2798,52 @@ function EntityPanel({ {view === "history" && ( )} - {view === "derived" && ( -
-

+{view === "derived" && ( + <> +

{S.graph.derivedHint}

- {derived.map((d) => ( - + {/* **与 Relations 同一个骨架**:方向箭头 + 谓词 + 条数的小标题, + 底下是紧凑行。规则(传递/对称)并进标题——它对整组都成立, + 挂在每一行上是重复,而且那个 `--u-warn` 琥珀色又是一处 + 与派生边抢色相的地方 */} + {derivedGroups.map((gr) => ( +
+
+ {gr.direction === "in" ? ( + + ) : ( + + )} + {gr.predicate} + {gr.rule} + {gr.rows.length > 1 && ( + + {gr.rows.length} + + )} +
+
+ {gr.rows.map((d) => { + const out = d.subject_id === entityId; + return ( + + setOpenFact(openFact === d.id ? null : d.id) + } + onNavigate={onNavigate} + /> + ); + })} +
+
))} -
+ )} {view !== "history" && view !== "derived" && @@ -2016,7 +2883,7 @@ function TimelineView({
{dated.map((f) => (
- + ( diff --git a/web/src/pages/KbScope.tsx b/web/src/pages/KbScope.tsx new file mode 100644 index 000000000..fa5125b83 --- /dev/null +++ b/web/src/pages/KbScope.tsx @@ -0,0 +1,99 @@ +// KB 作用域层:`/kb/$kbId` 之下的一切都属于这个知识库。 +// +// **为什么把库放进路径而不是查询参数**:库是容器不是筛选条件——图谱、搜索、 +// 文库、本体、复核全都在它下面。路径能表达这种包含关系,而且路由器会替你兜底: +// `/kb/$kbId/search` 没有 id 根本构造不出来。`?kb=` 是可选的,标签页之间跳一下 +// 就掉了,掉了还不报错——于是悄悄看的是另一个库的数据,界面一模一样。 +// +// **为什么还要 localStorage**:两者回答的不是同一个问题。URL 回答"这个链接指向 +// 什么",localStorage 回答"我上次在看什么"。所以 URL 里有就以 URL 为准, +// 没有才回落到记忆(见 kb.tsx 与下面的 KbRedirect)。 +import { useEffect } from "react"; +import { Link, Outlet, useNavigate, useParams } from "@tanstack/react-router"; +import { useQuery } from "@tanstack/react-query"; + +import { ApiError, api } from "../api"; +import { S } from "../i18n"; +import { useKb } from "../kb"; +import { kbStore, wsStore } from "../wsStore"; + +/** 库不存在或没权限时的落地页。**不能只给一张空图**——分享链接最常见的 + * 失败就是对方没权限,而空白的图谱看起来像"这个库是空的",是另一回事。 */ +function KbNoAccess({ status }: { status: number }) { + return ( +
+
+

+ {status === 404 ? S.kbScope.missingTitle : S.kbScope.deniedTitle} +

+

+ {status === 404 ? S.kbScope.missingBody : S.kbScope.deniedBody} +

+ + {S.kbScope.myKbs} + +
+
+ ); +} + +export function KbScope() { + const { kbId } = useParams({ from: "/app/kb/$kbId" }); + // **直接问后端,而不是在当前工作区的列表里找**:链接可能指向另一个工作区 + // 里的库,那时列表里没有它,但用户其实有权限——照列表判断会错杀 + const kb = useQuery({ + queryKey: ["kbOne", kbId], + queryFn: () => api.kbDetail(kbId), + retry: false, + }); + + // 打开哪个库,"上次看的"就跟到哪个库;工作区也一并对齐, + // 否则顶栏的切换器显示的还是上一个工作区 + useEffect(() => { + if (!kb.data) return; + kbStore.set(kb.data.id); + wsStore.set(kb.data.workspace_id); + }, [kb.data]); + + if (kb.isError) { + const status = kb.error instanceof ApiError ? kb.error.status : 500; + return ; + } + // 加载中什么都不画:这一层只是个作用域,闪一个 spinner 反而像页面在跳 + if (!kb.data) return null; + return ; +} + +/** 旧路径(`/graph` 这类不带库的地址)的接管:解析出该去哪个库再跳过去。 + * + * **不能在 beforeLoad 里直接重定向**——那时候库列表还没取回来, + * localStorage 里也可能什么都没有(新设备、清过缓存)。所以做成一个组件, + * 等 useKb 把库解析出来再走。 */ +export function KbRedirect({ + page, +}: { + page: + | "graph" + | "search" + | "chat" + | "library" + | "ontology" + | "mappings" + | "review" + | "settings"; +}) { + const { kb } = useKb(); + const navigate = useNavigate(); + useEffect(() => { + if (!kb) return; + navigate({ + to: `/kb/$kbId/${page}`, + params: { kbId: kb.id }, + replace: true, + }); + }, [kb, page, navigate]); + return null; +} diff --git a/web/src/pages/KbSettings.tsx b/web/src/pages/KbSettings.tsx index f60e5add8..bed499992 100644 --- a/web/src/pages/KbSettings.tsx +++ b/web/src/pages/KbSettings.tsx @@ -4,7 +4,7 @@ context)遮蔽的层级 bug。 */ import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useNavigate, useSearch } from "@tanstack/react-router"; +import { useParams, useNavigate } from "@tanstack/react-router"; import { History as HistoryIcon, Lock, @@ -14,7 +14,6 @@ import { } from "lucide-react"; import { api, type AuditEvent } from "../api"; import { LANG_NAMES, S } from "../i18n"; -import { useKb } from "../kb"; import { DangerConfirm, Dropdown, @@ -49,9 +48,9 @@ type Section = "general" | "members" | "activity" | "danger"; export function KbSettings() { const navigate = useNavigate(); const queryClient = useQueryClient(); - const { kb: currentKb } = useKb(); - const { kb: kbParam } = useSearch({ from: "/app/kb-settings" }); - const kbId = kbParam ?? currentKb?.id; + /* 库 id 来自路径。**从前是 `?kb=`**——那是这套路由改造之前唯一 + 带着库走的地方,现在整片都在 /kb/$kbId 之下,它就不必自成一格了 */ + const { kbId } = useParams({ from: "/app/kb/$kbId/settings" }); const kb = useQuery({ queryKey: ["kbOne", kbId], @@ -110,7 +109,7 @@ export function KbSettings() { mutationFn: () => api.deleteKb(kbId!), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["kbs"] }); - navigate({ to: "/library", search: {} }); + navigate({ to: "/kb/$kbId/library", params: { kbId } }); }, onError: (e) => setError((e as Error).message), }); diff --git a/web/src/pages/Library.tsx b/web/src/pages/Library.tsx index 3a472c143..60963a632 100644 --- a/web/src/pages/Library.tsx +++ b/web/src/pages/Library.tsx @@ -14,7 +14,7 @@ import { } from "lucide-react"; import { api, type Doc, type ExtractionDrop, type SourceView } from "../api"; import { S } from "../i18n"; -import { useKb } from "../kb"; +import { useKb, useKbId } from "../kb"; import { toast } from "../toast"; import { Chip, type ChipTone, DangerConfirm, Loading, Pager } from "../ui"; import { @@ -303,7 +303,7 @@ export function Library() { const queryClient = useQueryClient(); const fileInput = useRef(null); // 从文档查看页的来源栏跳回时带 ?src= 定位到对应文件夹 - const { src } = useSearch({ from: "/app/library" }); + const { src } = useSearch({ from: "/app/kb/$kbId/library" }); const [dragging, setDragging] = useState(false); const [selection, setSelection] = useState(src ?? "all"); const [adding, setAdding] = useState(false); @@ -1879,6 +1879,7 @@ function DocRow({ drops?: ExtractionDrop[]; onShowDrops: (rows: ExtractionDrop[]) => void; }) { + const kbId = useKbId(); const dropTotal = drops?.reduce((n, d) => n + d.count, 0) ?? 0; const statusText = S.library.status[doc.status as keyof typeof S.library.status] ?? doc.status; @@ -1890,8 +1891,8 @@ function DocRow({ diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx index 61799b9d1..a93958edb 100644 --- a/web/src/pages/Login.tsx +++ b/web/src/pages/Login.tsx @@ -39,7 +39,7 @@ export function Login() { onSuccess: () => { // 谢幕:卡片上浮淡出、巨构放大穿越,再进入图谱首页 setLeaving(true); - window.setTimeout(() => navigate({ to: "/graph" }), 650); + window.setTimeout(() => navigate({ to: "/" }), 650); }, }); diff --git a/web/src/pages/MyKbs.tsx b/web/src/pages/MyKbs.tsx index 4b690456c..5d730d708 100644 --- a/web/src/pages/MyKbs.tsx +++ b/web/src/pages/MyKbs.tsx @@ -35,7 +35,7 @@ export function MyKbs() { const rows = mine.data?.kbs ?? []; const openKb = (id: string) => { setKb(id); - navigate({ to: "/graph" }); + navigate({ to: "/kb/$kbId/graph", params: { kbId: id } }); }; return ( @@ -91,7 +91,7 @@ export function MyKbs() {
diff --git a/web/src/pages/Search.tsx b/web/src/pages/Search.tsx index af61b5f23..d75439547 100644 --- a/web/src/pages/Search.tsx +++ b/web/src/pages/Search.tsx @@ -3,12 +3,13 @@ import { useQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { api } from "../api"; import { S } from "../i18n"; -import { useKb } from "../kb"; +import { useKb, useKbId } from "../kb"; import { Pager, pageSlice } from "../ui"; const RESULT_PAGE = 10; export function Search() { + const kbId = useKbId(); const { kb } = useKb(); const [input, setInput] = useState(""); const [query, setQuery] = useState(""); @@ -60,8 +61,8 @@ export function Search() { {pageSlice(results.data?.results ?? [], page, RESULT_PAGE).rows.map((r) => ( diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index acdb079fb..4a4aa9c09 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -406,7 +406,7 @@ function KbsAdmin() { className="u-btn u-btn-ghost px-2.5 py-1 text-xs shrink-0" onClick={() => { setKb(kb.id); - navigate({ to: "/kb-settings", search: { kb: kb.id } }); + navigate({ to: "/kb/$kbId/settings", params: { kbId: kb.id } }); }} > {S.settings.kbs.openSettings} @@ -438,7 +438,7 @@ function KbsAdmin() { // 建完直达库设置:下一步几乎总是邀人/配置 if (id) { setKb(id); - navigate({ to: "/kb-settings", search: { kb: id } }); + navigate({ to: "/kb/$kbId/settings", params: { kbId: id } }); } }} /> diff --git a/web/src/pages/Shell.tsx b/web/src/pages/Shell.tsx index 06c2d06dd..3f55d514e 100644 --- a/web/src/pages/Shell.tsx +++ b/web/src/pages/Shell.tsx @@ -19,7 +19,7 @@ import { } from "lucide-react"; import { api, ApiError } from "../api"; import { S } from "../i18n"; -import { useKb } from "../kb"; +import { useKb, useKbId } from "../kb"; import { Dropdown, GithubMark, Wordmark } from "../ui"; import { AlertBell } from "./AlertBell"; import { UserMenu } from "./UserMenu"; @@ -30,20 +30,21 @@ import { usePageTitle } from "../useTitle"; const TABS = [ // 图谱是门面,排第一;两种查询方式(Search/Ask)随后 - { to: "/graph", label: S.nav.graph, Icon: Waypoints }, - { to: "/search", label: S.nav.search, Icon: SearchIcon }, - { to: "/chat", label: S.nav.ask, Icon: MessagesSquare }, - { to: "/library", label: S.nav.library, Icon: LibraryIcon }, - { to: "/review", label: S.review.title, Icon: ListChecks }, - { to: "/ontology", label: S.ontology.title, Icon: Shapes }, + { to: "/kb/$kbId/graph", label: S.nav.graph, Icon: Waypoints }, + { to: "/kb/$kbId/search", label: S.nav.search, Icon: SearchIcon }, + { to: "/kb/$kbId/chat", label: S.nav.ask, Icon: MessagesSquare }, + { to: "/kb/$kbId/library", label: S.nav.library, Icon: LibraryIcon }, + { to: "/kb/$kbId/review", label: S.review.title, Icon: ListChecks }, + { to: "/kb/$kbId/ontology", label: S.ontology.title, Icon: Shapes }, // 本体说「世界上有什么」,数据映射说「这个数在库里怎么算」——挨着放 - { to: "/mappings", label: S.mapping.title, Icon: Database }, + { to: "/kb/$kbId/mappings", label: S.mapping.title, Icon: Database }, // 库设置与其它 tab 同为"当前知识库作用域",并列于内容导航 - { to: "/kb-settings", label: S.nav.settings, Icon: SettingsIcon }, + { to: "/kb/$kbId/settings", label: S.nav.settings, Icon: SettingsIcon }, ] as const; export function Shell() { const navigate = useNavigate(); + const kbId = useKbId(); const me = useQuery({ queryKey: ["me"], queryFn: api.me }); const health = useQuery({ @@ -154,6 +155,7 @@ export function Shell() { appRoute, - path: "/chat", + path: "/kb/$kbId", + component: KbScope, +}); + +const chatRoute = createRoute({ + getParentRoute: () => kbRoute, + path: "chat", component: Chat, }); @@ -74,23 +84,34 @@ const chatConversationRoute = createRoute({ }); const searchRoute = createRoute({ - getParentRoute: () => appRoute, - path: "/search", + getParentRoute: () => kbRoute, + path: "search", component: Search, }); const graphRoute = createRoute({ - getParentRoute: () => appRoute, - path: "/graph", - validateSearch: (search: Record): { entity?: string } => ({ + getParentRoute: () => kbRoute, + path: "graph", + /* 图谱页的可分享状态。**三个都是"你在看什么",不是"你怎么看"**—— + 所以档位(画多少个)刻意不进 URL:那是本地观感,换台机器不该跟着走。 + + - entity:选中了谁 + - focus:是否处在某个实体的邻域(与"在全图里选中"是两个画面) + - at:时间轴停在哪一刻。**这条最不能少**——这产品的卖点就是 + "看某个时刻的世界",不带时刻的链接把最有意思的那部分丢了 */ + validateSearch: ( + search: Record, + ): { entity?: string; focus?: string; at?: string } => ({ entity: typeof search.entity === "string" ? search.entity : undefined, + focus: typeof search.focus === "string" ? search.focus : undefined, + at: typeof search.at === "string" ? search.at : undefined, }), component: Graph, }); const docRoute = createRoute({ - getParentRoute: () => appRoute, - path: "/doc/$docId", + getParentRoute: () => kbRoute, + path: "doc/$docId", validateSearch: (search: Record): { chunk?: string } => ({ chunk: typeof search.chunk === "string" ? search.chunk : undefined, }), @@ -98,8 +119,8 @@ const docRoute = createRoute({ }); const libraryRoute = createRoute({ - getParentRoute: () => appRoute, - path: "/library", + getParentRoute: () => kbRoute, + path: "library", validateSearch: (search: Record): { src?: string } => ({ src: typeof search.src === "string" ? search.src : undefined, }), @@ -107,20 +128,20 @@ const libraryRoute = createRoute({ }); const ontologyRoute = createRoute({ - getParentRoute: () => appRoute, - path: "/ontology", + getParentRoute: () => kbRoute, + path: "ontology", component: Ontology, }); const mappingsRoute = createRoute({ - getParentRoute: () => appRoute, - path: "/mappings", + getParentRoute: () => kbRoute, + path: "mappings", component: Mappings, }); const reviewRoute = createRoute({ - getParentRoute: () => appRoute, - path: "/review", + getParentRoute: () => kbRoute, + path: "review", component: Review, }); @@ -187,12 +208,55 @@ const settingsRoute = createRoute({ }); const kbSettingsRoute = createRoute({ + getParentRoute: () => kbRoute, + path: "settings", + component: KbSettings, +}); + +/* 旧路径兼容:`/graph` 这类不带库的地址仍然可用,解析出该去哪个库再跳。 + **不做成 beforeLoad 重定向**——那时候库列表还没取回来,localStorage 里 + 也可能什么都没有(新设备、清过缓存),只能等 useKb 解析出来 */ +/* **逐条写出来,不用工厂函数**:工厂里的 path 是 string,类型系统认不出 + 字面量,别处 `redirect({ to: "/graph" })` 就通不过。啰嗦换类型安全 */ +const legacyGraphRoute = createRoute({ + getParentRoute: () => appRoute, + path: "/graph", + component: () => , +}); +const legacySearchRoute = createRoute({ + getParentRoute: () => appRoute, + path: "/search", + component: () => , +}); +const legacyChatRoute = createRoute({ + getParentRoute: () => appRoute, + path: "/chat", + component: () => , +}); +const legacyLibraryRoute = createRoute({ + getParentRoute: () => appRoute, + path: "/library", + component: () => , +}); +const legacyOntologyRoute = createRoute({ + getParentRoute: () => appRoute, + path: "/ontology", + component: () => , +}); +const legacyMappingsRoute = createRoute({ + getParentRoute: () => appRoute, + path: "/mappings", + component: () => , +}); +const legacyReviewRoute = createRoute({ + getParentRoute: () => appRoute, + path: "/review", + component: () => , +}); +const legacyKbSettingsRoute = createRoute({ getParentRoute: () => appRoute, path: "/kb-settings", - validateSearch: (search: Record): { kb?: string } => ({ - kb: typeof search.kb === "string" ? search.kb : undefined, - }), - component: KbSettings, + component: () => , }); const routeTree = rootRoute.addChildren([ @@ -205,15 +269,25 @@ const routeTree = rootRoute.addChildren([ accountShellRoute.addChildren([accountRoute, myKbsRoute, adminRoute]), appRoute.addChildren([ indexRoute, - chatRoute.addChildren([chatConversationRoute]), - searchRoute, - graphRoute, - docRoute, - libraryRoute, - reviewRoute, - ontologyRoute, - mappingsRoute, - kbSettingsRoute, + legacyGraphRoute, + legacySearchRoute, + legacyChatRoute, + legacyLibraryRoute, + legacyOntologyRoute, + legacyMappingsRoute, + legacyReviewRoute, + legacyKbSettingsRoute, + kbRoute.addChildren([ + chatRoute.addChildren([chatConversationRoute]), + searchRoute, + graphRoute, + docRoute, + libraryRoute, + reviewRoute, + ontologyRoute, + mappingsRoute, + kbSettingsRoute, + ]), ]), ]); diff --git a/web/src/styles.css b/web/src/styles.css index fa5dfd517..40cdb6aa6 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -39,6 +39,15 @@ --u-line-strong: rgba(255, 255, 255, 0.14); --u-surface: rgba(255, 255, 255, 0.04); --u-surface-strong: rgba(10, 10, 10, 0.72); + /* 指针停在面上时的实底。**毛玻璃是给"没在看它"的时候用的**—— + 半透让浮层不至于把画布切断;可一旦人把指针移进来,他要读的就是面板本身, + 这时候背后的图反而是噪声。所以这两档不是审美偏好,是同一块面在 + "余光里"和"正在读"两种状态下该有的样子。 + 统一在这里定义,改一处四处都跟着:顶栏、导航、时间岛、控件塔、 + 以及所有 u-menu-glass 的弹出面板 */ + --u-surface-strong-hover: rgba(10, 10, 10, 0.94); + --u-menu-surface: rgba(14, 14, 14, 0.85); + --u-menu-surface-hover: rgba(14, 14, 14, 0.97); } body { @@ -77,6 +86,36 @@ body::before { backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border: 1px solid var(--u-line); + /* 基态上的时长是**离开**时用的:慢一点,指针擦过边缘不会闪。 + 进入的时长写在 :hover 里,更快,为的是跟手 */ + transition: background-color 260ms ease; +} +.glass-strong:hover { + background: var(--u-surface-strong-hover); + transition-duration: 120ms; +} +/* 正在工作时也按下去,与 hover 同一档。**这比在轨道上循环扫光更准**: + 它说的是"这块面正在工作",而不是"在等一件不知道多久的事"—— + 循环微光是不确定进度的语言,而时间轴是确定的。 + 播放中人本来就盯着它看,半透的毛玻璃这时候只是噪声 */ +/* 时间岛:宽度随单位变,所以它要**同时**过渡背景与宽度。 + 不能只挂 Tailwind 的 `transition-[width]`——上面 .glass-strong 用的是 + `transition` 简写,会把 transition-property 整个覆盖掉,而自定义 CSS + 不在 utilities 层里、胜过那个工具类。实测:岛的计算过渡只剩 + background-color,宽度变成瞬变。要过渡两样就得写在同一条声明里 */ +.u-scrub-island { + transition: + background-color 260ms ease, + width 300ms ease; +} +.u-scrub-island:hover, +.u-scrub-island.u-solid { + transition-duration: 120ms, 300ms; +} + +.glass-strong.u-solid { + background: var(--u-surface-strong-hover); + transition-duration: 120ms; } /* 模态对话框的面:**近实底**。 @@ -85,7 +124,10 @@ body::before { 身后的回答文字清晰可辨,标题几乎读不出)。 遮罩也一并加深:0.6 的黑压不住一屏白字。 */ .u-modal-panel { - background: rgba(12, 12, 14, 0.97); + /* **中性,不掺色相**:原本是 rgba(12,12,14),蓝比红绿高 2—— + 在大面积深底上肉眼看得出偏冷。这产品的既定原则是 + 「chrome 零色偏、彩色只属于数据」,这里也照办 */ + background: rgba(12, 12, 12, 0.97); backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); border: 1px solid var(--u-line-strong); @@ -152,6 +194,72 @@ body::before { .u-pop-in-tl { transform-origin: top left; } +/* 走到终点时扫一道光:从左到右扫过整条轨道。 + **它说的是"这条走完了"**——播放停下、时间跳回全时段, + 没有任何交代的话看着像中途断了。方向与刚刚走过的方向一致。 + 只演一次,走完就卸载;轨道自身 overflow-hidden,光不会溢出圆角 */ +@keyframes u-sweep-kf { + from { + transform: translateX(-100%); + opacity: 0; + } + 18% { + opacity: 1; + } + to { + transform: translateX(100%); + opacity: 0; + } +} +.u-sweep { + position: absolute; + inset: 0; + pointer-events: none; + background: linear-gradient( + 90deg, + transparent, + rgba(255, 255, 255, 0.18), + transparent + ); + animation: u-sweep-kf 720ms cubic-bezier(0.4, 0, 0.2, 1) both; +} + +/* 停靠面板的入场:从它停靠的那条边滑进来。 + **不用 u-pop-in 那种缩放**——那是给锚在某个按钮上的弹出菜单的, + 它有明确的"从哪儿长出来";侧栏是贴着右边缘停的一块面,该从边缘进来。 + 只在打开时演一次:在实体之间切换时面板留在原地只换内容, + 每点一个节点就抖一下反而吵 */ +@keyframes u-dock-in-kf { + from { + opacity: 0; + transform: translateX(14px); + } + to { + opacity: 1; + transform: translateX(0); + } +} +.u-dock-in { + animation: u-dock-in-kf 0.18s cubic-bezier(0.16, 1, 0.3, 1) both; +} +/* 退场。**比入场快、缓动相反**:进来要"落位"(减速停住), + 出去要"离开"(加速甩出)。同一条曲线两头用会让关闭显得拖沓。 + 退场期间不再接受点击——它已经在走了,这时候点上去只会让人困惑 */ +@keyframes u-dock-out-kf { + from { + opacity: 1; + transform: translateX(0); + } + to { + opacity: 0; + transform: translateX(14px); + } +} +.u-dock-out { + animation: u-dock-out-kf 0.15s cubic-bezier(0.4, 0, 1, 1) both; + pointer-events: none; +} + /* 向上弹出的变体(composer 停靠底部时菜单开口朝上) */ @keyframes u-pop-up-kf { from { @@ -172,15 +280,27 @@ body::before { 原地变形动画在组件内用 FLIP 实现(首帧压到胶囊真实边界再展开), CSS 只负责面板的皮 */ .u-menu-glass { - background: rgba(14, 14, 16, 0.85); + /* 中性,不掺色相:原本 rgba(14,14,16) 蓝比红绿高 2。这个面同时用在 + 通知、用户菜单、图例的「+N 个类」和 Inference 面板上,一处偏色四处都偏 */ + background: var(--u-menu-surface); + /* 基态上的时长是**离开**时用的:慢一点,指针擦过边缘不会闪。 + 进入的时长写在 :hover 里,更快,为的是跟手 */ + transition: background-color 260ms ease; backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); border: 1px solid rgba(255, 255, 255, 0.14); } +.u-menu-glass:hover { + background: var(--u-menu-surface-hover); + transition-duration: 120ms; +} @media (prefers-reduced-motion: reduce) { .u-pop-in, - .u-pop-up { + .u-pop-up, + .u-dock-in, + .u-dock-out, + .u-sweep { animation: none; } } @@ -708,3 +828,45 @@ a:hover > .u-mark-arrow { color: var(--u-text-2); margin: 0.4em 0; } + +/* 左下控件塔:**整组一起展开**,不是逐个按钮。 + 一组是一个语义单元(派生 / 布局 / 相机),鼠标进了这一组, + 这一组的名字就都该出来——逐个展开的话,名字会随指针跳来跳去, + 而人想知道的往往正是"旁边那个是什么"。 + + 用 max-width 而不是 width 做过渡:名字长短不一,写死宽度要么截断 + 要么留白,max-width 让每个按钮按自己的内容收放。 + overflow-hidden 已经在组容器上了,收起时不会漏字。 */ +.u-tower-label { + max-width: 0; + overflow: hidden; + white-space: nowrap; + opacity: 0; + font-size: 11px; + line-height: 1; + /* 图标与文字之间的空当用 **margin**,既不能用按钮上的 gap,也不能用 padding: + gap 对宽度为 0 的项目照样生效;而 padding 不会被 max-width 压缩 + (border-box 下宽度最小就等于那点 padding)——两种写法都会让收起态 + 凭空多出 8px,方块就不方了。实测 39x31,改成 margin 后才是 31x31 */ + margin-left: 0; + transition: + max-width 220ms ease, + margin-left 220ms ease, + opacity 160ms ease; +} +.u-tower:hover .u-tower-label { + max-width: 9rem; + margin-left: 0.5rem; + opacity: 1; +} +/* 键盘走焦点时也要出来:只认 hover 的话,用键盘的人永远看不到名字 */ +.u-tower:focus-within .u-tower-label { + max-width: 9rem; + margin-left: 0.5rem; + opacity: 1; +} +@media (prefers-reduced-motion: reduce) { + .u-tower-label { + transition: none; + } +} diff --git a/web/src/ui/index.tsx b/web/src/ui/index.tsx index 321d16483..b7447f14e 100644 --- a/web/src/ui/index.tsx +++ b/web/src/ui/index.tsx @@ -885,7 +885,7 @@ import { Link as RouterLink } from "@tanstack/react-router"; export function SectionMark({ text, title }: { text: string; title: string }) { return ( () { +export function usePopoverFlip( + /** 变形的锚点角。**面板贴哪边就写哪边**:顶栏右侧的面板贴右上角, + * 贴左边的面板(比如图例的「+N 个类」)要写 "top left", + * 否则它会从右边缘往左长出来,看着像从别处飞过来的 */ + origin: "top right" | "top left" | "bottom left" = "top right", +) { const [open, setOpen] = useState(false); const rootRef = useRef(null); const anchorRef = useRef(null); @@ -33,7 +38,7 @@ export function usePopoverFlip() { const a = anchor.getBoundingClientRect(); const p = panel.getBoundingClientRect(); if (p.width < 1 || p.height < 1) return; - panel.style.transformOrigin = "top right"; + panel.style.transformOrigin = origin; panel.style.transform = `scale(${a.width / p.width}, ${a.height / p.height})`; panel.style.borderRadius = "999px"; panel.style.opacity = "0.35"; @@ -61,7 +66,7 @@ export function usePopoverFlip() { cancelAnimationFrame(raf); if (done !== undefined) window.clearTimeout(done); }; - }, [open]); + }, [open, origin]); const close = () => { const panel = panelRef.current; @@ -72,6 +77,7 @@ export function usePopoverFlip() { return; } closingRef.current = true; + panel.style.transformOrigin = origin; const a = anchor.getBoundingClientRect(); // offsetWidth/Height 是布局尺寸,不受当前 transform 影响—— // 用 getBoundingClientRect 会拿到已经缩过的值,越缩越小