mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-08 16:43:59 +08:00
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: jdzhang <625013594@qq.com> Co-authored-by: jd <59188306+zhangjiadi225@users.noreply.github.com> Co-authored-by: fullex <106392080+0xfullex@users.noreply.github.com> Signed-off-by: suyao <sy20010504@gmail.com> Signed-off-by: jdzhang <625013594@qq.com>
237 lines
12 KiB
Markdown
237 lines
12 KiB
Markdown
# Message Tree — Per-Topic Virtual Root
|
|
|
|
**Status:** implemented on `feat/message-tree-virtual-root` (follow-up to
|
|
`#15951`). Originates from the `#15951` (chat message flows) review thread:
|
|
the reviewer questioned the `null for root sibling groups` shape and asked
|
|
for a virtual root node so the tree has a single guaranteed root (see
|
|
[Decisions](#decisions)).
|
|
|
|
## Problem
|
|
|
|
The message tree is an adjacency list (`message.parentId`) with the
|
|
convention **`parentId = null` ⟺ root**.
|
|
|
|
- `MessageService.create({ parentId: null })` enforces a single root —
|
|
it rejects a second root with *"Topic already has a root message"*
|
|
(`MessageService.ts:848`).
|
|
- But `createSibling()` on a root message **bypasses** that check: it
|
|
inserts another `parentId = null` row as a sibling
|
|
(`MessageService.ts:769`), so a topic can hold **multiple physical
|
|
roots**, grouped by `siblingsGroupId`. This is how "resend / edit the
|
|
first user message" is implemented today — as a *root sibling*.
|
|
|
|
Consequences of multiple physical roots:
|
|
|
|
- The read path special-cases root sibling groups with an
|
|
`isNull(parentId)` branch (`MessageService.ts:557`).
|
|
- `SiblingsGroup.parentId` must be nullable — the literal
|
|
`null for root sibling groups` comment at
|
|
`src/shared/data/types/message.ts:490` that started the review.
|
|
- The flow canvas carries dedicated "expand root sibling groups into
|
|
independent root trees" / "multiple root trees" logic
|
|
(`flow/topicMessageFlowGraph.ts`, `flow/topicMessageFlowLiveTree.ts`).
|
|
- The `parentId IS NULL = root` assumption is spread across **~101
|
|
(main) / 8 (shared) / 25 (renderer)** sites, each of which conflates
|
|
"the root" with "the first user message."
|
|
|
|
Product requirement (from the thread): resending the first user message
|
|
must stay in the **same topic** (DeepSeek / ChatGPT UX), not spawn a new
|
|
topic — so "forbid first-turn resend" is not an option.
|
|
|
|
## Target design — virtual root sentinel
|
|
|
|
Every topic owns exactly **one content-less virtual root message row**
|
|
(`parentId = null`). Every real conversation message hangs **below** it,
|
|
so the first user turn and its resends are ordinary siblings under a
|
|
shared parent:
|
|
|
|
```
|
|
virtual root (parentId = null, no content, never rendered)
|
|
├─ user "v1" ┐
|
|
├─ user "v2" ├─ one siblingsGroup — "resend first message" = a normal sibling
|
|
└─ user "v3" ┘
|
|
└─ assistant → user → assistant → …
|
|
```
|
|
|
|
This makes first-turn resend **structurally identical** to any other
|
|
sibling creation, and the single-root guarantee becomes a DB invariant
|
|
instead of application discipline.
|
|
|
|
### Decisions
|
|
|
|
1. **Dedicated `role = 'root'`, no marker column.** The virtual root is a
|
|
self-identifying `role = 'root'` row (`data = { parts: [] }`,
|
|
`status = 'success'`, `siblingsGroupId = 0`), exactly one per topic.
|
|
`role = 'root'` and `parentId IS NULL` are equivalent — `parentId IS NULL`
|
|
stays the root *lookup* key (what `message_topic_root_uniq` covers) and
|
|
`createRootMessageTx` / the migrator are the sole writers of both. Because
|
|
the role is dedicated, role-filtered *content* queries (`WHERE role = 'system'`
|
|
etc.) exclude the root for free — no `parentId IS NOT NULL` caveat. (A
|
|
separate discriminator column was rejected: it would have to be threaded
|
|
through every query/type; extending the role enum is lighter and
|
|
self-describing.)
|
|
2. **Eager creation.** The virtual root is inserted in the **same
|
|
transaction that creates the topic**, so every topic has its root from
|
|
birth. No lazy "ensure-on-first-message" branch.
|
|
3. **Explicit create + read, not an idempotent ensure.** Every topic-creation
|
|
path calls `createRootMessageTx` (pure insert); message-creation paths call
|
|
`getRootMessageIdTx` (read, throws if absent). No create-if-missing in message
|
|
paths — a missing root is a loud bug (a creation path forgot it), not silently
|
|
papered over.
|
|
4. **getTree exposes the real parent; tree `parentId` is non-null.** A first turn
|
|
keeps its real parent — the topic's virtual root — in the `getTree` response
|
|
(no re-null), so `SiblingsGroup.parentId` and `TreeNode.parentId` are non-null
|
|
`string`, eliminating the `null for root sibling groups` shape that prompted the
|
|
review. The virtual root is never returned as a tree node; the flow-graph edge
|
|
builder skips edges whose parent isn't a rendered node, so first turns still
|
|
render as graph roots. Non-null is established by control-flow narrowing (a guard
|
|
in `messageToTreeNode`, a skip in the live builder), not assertions. (An earlier
|
|
draft re-nulled at the boundary to avoid touching the renderer — dropped because
|
|
it kept the `null` shape and the live-tree merge fed virtual-root parentIds into
|
|
the canvas anyway, so the edge guard was needed regardless.)
|
|
|
|
> `topic.rootMessageId` was considered and **rejected**: the partial
|
|
> unique index below already (a) guarantees a single root and (b) gives
|
|
> indexed O(1) access via `WHERE topic_id = ? AND parent_id IS NULL`. A
|
|
> pointer column would only duplicate a derivable fact and add a sync
|
|
> burden on create/delete/migrate. (Contrast `topic.activeNodeId`, which
|
|
> is genuine non-derivable navigation state and stays.)
|
|
|
|
### Schema (`src/main/data/db/schemas/message.ts`)
|
|
|
|
- Redefine `parentId IS NULL` to mean **only the virtual root**; all
|
|
content messages get a non-null `parentId`.
|
|
- Add a partial unique index — the actual single-root guarantor and the
|
|
root-access index in one:
|
|
|
|
```sql
|
|
CREATE UNIQUE INDEX message_topic_root_uniq ON message(topic_id)
|
|
WHERE parent_id IS NULL;
|
|
```
|
|
|
|
- The existing self-FK (`parentId → message.id`, `ON DELETE SET NULL`)
|
|
and `message_role_check` are unchanged.
|
|
|
|
No `topic` schema change. v2 schemas are throwaway, so this lands as a
|
|
regenerated migration, not a patch.
|
|
|
|
### Invariants
|
|
|
|
- Each topic has **exactly one** `parentId IS NULL` row = the virtual
|
|
root; it is content-less and **never rendered**.
|
|
- Every content message (`user` / `assistant` / `system`) has a non-null
|
|
`parentId`. First-turn user messages have `parentId = <virtual root>`.
|
|
- `activeNodeId` never points at the virtual root (it is `null` for an
|
|
empty topic, otherwise a content message).
|
|
- The "root sibling" concept no longer exists — first-turn siblings are a
|
|
normal `(parentId = root, siblingsGroupId)` group.
|
|
|
|
### Write paths (`MessageService` / `TopicService`)
|
|
|
|
- Every topic-creation path inserts the virtual root via
|
|
`MessageService.createRootMessageTx(tx, topicId)` (pure insert):
|
|
`TopicService.create`, `TopicService.duplicate`, and `TemporaryChatService`
|
|
persist. The v1→v2 `ChatMigrator` builds the same row inline per topic
|
|
(batch insert) and reparents former physical roots onto it, so migrated
|
|
topics match freshly created ones.
|
|
- Message-creation paths resolve the parent via `getRootMessageIdTx(tx, topicId)`
|
|
(read + throw-if-missing): `MessageService.create` (`parentId: undefined` on an
|
|
empty topic / explicit `null`), `createUserMessageWithPlaceholders`, and
|
|
`copyPathRowsTx` (destination root). The *"Topic already has a root message"* /
|
|
*"…no activeNodeId"* error branches are **deleted**.
|
|
- `createSibling()`: the source `parentId` is now always non-null, so the
|
|
root-sibling special case disappears; it becomes a uniform insert.
|
|
|
|
### Read paths
|
|
|
|
- `getPathRowsToNodeTx` (`:508`) walks up, stops at the virtual root, and
|
|
**excludes it** from the returned path (the displayed conversation
|
|
starts at the first user message).
|
|
- `getBranchMessages`: first-turn siblings now have `parentId = <virtual root>`,
|
|
so they match the normal `eq(parentId, …)` sibling path (the `isNull` branch is
|
|
simply never hit, since the path excludes the virtual root).
|
|
- `getTree`: fetch the virtual root, drop it from the active path, and treat its
|
|
children as the logical roots. First-turn nodes keep their **real** parent (the
|
|
virtual root id) — no re-null — and the virtual root is never returned as a node.
|
|
So `SiblingsGroup.parentId` and `TreeNode.parentId` are non-null `string`
|
|
(`message.ts` drops `| null` on both); `messageToTreeNode` guards the (impossible)
|
|
null parent to narrow without an assertion.
|
|
|
|
### Renderer
|
|
|
|
The flow canvas needs **one** change: the edge builder
|
|
(`flow/topicMessageFlowGraph.ts`) skips edges whose parent isn't a rendered node —
|
|
the virtual root, which first turns hang off but which is never a node — so first
|
|
turns still render as graph roots. `GraphInputNode` keeps a nullable internal
|
|
`parentId` (null = "no rendered parent"). The live builder
|
|
(`topicMessageFlowLiveTree.ts`) skips a parentless row (never occurs) so its node
|
|
`parentId` is non-null too. This edge guard was needed regardless: the live-tree
|
|
merge feeds the real (virtual-root) parentId into the canvas, so re-nulling in
|
|
`getTree` alone never sufficed — which is why Option X (above) is both cleaner and
|
|
the only consistent option.
|
|
|
|
## Edge cases
|
|
|
|
- **Empty / never-used topic:** holds just the virtual root + `null`
|
|
`activeNodeId`. Acceptable (one tiny content-less row).
|
|
- **Concurrent first messages:** the virtual root already exists (created in the
|
|
topic's creation tx), so racing first messages both resolve it via
|
|
`getRootMessageIdTx` and insert as siblings — no root race. The partial unique
|
|
index is the backstop against a buggy double-create.
|
|
- **Multi-model first turn:** unchanged — N assistant placeholders are
|
|
children of the (now non-root) first user message.
|
|
- **Role-filtered content queries** (`WHERE role = 'system'` etc.) need no
|
|
special handling — the virtual root is `role = 'root'`, so it is excluded
|
|
by construction.
|
|
|
|
## Alternatives considered
|
|
|
|
| Alternative | Why rejected |
|
|
|---|---|
|
|
| **Synthetic (presentation-only) root** — keep `parentId = null` roots in DB, fabricate a single root only in the tree layer | Does not give the *DB-level* single-root guarantee the reviewer asked for; the multi-root data shape and scattered assumptions remain |
|
|
| **`topic.rootMessageId` pointer** | Redundant with the partial unique index (which already guarantees + indexes the root); adds a sync burden — rejected in-thread |
|
|
| **`parentId = topicId` (topic *is* the root)** | Breaks the `parentId → message.id` self-FK |
|
|
| **Forbid first-turn resend (treat as new topic)** | Violates the same-topic product requirement |
|
|
|
|
## Phased plan & blast radius
|
|
|
|
1. **Schema** ✅ — partial unique index `message_topic_root_uniq`; regenerated
|
|
migration.
|
|
2. **Service** ✅ — `createRootMessageTx` (topic-creation paths) + `getRootMessageIdTx`
|
|
(message paths); rewire `create` / `createSibling` /
|
|
`createUserMessageWithPlaceholders` / `getPathRowsToNodeTx` / `getBranchMessages`
|
|
/ `getTree` / `copyPathRowsTx` / `duplicate` / temp-chat; delete root-sibling
|
|
special cases. Tests updated + invariant coverage added.
|
|
3. **Renderer** ✅ — flow-graph edge guard (skip edges to the unrendered virtual
|
|
root) + live-builder skip of parentless rows; `GraphInputNode` keeps a nullable
|
|
internal parentId. Tidy-up: dropped a vestigial `parentId == null` find in
|
|
`handleClearTopicMessages` (it always fell back to `uiMessages[0]`).
|
|
4. **Types** ✅ — `SiblingsGroup.parentId` and `TreeNode.parentId` are now non-null
|
|
`string`; the `null for root sibling groups` shape is **removed** (the reviewer's
|
|
original concern), since first-turn groups carry the virtual root as their parent.
|
|
|
|
Done as a follow-up to `#15951`, separate from it.
|
|
|
|
## Validation
|
|
|
|
- `MessageService.test.ts` — root-sibling cases rewritten as virtual-root child
|
|
siblings; invariant coverage added: topic-create inserts exactly one root, a
|
|
second `createRootMessageTx` hits `message_topic_root_uniq`, two `parentId:null`
|
|
creates become siblings under one root (not two physical roots), `getPath`
|
|
excludes the root, `getTree` keeps first-turn `parentId` = the virtual root id.
|
|
- `TopicService` / `TemporaryChatService` / `PersistentChatContextProvider` /
|
|
`ChatMigrator` / orphan-checker suites — seed fixtures moved to the single-root
|
|
model (one virtual root per topic) via the shared `rootRow`/`withRoot` helper in
|
|
`@test-helpers/db`.
|
|
- Flow-canvas suites (`topicMessageFlowGraph` / `LiveTree`) — fixtures updated for
|
|
non-null `parentId` (roots use the virtual-root sentinel); `ChatContent.test.tsx`
|
|
is unchanged, and the first-message edit+resend test still passes (sibling under
|
|
the virtual root via backend `createSibling`).
|
|
- Full data-layer sweep green (2216 tests); node + web typecheck 0.
|
|
|
|
## Related
|
|
|
|
- [`branch-navigation.md`](../ai/branch-navigation.md) — branch DAG UX.
|
|
- [Data Layer cluster](../ai/data-cluster.md) — `MessageService`,
|
|
migrators, shared message types.
|