Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
7846349
The derived toggle should not look like a tenth class
WaylandYang Aug 31, 2026
3e35ce8
A legend with a hundred classes is not a legend
WaylandYang Aug 31, 2026
34aaa9b
Chrome stays grey, so the toggle moves instead of turning gold
WaylandYang Aug 31, 2026
2237e6c
Only, and a way back
WaylandYang Aug 31, 2026
b0895c6
The ring says which node, not which colour we picked
WaylandYang Aug 31, 2026
55e935f
Two idioms in one sentence made the count look broken
WaylandYang Aug 31, 2026
7617c4f
Say how long one step is, and let it be chosen
WaylandYang Aug 31, 2026
b4a453b
A tower of icons should be able to say its own names
WaylandYang Aug 31, 2026
544141f
The chip should grow into the panel, like the others do
WaylandYang Aug 31, 2026
98404cd
One group expands, the others keep their squares
WaylandYang Aug 31, 2026
16d4782
A track squeezed to nothing shows no bars at all
WaylandYang Aug 31, 2026
c9a46bd
Time should move, not step; and what is ahead is not lit
WaylandYang Aug 31, 2026
cde5bbc
Chrome that says it has no hue should actually have none
WaylandYang Aug 31, 2026
504fadd
Two clicks on one control means close, so ask on another
WaylandYang Aug 31, 2026
ca97a93
Glass is for when you are not reading it
WaylandYang Aug 31, 2026
61a2afb
A docked panel should arrive from the edge it docks to
WaylandYang Aug 31, 2026
814e2bd
Leaving should look like leaving, and the force was tuned for 150
WaylandYang Aug 31, 2026
2170249
A link should carry what you are looking at
WaylandYang Aug 31, 2026
0224d93
The knowledge base belongs in the path, not in memory
WaylandYang Aug 31, 2026
350a221
Scrubbing means show me that part, not stop
WaylandYang Aug 31, 2026
151c7d9
Never rip a React-owned node out of the DOM
WaylandYang Aug 31, 2026
ca24480
Playing is a state of the panel, not a shimmer on the track
WaylandYang Aug 31, 2026
485f0a4
Show the derivation, not just its conclusion
WaylandYang Aug 31, 2026
bfe54d0
A premise that has gone dark cannot show you the chain
WaylandYang Aug 31, 2026
5577c85
Two attempts at showing the chain, and the plain fade won
WaylandYang Aug 31, 2026
ca69bd3
Hover dims too, and a fade should start from where it was
WaylandYang Aug 31, 2026
31f6437
World time has no timezone; belief time has yours
WaylandYang Aug 31, 2026
880e584
Write down the day we round away
WaylandYang Aug 31, 2026
19bfcd0
Time to the moment, on the roadmap
WaylandYang Aug 31, 2026
2e49349
A transition shorthand quietly ate the width transition
WaylandYang Aug 31, 2026
dff1a8e
An edge that does not exist yet must not brighten on hover
WaylandYang Aug 31, 2026
2088882
Hover dims harder
WaylandYang Aug 31, 2026
4590a6d
Merge remote-tracking branch 'origin/main' into ui/derived-switch-and…
WaylandYang Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 万文档级别的性能基准

Expand Down
16 changes: 14 additions & 2 deletions crates/utopia-server/src/api/graph_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,22 @@ fn parse_at(raw: Option<&str>) -> Result<Option<chrono::DateTime<chrono::Utc>>,
.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<String>,
/// 画多少个。不给就用默认值;给了也钳在 [10, GRAPH_NODE_CAP_MAX]——
/// 界面上的按钮只给几档,但接口是公开的,别让一个 `limit=999999` 把库拖垮
#[serde(default)]
pub limit: Option<i64>,
}

pub async fn overview(
Expand All @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions docs/decisions/0013-a-source-should-hand-over-its-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

接第二个供应商正是为了看清该不该抽象。答案是**暂时不该**:取法根本不同
Expand Down
6 changes: 4 additions & 2 deletions web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1174,15 +1174,17 @@ export const api = {
method: "PUT",
body: JSON.stringify(body),
}),
graphOverview: (kbId: string) =>
graphOverview: (kbId: string, limit?: number) =>
request<{
nodes: GraphNode[];
edges: GraphEdge[];
/** 库里一共有多少。**与 nodes.length 不是一回事**——画布只画度数最高的
* 那一批,把上限当成规模显示是这个接口从前最误导人的地方 */
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) =>
Expand Down
41 changes: 37 additions & 4 deletions web/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
39 changes: 35 additions & 4 deletions web/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,14 @@ export const zh: Strings = {
unknownKind: (kind: string) => kind,
},

kbScope: {
deniedTitle: "你没有这个知识库的权限",
deniedBody:
"链接指向的库你打不开。找分享给你的人开通权限,或者回到自己的库。",
missingTitle: "这个知识库不在了",
missingBody: "它已被删除,或者链接抄错了。下面是你自己的库。",
myKbs: "我的知识库",
},
nav: {
workspaceLabel: "工作区",
kbLabel: "知识库",
Expand Down Expand Up @@ -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: "缩小",
Expand Down Expand Up @@ -566,6 +583,9 @@ export const zh: Strings = {
"没有人断言过的边——引擎按本体声明的公理推出来的。每一条都附着它用到的前提。",
derivedNoProof: "前提已经不在了。",
derivedPanel: "推理",
derivedRunAsk: "对整个库重跑一遍推理?",
derivedRunGo: "跑",
derivedRunCancel: "取消",
derivedCountLabel: "推出来的边",
derivedStateLabel: "定时",
derivedLastLabel: "上次推理",
Expand Down Expand Up @@ -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: "播放时间线",
Expand Down
18 changes: 17 additions & 1 deletion web/src/kb.tsx
Original file line number Diff line number Diff line change
@@ -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[];
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion web/src/pages/AccountShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export function AccountShell() {
<SectionMark text={S.account.brand} title={S.docs.backTitle} />
<div className="ml-auto flex items-center gap-1.5">
<Link
to="/graph"
to="/"
className="px-2 py-1 rounded-lg text-[12.5px] text-neutral-500 hover:text-neutral-200 hover:bg-white/[0.05] transition-colors"
>
{S.account.backToApp}
Expand Down
27 changes: 16 additions & 11 deletions web/src/pages/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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();
Expand Down Expand Up @@ -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]);
Expand All @@ -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,
});
}
Expand All @@ -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?.();
Expand All @@ -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 });
}
};

Expand All @@ -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();
};

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 (
<div className="flex justify-end">
Expand Down Expand Up @@ -646,8 +651,8 @@ function TurnView({ turn, live }: { turn: Turn; live?: boolean }) {
) : (
<Link
key={s.n}
to="/doc/$docId"
params={{ docId: s.document_id! }}
to="/kb/$kbId/doc/$docId"
params={{ kbId, docId: s.document_id! }}
search={{ chunk: s.chunk_id }}
title={s.excerpt}
className="block text-xs text-neutral-500 glass rounded-lg px-3 py-1.5 glass-hover hover:text-neutral-300"
Expand Down
Loading
Loading