Skip to content

Conversation

@ctrlkk
Copy link
Contributor

@ctrlkk ctrlkk commented Sep 28, 2025

fixes #2905


Motivation / 动机

为会话管理增加分页功能,减少数据库查询次数,将查询时间从最高10秒优化至80毫秒左右。

Modifications / 改动点

修改前端代码,使用分页接口并实现分页。
修改后端代码,实现分页查询、排序、过滤等功能。

Verification Steps / 验证步骤

Screenshots or Test Results / 运行截图或测试结果

图片 图片

Compatibility & Breaking Changes / 兼容性与破坏性变更

  • 这是一个破坏性变更 (Breaking Change)。/ This is a breaking change.
  • 这不是一个破坏性变更。/ This is NOT a breaking change.

Checklist / 检查清单

  • 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。/ If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
  • 👀 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”。/ My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
  • 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 requirements.txtpyproject.toml 文件相应位置。/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in requirements.txt and pyproject.toml.
  • 😮 我的更改没有引入恶意代码。/ My changes do not introduce malicious code.

Sourcery 总结

为会话管理 API 实现了服务器端分页、搜索和平台过滤功能,并更新了仪表板以消费分页数据,从而大幅提升了查询性能。

新功能:

  • 在会话列表 API 端点中暴露分页、搜索和平台过滤参数
  • 添加 get_session_conversations 方法,以返回带有总数的 paginate 会话记录

增强功能:

  • 通过联接数据库查询优化会话检索,将响应时间从秒级减少到毫秒级
  • 将前端会话表切换到使用 v-data-table-server 和相应事件处理程序的服务器端分页
Original summary in English

Summary by Sourcery

Implement server-side pagination, search, and platform filtering for the session management API and update the dashboard to consume paginated data, greatly improving query performance.

New Features:

  • Expose pagination, search, and platform filter parameters in the session list API endpoint
  • Add get_session_conversations method to return paginated session records with total count

Enhancements:

  • Optimize session retrieval with joined database queries to reduce response time from seconds to milliseconds
  • Switch front-end session table to server-side pagination using v-data-table-server and corresponding event handlers

@auto-assign auto-assign bot requested review from Larch-C and Soulter September 28, 2025 07:22
Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes and they look great!

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location> `astrbot/core/db/sqlite.py:260-265` </location>
<code_context>
+
+
+    @abc.abstractmethod
+    async def get_session_conversations(
+        self,
+        page: int = 1,
</code_context>

<issue_to_address>
**suggestion:** Inconsistent parameter naming for platform filtering.

The interface uses 'platform_ids: list[str] | None', while this implementation uses 'platform: str = None'. Please align the parameter name and type for consistency and to support potential multi-platform filtering.

Suggested implementation:

```python
    async def get_session_conversations(
        self,
        page: int = 1,
        page_size: int = 20,
        search_query: str = None,
        platform_ids: list[str] | None = None,
    ) -> tuple[list[dict], int]:

```

```python
        async with self.get_db() as session:
            session: AsyncSession

            # Apply platform_ids filtering if provided
            platform_filter = None
            if platform_ids is not None:
                from sqlalchemy import or_
                platform_filter = or_(
                    *[ConversationV2.platform == pid for pid in platform_ids]
                )

```

You will need to:
1. Integrate `platform_filter` into your query's `where` clause, replacing any previous usage of `platform`.
2. Update any other references to `platform` in this function to use `platform_ids` and the new filtering logic.
3. Update the interface and any calls to this function elsewhere in your codebase to use `platform_ids: list[str] | None` instead of `platform: str = None`.
</issue_to_address>

### Comment 2
<location> `astrbot/core/db/sqlite.py:307-308` </location>
<code_context>
+                )
+
+            # 平台筛选
+            if platform:
+                platform_pattern = f"{platform}:%"
+                base_query = base_query.where(Preference.scope_id.like(platform_pattern))
+
</code_context>

<issue_to_address>
**issue (bug_risk):** Platform filtering assumes a specific format for scope_id.

If the scope_id format changes or platforms include colons, filtering may fail. Consider enforcing the format or validating platform values.
</issue_to_address>

### Comment 3
<location> `astrbot/core/db/sqlite.py:352-361` </location>
<code_context>
+
+            # 构建列表
+            sessions_data = []
+            for row in rows:
+                sessions_data.append(
+                    {
+                        "session_id": row.session_id,
</code_context>

<issue_to_address>
**suggestion:** No handling for possible None values in row fields.

Normalize None values in joined fields to empty strings or defaults to prevent issues for API consumers.

```suggestion
            for row in rows:
                sessions_data.append(
                    {
                        "session_id": row.session_id if row.session_id is not None else "",
                        "conversation_id": row.conversation_id if row.conversation_id is not None else "",
                        "persona_id": row.persona_id if row.persona_id is not None else "",
                        "title": row.title if row.title is not None else "",
                        "persona_name": row.persona_name if row.persona_name is not None else "",
                    }
                )
```
</issue_to_address>

### Comment 4
<location> `dashboard/src/views/SessionManagementPage.vue:440-441` </location>
<code_context>

-    // 懒加载过滤会话 - 使用客户端分页
+    // 现在分页由后端处理,前端直接显示后端返回的数据
     filteredSessions() {
-      let filtered = this.sessions;
-
-      // 搜索筛选
-      if (this.searchQuery) {
-        const query = this.searchQuery.toLowerCase().trim();
-        filtered = filtered.filter(session =>
-          session.session_name.toLowerCase().includes(query) ||
-          session.platform.toLowerCase().includes(query) ||
-          session.persona_name?.toLowerCase().includes(query) ||
-          session.chat_provider_name?.toLowerCase().includes(query) ||
-          session.session_id.toLowerCase().includes(query)
-        );
-      }
-
-      // 平台筛选
-      if (this.filterPlatform) {
-        filtered = filtered.filter(session => session.platform === this.filterPlatform);
-      }
-
-      return filtered;
+      return this.sessions;
     },

</code_context>

<issue_to_address>
**suggestion:** filteredSessions now just returns sessions, making the computed property redundant.

Since filtering is now server-side, you can remove filteredSessions and reference 'sessions' directly to simplify the code.

Suggested implementation:

```
    // 现在分页由后端处理,前端直接显示后端返回的数据
    // 已移除 filteredSessions,直接使用 sessions

```

```
          <v-data-table-server
            :headers="headers"
            :items="sessions"
            :loading="loading"
            :items-per-page="itemsPerPage"
            :page="currentPage"

```
</issue_to_address>

### Comment 5
<location> `dashboard/src/views/SessionManagementPage.vue:954-957` </location>
<code_context>
     },
+
+    // 处理分页更新事件
+    handlePaginationUpdate(options) {
+      this.currentPage = options.page;
+      this.itemsPerPage = options.itemsPerPage;
+      this.loadSessions();
+    },
+
</code_context>

<issue_to_address>
**suggestion (performance):** No debounce/throttle on pagination, search, or filter changes.

Debouncing these actions will help prevent excessive API calls and reduce backend load.

Suggested implementation:

```
+    // 引入lodash的debounce
+    import debounce from 'lodash/debounce';
+
+    // 在created或data中定义debouncedLoadSessions
+    data() {
+      return {
+        // ...其他data属性
+        debouncedLoadSessions: null,
+      };
+    },
+
+    created() {
+      this.debouncedLoadSessions = debounce(this.loadSessions, 300);
+    },
+
+    // 处理分页更新事件
+    handlePaginationUpdate(options) {
+      this.currentPage = options.page;
+      this.itemsPerPage = options.itemsPerPage;
+      this.debouncedLoadSessions();
+    },

```

```
            :items="filteredSessions"
            :loading="loading"
            :items-per-page="itemsPerPage"
            :page="currentPage"

```

1. If you have search or filter change handlers (e.g., `handleSearchUpdate`, `handleFilterUpdate`), replace their `this.loadSessions()` calls with `this.debouncedLoadSessions()`.
2. Make sure lodash is installed in your project (`npm install lodash`).
3. If you use the Options API, move the debounce initialization to `mounted` or `created` as appropriate.
4. If you use a different debounce utility, adjust the import and usage accordingly.
</issue_to_address>

### Comment 6
<location> `astrbot/dashboard/routes/session_management.py:52` </location>
<code_context>
+            sessions_data, total = await self.db_helper.get_session_conversations(
+                page, page_size, search_query, platform
+            )
+            logger.warning(f"{search_query}  {platform}")
+
             provider_manager = self.core_lifecycle.provider_manager
</code_context>

<issue_to_address>
**suggestion:** Debug logging left in production code.

Please remove or change the log level to prevent excessive logging in production.

```suggestion
            logger.debug(f"{search_query}  {platform}")
```
</issue_to_address>

### Comment 7
<location> `astrbot/core/db/__init__.py:293-298` </location>
<code_context>
+
+
+    @abc.abstractmethod
+    async def get_session_conversations(
+        self,
+        page: int = 1,
+        page_size: int = 20,
+        search_query: str = "",
+        platform_ids: list[str] | None = None,
+    ) -> tuple[list[dict], int]:
+        """Get paginated session conversations with joined conversation and persona details, support search and platform filter."""
</code_context>

<issue_to_address>
**issue:** Parameter type and name mismatch with implementation.

The implementation in sqlite.py uses 'platform: str = None' instead of 'platform_ids: list[str] | None', which may cause confusion or bugs in future backend implementations. Please align the parameter types and names across all implementations.
</issue_to_address>

### Comment 8
<location> `astrbot/dashboard/routes/session_management.py:40` </location>
<code_context>
     async def list_sessions(self):
         """获取所有会话的列表,包括 persona 和 provider 信息"""
         try:
</code_context>

<issue_to_address>
**issue (complexity):** Consider extracting persona name resolution and session mapping logic into helper functions to simplify the main handler.

```markdown
Extract the “map and enrich” logic out of `list_sessions` into small helpers. For example:

1) Persona‐name resolution  
```python
def _resolve_persona_name(self, persona_id: str, persona_name: str) -> str:
    if persona_id and persona_id != "[%None]":
        return persona_name or next(
            (p["name"] for p in self.persona_mgr.personas_v3 if p["name"] == persona_id),
            persona_name
        )
    if persona_id == "[%None]":
        return "无人格"
    default = self.persona_mgr.selected_default_persona_v3
    return default["name"] if default else persona_name
```

2) Single‐row mapping (incl. providers / flags)  
```python
async def _map_session_row(self, row: dict) -> dict:
    sid = row["session_id"]
    info = {
        "session_id": sid,
        "conversation_id": row["conversation_id"],
        "persona_id": self._resolve_persona_name(row["persona_id"], row["persona_name"]),
        "chat_provider_id": None,
        "stt_provider_id": None,
        "tts_provider_id": None,
        "session_enabled": SessionServiceManager.is_session_enabled(sid),
        "llm_enabled": SessionServiceManager.is_llm_enabled_for_session(sid),
        "tts_enabled": SessionServiceManager.is_tts_enabled_for_session(sid),
        "message_type": sid.split(":", 1)[1] if ":" in sid else "unknown",
        "session_name": SessionServiceManager.get_session_display_name(sid),
        "session_raw_name": sid.split(":", 2)[-1],
        "title": row["title"],
    }
    for t in (ProviderType.CHAT_COMPLETION, ProviderType.TEXT_TO_SPEECH, ProviderType.SPEECH_TO_TEXT):
        prov = self.core_lifecycle.provider_manager.get_using_provider(t, sid)
        if prov:
            info[f"{t.name.lower()}_provider_id"] = prov.provider_id
    return info
```

3) Slimmed `list_sessions` body  
```python
async def list_sessions(self):
    page = int(request.args.get("page", 1))
    page_size = int(request.args.get("page_size", 20))
    data, total = await self.db_helper.get_session_conversations(
        page, page_size, request.args.get("search", ""), request.args.get("platform", "")
    )
    sessions = [await self._map_session_row(r) for r in data]
    return Response().ok({
        "sessions": sessions,
        "pagination": {
            "page": page,
            "page_size": page_size,
            "total": total,
            "total_pages": (total + page_size - 1) // page_size if page_size else 0,
        },
        # ...available_* lists...
    }).__dict__
```

This keeps all functionality, but isolates branching in helpers and makes the main handler ≪ 30 lines.
</issue_to_address>

### Comment 9
<location> `astrbot/core/db/sqlite.py:350-362` </location>
<code_context>
    async def get_session_conversations(
        self,
        page: int = 1,
        page_size: int = 20,
        search_query: str = None,
        platform: str = None,
    ) -> tuple[list[dict], int]:
        """Get paginated session conversations with joined conversation and persona details."""
        from sqlalchemy import select, func, or_
        from astrbot.core.db.po import Preference, ConversationV2, Persona

        async with self.get_db() as session:
            session: AsyncSession
            offset = (page - 1) * page_size

            base_query = (
                select(
                    Preference.scope_id.label("session_id"),
                    func.json_extract(Preference.value, "$.val").label(
                        "conversation_id"
                    ),
                    ConversationV2.persona_id,
                    ConversationV2.title,
                    Persona.persona_id.label("persona_name"),
                )
                .select_from(Preference)
                .outerjoin(
                    ConversationV2,
                    func.json_extract(Preference.value, "$.val")
                    == ConversationV2.conversation_id,
                )
                .outerjoin(Persona, ConversationV2.persona_id == Persona.persona_id)
                .where(Preference.scope == "umo", Preference.key == "sel_conv_id")
            )

            # 搜索筛选
            if search_query:
                search_pattern = f"%{search_query}%"
                base_query = base_query.where(
                    or_(
                        Preference.scope_id.ilike(search_pattern),
                        ConversationV2.title.ilike(search_pattern),
                        Persona.persona_id.ilike(search_pattern),
                    )
                )

            # 平台筛选
            if platform:
                platform_pattern = f"{platform}:%"
                base_query = base_query.where(Preference.scope_id.like(platform_pattern))

            # 排序
            base_query = base_query.order_by(Preference.scope_id)

            # 分页结果
            result_query = base_query.offset(offset).limit(page_size)
            result = await session.execute(result_query)
            rows = result.fetchall()

            # 查询总数(应用相同的筛选条件)
            count_base_query = (
                select(func.count(Preference.scope_id))
                .select_from(Preference)
                .outerjoin(
                    ConversationV2,
                    func.json_extract(Preference.value, "$.val")
                    == ConversationV2.conversation_id,
                )
                .outerjoin(Persona, ConversationV2.persona_id == Persona.persona_id)
                .where(Preference.scope == "umo", Preference.key == "sel_conv_id")
            )

            # 应用相同的搜索和平台筛选条件到计数查询
            if search_query:
                search_pattern = f"%{search_query}%"
                count_base_query = count_base_query.where(
                    or_(
                        Preference.scope_id.ilike(search_pattern),
                        ConversationV2.title.ilike(search_pattern),
                        Persona.persona_id.ilike(search_pattern),
                    )
                )

            if platform:
                platform_pattern = f"{platform}:%"
                count_base_query = count_base_query.where(Preference.scope_id.like(platform_pattern))

            total_result = await session.execute(count_base_query)
            total = total_result.scalar() or 0

            # 构建列表
            sessions_data = []
            for row in rows:
                sessions_data.append(
                    {
                        "session_id": row.session_id,
                        "conversation_id": row.conversation_id,
                        "persona_id": row.persona_id,
                        "title": row.title,
                        "persona_name": row.persona_name,
                    }
                )

            return sessions_data, total

</code_context>

<issue_to_address>
**suggestion (code-quality):** Convert for loop into list comprehension ([`list-comprehension`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/list-comprehension/))

```suggestion
            sessions_data = [
                {
                    "session_id": row.session_id,
                    "conversation_id": row.conversation_id,
                    "persona_id": row.persona_id,
                    "title": row.title,
                    "persona_name": row.persona_name,
                }
                for row in rows
            ]
```
</issue_to_address>

### Comment 10
<location> `astrbot/dashboard/routes/session_management.py:78-79` </location>
<code_context>
    async def list_sessions(self):
        """获取所有会话的列表,包括 persona 和 provider 信息"""
        try:
            page = int(request.args.get("page", 1))
            page_size = int(request.args.get("page_size", 20))
            search_query = request.args.get("search", "")
            platform = request.args.get("platform", "")

            # 使用数据库JOIN查询获取分页会话数据
            sessions_data, total = await self.db_helper.get_session_conversations(
                page, page_size, search_query, platform
            )
            logger.warning(f"{search_query}  {platform}")

            provider_manager = self.core_lifecycle.provider_manager
            persona_mgr = self.core_lifecycle.persona_mgr
            personas = persona_mgr.personas_v3

            sessions = []

            # 循环补充非数据库信息,如provider和session状态
            for data in sessions_data:
                session_id = data["session_id"]
                conversation_id = data["conversation_id"]
                conv_persona_id = data["persona_id"]
                title = data["title"]
                persona_name = data["persona_name"]

                # 处理 persona 显示
                if conv_persona_id and conv_persona_id != "[%None]":
                    if not persona_name:
                        for p in personas:
                            if p["name"] == conv_persona_id:
                                persona_name = p["name"]
                                break
                elif conv_persona_id == "[%None]":
                    persona_name = "无人格"
                else:
                    default_persona = persona_mgr.selected_default_persona_v3
                    if default_persona:
                        persona_name = default_persona["name"]

                session_info = {
                    "session_id": session_id,
                    "conversation_id": conversation_id,
                    "persona_id": persona_name,
                    "chat_provider_id": None,
                    "stt_provider_id": None,
                    "tts_provider_id": None,
                    "session_enabled": SessionServiceManager.is_session_enabled(
                        session_id
                    ),
                    "llm_enabled": SessionServiceManager.is_llm_enabled_for_session(
                        session_id
                    ),
                    "tts_enabled": SessionServiceManager.is_tts_enabled_for_session(
                        session_id
                    ),
                    "platform": session_id.split(":")[0]
                    if ":" in session_id
                    else "unknown",
                    "message_type": session_id.split(":")[1]
                    if session_id.count(":") >= 1
                    else "unknown",
                    "session_name": SessionServiceManager.get_session_display_name(
                        session_id
                    ),
                    "session_raw_name": session_id.split(":")[2]
                    if session_id.count(":") >= 2
                    else session_id,
                    "title": title,
                }

                # 获取 provider 信息
                chat_provider = provider_manager.get_using_provider(
                    provider_type=ProviderType.CHAT_COMPLETION, umo=session_id
                )
                tts_provider = provider_manager.get_using_provider(
                    provider_type=ProviderType.TEXT_TO_SPEECH, umo=session_id
                )
                stt_provider = provider_manager.get_using_provider(
                    provider_type=ProviderType.SPEECH_TO_TEXT, umo=session_id
                )
                if chat_provider:
                    meta = chat_provider.meta()
                    session_info["chat_provider_id"] = meta.id
                if tts_provider:
                    meta = tts_provider.meta()
                    session_info["tts_provider_id"] = meta.id
                if stt_provider:
                    meta = stt_provider.meta()
                    session_info["stt_provider_id"] = meta.id

                sessions.append(session_info)

            # 获取可用的 personas 和 providers 列表
            available_personas = [
                {"name": p["name"], "prompt": p.get("prompt", "")} for p in personas
            ]

            available_chat_providers = []
            for provider in provider_manager.provider_insts:
                meta = provider.meta()
                available_chat_providers.append(
                    {
                        "id": meta.id,
                        "name": meta.id,
                        "model": meta.model,
                        "type": meta.type,
                    }
                )

            available_stt_providers = []
            for provider in provider_manager.stt_provider_insts:
                meta = provider.meta()
                available_stt_providers.append(
                    {
                        "id": meta.id,
                        "name": meta.id,
                        "model": meta.model,
                        "type": meta.type,
                    }
                )

            available_tts_providers = []
            for provider in provider_manager.tts_provider_insts:
                meta = provider.meta()
                available_tts_providers.append(
                    {
                        "id": meta.id,
                        "name": meta.id,
                        "model": meta.model,
                        "type": meta.type,
                    }
                )

            result = {
                "sessions": sessions,
                "available_personas": available_personas,
                "available_chat_providers": available_chat_providers,
                "available_stt_providers": available_stt_providers,
                "available_tts_providers": available_tts_providers,
                "pagination": {
                    "page": page,
                    "page_size": page_size,
                    "total": total,
                    "total_pages": (total + page_size - 1) // page_size
                    if page_size > 0
                    else 0,
                },
            }

            return Response().ok(result).__dict__

        except Exception as e:
            error_msg = f"获取会话列表失败: {str(e)}\n{traceback.format_exc()}"
            logger.error(error_msg)
            return Response().error(f"获取会话列表失败: {str(e)}").__dict__

</code_context>

<issue_to_address>
**suggestion (code-quality):** We've found these issues:

- Use named expression to simplify assignment and conditional ([`use-named-expression`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/use-named-expression/))
- Low code quality found in SessionManagementRoute.list\_sessions - 13% ([`low-code-quality`](https://docs.sourcery.ai/Reference/Default-Rules/comments/low-code-quality/))

```suggestion
                    if default_persona := persona_mgr.selected_default_persona_v3:
```

<br/><details><summary>Explanation</summary>
The quality score for this function is below the quality threshold of 25%.
This score is a combination of the method length, cognitive complexity and working memory.

How can you solve this?

It might be worth refactoring this function to make it shorter and more readable.

- Reduce the function length by extracting pieces of functionality out into
  their own functions. This is the most important thing you can do - ideally a
  function should be less than 10 lines.
- Reduce nesting, perhaps by introducing guard clauses to return early.
- Ensure that variables are tightly scoped, so that code using related concepts
  sits together within the function rather than being scattered.</details>
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@Soulter Soulter merged commit 68ff895 into AstrBotDevs:master Sep 28, 2025
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]在会话管理中使用分页查询

2 participants