feat(web): 前端接会话持久化——拉历史热分页 + 侧栏会话列表
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- store: 消息真源移到后端(sessions元数据map + activeSessionId,partialize 移除 messages 改存会话元数据,version:2 migrate 清旧数据)
- api: fetchSessions/createSubSession/fetchMessages(热分页before游标)/deleteSession + MessageDto→toChatMessage
- Nav: 侧栏「会话」区——主会话置顶 + 次级列表 + +新建会话(Miuix)
- 新增 HistoryList: 顶部「加载更早」向上分页 + 保守虚拟化(>200只渲染最近200)
- useChat: send 用 activeSessionId;App: token 后 fetchSessions→激活主会话→拉近50条
- Docs/api-client: v0.10 补 WS sessionId + history 回放事件契约
This commit is contained in:
Aska committed 2026-09-07 03:52:50 +08:00
1 parent 48421923f6
commit bcc53d68f3
9 files changed
+371 -32

No files matched your search

@@ -0,0 +1,83 @@
import { useState } from "react";
import { fetchMessages } from "../../lib/api";
import { useAppStore } from "../../store/useAppStore";
import { SplitChatBubble } from "../chat";
/** Page size for the history cursor (design/16 §5.1); the wire returns rows
* newest-first, `fetchMessages` reverses them into display order for us. */
const PAGE = 50;
/** Conservative DOM cap: beyond this we stop mounting every bubble so an
* extremely long session never explodes the DOM (design/16 §5.2). Older messages
* already loaded into the store are still fetched on "load earlier"; we just
* stop rendering the furthest-back ones. */
const MAX_RENDER = 200;
/**
* The scrollable message history for the focused session. Owns the "load earlier"
* hot-pagination button at the top and a conservative render cap. Streaming
* bubbles (the active turn) render exactly as before via SplitChatBubble.
*/
export function HistoryList({
lastAssistantId,
}: {
lastAssistantId?: string;
}): React.ReactNode {
const messages = useAppStore((s) => s.messages);
const activeSessionId = useAppStore((s) => s.activeSessionId);
const prependMessages = useAppStore((s) => s.prependMessages);
const [exhausted, setExhausted] = useState(false);
const [loadingEarlier, setLoadingEarlier] = useState(false);
// The oldest loaded message's createdAt is the `before` cursor. We show the
// button only after a full page has loaded (so there might be more) and until
// an upward fetch returns a short page (signalling the beginning reached).
const before = messages[0]?.createdAt;
const showLoadEarlier = !exhausted && messages.length >= PAGE;
const loadEarlier = async () => {
if (!activeSessionId || before === undefined || loadingEarlier) return;
setLoadingEarlier(true);
try {
const older = await fetchMessages(activeSessionId, before, PAGE);
prependMessages(older);
if (older.length < PAGE) setExhausted(true);
} catch {
// Transient backend not wired / offline — user can retry; keep the button.
} finally {
setLoadingEarlier(false);
}
};
const collapsed = messages.length > MAX_RENDER;
const visible = collapsed ? messages.slice(-MAX_RENDER) : messages;
return (
<>
{showLoadEarlier ? (
<button
onClick={() => void loadEarlier()}
disabled={loadingEarlier}
className="mx-auto flex items-center gap-2 rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)] px-4 py-2 text-sm text-[var(--mu-text-mid)] transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-4)] active:scale-[0.98] disabled:opacity-60"
>
{loadingEarlier ? "加载中…" : "↥ 加载更早的消息"}
</button>
) : null}
{collapsed ? (
<div className="px-2 text-center text-[11px] text-[var(--mu-text-lo)]">
已收起 {messages.length - MAX_RENDER} 条较早消息
</div>
) : null}
{visible.map((m) => (
<SplitChatBubble
key={m.id}
message={m}
active={
m.id === lastAssistantId &&
m.role === "assistant" &&
(m.streaming ?? false)
}
/>
))}
</>
);
}