feat(web): 前端接会话持久化——拉历史热分页 + 侧栏会话列表
- 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:
1 parent
48421923f6
commit
bcc53d68f3
9 files changed
+371
-32
No files matched your search
@@ -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)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user