Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions astrbot/core/db/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,23 @@ async def get_platform_message_history(
"""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,
Expand Down
24 changes: 24 additions & 0 deletions astrbot/core/db/sqlite.py
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,30 @@ async def get_platform_message_history(
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:
Expand Down
15 changes: 15 additions & 0 deletions astrbot/core/platform_message_history_mgr.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,21 @@ async def get(
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:
Expand Down
66 changes: 63 additions & 3 deletions astrbot/dashboard/api/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

from typing import Any

from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, Depends, Path, Query, Request
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse

from astrbot.dashboard.async_utils import run_maybe_async
from astrbot.dashboard.responses import error, ok
from astrbot.dashboard.responses import ApiError, error, ok
from astrbot.dashboard.schemas import (
ChatMessagePatchRequest,
ChatMessageRegenerateRequest,
Expand All @@ -15,6 +15,7 @@
ChatThreadCreateRequest,
ChatThreadMessageRequest,
)
from astrbot.dashboard.services.auth_service import CHAT_ADMIN_SCOPE
from astrbot.dashboard.services.chat_service import (
ChatService,
ChatServiceError,
Expand Down Expand Up @@ -137,10 +138,69 @@ 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,
strip_reasoning=True,
)
)


@router.get(
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
"/chat/messages/{message_id}",
openapi_extra={"x-astrbot-sensitive-scopes": [CHAT_ADMIN_SCOPE]},
)
async def get_chat_message(
message_id: int = Path(..., gt=0),
username: str | None = Query(default=None),
auth: AuthContext = Depends(require_chat_scope),
service: ChatService = Depends(get_service),
):
"""Get a full WebChat history message after ownership validation.

Args:
message_id: Positive platform history record ID.
username: Effective username for API-key callers. JWT callers cannot
override the username claim with this query value.
auth: Authenticated dashboard or API-key context.
service: Chat service used to load and authorize the message.

Returns:
An ``ok`` envelope containing the complete, non-stripped message.

Raises:
ApiError: If the username is missing, reserved, or does not own the
message. All lookup and ownership failures return the same 404.
"""
effective_username = auth.username
if auth.via == "api_key":
effective_username = str(username or "").strip()
if not effective_username:
raise ApiError("Message not found", status_code=404)
if "*" not in auth.scopes and CHAT_ADMIN_SCOPE not in auth.scopes:
configs = getattr(
getattr(service, "core_lifecycle", None),
"astrbot_config_mgr",
None,
)
for config in getattr(configs, "confs", {}).values():
admin_ids = (
config.get("admins_id", []) if isinstance(config, dict) else []
)
if any(str(admin_id) == effective_username for admin_id in admin_ids):
raise ApiError("Message not found", status_code=404)
try:
return ok(await service.get_message(effective_username, message_id))
except ChatServiceError as exc:
raise ApiError("Message not found", status_code=404) from exc


@router.patch("/chat/sessions/{session_id}")
Expand Down
132 changes: 126 additions & 6 deletions astrbot/dashboard/services/chat_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,21 +333,62 @@ def serialize_thread(thread) -> dict:
}


def serialize_history_entry(history) -> dict:
def serialize_history_entry(history, strip_reasoning: bool = False) -> dict:
"""Serialize a PlatformMessageHistory record with UTC-aware timestamps.

Args:
history: A PlatformMessageHistory instance. Must not be None.
strip_reasoning: Omit persisted thinking parts from list responses while
retaining a lightweight marker for lazy loading.

Returns:
Dict with all model fields plus created_at/updated_at serialized as
UTC-aware ISO strings (e.g. ``2026-07-06T04:00:00+00:00``).
"""
return {
serialized = {
**history.model_dump(),
"created_at": to_utc_isoformat(history.created_at),
"updated_at": to_utc_isoformat(history.updated_at),
}
if not strip_reasoning:
return serialized

content = serialized.get("content")
if not isinstance(content, dict) or content.get("type") != "bot":
return serialized

content = deepcopy(content)
message_parts = content.get("message")
reasoning_length = 0
has_reasoning = False
stripped_parts: list[dict] = []
if isinstance(message_parts, list):
for part in message_parts:
if not isinstance(part, dict):
stripped_parts.append(part)
continue
if part.get("type") in {"think", "reasoning"}:
reasoning_text = part.get("think")
if not isinstance(reasoning_text, str):
reasoning_text = part.get("text")
if isinstance(reasoning_text, str) and reasoning_text:
has_reasoning = True
reasoning_length += len(reasoning_text)
continue
stripped_parts.append(part)
if isinstance(message_parts, list):
content["message"] = stripped_parts

if not has_reasoning:
top_level_reasoning = content.get("reasoning")
if isinstance(top_level_reasoning, str) and top_level_reasoning:
has_reasoning = True
reasoning_length = len(top_level_reasoning)
content.pop("reasoning", None)
serialized["content"] = content
serialized["has_reasoning"] = has_reasoning
serialized["reasoning_len"] = reasoning_length
return serialized


def find_checkpoint_index(history: list[dict], checkpoint_id: str) -> int | None:
Expand Down Expand Up @@ -1411,7 +1452,33 @@ async def get_sessions_from_dashboard_query(
) -> 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,
strip_reasoning: bool = False,
) -> 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).
strip_reasoning: Whether to omit thinking content from list records.

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")
Expand All @@ -1425,16 +1492,27 @@ async def get_session(self, username: str, session_id: str) -> dict:
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,
creator=username,
)

response_data = {
"history": [serialize_history_entry(history) for history in history_ls],
"history": [
serialize_history_entry(history, strip_reasoning=strip_reasoning)
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),
Expand Down Expand Up @@ -1565,6 +1643,48 @@ async def get_thread_from_dashboard_query(
raise ChatServiceError("Missing key: thread_id")
return await self.get_thread(username, thread_id)

async def get_message(self, username: str, message_id: int) -> dict:
"""Get one full WebChat history record after ownership validation.

Args:
username: Authenticated dashboard username.
message_id: Positive platform history record ID.

Returns:
A full, non-stripped serialized history record.

Raises:
ChatServiceError: If the record is missing, unsupported, or not owned
by the authenticated user. All such cases use the same message.
"""
if message_id < 1:
raise ChatServiceError("Message not found")
record = await self.db.get_platform_message_history_by_id(message_id)
if not record:
raise ChatServiceError("Message not found")

if record.platform_id == "webchat":
session = await self.db.get_platform_session_by_id(record.user_id)
if (
not session
or session.platform_id != "webchat"
or session.creator != username
or session.session_id != record.user_id
):
raise ChatServiceError("Message not found")
elif record.platform_id == "webchat_thread":
thread = await self.db.get_webchat_thread_by_id(record.user_id)
if (
not thread
or thread.creator != username
or thread.thread_id != record.user_id
):
raise ChatServiceError("Message not found")
else:
raise ChatServiceError("Message not found")

return {"message": serialize_history_entry(record)}

async def prepare_thread_chat_payload(self, username: str, data: dict) -> dict:
thread_id = data.get("thread_id")
if not thread_id:
Expand Down
12 changes: 11 additions & 1 deletion dashboard/src/api/generated/openapi-v1/sdk.gen.ts

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions dashboard/src/api/generated/openapi-v1/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1376,6 +1376,10 @@ export type GetChatSessionData = {
path: {
session_id: string;
};
query?: {
page?: number;
page_size?: number;
};
};

export type GetChatSessionResponse = (SuccessEnvelope);
Expand Down Expand Up @@ -1413,6 +1417,22 @@ export type StopChatSessionResponse = (SuccessEnvelope);

export type StopChatSessionError = unknown;

export type GetChatMessageData = {
path: {
message_id: number;
};
query?: {
/**
* Required for API-key callers; ignored for JWT callers.
*/
username?: string;
};
};

export type GetChatMessageResponse = (SuccessEnvelope);

export type GetChatMessageError = (unknown);

export type ResumeChatRunData = {
path: {
run_id: string;
Expand Down Expand Up @@ -1463,6 +1483,10 @@ export type GetChatThreadData = {
path: {
thread_id: string;
};
query?: {
page?: number;
page_size?: number;
};
};

export type GetChatThreadResponse = (SuccessEnvelope);
Expand Down
Loading
Loading