Skip to content

Commit 11d193a

Browse files
committed
feat(chat): Phase G — 链式时间线网状结构
新增 prevMessageEventId 字段:AI 消息写入 DAG 时记录生成时的上文 eventId, 使具有相同 prevMessageEventId 的多条回复成为同一分支点的 alternatives。 新增 buildDisplayChain:基于 activeBranches 指针从所有消息中 遍历出当前活跃分支路径,替代旧的线性 mergeChannelMessagesForDisplay。 前端链式导航: - 每个 alternative 消息下方显示 '◀ N/M ▶' 导航条,点击切换分支 - 切换时更新 activeBranches 并持久化到 localStorage - 切换某处的 alternative 自动带动后续消息跟随对应分支 Swipe 手势扩展到所有消息类型(不再仅限 char 消息), 有 branchData 时沿链切换,无时退化为旧 modifyTimeLine。 向后兼容:无 prevMessageEventId 的旧数据直接返回原始消息列表。
1 parent e1c210e commit 11d193a

5 files changed

Lines changed: 191 additions & 7 deletions

File tree

src/public/parts/shells/chat/public/src/group.mjs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,33 @@ async function applyGroupHash() {
133133
sessionStorage.setItem('group:wsClientId', wsClientId)
134134
setLocalGroupRpcClientNodeId(wsClientId)
135135

136+
/**
137+
* 从 localStorage 恢复指定频道的 activeBranches。
138+
* @param {string} gId 群组 ID
139+
* @param {string} chId 频道 ID
140+
* @returns {Map<string, string>} prevMessageEventId 到选中 eventId 的映射
141+
*/
142+
function loadActiveBranches(gId, chId) {
143+
try {
144+
const raw = localStorage.getItem(`fount:branches:${gId}:${chId}`)
145+
return new Map(raw ? JSON.parse(raw) : [])
146+
} catch { return new Map() }
147+
}
148+
149+
/**
150+
* 将 activeBranches 持久化到 localStorage(最多保留 500 个分支点)。
151+
* @param {string} gId 群组 ID
152+
* @param {string} chId 频道 ID
153+
* @param {Map<string, string>} map 当前 activeBranches
154+
* @returns {void}
155+
*/
156+
function saveActiveBranches(gId, chId, map) {
157+
try {
158+
const entries = [...map].slice(-500)
159+
localStorage.setItem(`fount:branches:${gId}:${chId}`, JSON.stringify(entries))
160+
} catch { /* ignore */ }
161+
}
162+
136163
/** 共享可变状态(消息列表、虚拟列表、流式渲染、AV 会话等) */
137164
const channelState = {
138165
/** @type {object | null} */
@@ -154,6 +181,10 @@ async function applyGroupHash() {
154181
patchScheduled: false,
155182
/** @type {object | null} */
156183
avSession: null,
184+
/** @type {Map<string, string>} prevMessageEventId -> 选中的 eventId */
185+
activeBranches: loadActiveBranches(groupId, channelId),
186+
/** @type {Map<string, { alternatives: object[], selectedIdx: number, branchKey: string }>} */
187+
branchInfo: new Map(),
157188
}
158189

159190
signal.addEventListener('abort', () => {
@@ -485,6 +516,20 @@ async function applyGroupHash() {
485516
* @returns {void}
486517
*/
487518
onOpenThread: threadChannelId => { threadDrawerInstance?.open(threadChannelId) },
519+
/**
520+
* @returns {Map<string, { alternatives: object[], selectedIdx: number, branchKey: string }>} 当前分支信息
521+
*/
522+
getBranchInfo: () => channelState.branchInfo,
523+
/**
524+
* @param {string} branchKey prevMessageEventId 分支点 key
525+
* @param {string} selectedEventId 选中的消息 eventId
526+
* @returns {void}
527+
*/
528+
onBranchSelect: (branchKey, selectedEventId) => {
529+
channelState.activeBranches.set(branchKey, selectedEventId)
530+
saveActiveBranches(groupId, channelId, channelState.activeBranches)
531+
void loadMessages()
532+
},
488533
})
489534

490535
// ─── Thread Drawer ────────────────────────────────────────────────────────
@@ -622,6 +667,10 @@ async function applyGroupHash() {
622667
setAsDefaultChannel,
623668
renderMessageItem,
624669
attachLastMessageTimeline,
670+
/**
671+
* @returns {Map<string, string>} 当前 activeBranches
672+
*/
673+
getActiveBranches: () => channelState.activeBranches,
625674
}))
626675

627676
await pullIncrementalDagEvents()

src/public/parts/shells/chat/public/src/ui/channelView.mjs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { createVirtualList } from '../../../../../../pages/scripts/virtualList.m
55
import { geti18n, setLocalizeLogic } from '../../../../../../scripts/i18n.mjs'
66
import { handleUIError } from '../utils.mjs'
77

8-
import { escapeHtml, mergeChannelMessagesForDisplay, orderedActivePinTargets, plainPreviewFromLine } from './dagMessageUtils.mjs'
8+
import { buildDisplayChain, escapeHtml, mergeChannelMessagesForDisplay, orderedActivePinTargets, plainPreviewFromLine } from './dagMessageUtils.mjs'
99
import { addDragAndDropSupport } from './dragAndDrop.mjs'
1010

1111
/**
@@ -26,6 +26,7 @@ import { addDragAndDropSupport } from './dragAndDrop.mjs'
2626
* @param {Function} params.setAsDefaultChannel 设为默认频道
2727
* @param {Function} params.renderMessageItem 渲染单条消息 DOM
2828
* @param {Function} params.attachLastMessageTimeline 附加时间线到末尾消息
29+
* @param {Function} params.getActiveBranches 获取当前 activeBranches Map
2930
* @returns {{ loadMessages: Function, scheduleMessagePatch: Function }} 频道视图控制函数集合
3031
*/
3132
export function createChannelView({
@@ -44,6 +45,7 @@ export function createChannelView({
4445
setAsDefaultChannel,
4546
renderMessageItem,
4647
attachLastMessageTimeline,
48+
getActiveBranches,
4749
}) {
4850
/**
4951
* 将 DAG 消息行调度进增量 patch 队列(同一 eventId 后写覆盖)。
@@ -79,7 +81,10 @@ export function createChannelView({
7981
}
8082
}
8183
if (!changed) return
82-
state.displayMessages = mergeChannelMessagesForDisplay(state.rawMessages)
84+
const mergedPatch = mergeChannelMessagesForDisplay(state.rawMessages)
85+
const { messages: chainMessagesPatch, branchInfo: branchInfoPatch } = buildDisplayChain(mergedPatch, getActiveBranches?.() ?? new Map())
86+
state.displayMessages = chainMessagesPatch
87+
state.branchInfo = branchInfoPatch
8388
state.msgVirtualList?.refresh?.()
8489
}
8590
catch (e) {
@@ -164,7 +169,9 @@ export function createChannelView({
164169
const { messages } = await r.json()
165170
state.rawMessages = Array.isArray(messages) ? [...messages] : []
166171
const merged = mergeChannelMessagesForDisplay(state.rawMessages)
167-
state.displayMessages = merged
172+
const { messages: chainMessages, branchInfo } = buildDisplayChain(merged, getActiveBranches?.() ?? new Map())
173+
state.displayMessages = chainMessages
174+
state.branchInfo = branchInfo
168175
const volHold = state.volatileStreamEl
169176
if (volHold?.parentNode) volHold.parentNode.removeChild(volHold)
170177
state.msgVirtualList?.destroy()

src/public/parts/shells/chat/public/src/ui/dagMessageUtils.mjs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,62 @@ function mergeChannelMessagesForDisplay(messages) {
8181
return out
8282
}
8383

84+
/**
85+
* 基于 prevMessageEventId 链式指针和 activeBranches 选择,从所有消息中构建当前显示路径。
86+
* 在 mergeChannelMessagesForDisplay 的结果之上运行(edit/delete 已折叠完毕)。
87+
* @param {object[]} mergedMessages mergeChannelMessagesForDisplay 返回的消息数组
88+
* @param {Map<string, string>} activeBranches Key=prevMessageEventId, Value=选中的 eventId
89+
* @returns {{ messages: object[], branchInfo: Map<string, { alternatives: object[], selectedIdx: number, branchKey: string }> }} 当前分支路径上的消息列表及各分支点信息
90+
*/
91+
function buildDisplayChain(mergedMessages, activeBranches) {
92+
const hasPrevPointer = mergedMessages.some(m => m.content?.prevMessageEventId)
93+
if (!hasPrevPointer)
94+
return { messages: mergedMessages, branchInfo: new Map() }
95+
96+
const byEventId = new Map()
97+
const childrenOf = new Map()
98+
for (const m of mergedMessages) {
99+
if (m.eventId) byEventId.set(m.eventId, m)
100+
const prev = m.content?.prevMessageEventId
101+
if (prev) {
102+
if (!childrenOf.has(prev)) childrenOf.set(prev, [])
103+
childrenOf.get(prev).push(m)
104+
}
105+
}
106+
107+
const roots = mergedMessages.filter(m => !m.content?.prevMessageEventId)
108+
const result = [...roots]
109+
const branchInfo = new Map()
110+
111+
let cursor = roots[roots.length - 1]?.eventId ?? null
112+
113+
const visited = new Set()
114+
while (cursor !== null && !visited.has(cursor)) {
115+
visited.add(cursor)
116+
const children = childrenOf.get(cursor)
117+
if (!children?.length) break
118+
119+
if (children.length === 1) {
120+
result.push(children[0])
121+
cursor = children[0].eventId
122+
}
123+
else {
124+
const activeId = activeBranches.get(cursor)
125+
const selected = children.find(m => m.eventId === activeId) ?? children[children.length - 1]
126+
const selectedIdx = children.indexOf(selected)
127+
branchInfo.set(selected.eventId, {
128+
alternatives: children,
129+
selectedIdx,
130+
branchKey: cursor,
131+
})
132+
result.push(selected)
133+
cursor = selected.eventId
134+
}
135+
}
136+
137+
return { messages: result, branchInfo }
138+
}
139+
84140
/**
85141
* 按时间线重放 pin/unpin,得到当前仍置顶的 targetEventId 列表(最近置顶在前)。
86142
* @param {object[]} messages 频道原始消息行(未 merge)
@@ -323,6 +379,7 @@ export {
323379
escapeHtml,
324380
senderColor,
325381
mergeChannelMessagesForDisplay,
382+
buildDisplayChain,
326383
orderedActivePinTargets,
327384
flattenChannelTree,
328385
plainPreviewFromLine,

src/public/parts/shells/chat/public/src/ui/messageItem.mjs

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import { showEmojiPicker } from './emojiPicker.mjs'
2929
* getEditedIds?: () => Set<string>,
3030
* onContentResize?: () => void,
3131
* onOpenThread?: (threadChannelId: string) => void,
32+
* getBranchInfo?: () => Map<string, { alternatives: object[], selectedIdx: number, branchKey: string }>,
33+
* onBranchSelect?: (branchKey: string, selectedEventId: string) => void,
3234
* }} ctx 群聊 UI 依赖(消息列表、头像缓存、表情与文件处理等)。
3335
* @returns {{ renderMessageItem: (m: object, index: number) => Promise<HTMLElement>, showMessageMenu: (e: MouseEvent, m: object, bubble: HTMLElement) => void, attachLastMessageTimeline: (merged: object[]) => Promise<void> }} 渲染与菜单、时间线方法集合
3436
*/
@@ -49,6 +51,8 @@ export function createMessageItemRenderer(ctx) {
4951
getEditedIds,
5052
onContentResize,
5153
onOpenThread,
54+
getBranchInfo,
55+
onBranchSelect,
5256
} = ctx
5357

5458
const standaloneHtmlCache = new WeakMap()
@@ -99,10 +103,12 @@ export function createMessageItemRenderer(ctx) {
99103

100104
/**
101105
* 在最后一条非用户消息上附加时间线导航(◀/▶ + swipe)。
106+
* 若已有链式分支信息(branchInfo),则导航已由 renderMessageItem 处理,跳过。
102107
* @param {object[]} merged 合并后的消息列表
103108
* @returns {Promise<void>}
104109
*/
105110
async function attachLastMessageTimeline(merged) {
111+
if (getBranchInfo?.()?.size > 0) return
106112
const lastMsg = [...merged].reverse().find(m => m.type === 'message' && m.sender && m.sender !== 'local')
107113
|| [...merged].reverse().find(m => m.type === 'message')
108114
if (!lastMsg?.eventId) return
@@ -862,19 +868,79 @@ export function createMessageItemRenderer(ctx) {
862868
}
863869
}
864870

871+
const branchData = getBranchInfo?.()?.get(m.eventId)
872+
if (branchData && branchData.alternatives.length > 1) {
873+
const { alternatives, selectedIdx, branchKey } = branchData
874+
const nav = document.createElement('div')
875+
nav.className = 'flex items-center gap-1 mt-1 text-xs opacity-70'
876+
nav.setAttribute('data-timeline-nav', '1')
877+
878+
const prevBtn = document.createElement('button')
879+
prevBtn.type = 'button'
880+
prevBtn.className = 'btn btn-xs btn-ghost px-1'
881+
prevBtn.textContent = '◀'
882+
prevBtn.disabled = selectedIdx <= 0
883+
prevBtn.addEventListener('click', () => {
884+
const newSelected = alternatives[selectedIdx - 1]
885+
if (newSelected) onBranchSelect?.(branchKey, newSelected.eventId)
886+
})
887+
888+
const label = document.createElement('span')
889+
label.className = 'px-1'
890+
label.textContent = `${selectedIdx + 1}/${alternatives.length}`
891+
892+
const nextBtn = document.createElement('button')
893+
nextBtn.type = 'button'
894+
nextBtn.className = 'btn btn-xs btn-ghost px-1'
895+
nextBtn.textContent = '▶'
896+
nextBtn.disabled = selectedIdx >= alternatives.length - 1
897+
nextBtn.addEventListener('click', async () => {
898+
const newSelected = alternatives[selectedIdx + 1]
899+
if (newSelected)
900+
onBranchSelect?.(branchKey, newSelected.eventId)
901+
else {
902+
setCurrentChannel(groupId, channelId, 'chat')
903+
await modifyTimeLine(1)
904+
await loadMessages()
905+
}
906+
})
907+
908+
nav.appendChild(prevBtn)
909+
nav.appendChild(label)
910+
nav.appendChild(nextBtn)
911+
912+
if (bubble) bubble.after(nav)
913+
else div.appendChild(nav)
914+
}
915+
865916
const isCharRoleMessage = m.role === 'char' || (m.type === 'message' && !!m.charId)
866-
if (isCharRoleMessage) {
917+
if (branchData || isCharRoleMessage) {
867918
bubble.setAttribute('data-char-swipe-timeline', '1')
868919
let touchStartX = 0
869920
bubble.addEventListener('touchstart', e => {
870921
touchStartX = e.touches[0].clientX
871922
}, { passive: true })
872-
bubble.addEventListener('touchend', e => {
923+
bubble.addEventListener('touchend', async e => {
873924
const dx = e.changedTouches[0].clientX - touchStartX
874925
if (Math.abs(dx) <= SWIPE_THRESHOLD) return
875926
e.stopPropagation()
876-
setCurrentChannel(groupId, channelId, 'chat')
877-
void modifyTimeLine(dx < 0 ? 1 : -1).then(() => loadMessages())
927+
const direction = dx < 0 ? 1 : -1
928+
929+
if (branchData) {
930+
const { alternatives, selectedIdx, branchKey } = branchData
931+
const newIdx = selectedIdx + direction
932+
if (newIdx >= 0 && newIdx < alternatives.length)
933+
onBranchSelect?.(branchKey, alternatives[newIdx].eventId)
934+
else if (newIdx >= alternatives.length) {
935+
setCurrentChannel(groupId, channelId, 'chat')
936+
await modifyTimeLine(direction)
937+
await loadMessages()
938+
}
939+
}
940+
else {
941+
setCurrentChannel(groupId, channelId, 'chat')
942+
void modifyTimeLine(direction).then(() => loadMessages())
943+
}
878944
}, { passive: true })
879945
}
880946
return div

src/public/parts/shells/chat/src/chat/session.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1269,6 +1269,11 @@ async function syncChatLogEntryToDag(groupId, entry, username) {
12691269
const channelIdForDag = typeof groupCh === 'string' && /^[\w.-]+$/.test(groupCh) && groupCh.length <= 128
12701270
? groupCh
12711271
: await getDefaultChannelId(username, groupId)
1272+
try {
1273+
const recentMessages = await listChannelMessages(username, groupId, channelIdForDag, { limit: 1 })
1274+
const prevMessageEventId = recentMessages[recentMessages.length - 1]?.eventId
1275+
if (prevMessageEventId) content.prevMessageEventId = prevMessageEventId
1276+
} catch { /* ignore,prevMessageEventId 缺失时退化为旧行为 */ }
12721277
await appendEvent(username, groupId, {
12731278
type: 'message',
12741279
channelId: channelIdForDag,

0 commit comments

Comments
 (0)