From fae2c120ef5896b8ce4888382477d9de5a2a2a2a Mon Sep 17 00:00:00 2001 From: Wei Chengqian Date: Sat, 12 Sep 2026 13:56:22 +0800 Subject: [PATCH] feat(chatui): paginate chat history (#9667) * feat(dashboard): add chat history pagination and reasoning blocks (#9652) * refactor(dashboard): remove reasoning char count and loading text * feat(dashboard): load earlier messages on scroll up * refactor(dashboard): remove reasoning retry label * refactor(dashboard): remove thread panel pagination and lazy reasoning Restore the thread panel to pre-#9652 behavior: full history without pagination and inline reasoning content. Reverts the out-of-scope thread additions while keeping main-session pagination and reasoning lazy-loading. * chore: retrigger smoke test * fix(dashboard): pause automatic history loading after failure * feat(dashboard): show progress while loading earlier messages * fix(dashboard): show reasoning loading errors and retry action * style(dashboard): align chat loading errors with chat UI * style(dashboard): make chat loading errors more compact * refactor(chat): retain complete message content in history pages * refactor(dashboard): expand reasoning directly from history pages * refactor(api): remove lazy chat message retrieval endpoint --- astrbot/core/db/__init__.py | 17 ++ astrbot/core/db/sqlite.py | 24 ++ astrbot/core/platform_message_history_mgr.py | 15 ++ astrbot/dashboard/api/chat.py | 13 +- astrbot/dashboard/services/chat_service.py | 38 ++- .../src/api/generated/openapi-v1/types.gen.ts | 4 + dashboard/src/api/v1.ts | 12 +- dashboard/src/components/chat/Chat.vue | 122 ++++++++- .../src/components/chat/ChatLoadError.vue | 62 +++++ .../src/components/chat/ChatMessageList.vue | 1 + dashboard/src/composables/useMessages.ts | 161 +++++++++++- .../src/i18n/locales/en-US/features/chat.json | 5 + .../src/i18n/locales/ja-JP/features/chat.json | 5 + .../src/i18n/locales/ru-RU/features/chat.json | 5 + .../src/i18n/locales/zh-CN/features/chat.json | 5 + dashboard/tests/chatHistoryContent.test.mjs | 61 +++++ dashboard/tests/chatHistoryLoading.test.mjs | 132 ++++++++++ docs/public/openapi.json | 19 ++ openspec/openapi-v1.yaml | 13 + tests/unit/test_chat_history_pagination.py | 235 ++++++++++++++++++ tests/unit/test_openapi_scope_docs.py | 16 +- 21 files changed, 932 insertions(+), 33 deletions(-) create mode 100644 dashboard/src/components/chat/ChatLoadError.vue create mode 100644 dashboard/tests/chatHistoryContent.test.mjs create mode 100644 dashboard/tests/chatHistoryLoading.test.mjs create mode 100644 tests/unit/test_chat_history_pagination.py diff --git a/astrbot/core/db/__init__.py b/astrbot/core/db/__init__.py index 113218dd7..3b4045441 100644 --- a/astrbot/core/db/__init__.py +++ b/astrbot/core/db/__init__.py @@ -268,6 +268,23 @@ class BaseDatabase(abc.ABC): """Get platform message history for a specific user.""" ... + @abc.abstractmethod + async def count_platform_message_history( + self, + platform_id: str, + user_id: str, + ) -> int: + """Count platform message history records for a scope. + + Args: + platform_id: Platform identifier used to partition history. + user_id: Platform user or session identifier. + + Returns: + Number of records belonging to the platform/user scope. + """ + ... + @abc.abstractmethod async def get_platform_message_history_by_id( self, diff --git a/astrbot/core/db/sqlite.py b/astrbot/core/db/sqlite.py index 15e2b803d..b644ef331 100644 --- a/astrbot/core/db/sqlite.py +++ b/astrbot/core/db/sqlite.py @@ -886,6 +886,30 @@ class SQLiteDatabase(BaseDatabase): result = await session.execute(query.offset(offset).limit(page_size)) return result.scalars().all() + async def count_platform_message_history( + self, + platform_id: str, + user_id: str, + ) -> int: + """Count platform message history records for a scope. + + Args: + platform_id: Platform identifier used to partition history. + user_id: Platform user or session identifier. + + Returns: + Number of records matching the platform/user scope. + """ + async with self.get_db() as session: + session: AsyncSession + result = await session.execute( + select(func.count(PlatformMessageHistory.id)).where( + PlatformMessageHistory.platform_id == platform_id, + PlatformMessageHistory.user_id == user_id, + ) + ) + return int(result.scalar_one() or 0) + async def get_platform_message_history_by_id( self, message_id: int ) -> PlatformMessageHistory | None: diff --git a/astrbot/core/platform_message_history_mgr.py b/astrbot/core/platform_message_history_mgr.py index 82384d5a3..12dc2fff1 100644 --- a/astrbot/core/platform_message_history_mgr.py +++ b/astrbot/core/platform_message_history_mgr.py @@ -122,6 +122,21 @@ class PlatformMessageHistoryManager: history.reverse() return history + async def count(self, platform_id: str, user_id: str) -> int: + """Count history rows for a platform/user scope. + + Args: + platform_id: Platform identifier used to partition history. + user_id: Platform user or session identifier. + + Returns: + Number of matching history rows. + """ + return await self.db.count_platform_message_history( + platform_id=platform_id, + user_id=user_id, + ) + async def delete( self, platform_id: str, user_id: str, offset_sec: int = 86400 ) -> None: diff --git a/astrbot/dashboard/api/chat.py b/astrbot/dashboard/api/chat.py index 17ffd3bde..2538a06b3 100644 --- a/astrbot/dashboard/api/chat.py +++ b/astrbot/dashboard/api/chat.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, Query, Request from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from astrbot.dashboard.async_utils import run_maybe_async @@ -137,10 +137,19 @@ async def batch_delete_chat_sessions( @router.get("/chat/sessions/{session_id}") async def get_chat_session( session_id: str, + page: int = Query(default=1, ge=1), + page_size: int = Query(default=1000, ge=1, le=1000), auth: AuthContext = Depends(require_chat_scope), service: ChatService = Depends(get_service), ): - return await _run(lambda: service.get_session(auth.username, session_id)) + return await _run( + lambda: service.get_session( + auth.username, + session_id, + page=page, + page_size=page_size, + ) + ) @router.patch("/chat/sessions/{session_id}") diff --git a/astrbot/dashboard/services/chat_service.py b/astrbot/dashboard/services/chat_service.py index 0b72b582d..ca651f645 100644 --- a/astrbot/dashboard/services/chat_service.py +++ b/astrbot/dashboard/services/chat_service.py @@ -1411,7 +1411,31 @@ class ChatService: ) -> list[dict]: return await self.get_sessions(username, platform_id) - async def get_session(self, username: str, session_id: str) -> dict: + async def get_session( + self, + username: str, + session_id: str, + page: int = 1, + page_size: int = 1000, + ) -> dict: + """Get one WebChat session and a page of its history. + + Args: + username: Authenticated dashboard username. + session_id: WebChat session identifier. + page: One-based history page, with page one containing the newest rows. + page_size: Number of history records to return (at most 1000). + + Returns: + Session metadata, history page, and pagination metadata. + + Raises: + ChatServiceError: If pagination is invalid or the session is inaccessible. + """ + if page < 1: + raise ChatServiceError("page must be at least 1") + if page_size < 1 or page_size > 1000: + raise ChatServiceError("page_size must be between 1 and 1000") session = await self.db.get_platform_session_by_id(session_id) if not session: raise ChatServiceError(f"Session {session_id} not found") @@ -1425,8 +1449,12 @@ class ChatService: history_ls = await self.platform_history_mgr.get( platform_id=platform_id, user_id=session_id, - page=1, - page_size=1000, + page=page, + page_size=page_size, + ) + total = await self.platform_history_mgr.count( + platform_id=platform_id, + user_id=session_id, ) threads = await self.db.get_webchat_threads_by_parent_session( parent_session_id=session_id, @@ -1435,6 +1463,10 @@ class ChatService: response_data = { "history": [serialize_history_entry(history) for history in history_ls], + "total": total, + "page": page, + "page_size": page_size, + "has_more": (page - 1) * page_size + len(history_ls) < total, "threads": [serialize_thread(thread) for thread in threads], "is_running": self.running_convs.get(session_id, False), "active_runs": self.get_active_chat_runs(username, session_id), diff --git a/dashboard/src/api/generated/openapi-v1/types.gen.ts b/dashboard/src/api/generated/openapi-v1/types.gen.ts index c6ed99e11..4ac2fee95 100644 --- a/dashboard/src/api/generated/openapi-v1/types.gen.ts +++ b/dashboard/src/api/generated/openapi-v1/types.gen.ts @@ -1380,6 +1380,10 @@ export type GetChatSessionData = { path: { session_id: string; }; + query?: { + page?: number; + page_size?: number; + }; }; export type GetChatSessionResponse = (SuccessEnvelope); diff --git a/dashboard/src/api/v1.ts b/dashboard/src/api/v1.ts index 709d4060a..2ea5b3086 100644 --- a/dashboard/src/api/v1.ts +++ b/dashboard/src/api/v1.ts @@ -191,6 +191,11 @@ export interface ChatSessionListParams { username?: string; } +export interface ChatHistoryPageParams { + page?: number; + page_size?: number; +} + export interface CronJobListParams { type?: string; } @@ -822,9 +827,12 @@ export const chatApi = { }), ); }, - getSession(sessionId: string) { + getSession(sessionId: string, params?: ChatHistoryPageParams) { return typed( - openApiV1.getChatSession({ path: { session_id: sessionId } }), + openApiV1.getChatSession({ + path: { session_id: sessionId }, + query: generatedQuery(params), + }), ); }, updateSession(sessionId: string, payload: ChatSessionPatchRequest) { diff --git a/dashboard/src/components/chat/Chat.vue b/dashboard/src/components/chat/Chat.vue index 60f37fdf9..266aac03b 100644 --- a/dashboard/src/components/chat/Chat.vue +++ b/dashboard/src/components/chat/Chat.vue @@ -268,6 +268,16 @@ class="conversation-stack" :class="{ 'is-empty': isEmptyChat }" > +
+
+ +
+
{{ tm("welcome.title") }}
@@ -292,6 +313,13 @@ ref="messagesContent" class="messages-list-shell" > + (null); const composerShell = ref(null); const inputRef = ref | null>(null); const shouldStickToBottom = ref(true); +const suppressAutoScroll = ref(false); +const LOAD_EARLIER_SCROLL_THRESHOLD = 120; const isAwayFromBottom = ref(false); let lastMessagesScrollTop = 0; let touchScrollY = 0; @@ -692,10 +723,12 @@ const { loadedSessions, sessionProjects, activeMessages, + paginationBySession, isSessionRunning, isUserMessage, messageParts, loadSessionMessages, + loadEarlierMessages, createLocalExchange, sendMessageStream, editMessage, @@ -712,6 +745,10 @@ const { }, }); +const activeSessionPagination = computed(() => + currSessionId.value ? paginationBySession[currSessionId.value] : undefined, +); + const transportMode = ref( (localStorage.getItem("chat.transportMode") as TransportMode) === "websocket" ? "websocket" @@ -956,7 +993,7 @@ watch( ); watch(activeMessages, () => { - if (shouldStickToBottom.value) { + if (!suppressAutoScroll.value && shouldStickToBottom.value) { scrollToBottom(); } }); @@ -1245,9 +1282,11 @@ async function selectSession(sessionId: string, pushRoute = true) { replyTarget.value = null; if (pushRoute && route.path !== `${basePath()}/${sessionId}`) { await router.push(`${basePath()}/${sessionId}`); + if (currSessionId.value !== sessionId) return; } if (!loadedSessions[sessionId]) { await loadSessionMessages(sessionId); + if (currSessionId.value !== sessionId) return; } scrollToBottom(true); closeMobileSidebar(); @@ -1409,20 +1448,24 @@ function cancelMessageEdit() { async function saveMessageEdit() { if (!currSessionId.value || !editingMessage.value) return; + const sessionId = currSessionId.value; + const target = editingMessage.value; savingMessageEdit.value = true; try { - const target = editingMessage.value; - const result = await editMessage( - currSessionId.value, - target, - messageEditDraft.value, - ); + const result = await editMessage(sessionId, target, messageEditDraft.value); + if ( + currSessionId.value !== sessionId || + !activeMessages.value.includes(target) + ) { + cancelMessageEdit(); + return; + } cancelMessageEdit(); if (result.needsRegenerate && result.truncatedAfterMessage) { const selection = getSelectedProviderSelection(); continueEditedMessage({ - sessionId: currSessionId.value, + sessionId, sourceRecord: target, enableStreaming: enableStreaming.value, enableReasoning: enableReasoning.value, @@ -1566,6 +1609,46 @@ function openReasoningPanel(payload: { reasoningPanelOpen.value = true; } +async function loadEarlierWithAnchor() { + const sessionId = currSessionId.value; + if (!sessionId || activeSessionPagination.value?.loading) return; + const container = messagesContainer.value; + const firstMessage = activeMessages.value[0]; + const firstId = firstMessage?.id == null ? "" : String(firstMessage.id); + const beforeTop = firstId + ? container + ?.querySelector( + `[data-message-id="${CSS.escape(firstId)}"]`, + ) + ?.getBoundingClientRect().top + : undefined; + suppressAutoScroll.value = true; + try { + await loadEarlierMessages(sessionId); + if (currSessionId.value !== sessionId) return; + await nextTick(); + if (beforeTop == null || !container || !firstId) return; + const row = container.querySelector( + `[data-message-id="${CSS.escape(firstId)}"]`, + ); + if (row) { + container.scrollTop += row.getBoundingClientRect().top - beforeTop; + } + } finally { + suppressAutoScroll.value = false; + } +} + +async function retryCurrentSessionLoad() { + const sessionId = currSessionId.value; + if (!sessionId || activeSessionPagination.value?.loading) return; + if (activeMessages.value.length && activeSessionPagination.value?.has_more) { + await loadEarlierWithAnchor(); + return; + } + await loadSessionMessages(sessionId, true, true); +} + async function deleteThread(thread: ChatThread) { if (deletingThread.value) return; if (!(await askForConfirmation(tm("thread.confirmDelete"), confirmDialog))) @@ -1688,6 +1771,17 @@ function handleMessagesScroll() { shouldStickToBottom.value = true; } lastMessagesScrollTop = scrollTop; + maybeLoadEarlierOnScroll(container); +} + +function maybeLoadEarlierOnScroll(container: HTMLElement) { + const sessionId = currSessionId.value; + const pagination = activeSessionPagination.value; + if (!sessionId || !pagination) return; + if (!pagination.has_more || pagination.loading || pagination.error) return; + if (container.scrollHeight <= container.clientHeight) return; + if (container.scrollTop > LOAD_EARLIER_SCROLL_THRESHOLD) return; + void loadEarlierWithAnchor(); } function scrollToBottom(resumeFollowing = false) { @@ -1698,7 +1792,8 @@ function scrollToBottom(resumeFollowing = false) { nextTick(() => { const container = messagesContainer.value; // Recheck after rendering so queued stream updates cannot override user intent. - if (!container || !shouldStickToBottom.value) return; + if (!container || suppressAutoScroll.value || !shouldStickToBottom.value) + return; container.scrollTop = container.scrollHeight; lastMessagesScrollTop = Math.max(0, container.scrollTop); isAwayFromBottom.value = false; @@ -2208,6 +2303,11 @@ async function stopCurrentSession() { scroll-padding-bottom: calc(var(--chat-composer-height, 82px) + 34px); } +.history-loading { + z-index: 2; + pointer-events: none; +} + .conversation-stack.is-empty .messages-panel { flex: none; min-height: auto; @@ -2232,6 +2332,10 @@ async function stopCurrentSession() { text-align: center; } +.history-load-error { + margin: 0 auto 12px; +} + .conversation-stack.is-empty .welcome-state { height: auto; } diff --git a/dashboard/src/components/chat/ChatLoadError.vue b/dashboard/src/components/chat/ChatLoadError.vue new file mode 100644 index 000000000..814ef21b6 --- /dev/null +++ b/dashboard/src/components/chat/ChatLoadError.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/dashboard/src/components/chat/ChatMessageList.vue b/dashboard/src/components/chat/ChatMessageList.vue index bbf5ec3d2..ba092a8d7 100644 --- a/dashboard/src/components/chat/ChatMessageList.vue +++ b/dashboard/src/components/chat/ChatMessageList.vue @@ -10,6 +10,7 @@ :key="msg.id || `${msgIndex}-${msg.created_at || ''}`" class="message-row" :class="isUserMessage(msg) ? 'from-user' : 'from-bot'" + :data-message-id="msg.id == null ? undefined : String(msg.id)" > >({}); const loadedSessions = reactive>({}); + const paginationBySession = reactive>( + {}, + ); const activeConnections = reactive>({}); const chatWebSockets: Record = {}; const closingChatWebSockets = new WeakSet(); const deferredBotAnchors = new WeakMap(); const attachmentBlobCache = new Map>(); + const sessionLoadEpochs = reactive>({}); + const loadingSessionId = ref(null); const sessionProjects = reactive>( {}, ); @@ -162,6 +176,11 @@ export function useMessages(options: UseMessagesOptions) { ? messagesBySession[options.currentSessionId.value] || [] : [], ); + const loadingMessages = computed( + () => + loadingMessagesState.value && + loadingSessionId.value === options.currentSessionId.value, + ); onBeforeUnmount(() => { cleanupConnections(); @@ -264,27 +283,147 @@ export function useMessages(options: UseMessagesOptions) { sessionId: string, resumeRuns = true, showLoading = true, + preserveLoadedPages = false, ) { if (!sessionId) return; - if (showLoading) loadingMessages.value = true; + if (showLoading) { + loadingMessagesState.value = true; + loadingSessionId.value = sessionId; + } + const epoch = (sessionLoadEpochs[sessionId] || 0) + 1; + sessionLoadEpochs[sessionId] = epoch; try { - const response = await chatApi.getSession(sessionId); + const response = await chatApi.getSession(sessionId, { + page: 1, + page_size: 50, + }); + if (response.data?.status !== "ok") { + throw new Error( + response.data?.message || "Failed to load session messages", + ); + } const payload = response.data?.data || {}; const history = payload.history || []; - const records = history.map(normalizeHistoryRecord); + const records: ChatRecord[] = history.map( + (record: any): ChatRecord => normalizeHistoryRecord(record), + ); attachThreads(records, payload.threads || []); await resolveRecordMedia(records); - messagesBySession[sessionId] = records; + if (sessionLoadEpochs[sessionId] !== epoch) return; + const previousPagination = paginationBySession[sessionId]; + if (preserveLoadedPages && previousPagination?.page > 1) { + const refreshedById = new Map( + records + .filter((record) => record.id != null) + .map((record) => [String(record.id), record]), + ); + const existing = messagesBySession[sessionId] || []; + const merged = existing.map( + (record) => refreshedById.get(String(record.id)) || record, + ); + const existingIds = new Set( + existing + .filter((record) => record.id != null) + .map((record) => String(record.id)), + ); + messagesBySession[sessionId] = [ + ...merged, + ...records.filter( + (record) => + record.id == null || !existingIds.has(String(record.id)), + ), + ]; + paginationBySession[sessionId] = { + ...previousPagination, + total: Number(payload.total) || previousPagination.total, + has_more: previousPagination.has_more, + loading: false, + error: undefined, + }; + } else { + messagesBySession[sessionId] = records; + paginationBySession[sessionId] = { + page: Number(payload.page) || 1, + page_size: Number(payload.page_size) || 50, + total: Number(payload.total) || records.length, + has_more: Boolean(payload.has_more), + loading: false, + }; + } sessionProjects[sessionId] = normalizeSessionProject(payload.project); loadedSessions[sessionId] = true; if (resumeRuns && Array.isArray(payload.active_runs)) { await restoreNextActiveRun(sessionId, payload.active_runs); } } catch (error) { + if (sessionLoadEpochs[sessionId] !== epoch) return; console.error("Failed to load session messages:", error); messagesBySession[sessionId] = messagesBySession[sessionId] || []; + const previousPagination = paginationBySession[sessionId]; + paginationBySession[sessionId] = { + page: previousPagination?.page || 1, + page_size: previousPagination?.page_size || 50, + total: previousPagination?.total || messagesBySession[sessionId].length, + has_more: previousPagination?.has_more || false, + loading: false, + error: String((error as Error)?.message || error), + }; } finally { - if (showLoading) loadingMessages.value = false; + if ( + showLoading && + sessionLoadEpochs[sessionId] === epoch && + loadingSessionId.value === sessionId + ) { + loadingMessagesState.value = false; + loadingSessionId.value = null; + } + } + } + + async function loadEarlierMessages(sessionId: string) { + if (!sessionId) return; + const state = paginationBySession[sessionId]; + if (!state || !state.has_more || state.loading) return; + const epoch = sessionLoadEpochs[sessionId] || 0; + const nextPage = state.page + 1; + state.loading = true; + state.error = undefined; + try { + const response = await chatApi.getSession(sessionId, { + page: nextPage, + page_size: state.page_size, + }); + if (sessionLoadEpochs[sessionId] !== epoch) return; + if (response.data?.status !== "ok") { + throw new Error( + response.data?.message || "Failed to load earlier messages", + ); + } + const payload = response.data?.data || {}; + const records = (payload.history || []).map(normalizeHistoryRecord); + attachThreads(records, payload.threads || []); + await resolveRecordMedia(records); + if (sessionLoadEpochs[sessionId] !== epoch) return; + const existing = messagesBySession[sessionId] || []; + const existingIds = new Set( + existing + .filter((record) => record.id != null) + .map((record) => String(record.id)), + ); + const freshRecords = records.filter( + (record: ChatRecord) => + record.id == null || !existingIds.has(String(record.id)), + ); + messagesBySession[sessionId] = [...freshRecords, ...existing]; + state.page = Number(payload.page) || nextPage; + state.total = Number(payload.total) || state.total; + state.has_more = Boolean(payload.has_more); + } catch (error) { + if (sessionLoadEpochs[sessionId] !== epoch) return; + state.error = String((error as Error)?.message || error); + console.error("Failed to load earlier session messages:", error); + } finally { + if (sessionLoadEpochs[sessionId] === epoch) state.loading = false; } } @@ -437,6 +576,12 @@ export function useMessages(options: UseMessagesOptions) { } if (payload.truncated_after_message) { truncateMessagesAfter(sessionId, record); + const pagination = paginationBySession[sessionId]; + if (pagination?.has_more) { + // Deleting later rows shifts OFFSET pages; restart from the newest page. + pagination.page = 0; + pagination.error = undefined; + } } return { needsRegenerate: Boolean(payload.needs_regenerate), @@ -769,7 +914,7 @@ export function useMessages(options: UseMessagesOptions) { if (ownsConnection) delete activeConnections[runId]; if (!abort.signal.aborted && ownsConnection) { if (!isSessionRunning(sessionId)) { - await loadSessionMessages(sessionId, true, false); + await loadSessionMessages(sessionId, true, false, true); } await options.onSessionsChanged?.(); } @@ -1178,6 +1323,7 @@ export function useMessages(options: UseMessagesOptions) { sending, messagesBySession, loadedSessions, + paginationBySession, sessionProjects, activeMessages, isSessionRunning, @@ -1186,6 +1332,7 @@ export function useMessages(options: UseMessagesOptions) { messageContent, messageParts, loadSessionMessages, + loadEarlierMessages, createLocalExchange, sendMessageStream, editMessage, diff --git a/dashboard/src/i18n/locales/en-US/features/chat.json b/dashboard/src/i18n/locales/en-US/features/chat.json index 5305103ea..2929fee73 100644 --- a/dashboard/src/i18n/locales/en-US/features/chat.json +++ b/dashboard/src/i18n/locales/en-US/features/chat.json @@ -149,6 +149,11 @@ "think": "Thinking", "toolUsed": "Using Tool" }, + "history": { + "loadFailed": "Could not load messages", + "loadEarlierFailed": "Could not load earlier messages", + "loading": "Loading earlier messages" + }, "reply": { "replyTo": "Reply to", "notFound": "Message not found" diff --git a/dashboard/src/i18n/locales/ja-JP/features/chat.json b/dashboard/src/i18n/locales/ja-JP/features/chat.json index 995388240..f694ee822 100644 --- a/dashboard/src/i18n/locales/ja-JP/features/chat.json +++ b/dashboard/src/i18n/locales/ja-JP/features/chat.json @@ -149,6 +149,11 @@ "think": "思考", "toolUsed": "ツールを使用" }, + "history": { + "loadFailed": "メッセージを読み込めませんでした", + "loadEarlierFailed": "以前のメッセージを読み込めませんでした", + "loading": "以前のメッセージを読み込み中" + }, "reply": { "replyTo": "引用", "notFound": "メッセージが見つかりません" diff --git a/dashboard/src/i18n/locales/ru-RU/features/chat.json b/dashboard/src/i18n/locales/ru-RU/features/chat.json index 5d2d53009..3dc959104 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/chat.json +++ b/dashboard/src/i18n/locales/ru-RU/features/chat.json @@ -149,6 +149,11 @@ "think": "Размышление", "toolUsed": "Использование инструмента" }, + "history": { + "loadFailed": "Не удалось загрузить сообщения", + "loadEarlierFailed": "Не удалось загрузить предыдущие сообщения", + "loading": "Загрузка предыдущих сообщений" + }, "reply": { "replyTo": "В ответ на", "notFound": "Сообщение не найдено" diff --git a/dashboard/src/i18n/locales/zh-CN/features/chat.json b/dashboard/src/i18n/locales/zh-CN/features/chat.json index 96810a705..aefedad1f 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/chat.json +++ b/dashboard/src/i18n/locales/zh-CN/features/chat.json @@ -149,6 +149,11 @@ "think": "思考", "toolUsed": "使用工具" }, + "history": { + "loadFailed": "历史消息加载失败", + "loadEarlierFailed": "更早的消息加载失败", + "loading": "正在加载更早的消息" + }, "reply": { "replyTo": "引用", "notFound": "无法定位消息" diff --git a/dashboard/tests/chatHistoryContent.test.mjs b/dashboard/tests/chatHistoryContent.test.mjs new file mode 100644 index 000000000..0ae6b5cbb --- /dev/null +++ b/dashboard/tests/chatHistoryContent.test.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; +import ts from "typescript"; + +test("history pages retain reasoning boundaries and complete tool results", () => { + const source = readFileSync( + new URL("../src/composables/useMessages.ts", import.meta.url), + "utf8", + ); + const ast = ts.createSourceFile( + "messages.ts", + source, + ts.ScriptTarget.Latest, + true, + ); + const names = new Set([ + "normalizeHistoryRecord", + "normalizeMessageParts", + "normalizePartsInternal", + "extractReasoningText", + "messageBlocks", + "isEmptyPlainPart", + "isThinkingPart", + ]); + const functions = []; + const visit = (node) => { + if (ts.isFunctionDeclaration(node) && names.has(node.name?.text)) { + functions.push(node.getText(ast)); + } + ts.forEachChild(node, visit); + }; + visit(ast); + assert.equal(functions.length, names.size); + const context = vm.createContext({ exports: {} }); + vm.runInContext(ts.transpile(functions.join("\n")), context); + + const parts = [ + { type: "think", think: "Before the tool" }, + { + type: "tool_call", + tool_calls: [{ id: "tool-1", name: "search", result: "Complete result" }], + }, + { type: "plain", text: "Intermediate answer" }, + { type: "think", think: "After the tool" }, + { type: "plain", text: "Final answer" }, + ]; + const record = context.normalizeHistoryRecord({ + id: 1, + content: { type: "bot", message: parts }, + }); + assert.deepEqual(JSON.parse(JSON.stringify(record.content.message)), parts); + const blocks = context.messageBlocks(record.content); + assert.deepEqual(JSON.parse(JSON.stringify(blocks)), [ + { kind: "thinking", parts: parts.slice(0, 2) }, + { kind: "content", parts: parts.slice(2, 3) }, + { kind: "thinking", parts: parts.slice(3, 4) }, + { kind: "content", parts: parts.slice(4) }, + ]); +}); diff --git a/dashboard/tests/chatHistoryLoading.test.mjs b/dashboard/tests/chatHistoryLoading.test.mjs new file mode 100644 index 000000000..748f4ad31 --- /dev/null +++ b/dashboard/tests/chatHistoryLoading.test.mjs @@ -0,0 +1,132 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; +import ts from "typescript"; + +test("failed history loading pauses scroll requests until an explicit retry", async () => { + const chat = readFileSync( + new URL("../src/components/chat/Chat.vue", import.meta.url), + "utf8", + ) + .split('")[0]; + const messages = readFileSync( + new URL("../src/composables/useMessages.ts", import.meta.url), + "utf8", + ); + const names = new Set([ + "maybeLoadEarlierOnScroll", + "loadEarlierWithAnchor", + "retryCurrentSessionLoad", + "loadEarlierMessages", + ]); + const functions = []; + for (const source of [chat, messages]) { + const ast = ts.createSourceFile( + "source.ts", + source, + ts.ScriptTarget.Latest, + true, + ); + const visit = (node) => { + if (ts.isFunctionDeclaration(node) && names.has(node.name?.text)) { + functions.push(node.getText(ast)); + } + ts.forEachChild(node, visit); + }; + visit(ast); + } + assert.equal(functions.length, names.size); + const state = { + page: 1, + page_size: 50, + total: 101, + has_more: true, + loading: false, + }; + const current = { id: 51, content: { message: [] } }; + const records = { session: [current] }; + const container = { + scrollHeight: 1000, + clientHeight: 400, + scrollTop: 0, + querySelector: () => null, + }; + const requests = []; + const context = vm.createContext({ + currSessionId: { value: "session" }, + activeSessionPagination: { value: state }, + activeMessages: { value: records.session }, + messagesContainer: { value: container }, + suppressAutoScroll: { value: false }, + LOAD_EARLIER_SCROLL_THRESHOLD: 120, + CSS: { escape: (value) => value }, + nextTick: async () => {}, + paginationBySession: { session: state }, + messagesBySession: records, + sessionLoadEpochs: { session: 1 }, + console: { error: () => {} }, + normalizeHistoryRecord: (record) => record, + attachThreads: () => {}, + resolveRecordMedia: async () => {}, + chatApi: { + getSession: (sessionId, params) => + new Promise((resolve, reject) => { + requests.push({ sessionId, params, resolve, reject }); + }), + }, + }); + vm.runInContext(ts.transpile(functions.join("\n")), context); + context.maybeLoadEarlierOnScroll(container); + assert.equal(requests.length, 1); + assert.equal(state.loading, true); + for (let i = 0; i < 10; i++) context.maybeLoadEarlierOnScroll(container); + assert.equal(requests.length, 1, "pending requests must be deduplicated"); + requests[0].reject(new Error("Network Error")); + await new Promise(setImmediate); + assert.equal(state.error, "Network Error"); + assert.equal(state.loading, false); + assert.equal(state.page, 1); + assert.deepEqual(records.session, [current]); + for (let i = 0; i < 10; i++) context.maybeLoadEarlierOnScroll(container); + assert.equal( + requests.length, + 1, + "scroll events must not retry a failed page", + ); + + const retry = context.retryCurrentSessionLoad(); + assert.equal(requests.length, 2); + assert.equal( + requests[1].params.page, + 2, + "retry must request the failed page again", + ); + assert.equal(state.error, undefined); + context.maybeLoadEarlierOnScroll(container); + assert.equal(requests.length, 2); + requests[1].resolve({ + data: { + status: "ok", + data: { + history: [{ id: 1, content: { message: [] } }], + page: 2, + total: 101, + has_more: true, + }, + }, + }); + await retry; + assert.equal(state.page, 2); + assert.equal(state.error, undefined); + assert.equal(records.session.length, 2); + context.maybeLoadEarlierOnScroll(container); + assert.equal( + requests.length, + 3, + "successful retry must restore automatic pagination", + ); + requests[2].reject(new Error("Network Error")); + await new Promise(setImmediate); +}); diff --git a/docs/public/openapi.json b/docs/public/openapi.json index f16c54622..b666e92af 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -1871,6 +1871,25 @@ "parameters": [ { "$ref": "#/components/parameters/SessionId" + }, + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "name": "page_size", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000, + "default": 1000 + } } ], "responses": { diff --git a/openspec/openapi-v1.yaml b/openspec/openapi-v1.yaml index b9fda185c..51a478f04 100644 --- a/openspec/openapi-v1.yaml +++ b/openspec/openapi-v1.yaml @@ -1276,6 +1276,19 @@ paths: x-astrbot-scope: chat parameters: - $ref: "#/components/parameters/SessionId" + - name: page + in: query + schema: + type: integer + minimum: 1 + default: 1 + - name: page_size + in: query + schema: + type: integer + minimum: 1 + maximum: 1000 + default: 1000 responses: "200": $ref: "#/components/responses/Ok" diff --git a/tests/unit/test_chat_history_pagination.py b/tests/unit/test_chat_history_pagination.py new file mode 100644 index 000000000..75a79973e --- /dev/null +++ b/tests/unit/test_chat_history_pagination.py @@ -0,0 +1,235 @@ +from __future__ import annotations + +from copy import deepcopy +from datetime import datetime, timezone +from inspect import signature +from types import SimpleNamespace + +import pytest + +from astrbot.core.db.po import PlatformMessageHistory +from astrbot.core.db.sqlite import SQLiteDatabase +from astrbot.core.platform_message_history_mgr import PlatformMessageHistoryManager +from astrbot.dashboard.api.auth import AuthContext +from astrbot.dashboard.api.chat import ( + get_chat_session, +) +from astrbot.dashboard.services.chat_service import ( + ChatService, + ChatServiceError, +) + + +class FakeHistory: + def __init__( + self, content: dict, *, record_id: int = 1, platform_id: str = "webchat" + ): + self.id = record_id + self.platform_id = platform_id + self.user_id = "session-1" + self.sender_id = "bot" + self.sender_name = "bot" + self.content = content + self.llm_checkpoint_id = "checkpoint" + self.created_at = datetime.now(timezone.utc) + self.updated_at = self.created_at + + def model_dump(self) -> dict: + return { + "id": self.id, + "platform_id": self.platform_id, + "user_id": self.user_id, + "sender_id": self.sender_id, + "sender_name": self.sender_name, + "content": deepcopy(self.content), + "llm_checkpoint_id": self.llm_checkpoint_id, + } + + +def test_v1_history_routes_keep_legacy_default_page_size(): + assert signature(get_chat_session).parameters["page_size"].default.default == 1000 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("page", "total", "has_row", "has_more"), + [(1, 0, False, False), (2, 3, True, True), (3, 3, True, False), (4, 3, False, False)], +) +async def test_get_session_returns_complete_content_and_pagination_metadata( + page, total, has_row, has_more +): + content = { + "type": "bot", + "message": [ + {"type": "think", "think": "first thought"}, + { + "type": "tool_call", + "tool_calls": [ + {"id": "tool-1", "name": "search", "arguments": {"q": "x"}, "result": "full tool result"} + ], + }, + {"type": "plain", "text": "intermediate answer"}, + {"type": "reasoning", "text": "legacy thought"}, + {"type": "think", "think": "second thought"}, + {"type": "plain", "text": "final answer"}, + ], + "reasoning": "top-level reasoning", + } + original = deepcopy(content) + history = [FakeHistory(content, record_id=2)] if has_row else [] + + class Manager: + async def get(self, **kwargs): + assert kwargs == { + "platform_id": "webchat", + "user_id": "session-1", + "page": page, + "page_size": 1, + } + return history + + async def count(self, **kwargs): + assert kwargs == {"platform_id": "webchat", "user_id": "session-1"} + return total + + class Database: + async def get_platform_session_by_id(self, session_id): + return SimpleNamespace( + session_id=session_id, platform_id="webchat", creator="owner" + ) + + async def get_project_by_session(self, **kwargs): + return None + + async def get_webchat_threads_by_parent_session(self, **kwargs): + return [] + + service = object.__new__(ChatService) + service.db = Database() + service.platform_history_mgr = Manager() + service.running_convs = {} + service.get_active_chat_runs = lambda _username, _session_id: [] + + result = await service.get_session("owner", "session-1", page=page, page_size=1) + assert result["total"] == total + assert result["page"] == page + assert result["page_size"] == 1 + assert result["has_more"] is has_more + if has_row: + assert result["history"][0]["content"] == original + assert "has_reasoning" not in result["history"][0] + assert "reasoning_len" not in result["history"][0] + assert history[0].content == original + else: + assert result["history"] == [] + + +@pytest.mark.asyncio +async def test_real_history_pagination_and_count_are_scope_isolated(tmp_path): + db = SQLiteDatabase(str(tmp_path / "history.db")) + await db.initialize() + manager = PlatformMessageHistoryManager(db) + base_time = datetime(2026, 1, 1, tzinfo=timezone.utc) + async with db.get_db() as session: + async with session.begin(): + session.add_all( + [ + PlatformMessageHistory( + platform_id="webchat", + user_id="session-1", + content={ + "type": "user", + "message": [{"type": "plain", "text": "m1"}], + }, + created_at=base_time, + updated_at=base_time, + ), + PlatformMessageHistory( + platform_id="webchat", + user_id="session-1", + content={ + "type": "user", + "message": [{"type": "plain", "text": "m2"}], + }, + created_at=base_time.replace(minute=1), + updated_at=base_time.replace(minute=1), + ), + PlatformMessageHistory( + platform_id="webchat", + user_id="session-1", + content={ + "type": "user", + "message": [{"type": "plain", "text": "m3"}], + }, + created_at=base_time.replace(minute=2), + updated_at=base_time.replace(minute=2), + ), + PlatformMessageHistory( + platform_id="webchat", + user_id="session-1", + content={ + "type": "user", + "message": [{"type": "plain", "text": "m4"}], + }, + created_at=base_time.replace(minute=3), + updated_at=base_time.replace(minute=3), + ), + PlatformMessageHistory( + platform_id="webchat", + user_id="session-2", + content={ + "type": "user", + "message": [{"type": "plain", "text": "other"}], + }, + created_at=base_time, + updated_at=base_time, + ), + PlatformMessageHistory( + platform_id="webchat_thread", + user_id="thread-1", + content={ + "type": "user", + "message": [{"type": "plain", "text": "t1"}], + }, + created_at=base_time, + updated_at=base_time, + ), + ] + ) + + page_one = await manager.get("webchat", "session-1", page=1, page_size=2) + page_two = await manager.get("webchat", "session-1", page=2, page_size=2) + assert [item.content["message"][0]["text"] for item in page_one] == ["m3", "m4"] + assert [item.content["message"][0]["text"] for item in page_two] == ["m1", "m2"] + assert await manager.count("webchat", "session-1") == 4 + assert await manager.count("webchat", "session-2") == 1 + assert await manager.get("webchat", "session-1", page=3, page_size=2) == [] + assert await manager.get("webchat", "session-1", page=99, page_size=2) == [] + thread_page = await manager.get("webchat_thread", "thread-1", page=1, page_size=2) + assert [item.content["message"][0]["text"] for item in thread_page] == ["t1"] + await db.engine.dispose() + + +@pytest.mark.asyncio +async def test_v1_session_route_forwards_pagination_parameters(): + seen = {} + + class Service: + async def get_session(self, username, session_id, **kwargs): + seen.update(username=username, session_id=session_id, **kwargs) + return {"history": []} + + result = await get_chat_session( + "session-1", + page=2, + page_size=50, + auth=AuthContext("owner", ["chat"], via="jwt"), + service=Service(), + ) + assert result["status"] == "ok" + assert seen == { + "username": "owner", + "session_id": "session-1", + "page": 2, + "page_size": 50, + } diff --git a/tests/unit/test_openapi_scope_docs.py b/tests/unit/test_openapi_scope_docs.py index 1dcd7d455..902069cec 100644 --- a/tests/unit/test_openapi_scope_docs.py +++ b/tests/unit/test_openapi_scope_docs.py @@ -8,9 +8,10 @@ from docs.scripts.update_openapi_json import ( render_scope_reference, ) - SPEC_PATH = Path(__file__).resolve().parents[2] / "openspec" / "openapi-v1.yaml" -PUBLIC_SPEC_PATH = Path(__file__).resolve().parents[2] / "docs" / "public" / "openapi.json" +PUBLIC_SPEC_PATH = ( + Path(__file__).resolve().parents[2] / "docs" / "public" / "openapi.json" +) ZH_REFERENCE_PATH = ( Path(__file__).resolve().parents[2] / "docs" / "zh" / "dev" / "openapi-scopes.md" ) @@ -23,9 +24,7 @@ def test_public_openapi_is_filtered_by_supported_scope() -> None: spec = filter_public_openapi(load_yaml(SPEC_PATH)) assert "/api/v1/conversations" in spec["paths"] - assert spec["paths"]["/api/v1/conversations"]["get"][ - "x-astrbot-scope" - ] == "data" + assert spec["paths"]["/api/v1/conversations"]["get"]["x-astrbot-scope"] == "data" assert "/api/v1/commands" not in spec["paths"] assert "/api/v1/files/tokens/{file_token}" not in spec["paths"] assert "/api/v1/stats/versions" not in spec["paths"] @@ -42,14 +41,11 @@ def test_public_openapi_documents_sensitive_subscopes() -> None: chat_send = spec["paths"]["/api/v1/chat"]["post"] assert chat_send["x-astrbot-sensitive-scopes"] == ["chat:admin"] assert chat_send["description"] == ( - "**Required scope:** `chat`\n\n" - "**Conditional sensitive scope:** `chat:admin`" + "**Required scope:** `chat`\n\n**Conditional sensitive scope:** `chat:admin`" ) system_config_update = spec["paths"]["/api/v1/system-config"]["put"] - assert system_config_update["x-astrbot-sensitive-scopes"] == [ - "config:edit_admin" - ] + assert system_config_update["x-astrbot-sensitive-scopes"] == ["config:edit_admin"] assert "`config:edit_admin`" in system_config_update["description"]