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
This commit is contained in:
Wei Chengqian
2026-09-12 13:56:22 +08:00
committed by GitHub
parent 72928aa7c9
commit fae2c120ef
21 changed files with 932 additions and 33 deletions
+17
View File
@@ -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,
+24
View File
@@ -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:
@@ -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:
+11 -2
View File
@@ -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}")
+35 -3
View File
@@ -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),
@@ -1380,6 +1380,10 @@ export type GetChatSessionData = {
path: {
session_id: string;
};
query?: {
page?: number;
page_size?: number;
};
};
export type GetChatSessionResponse = (SuccessEnvelope);
+10 -2
View File
@@ -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<any>(
openApiV1.getChatSession({ path: { session_id: sessionId } }),
openApiV1.getChatSession({
path: { session_id: sessionId },
query: generatedQuery(params),
}),
);
},
updateSession(sessionId: string, payload: ChatSessionPatchRequest) {
+113 -9
View File
@@ -268,6 +268,16 @@
class="conversation-stack"
:class="{ 'is-empty': isEmptyChat }"
>
<v-progress-linear
v-if="activeSessionPagination?.loading"
class="history-loading"
color="primary"
height="2"
indeterminate
absolute
location="top"
:aria-label="tm('history.loading')"
/>
<section
ref="messagesContainer"
class="messages-panel"
@@ -283,6 +293,17 @@
<v-progress-circular indeterminate size="32" width="3" />
</div>
<div
v-else-if="!activeMessages.length && activeSessionPagination?.error"
class="welcome-state"
>
<ChatLoadError
:message="tm('history.loadFailed')"
:loading="loadingMessages"
@retry="retryCurrentSessionLoad"
/>
</div>
<div v-else-if="!activeMessages.length" class="welcome-state">
<div class="welcome-title">{{ tm("welcome.title") }}</div>
</div>
@@ -292,6 +313,13 @@
ref="messagesContent"
class="messages-list-shell"
>
<ChatLoadError
v-if="activeSessionPagination?.error"
class="history-load-error"
:message="tm('history.loadEarlierFailed')"
:loading="activeSessionPagination.loading"
@retry="retryCurrentSessionLoad"
/>
<ChatMessageList
v-model:edit-draft="messageEditDraft"
:messages="activeMessages"
@@ -490,6 +518,7 @@ import ChatInput from "@/components/chat/ChatInput.vue";
import ChatMessageList from "@/components/chat/ChatMessageList.vue";
import ChatUILogo from "@/components/chat/ChatUILogo.vue";
import type { RegenerateModelSelection } from "@/components/chat/RegenerateMenu.vue";
import ChatLoadError from "@/components/chat/ChatLoadError.vue";
import ReasoningSidebar from "@/components/chat/ReasoningSidebar.vue";
import ThreadPanel from "@/components/chat/ThreadPanel.vue";
import WorkspaceFilesPanel from "@/components/chat/WorkspaceFilesPanel.vue";
@@ -610,6 +639,8 @@ const messagesContent = ref<HTMLElement | null>(null);
const composerShell = ref<HTMLElement | null>(null);
const inputRef = ref<InstanceType<typeof ChatInput> | 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<TransportMode>(
(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<HTMLElement>(
`[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<HTMLElement>(
`[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;
}
@@ -0,0 +1,62 @@
<template>
<div class="chat-load-error" role="alert">
<CircleAlert class="chat-load-error-icon" :size="16" aria-hidden="true" />
<span class="chat-load-error-message">{{ message }}</span>
<v-btn
class="chat-load-error-retry"
size="small"
variant="text"
rounded="pill"
:loading="loading"
@click="emit('retry')"
>
<RotateCw :size="14" aria-hidden="true" />
{{ tm("actions.retry") }}
</v-btn>
</div>
</template>
<script setup lang="ts">
import { CircleAlert, RotateCw } from "@lucide/vue";
import { useModuleI18n } from "@/i18n/composables";
defineProps<{ message: string; loading?: boolean }>();
const emit = defineEmits<{ retry: [] }>();
const { tm } = useModuleI18n("features/chat");
</script>
<style scoped>
.chat-load-error {
display: flex;
align-items: center;
gap: 8px;
width: fit-content;
max-width: 100%;
padding: 1px 0;
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 0.8125rem;
line-height: 1.5;
text-align: start;
}
.chat-load-error-icon {
flex-shrink: 0;
color: rgba(var(--v-theme-on-surface), 0.45);
}
.chat-load-error-message {
min-width: 0;
overflow-wrap: anywhere;
}
.chat-load-error-retry {
flex-shrink: 0;
color: rgba(var(--v-theme-on-surface), 0.82);
font: inherit;
letter-spacing: normal;
}
.chat-load-error-retry :deep(.v-btn__content) {
gap: 6px;
}
</style>
@@ -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)"
>
<v-avatar
v-if="!isUserMessage(msg)"
+154 -7
View File
@@ -65,6 +65,15 @@ export interface ChatRecord {
threads?: ChatThread[];
}
export interface HistoryPaginationState {
page: number;
page_size: number;
total: number;
has_more: boolean;
loading: boolean;
error?: string;
}
export interface ChatThread {
thread_id: string;
parent_session_id: string;
@@ -144,15 +153,20 @@ interface UseMessagesOptions {
}
export function useMessages(options: UseMessagesOptions) {
const loadingMessages = ref(false);
const loadingMessagesState = ref(false);
const sending = ref(false);
const messagesBySession = reactive<Record<string, ChatRecord[]>>({});
const loadedSessions = reactive<Record<string, boolean>>({});
const paginationBySession = reactive<Record<string, HistoryPaginationState>>(
{},
);
const activeConnections = reactive<Record<string, ActiveConnection>>({});
const chatWebSockets: Record<string, WebSocket> = {};
const closingChatWebSockets = new WeakSet<WebSocket>();
const deferredBotAnchors = new WeakMap<ChatRecord, ChatRecord>();
const attachmentBlobCache = new Map<string, Promise<string>>();
const sessionLoadEpochs = reactive<Record<string, number>>({});
const loadingSessionId = ref<string | null>(null);
const sessionProjects = reactive<Record<string, ChatSessionProject | null>>(
{},
);
@@ -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,
@@ -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"
@@ -149,6 +149,11 @@
"think": "思考",
"toolUsed": "ツールを使用"
},
"history": {
"loadFailed": "メッセージを読み込めませんでした",
"loadEarlierFailed": "以前のメッセージを読み込めませんでした",
"loading": "以前のメッセージを読み込み中"
},
"reply": {
"replyTo": "引用",
"notFound": "メッセージが見つかりません"
@@ -149,6 +149,11 @@
"think": "Размышление",
"toolUsed": "Использование инструмента"
},
"history": {
"loadFailed": "Не удалось загрузить сообщения",
"loadEarlierFailed": "Не удалось загрузить предыдущие сообщения",
"loading": "Загрузка предыдущих сообщений"
},
"reply": {
"replyTo": "В ответ на",
"notFound": "Сообщение не найдено"
@@ -149,6 +149,11 @@
"think": "思考",
"toolUsed": "使用工具"
},
"history": {
"loadFailed": "历史消息加载失败",
"loadEarlierFailed": "更早的消息加载失败",
"loading": "正在加载更早的消息"
},
"reply": {
"replyTo": "引用",
"notFound": "无法定位消息"
@@ -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) },
]);
});
+132
View File
@@ -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('<script setup lang="ts">')[1]
.split("</script>")[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);
});
+19
View File
@@ -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": {
+13
View File
@@ -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"
+235
View File
@@ -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,
}
+6 -10
View File
@@ -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"]