mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-10-16 06:58:51 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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) },
|
||||
]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user