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

+61 -1
View File
@@ -1,6 +1,6 @@
import { useState } from "react";
import { applyTheme, readTheme, type ThemeMode } from "../lib/theme";
import { getAuthToken } from "../lib/api";
import { createSubSession, getAuthToken } from "../lib/api";
import { useAppStore } from "../store/useAppStore";
import { Icon, type IconName } from "./Icon";
import { Tag } from "./primitives";
@@ -20,7 +20,12 @@ import { Tag } from "./primitives";
function NavContent({ onNavigate }: { onNavigate: () => void }): React.ReactNode {
const wsStatus = useAppStore((s) => s.wsStatus);
const mood = useAppStore((s) => s.mood);
const sessions = useAppStore((s) => s.sessions);
const activeSessionId = useAppStore((s) => s.activeSessionId);
const setSessions = useAppStore((s) => s.setSessions);
const setActiveSession = useAppStore((s) => s.setActiveSession);
const [theme, setTheme] = useState<ThemeMode>(readTheme);
const [creating, setCreating] = useState(false);
const connected = wsStatus === "connected";
const hasToken = !!getAuthToken();
@@ -30,6 +35,35 @@ function NavContent({ onNavigate }: { onNavigate: () => void }): React.ReactNode
setTheme(next);
};
// Main session pinned first; secondary sessions follow by creation time.
const sessionList = Object.values(sessions).sort((a, b) => {
if (a.type !== b.type) return a.type === "main" ? -1 : 1;
return a.createdAt - b.createdAt;
});
const selectSession = (id: string) => {
setActiveSession(id);
onNavigate();
};
const createSession = async () => {
if (creating) return;
setCreating(true);
try {
const meta = await createSubSession();
// Optimistically append so the new session shows immediately; the backend
// will return it on the next GET /sessions too.
const next = Object.values(useAppStore.getState().sessions);
setSessions([...next, ...(next.some((s) => s.id === meta.id) ? [] : [meta])]);
setActiveSession(meta.id);
onNavigate();
} catch {
// Backend not wired yet — leave the list unchanged.
} finally {
setCreating(false);
}
};
return (
<>
<div className="flex items-center gap-2 px-2 py-2">
@@ -55,6 +89,32 @@ function NavContent({ onNavigate }: { onNavigate: () => void }): React.ReactNode
</div>
) : null}
{/* conversation sessions (design/16 §2) — main fixed, secondary created */}
{hasToken ? (
<div className="flex flex-col gap-1">
<div className="px-2 pt-1 text-[11px] text-[var(--mu-text-lo)]">
会话
</div>
{sessionList.map((s) => (
<NavItem
key={s.id}
active={s.id === activeSessionId}
icon={s.type === "main" ? "chat" : "folder"}
label={s.title ?? (s.type === "main" ? "主会话" : "次级会话")}
onClick={() => selectSession(s.id)}
/>
))}
<button
onClick={() => void createSession()}
disabled={creating}
className="flex items-center gap-2.5 rounded-[var(--mu-radius)] px-3 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"
>
<Icon name="chat" size={18} />
<span>{creating ? "创建中…" : "+ 新建会话"}</span>
</button>
</div>
) : null}
<div className="mt-auto flex flex-col gap-1">
<NavItem
icon="settings"
@@ -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)
}
/>
))}
</>
);
}