Date: Sun, 19 Apr 2026 21:30:04 -0700
Subject: [PATCH 09/24] fix: normalize invalid MCP required flags in MCP
schemas (#6077)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix: normalize invalid MCP required flags
* style: format mcp schema normalization tests
* style: sort mcp client imports
* fix: preserve nested mcp required flags
* test: cover malformed mcp required fields
---------
Co-authored-by: Stable Genius <259448942+stablegenius49@users.noreply.github.com>
Co-authored-by: エイカク <1259085392z@gmail.com>
Co-authored-by: 邹永赫 <1259085392@qq.com>
---
astrbot/core/agent/mcp_client.py | 60 +++++++++++++-
tests/unit/test_mcp_client_schema.py | 117 +++++++++++++++++++++++++++
2 files changed, 175 insertions(+), 2 deletions(-)
create mode 100644 tests/unit/test_mcp_client_schema.py
diff --git a/astrbot/core/agent/mcp_client.py b/astrbot/core/agent/mcp_client.py
index c149792dc..b75999ea6 100644
--- a/astrbot/core/agent/mcp_client.py
+++ b/astrbot/core/agent/mcp_client.py
@@ -1,4 +1,5 @@
import asyncio
+import copy
import logging
import os
import re
@@ -6,7 +7,7 @@ import sys
from contextlib import AsyncExitStack
from datetime import timedelta
from pathlib import Path, PureWindowsPath
-from typing import Generic
+from typing import Any, Generic
from tenacity import (
before_sleep_log,
@@ -325,6 +326,61 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]:
return False, f"{e!s}"
+def _normalize_mcp_input_schema(schema: dict[str, Any]) -> dict[str, Any]:
+ """Normalize common non-standard MCP JSON Schema variants.
+
+ Some MCP servers incorrectly mark required properties with a boolean
+ `required: true` on the property schema itself. Draft 2020-12 requires the
+ parent object to declare `required` as an array of property names instead.
+ We lift those booleans to the parent object so the schema remains usable
+ without disabling validation entirely.
+ """
+
+ def _normalize(node: Any) -> Any:
+ if isinstance(node, list):
+ return [_normalize(item) for item in node]
+
+ if not isinstance(node, dict):
+ return node
+
+ normalized = {key: _normalize(value) for key, value in node.items()}
+
+ properties = normalized.get("properties")
+ if isinstance(properties, dict):
+ original_properties = (
+ node.get("properties")
+ if isinstance(node.get("properties"), dict)
+ else {}
+ )
+ required = normalized.get("required")
+ required_list = required[:] if isinstance(required, list) else []
+
+ for prop_name, prop_schema in properties.items():
+ if not isinstance(prop_schema, dict):
+ continue
+
+ original_prop_schema = original_properties.get(prop_name, {})
+ prop_required = (
+ original_prop_schema.get("required")
+ if isinstance(original_prop_schema, dict)
+ else None
+ )
+ if isinstance(prop_required, bool):
+ if prop_schema.get("required") is prop_required:
+ prop_schema.pop("required", None)
+ if prop_required:
+ required_list.append(prop_name)
+
+ if required_list:
+ normalized["required"] = list(dict.fromkeys(required_list))
+ elif isinstance(required, list):
+ normalized.pop("required", None)
+
+ return normalized
+
+ return _normalize(copy.deepcopy(schema))
+
+
class MCPClient:
def __init__(self) -> None:
# Initialize session and client objects
@@ -602,7 +658,7 @@ class MCPTool(FunctionTool, Generic[TContext]):
super().__init__(
name=mcp_tool.name,
description=mcp_tool.description or "",
- parameters=mcp_tool.inputSchema,
+ parameters=_normalize_mcp_input_schema(mcp_tool.inputSchema),
)
self.mcp_tool = mcp_tool
self.mcp_client = mcp_client
diff --git a/tests/unit/test_mcp_client_schema.py b/tests/unit/test_mcp_client_schema.py
new file mode 100644
index 000000000..0c3d9bc6a
--- /dev/null
+++ b/tests/unit/test_mcp_client_schema.py
@@ -0,0 +1,117 @@
+from types import SimpleNamespace
+from unittest.mock import MagicMock
+
+from astrbot.core.agent.mcp_client import MCPTool, _normalize_mcp_input_schema
+
+
+class TestNormalizeMcpInputSchema:
+ def test_lifts_property_level_required_booleans_to_parent_required_array(self):
+ schema = {
+ "type": "object",
+ "properties": {
+ "stock_code": {"type": "string", "required": True},
+ "market": {"type": "string", "required": False},
+ },
+ }
+
+ normalized = _normalize_mcp_input_schema(schema)
+
+ assert normalized["required"] == ["stock_code"]
+ assert "required" not in normalized["properties"]["stock_code"]
+ assert "required" not in normalized["properties"]["market"]
+ assert schema["properties"]["stock_code"]["required"] is True
+
+ def test_preserves_existing_required_arrays_while_fixing_nested_objects(self):
+ schema = {
+ "type": "object",
+ "required": ["server"],
+ "properties": {
+ "server": {
+ "type": "object",
+ "required": ["transport"],
+ "properties": {
+ "transport": {"type": "string"},
+ "stock_code": {"type": "string", "required": True},
+ "market": {"type": "string", "required": False},
+ },
+ }
+ },
+ }
+
+ normalized = _normalize_mcp_input_schema(schema)
+
+ assert normalized["required"] == ["server"]
+ assert normalized["properties"]["server"]["required"] == [
+ "transport",
+ "stock_code",
+ ]
+ assert (
+ "required"
+ not in normalized["properties"]["server"]["properties"]["stock_code"]
+ )
+ assert (
+ "required" not in normalized["properties"]["server"]["properties"]["market"]
+ )
+
+ def test_preserves_parent_required_flag_for_nested_object_properties(self):
+ schema = {
+ "type": "object",
+ "properties": {
+ "server": {
+ "type": "object",
+ "required": True,
+ "properties": {
+ "transport": {"type": "string", "required": True},
+ },
+ }
+ },
+ }
+
+ normalized = _normalize_mcp_input_schema(schema)
+
+ assert normalized["required"] == ["server"]
+ assert normalized["properties"]["server"]["required"] == ["transport"]
+ assert (
+ "required"
+ not in normalized["properties"]["server"]["properties"]["transport"]
+ )
+
+ def test_ignores_non_boolean_required_values_and_non_dict_properties(self):
+ schema = {
+ "type": "object",
+ "properties": {
+ "server": "invalid-property-schema",
+ "market": {"type": "string", "required": "yes"},
+ "stock_code": {"type": "string", "required": True},
+ },
+ }
+
+ normalized = _normalize_mcp_input_schema(schema)
+
+ assert normalized["required"] == ["stock_code"]
+ assert normalized["properties"]["server"] == "invalid-property-schema"
+ assert normalized["properties"]["market"]["required"] == "yes"
+ assert "required" not in normalized["properties"]["stock_code"]
+ assert schema["properties"]["server"] == "invalid-property-schema"
+ assert schema["properties"]["market"]["required"] == "yes"
+
+
+class TestMCPToolSchemaNormalization:
+ def test_mcp_tool_accepts_property_level_required_booleans(self):
+ mcp_tool = SimpleNamespace(
+ name="quote_lookup",
+ description="Lookup a quote",
+ inputSchema={
+ "type": "object",
+ "properties": {
+ "stock_code": {"type": "string", "required": True},
+ "market": {"type": "string", "required": False},
+ },
+ },
+ )
+
+ tool = MCPTool(mcp_tool, MagicMock(), "gf-securities")
+
+ assert tool.parameters["required"] == ["stock_code"]
+ assert "required" not in tool.parameters["properties"]["stock_code"]
+ assert "required" not in tool.parameters["properties"]["market"]
From 76ee4f27dd5879b11e103c5903423bbc9b7f55df Mon Sep 17 00:00:00 2001
From: Aster <54532857+Aster-amellus@users.noreply.github.com>
Date: Mon, 20 Apr 2026 15:24:07 +0800
Subject: [PATCH 10/24] feat: add epub support for knowledge base document
upload (#7594)
* feat: add EPUB parsing support for knowledge base and file reader
* feat: update supported file formats for document upload in knowledge base
* feat: enhance EPUB parser to support spine order and generic containers
* makeitdown parse epub
* update parser
* fix
---
astrbot/core/computer/file_read_utils.py | 41 +++-
astrbot/core/knowledge_base/kb_helper.py | 9 +-
.../core/knowledge_base/parsers/__init__.py | 2 +
.../knowledge_base/parsers/epub_parser.py | 162 ++++++++++++++
astrbot/core/knowledge_base/parsers/util.py | 4 +
.../en-US/features/alkaid/knowledge-base.json | 4 +-
.../en-US/features/knowledge-base/detail.json | 2 +-
.../ru-RU/features/knowledge-base/detail.json | 4 +-
.../zh-CN/features/alkaid/knowledge-base.json | 4 +-
.../zh-CN/features/knowledge-base/detail.json | 2 +-
dashboard/src/views/alkaid/KnowledgeBase.vue | 9 +-
.../views/knowledge-base/DocumentDetail.vue | 2 +
.../components/DocumentsTab.vue | 13 +-
requirements.txt | 2 +-
tests/test_computer_fs_tools.py | 119 ++++++++++
tests/test_epub_parser.py | 211 ++++++++++++++++++
16 files changed, 566 insertions(+), 24 deletions(-)
create mode 100644 astrbot/core/knowledge_base/parsers/epub_parser.py
create mode 100644 tests/test_epub_parser.py
diff --git a/astrbot/core/computer/file_read_utils.py b/astrbot/core/computer/file_read_utils.py
index 0f4d0811c..f22c1891f 100644
--- a/astrbot/core/computer/file_read_utils.py
+++ b/astrbot/core/computer/file_read_utils.py
@@ -73,7 +73,7 @@ class FileProbe:
@dataclass(frozen=True)
class ParsedDocument:
- kind: Literal["docx", "pdf"]
+ kind: Literal["docx", "epub", "pdf"]
file_bytes: bytes
text: str
@@ -371,6 +371,18 @@ def _is_docx_bytes(file_bytes: bytes) -> bool:
return any(name.startswith("word/") for name in names)
+def _is_epub_bytes(file_bytes: bytes) -> bool:
+ try:
+ with zipfile.ZipFile(io.BytesIO(file_bytes)) as archive:
+ names = set(archive.namelist())
+ with archive.open("mimetype") as mimetype_file:
+ mimetype = mimetype_file.read(64).decode("utf-8").strip()
+ except (KeyError, OSError, UnicodeDecodeError, zipfile.BadZipFile):
+ return False
+
+ return mimetype == "application/epub+zip" and "META-INF/container.xml" in names
+
+
async def _parse_local_docx_text(file_bytes: bytes, file_name: str) -> str:
from astrbot.core.knowledge_base.parsers.markitdown_parser import (
MarkitdownParser,
@@ -387,23 +399,48 @@ async def _parse_local_pdf_text(file_bytes: bytes, file_name: str) -> str:
return result.text
+async def _parse_local_epub_text(file_bytes: bytes, file_name: str) -> str:
+ from astrbot.core.knowledge_base.parsers.epub_parser import EpubParser
+
+ result = await EpubParser().parse(file_bytes, file_name)
+ return result.text
+
+
async def _parse_local_supported_document(
path: str,
sample: bytes,
) -> ParsedDocument | None:
file_name = Path(path).name
+ suffix = Path(path).suffix.lower()
if _looks_like_pdf(path, sample):
file_bytes = await _read_local_file_bytes(path)
text = await _parse_local_pdf_text(file_bytes, file_name)
return ParsedDocument(kind="pdf", file_bytes=file_bytes, text=text)
- if Path(path).suffix.lower() == ".docx" or _looks_like_zip_container(sample):
+ if suffix == ".epub":
+ file_bytes = await _read_local_file_bytes(path)
+ if not _is_epub_bytes(file_bytes):
+ return None
+ text = await _parse_local_epub_text(file_bytes, file_name)
+ return ParsedDocument(kind="epub", file_bytes=file_bytes, text=text)
+
+ if suffix == ".docx":
file_bytes = await _read_local_file_bytes(path)
if not _is_docx_bytes(file_bytes):
return None
text = await _parse_local_docx_text(file_bytes, file_name)
return ParsedDocument(kind="docx", file_bytes=file_bytes, text=text)
+ if _looks_like_zip_container(sample):
+ file_bytes = await _read_local_file_bytes(path)
+ if _is_epub_bytes(file_bytes):
+ text = await _parse_local_epub_text(file_bytes, file_name)
+ return ParsedDocument(kind="epub", file_bytes=file_bytes, text=text)
+ if _is_docx_bytes(file_bytes):
+ text = await _parse_local_docx_text(file_bytes, file_name)
+ return ParsedDocument(kind="docx", file_bytes=file_bytes, text=text)
+ return None
+
return None
diff --git a/astrbot/core/knowledge_base/kb_helper.py b/astrbot/core/knowledge_base/kb_helper.py
index fab080ea0..1f867ec27 100644
--- a/astrbot/core/knowledge_base/kb_helper.py
+++ b/astrbot/core/knowledge_base/kb_helper.py
@@ -109,6 +109,10 @@ Text chunk to process:
return [chunk]
+def _compact_chunks(chunks: list[str]) -> list[str]:
+ return [chunk.strip() for chunk in chunks if chunk and chunk.strip()]
+
+
class KBHelper:
vec_db: BaseVecDB
kb: KnowledgeBase
@@ -249,7 +253,7 @@ class KBHelper:
if pre_chunked_text is not None:
# 如果提供了预分块文本,直接使用
- chunks_text = pre_chunked_text
+ chunks_text = _compact_chunks(pre_chunked_text)
file_size = sum(len(chunk) for chunk in chunks_text)
logger.info(f"使用预分块文本进行上传,共 {len(chunks_text)} 个块。")
else:
@@ -316,6 +320,7 @@ class KBHelper:
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
)
+ chunks_text = _compact_chunks(chunks_text)
except KnowledgeBaseUploadError:
raise
except Exception as exc:
@@ -728,6 +733,8 @@ class KBHelper:
elif isinstance(result, list):
final_chunks.extend(result)
+ final_chunks = _compact_chunks(final_chunks)
+
logger.info(
f"文本修复完成: {len(initial_chunks)} 个原始块 -> {len(final_chunks)} 个最终块。"
)
diff --git a/astrbot/core/knowledge_base/parsers/__init__.py b/astrbot/core/knowledge_base/parsers/__init__.py
index 184f2fd41..f3b5967cf 100644
--- a/astrbot/core/knowledge_base/parsers/__init__.py
+++ b/astrbot/core/knowledge_base/parsers/__init__.py
@@ -1,11 +1,13 @@
"""文档解析器模块"""
from .base import BaseParser, MediaItem, ParseResult
+from .epub_parser import EpubParser
from .pdf_parser import PDFParser
from .text_parser import TextParser
__all__ = [
"BaseParser",
+ "EpubParser",
"MediaItem",
"PDFParser",
"ParseResult",
diff --git a/astrbot/core/knowledge_base/parsers/epub_parser.py b/astrbot/core/knowledge_base/parsers/epub_parser.py
new file mode 100644
index 000000000..159f6c736
--- /dev/null
+++ b/astrbot/core/knowledge_base/parsers/epub_parser.py
@@ -0,0 +1,162 @@
+"""EPUB document parser."""
+
+import html
+import re
+
+from astrbot.core.knowledge_base.parsers.base import BaseParser, ParseResult
+
+_KEYS = (
+ "Title|Author|Creator|Language|Publisher|Date|Modified|Identifier|ISBN|Description|"
+ "Subject|Rights|Source|Series|标题|书名|作者|语言|出版社|日期|出版日期|标识符|简介|描述|"
+ "主题|版权|来源|系列|タイトル|書名|著者|言語|出版社|日付|識別子|説明|件名|権利|ソース|シリーズ"
+)
+_META_RE = re.compile(rf"^\s*(?:[-*]\s*)?\*\*(?:{_KEYS})\s*[::]\*\*\s+\S")
+_TOC_HEAD_RE = re.compile(
+ r"^\s{0,3}(?:#{1,6}\s*)?(?:table of contents|contents|toc|目录|目次|もくじ)\s*$",
+ re.I,
+)
+_LINK_RE = re.compile(r"(? str:
+ return (
+ html.unescape(s)
+ .replace("\r\n", "\n")
+ .replace("\r", "\n")
+ .replace("\ufeff", "")
+ .replace("\u00a0", " ")
+ .replace("\u200b", "")
+ )
+
+
+def _is_internal(href: str) -> bool:
+ href = html.unescape(href).strip().lower()
+ return (
+ href.startswith("#")
+ or href.endswith(".html")
+ or href.endswith(".xhtml")
+ or ".html#" in href
+ or ".xhtml#" in href
+ )
+
+
+def _is_toc_line(s: str) -> bool:
+ s = s.strip()
+ if not s:
+ return False
+ s = re.sub(r"^\s*(?:[-*+]|\d+\.)\s+", "", s)
+ m = re.fullmatch(r"\[([^\]]+)\]\(([^)]+)\)", s)
+ return bool((m and _is_internal(m.group(2))) or _DOTTED_TOC_RE.match(s))
+
+
+def _strip_head(text: str) -> str:
+ lines = _n(text).split("\n")
+ i = 0
+ while i < len(lines) and not lines[i].strip():
+ i += 1
+ start = i
+ while i < len(lines) and _META_RE.match(lines[i].strip()):
+ i += 1
+ if i - start >= 2:
+ while i < len(lines) and not lines[i].strip():
+ i += 1
+ else:
+ i = start
+ toc0, had_head = i, False
+ if i < len(lines) and _TOC_HEAD_RE.match(lines[i].strip()):
+ had_head = True
+ i += 1
+ while i < len(lines) and not lines[i].strip():
+ i += 1
+ toc = 0
+ while i < len(lines) and i - toc0 < 120:
+ s = lines[i].strip()
+ if not s:
+ if toc and i + 1 < len(lines) and _is_toc_line(lines[i + 1]):
+ i += 1
+ continue
+ break
+ if not _is_toc_line(s):
+ break
+ toc += 1
+ i += 1
+ if toc >= 2 and (had_head or toc >= 3):
+ while i < len(lines) and not lines[i].strip():
+ i += 1
+ return "\n".join(lines[i:]).strip()
+ return "\n".join(lines[toc0:]).strip()
+
+
+def _strip_links(text: str) -> str:
+ def repl(m: re.Match[str]) -> str:
+ label = html.unescape(m.group(1)).strip()
+ href = html.unescape(m.group(2)).strip().lower()
+ if not _is_internal(href):
+ return m.group(0)
+ if _FOOTNOTE_HREF_RE.search(href) or (
+ href.startswith("#") and _FOOTNOTE_LABEL_RE.fullmatch(label)
+ ):
+ return ""
+ return label
+
+ return _LINK_RE.sub(repl, _n(text))
+
+
+def _img_alt(m: re.Match[str]) -> str:
+ alt = re.sub(r"\s+", " ", html.unescape(m.group(1)).strip())
+ if not alt or _GENERIC_ALT_RE.fullmatch(alt) or _FILENAME_ALT_RE.fullmatch(alt):
+ return ""
+ return alt
+
+
+def _sanitize(text: str) -> str:
+ out, prev_blank, prev = [], True, ""
+ for raw in _n(text).split("\n"):
+ line = _IMG_RE.sub(_img_alt, raw)
+ line = _EMPTY_IMG_LINK_RE.sub("", line).rstrip()
+ s = line.strip()
+ if not s:
+ if not prev_blank:
+ out.append("")
+ prev_blank = True
+ continue
+ if _SEP_RE.match(s) or _NOISE_RE.match(s):
+ continue
+ norm = re.sub(r"^\s{0,3}#{1,6}\s*", "", s).strip("*_ ").casefold()
+ if norm and norm == prev and len(norm) <= 120:
+ continue
+ out.append(line)
+ prev_blank = False
+ prev = norm
+ return "\n".join(out).strip()
+
+
+class EpubParser(BaseParser):
+ """Parse EPUB files via MarkItDown."""
+
+ async def parse(self, file_content: bytes, file_name: str) -> ParseResult:
+ from .markitdown_parser import MarkitdownParser
+
+ result = await MarkitdownParser().parse(file_content, file_name)
+ text = _sanitize(_strip_links(_strip_head(result.text)))
+ return ParseResult(text=text, media=result.media)
diff --git a/astrbot/core/knowledge_base/parsers/util.py b/astrbot/core/knowledge_base/parsers/util.py
index 7a4463202..52cffa5cd 100644
--- a/astrbot/core/knowledge_base/parsers/util.py
+++ b/astrbot/core/knowledge_base/parsers/util.py
@@ -6,6 +6,10 @@ async def select_parser(ext: str) -> BaseParser:
from .markitdown_parser import MarkitdownParser
return MarkitdownParser()
+ if ext == ".epub":
+ from .epub_parser import EpubParser
+
+ return EpubParser()
if ext == ".pdf":
from .pdf_parser import PDFParser
diff --git a/dashboard/src/i18n/locales/en-US/features/alkaid/knowledge-base.json b/dashboard/src/i18n/locales/en-US/features/alkaid/knowledge-base.json
index f4d8a33fd..98d5aea37 100644
--- a/dashboard/src/i18n/locales/en-US/features/alkaid/knowledge-base.json
+++ b/dashboard/src/i18n/locales/en-US/features/alkaid/knowledge-base.json
@@ -70,7 +70,7 @@
},
"upload": {
"title": "Upload Files to Knowledge Base",
- "subtitle": "Supports txt, pdf, word, excel and other formats",
+ "subtitle": "Supports txt, pdf, epub, word, excel and other formats",
"dropzone": "Drag and drop files here or click to upload",
"chunkSettings": {
"title": "Chunk Settings",
@@ -152,4 +152,4 @@
"preRequisite": "Hint: Please go to the plugin market to install astrbot_plugin_url_2_knowledge_base and follow the instructions in the plugin documentation to complete the playwright installation before using this feature.",
"allChunksUploaded": "All chunks uploaded successfully"
}
-}
\ No newline at end of file
+}
diff --git a/dashboard/src/i18n/locales/en-US/features/knowledge-base/detail.json b/dashboard/src/i18n/locales/en-US/features/knowledge-base/detail.json
index 90d3e6158..0ef8fa3d4 100644
--- a/dashboard/src/i18n/locales/en-US/features/knowledge-base/detail.json
+++ b/dashboard/src/i18n/locales/en-US/features/knowledge-base/detail.json
@@ -46,7 +46,7 @@
"title": "Upload Document",
"selectFile": "Select File",
"dropzone": "Drop files here or click to select",
- "supportedFormats": "Supported formats: ",
+ "supportedFormats": "Supported formats: .txt, .md, .pdf, .docx, .epub, .xls, .xlsx",
"maxSize": "Max file size: 128MB",
"chunkSettings": "Chunk Settings",
"batchSettings": "Batch Settings",
diff --git a/dashboard/src/i18n/locales/ru-RU/features/knowledge-base/detail.json b/dashboard/src/i18n/locales/ru-RU/features/knowledge-base/detail.json
index 10d2109bd..0e645389c 100644
--- a/dashboard/src/i18n/locales/ru-RU/features/knowledge-base/detail.json
+++ b/dashboard/src/i18n/locales/ru-RU/features/knowledge-base/detail.json
@@ -46,7 +46,7 @@
"title": "Добавление контента",
"selectFile": "Файл",
"dropzone": "Нажмите или перетащите файл сюда",
- "supportedFormats": "Форматы: ",
+ "supportedFormats": "Форматы: .txt, .md, .pdf, .docx, .epub, .xls, .xlsx",
"maxSize": "Максимум: 128MB",
"chunkSettings": "Фрагментация",
"batchSettings": "Пакетная обработка",
@@ -115,4 +115,4 @@
"saveFailed": "Ошибка сохранения",
"tips": "Внимание! Изменение этих параметров повлияет на будущую выдачу базы знаний."
}
-}
\ No newline at end of file
+}
diff --git a/dashboard/src/i18n/locales/zh-CN/features/alkaid/knowledge-base.json b/dashboard/src/i18n/locales/zh-CN/features/alkaid/knowledge-base.json
index 2467b1efc..d4c7ea839 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/alkaid/knowledge-base.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/alkaid/knowledge-base.json
@@ -70,7 +70,7 @@
},
"upload": {
"title": "上传文件到知识库",
- "subtitle": "支持 txt、pdf、word、excel 等多种格式",
+ "subtitle": "支持 txt、pdf、epub、word、excel 等多种格式",
"dropzone": "拖放文件到这里或点击上传",
"chunkSettings": {
"title": "分片设置",
@@ -152,4 +152,4 @@
"preRequisite": "提示:请先前往插件市场安装 astrbot_plugin_url_2_knowledge_base 并根据插件文档内的指示完成 playwright 安装后才可使用本功能",
"allChunksUploaded": "所有分片上传成功"
}
-}
\ No newline at end of file
+}
diff --git a/dashboard/src/i18n/locales/zh-CN/features/knowledge-base/detail.json b/dashboard/src/i18n/locales/zh-CN/features/knowledge-base/detail.json
index deb61f933..7473c6c1e 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/knowledge-base/detail.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/knowledge-base/detail.json
@@ -46,7 +46,7 @@
"title": "上传文档",
"selectFile": "选择文件",
"dropzone": "拖放文件到这里或点击选择",
- "supportedFormats": "支持的格式: ",
+ "supportedFormats": "支持的格式: .txt, .md, .pdf, .docx, .epub, .xls, .xlsx",
"maxSize": "最大文件大小: 128MB",
"chunkSettings": "分块设置",
"batchSettings": "批处理设置",
diff --git a/dashboard/src/views/alkaid/KnowledgeBase.vue b/dashboard/src/views/alkaid/KnowledgeBase.vue
index 185148257..00259062b 100644
--- a/dashboard/src/views/alkaid/KnowledgeBase.vue
+++ b/dashboard/src/views/alkaid/KnowledgeBase.vue
@@ -830,6 +830,7 @@ export default {
if (files.length > 0) {
this.selectedFile = files[0];
}
+ event.target.value = '';
},
onFileDrop(event) {
@@ -845,6 +846,8 @@ export default {
switch (extension) {
case 'pdf':
return 'mdi-file-pdf-box';
+ case 'epub':
+ return 'mdi-book-open-page-variant';
case 'doc':
case 'docx':
return 'mdi-file-word-box';
@@ -882,11 +885,7 @@ export default {
formData.append('chunk_overlap', this.overlap);
}
- axios.post('/api/plug/alkaid/kb/collection/add_file', formData, {
- headers: {
- 'Content-Type': 'multipart/form-data'
- }
- })
+ axios.post('/api/plug/alkaid/kb/collection/add_file', formData)
.then(response => {
if (response.data.status === 'ok') {
this.showSnackbar(this.tm('messages.operationSuccess', { message: response.data.message }));
diff --git a/dashboard/src/views/knowledge-base/DocumentDetail.vue b/dashboard/src/views/knowledge-base/DocumentDetail.vue
index 16d9ca67e..e147d7f8c 100644
--- a/dashboard/src/views/knowledge-base/DocumentDetail.vue
+++ b/dashboard/src/views/knowledge-base/DocumentDetail.vue
@@ -382,6 +382,7 @@ const deleteChunk = async (chunk: any) => {
const getFileIcon = (fileType: string) => {
const type = fileType?.toLowerCase() || ''
if (type.includes('pdf')) return 'mdi-file-pdf-box'
+ if (type.includes('epub')) return 'mdi-book-open-page-variant'
if (type.includes('md')) return 'mdi-language-markdown'
if (type.includes('txt')) return 'mdi-file-document-outline'
return 'mdi-file'
@@ -390,6 +391,7 @@ const getFileIcon = (fileType: string) => {
const getFileColor = (fileType: string) => {
const type = fileType?.toLowerCase() || ''
if (type.includes('pdf')) return 'error'
+ if (type.includes('epub')) return 'warning'
if (type.includes('md')) return 'info'
if (type.includes('txt')) return 'success'
return 'grey'
diff --git a/dashboard/src/views/knowledge-base/components/DocumentsTab.vue b/dashboard/src/views/knowledge-base/components/DocumentsTab.vue
index bf110f282..7be45efab 100644
--- a/dashboard/src/views/knowledge-base/components/DocumentsTab.vue
+++ b/dashboard/src/views/knowledge-base/components/DocumentsTab.vue
@@ -84,12 +84,10 @@
@dragover.prevent="isDragging = true" @dragleave="isDragging = false" @click="fileInput?.click()">
mdi-cloud-upload
{{ t('upload.dropzone') }}
- {{ t('upload.supportedFormats') }}.txt, .md, .pdf,
- .docx,
- .xls, .xlsx
+ {{ t('upload.supportedFormats') }}
{{ t('upload.maxSize') }}
最多可上传 10 个文件
-
@@ -369,6 +367,7 @@ const handleFileSelect = (event: Event) => {
const newFiles = Array.from(target.files)
addFiles(newFiles)
}
+ target.value = ''
}
// 添加文件(检查数量限制)
@@ -432,9 +431,7 @@ const uploadFiles = async () => {
formData.append('tasks_limit', uploadSettings.value.tasks_limit.toString())
formData.append('max_retries', uploadSettings.value.max_retries.toString())
- const response = await axios.post('/api/kb/document/upload', formData, {
- headers: { 'Content-Type': 'multipart/form-data' }
- })
+ const response = await axios.post('/api/kb/document/upload', formData)
if (response.data.status === 'ok') {
const result = response.data.data
@@ -717,6 +714,7 @@ const deleteDocument = async () => {
const getFileIcon = (fileType: string) => {
const type = fileType?.toLowerCase() || ''
if (type.includes('pdf')) return 'mdi-file-pdf-box'
+ if (type.includes('epub')) return 'mdi-book-open-page-variant'
if (type.includes('md') || type.includes('markdown')) return 'mdi-language-markdown'
if (type.includes('txt')) return 'mdi-file-document-outline'
if (type.includes('url')) return 'mdi-link-variant'
@@ -726,6 +724,7 @@ const getFileIcon = (fileType: string) => {
const getFileColor = (fileType: string) => {
const type = fileType?.toLowerCase() || ''
if (type.includes('pdf')) return 'error'
+ if (type.includes('epub')) return 'warning'
if (type.includes('md')) return 'info'
if (type.includes('txt')) return 'success'
if (type.includes('url')) return 'primary'
diff --git a/requirements.txt b/requirements.txt
index a6eb84bb0..e667c6755 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -53,4 +53,4 @@ shipyard-python-sdk>=0.2.4
shipyard-neo-sdk>=0.2.0
packaging>=24.2
qrcode>=8.2
-python-ripgrep==0.0.8
\ No newline at end of file
+python-ripgrep==0.0.8
diff --git a/tests/test_computer_fs_tools.py b/tests/test_computer_fs_tools.py
index 7fe33fe21..a9f6fa16d 100644
--- a/tests/test_computer_fs_tools.py
+++ b/tests/test_computer_fs_tools.py
@@ -92,6 +92,95 @@ def _make_large_text() -> str:
return "".join(f"line-{index:05d}-{'x' * 48}\n" for index in range(6000))
+def _make_epub_bytes(*, chapter_count: int = 1) -> bytes:
+ manifest_items = [
+ ' '
+ ]
+ spine_items = ['']
+ nav_links = []
+
+ buffer = io.BytesIO()
+ with zipfile.ZipFile(buffer, mode="w") as archive:
+ archive.writestr(
+ "mimetype",
+ "application/epub+zip",
+ compress_type=zipfile.ZIP_STORED,
+ )
+ archive.writestr(
+ "META-INF/container.xml",
+ """
+
+
+
+
+
+""",
+ )
+
+ for index in range(1, chapter_count + 1):
+ manifest_items.append(
+ f' '
+ )
+ spine_items.append(f'')
+ nav_links.append(f'Chapter {index}')
+ archive.writestr(
+ f"OEBPS/chapter{index}.xhtml",
+ f"""
+
+
+ Chapter {index}
+
+
+ Chapter {index}
+ Paragraph {index}
+
+
+""",
+ )
+
+ archive.writestr(
+ "OEBPS/nav.xhtml",
+ """
+
+
+ Navigation
+
+
+
+
+
+""".format(links="".join(nav_links)),
+ )
+ archive.writestr(
+ "OEBPS/content.opf",
+ """
+
+
+ test-book
+ Test Book
+ en
+
+
+ {manifest}
+
+
+ {spine}
+
+
+""".format(
+ manifest="".join(manifest_items),
+ spine="".join(spine_items),
+ ),
+ )
+
+ return buffer.getvalue()
+
+
def test_detect_text_encoding_allows_utf8_probe_cut_mid_character():
sample = '{"results": ["中文内容"]}'.encode()[:-1]
@@ -230,6 +319,36 @@ async def test_file_read_tool_reads_docx_via_parser_and_magic(
assert result == "doc-line-1\ndoc-line-2\n"
+def test_is_epub_bytes_rejects_plain_zip_archive():
+ buffer = io.BytesIO()
+ with zipfile.ZipFile(buffer, mode="w") as archive:
+ archive.writestr("README.txt", "hello")
+
+ assert file_read_utils._is_epub_bytes(buffer.getvalue()) is False
+
+
+@pytest.mark.asyncio
+async def test_file_read_tool_reads_epub_via_parser_and_magic(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path,
+):
+ workspace = _setup_local_fs_tools(monkeypatch, tmp_path)
+ epub_path = workspace / "novel.bin"
+ epub_path.write_bytes(_make_epub_bytes(chapter_count=2))
+
+ async def _fake_parse_epub(_file_bytes: bytes, _file_name: str) -> str:
+ return "# Chapter 1\n\nParagraph 1\n"
+
+ monkeypatch.setattr(file_read_utils, "_parse_local_epub_text", _fake_parse_epub)
+
+ result = await fs_tools.FileReadTool().call(
+ _make_context(),
+ path="novel.bin",
+ )
+
+ assert result == "# Chapter 1\n\nParagraph 1\n"
+
+
@pytest.mark.asyncio
async def test_file_read_tool_stores_long_converted_document_in_workspace(
monkeypatch: pytest.MonkeyPatch,
diff --git a/tests/test_epub_parser.py b/tests/test_epub_parser.py
new file mode 100644
index 000000000..a53f89f36
--- /dev/null
+++ b/tests/test_epub_parser.py
@@ -0,0 +1,211 @@
+from __future__ import annotations
+
+import io
+import zipfile
+
+import pytest
+
+from astrbot.core.knowledge_base.parsers.epub_parser import EpubParser
+from astrbot.core.knowledge_base.parsers.util import select_parser
+
+
+def _make_epub_bytes() -> bytes:
+ buffer = io.BytesIO()
+ with zipfile.ZipFile(buffer, mode="w") as archive:
+ archive.writestr(
+ "mimetype",
+ "application/epub+zip",
+ compress_type=zipfile.ZIP_STORED,
+ )
+ archive.writestr(
+ "META-INF/container.xml",
+ """
+
+
+
+
+
+""",
+ )
+ archive.writestr(
+ "OEBPS/nav.xhtml",
+ """
+
+
+ Navigation
+
+
+
+
+
+""",
+ )
+ archive.writestr(
+ "OEBPS/chapter2.xhtml",
+ """
+
+
+ Chapter 2
+
+
+ Second
+ Beta paragraph.
+
+
+""",
+ )
+ archive.writestr(
+ "OEBPS/chapter1.xhtml",
+ """
+
+
+ Chapter 1
+
+
+ First
+ Alpha paragraph.
+
+
+
+""",
+ )
+ archive.writestr(
+ "OEBPS/content.opf",
+ """
+
+
+ test-book
+ Test Book
+ en
+
+
+
+
+
+
+
+
+
+
+
+
+""",
+ )
+
+ return buffer.getvalue()
+
+
+def _make_epub_bytes_with_generic_content() -> bytes:
+ buffer = io.BytesIO()
+ with zipfile.ZipFile(buffer, mode="w") as archive:
+ archive.writestr(
+ "mimetype",
+ "application/epub+zip",
+ compress_type=zipfile.ZIP_STORED,
+ )
+ archive.writestr(
+ "META-INF/container.xml",
+ """
+
+
+
+
+
+""",
+ )
+ archive.writestr(
+ "OEBPS/chapter1.xhtml",
+ """
+
+
+ Chapter 1
+
+
+ First
+ Lead text
+ Piura*5, continued.
+
+ Inside div
+
+
+
+
+""",
+ )
+ archive.writestr(
+ "OEBPS/content.opf",
+ """
+
+
+ test-book
+ Test Book
+ en
+
+
+
+
+
+
+
+
+""",
+ )
+
+ return buffer.getvalue()
+
+
+@pytest.mark.asyncio
+async def test_select_parser_supports_epub():
+ parser = await select_parser(".epub")
+
+ assert isinstance(parser, EpubParser)
+
+
+@pytest.mark.asyncio
+async def test_epub_parser_reads_spine_order_as_text():
+ result = await EpubParser().parse(_make_epub_bytes(), "book.epub")
+
+ assert result.media == []
+ assert "**Title:**" not in result.text
+ assert "[Chapter 2](chapter2.xhtml)" not in result.text
+ assert result.text.startswith("1. Chapter 2")
+ assert "2. Chapter 1" in result.text
+ assert "Beta paragraph." in result.text
+ assert "# First" in result.text
+ assert "* Point A" in result.text
+ assert result.text.index("1. Chapter 2") < result.text.index("## Second")
+ assert result.text.index("## Second") < result.text.index("# First")
+
+
+@pytest.mark.asyncio
+async def test_epub_parser_preserves_generic_container_text():
+ result = await EpubParser().parse(
+ _make_epub_bytes_with_generic_content(),
+ "book.epub",
+ )
+
+ assert "**Title:**" not in result.text
+ assert "# First" in result.text
+ assert "Lead text" in result.text
+ assert r"Piura\*5, continued." in result.text
+ assert "filepos" not in result.text
+ assert r"[\*5]" not in result.text
+ assert "Image00000.jpg" not in result.text
+ assert "?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat: add pinUpdatesOnTop option to always show plugin updates at top
* fix: add missing pinUpdatesOnTop destructuring in InstalledPluginsTab
* fix: address AI review suggestions - add localStorage persistence and increase switch width
* fix: harden extension preference storage
* fix: refine extension preference sorting
* fix: simplify extension preference sorting
* refactor: simplify extension preference storage access
---------
Co-authored-by: Test User
Co-authored-by: 邹永赫 <1259085392@qq.com>
---
.../locales/en-US/features/extension.json | 3 +-
.../locales/ru-RU/features/extension.json | 5 +-
.../locales/zh-CN/features/extension.json | 3 +-
.../views/extension/InstalledPluginsTab.vue | 10 +++
.../extension/extensionPreferenceStorage.mjs | 84 +++++++++++++++++++
.../src/views/extension/useExtensionPage.js | 84 ++++++++++++-------
.../tests/extensionPreferenceStorage.test.mjs | 75 +++++++++++++++++
7 files changed, 229 insertions(+), 35 deletions(-)
create mode 100644 dashboard/src/views/extension/extensionPreferenceStorage.mjs
create mode 100644 dashboard/tests/extensionPreferenceStorage.test.mjs
diff --git a/dashboard/src/i18n/locales/en-US/features/extension.json b/dashboard/src/i18n/locales/en-US/features/extension.json
index 233f28dd5..0a4a7fe48 100644
--- a/dashboard/src/i18n/locales/en-US/features/extension.json
+++ b/dashboard/src/i18n/locales/en-US/features/extension.json
@@ -144,7 +144,8 @@
"updated": "Last Updated",
"updateStatus": "Update Status",
"ascending": "Ascending",
- "descending": "Descending"
+ "descending": "Descending",
+ "pinUpdatesOnTop": "Pin Updates on Top"
},
"tags": {
"danger": "Danger"
diff --git a/dashboard/src/i18n/locales/ru-RU/features/extension.json b/dashboard/src/i18n/locales/ru-RU/features/extension.json
index b51d0cf78..c085885db 100644
--- a/dashboard/src/i18n/locales/ru-RU/features/extension.json
+++ b/dashboard/src/i18n/locales/ru-RU/features/extension.json
@@ -1,4 +1,4 @@
-{
+{
"title": "Плагины",
"subtitle": "Управление и настройка расширений системы",
"tabs": {
@@ -143,7 +143,8 @@
"updated": "Дате обновления",
"updateStatus": "Статусу обновления",
"ascending": "По возрастанию",
- "descending": "По убыванию"
+ "descending": "По убыванию",
+ "pinUpdatesOnTop": "Обновления сверху"
},
"tags": {
"danger": "Опасно"
diff --git a/dashboard/src/i18n/locales/zh-CN/features/extension.json b/dashboard/src/i18n/locales/zh-CN/features/extension.json
index 04eaa8bfa..c06f38142 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/extension.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/extension.json
@@ -147,7 +147,8 @@
"updated": "更新时间",
"updateStatus": "更新状态",
"ascending": "升序",
- "descending": "降序"
+ "descending": "降序",
+ "pinUpdatesOnTop": "有更新置顶"
},
"tags": {
"danger": "危险"
diff --git a/dashboard/src/views/extension/InstalledPluginsTab.vue b/dashboard/src/views/extension/InstalledPluginsTab.vue
index f7cbda8e5..97b3a56a9 100644
--- a/dashboard/src/views/extension/InstalledPluginsTab.vue
+++ b/dashboard/src/views/extension/InstalledPluginsTab.vue
@@ -55,6 +55,7 @@ const {
installedStatusFilter,
installedSortBy,
installedSortOrder,
+ pinUpdatesOnTop,
loading_,
currentPage,
dangerConfirmDialog,
@@ -354,6 +355,15 @@ const pinnedPlugins = computed(() => {
:show-order="installedSortUsesOrder"
@update:order="installedSortOrder = $event"
/>
+
diff --git a/dashboard/src/views/extension/extensionPreferenceStorage.mjs b/dashboard/src/views/extension/extensionPreferenceStorage.mjs
new file mode 100644
index 000000000..31cd90bbe
--- /dev/null
+++ b/dashboard/src/views/extension/extensionPreferenceStorage.mjs
@@ -0,0 +1,84 @@
+export const SHOW_RESERVED_PLUGINS_STORAGE_KEY = "showReservedPlugins";
+export const PLUGIN_LIST_VIEW_MODE_STORAGE_KEY = "pluginListViewMode";
+export const PIN_UPDATES_ON_TOP_STORAGE_KEY = "pinUpdatesOnTop";
+
+/**
+ * Resolve the storage backend for reading preferences.
+ * Pass `null` to explicitly disable storage access in callers/tests.
+ */
+const getStorageForRead = (storageOverride) => {
+ if (storageOverride === null) {
+ return null;
+ }
+ if (storageOverride !== undefined) {
+ return typeof storageOverride?.getItem === "function"
+ ? storageOverride
+ : null;
+ }
+ if (typeof window === "undefined") {
+ return null;
+ }
+ try {
+ const localStorage = window.localStorage ?? null;
+ return typeof localStorage?.getItem === "function" ? localStorage : null;
+ } catch {
+ return null;
+ }
+};
+
+/**
+ * Resolve the storage backend for writing preferences.
+ * Pass `null` to explicitly disable storage access in callers/tests.
+ */
+const getStorageForWrite = (storageOverride) => {
+ if (storageOverride === null) {
+ return null;
+ }
+ if (storageOverride !== undefined) {
+ return typeof storageOverride?.setItem === "function"
+ ? storageOverride
+ : null;
+ }
+ if (typeof window === "undefined") {
+ return null;
+ }
+ try {
+ const localStorage = window.localStorage ?? null;
+ return typeof localStorage?.setItem === "function" ? localStorage : null;
+ } catch {
+ return null;
+ }
+};
+
+export const readBooleanPreference = (key, fallback, storage) => {
+ const targetStorage = getStorageForRead(storage);
+ if (!targetStorage) {
+ return fallback;
+ }
+
+ try {
+ const saved = targetStorage.getItem(key);
+ if (saved === "true") {
+ return true;
+ }
+ if (saved === "false") {
+ return false;
+ }
+ return fallback;
+ } catch {
+ return fallback;
+ }
+};
+
+export const writeBooleanPreference = (key, value, storage) => {
+ const targetStorage = getStorageForWrite(storage);
+ if (!targetStorage) {
+ return;
+ }
+
+ try {
+ targetStorage.setItem(key, String(value));
+ } catch {
+ // Ignore restricted storage environments.
+ }
+};
diff --git a/dashboard/src/views/extension/useExtensionPage.js b/dashboard/src/views/extension/useExtensionPage.js
index 994be0814..3ceecc85b 100644
--- a/dashboard/src/views/extension/useExtensionPage.js
+++ b/dashboard/src/views/extension/useExtensionPage.js
@@ -14,6 +14,13 @@ import {
getValidHashTab,
replaceTabRoute,
} from "@/utils/hashRouteTabs.mjs";
+import {
+ PIN_UPDATES_ON_TOP_STORAGE_KEY,
+ PLUGIN_LIST_VIEW_MODE_STORAGE_KEY,
+ SHOW_RESERVED_PLUGINS_STORAGE_KEY,
+ readBooleanPreference,
+ writeBooleanPreference,
+} from "./extensionPreferenceStorage.mjs";
import { ref, computed, onMounted, onUnmounted, reactive, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useDisplay } from "vuetify";
@@ -124,11 +131,7 @@ export const useExtensionPage = () => {
// 从 localStorage 恢复显示系统插件的状态,默认为 false(隐藏)
const getInitialShowReserved = () => {
- if (typeof window !== "undefined" && window.localStorage) {
- const saved = localStorage.getItem("showReservedPlugins");
- return saved === "true";
- }
- return false;
+ return readBooleanPreference(SHOW_RESERVED_PLUGINS_STORAGE_KEY, false);
};
const showReserved = ref(getInitialShowReserved());
const snack_message = ref("");
@@ -178,16 +181,20 @@ export const useExtensionPage = () => {
// 新增变量支持列表视图
// 从 localStorage 恢复显示模式,默认为 false(卡片视图)
const getInitialListViewMode = () => {
- if (typeof window !== "undefined" && window.localStorage) {
- return localStorage.getItem("pluginListViewMode") === "true";
- }
- return false;
+ return readBooleanPreference(PLUGIN_LIST_VIEW_MODE_STORAGE_KEY, false);
};
const isListView = ref(getInitialListViewMode());
const pluginSearch = ref("");
const installedStatusFilter = ref("all");
const installedSortBy = ref("default");
const installedSortOrder = ref("desc");
+ const getInitialPinUpdatesOnTop = () => {
+ return readBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, true);
+ };
+ const pinUpdatesOnTop = ref(getInitialPinUpdatesOnTop());
+ watch(pinUpdatesOnTop, (val) => {
+ writeBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, val);
+ });
const loading_ = ref(false);
// 分页相关
@@ -426,6 +433,17 @@ export const useExtensionPage = () => {
return Number.isFinite(parsed) ? parsed : null;
};
+ const compareInstalledFallback = (left, right) => {
+ const nameCompare = compareInstalledPluginNames(left.plugin, right.plugin);
+ return nameCompare !== 0 ? nameCompare : left.index - right.index;
+ };
+
+ const compareInstalledUpdatePinning = (left, right) => {
+ const leftHasUpdate = left.plugin?.has_update ? 1 : 0;
+ const rightHasUpdate = right.plugin?.has_update ? 1 : 0;
+ return rightHasUpdate - leftHasUpdate;
+ };
+
const sortInstalledPlugins = (plugins) => {
return plugins
.map((plugin, index) => ({
@@ -434,19 +452,24 @@ export const useExtensionPage = () => {
installedAtTimestamp: getInstalledAtTimestamp(plugin),
}))
.sort((left, right) => {
- const fallbackNameCompare = compareInstalledPluginNames(
- left.plugin,
- right.plugin,
- );
- const fallbackResult =
- fallbackNameCompare !== 0 ? fallbackNameCompare : left.index - right.index;
+ if (
+ pinUpdatesOnTop.value &&
+ installedSortBy.value !== "update_status"
+ ) {
+ // Pinning updates is a primary grouping; the selected sort order still
+ // applies within the "has update" and "no update" groups below.
+ const pinCompare = compareInstalledUpdatePinning(left, right);
+ if (pinCompare !== 0) {
+ return pinCompare;
+ }
+ }
if (installedSortBy.value === "install_time") {
const leftTimestamp = left.installedAtTimestamp;
const rightTimestamp = right.installedAtTimestamp;
if (leftTimestamp == null && rightTimestamp == null) {
- return fallbackResult;
+ return compareInstalledFallback(left, right);
}
if (leftTimestamp == null) {
return 1;
@@ -459,7 +482,9 @@ export const useExtensionPage = () => {
installedSortOrder.value === "desc"
? rightTimestamp - leftTimestamp
: leftTimestamp - rightTimestamp;
- return timeDiff !== 0 ? timeDiff : fallbackResult;
+ return timeDiff !== 0
+ ? timeDiff
+ : compareInstalledFallback(left, right);
}
if (installedSortBy.value === "name") {
@@ -469,7 +494,7 @@ export const useExtensionPage = () => {
? -nameCompare
: nameCompare;
}
- return left.index - right.index;
+ return compareInstalledFallback(left, right);
}
if (installedSortBy.value === "author") {
@@ -482,20 +507,20 @@ export const useExtensionPage = () => {
? -authorCompare
: authorCompare;
}
- return fallbackResult;
+ return compareInstalledFallback(left, right);
}
if (installedSortBy.value === "update_status") {
- const leftHasUpdate = left.plugin?.has_update ? 1 : 0;
- const rightHasUpdate = right.plugin?.has_update ? 1 : 0;
const updateDiff =
installedSortOrder.value === "desc"
- ? rightHasUpdate - leftHasUpdate
- : leftHasUpdate - rightHasUpdate;
- return updateDiff !== 0 ? updateDiff : fallbackResult;
+ ? compareInstalledUpdatePinning(left, right)
+ : compareInstalledUpdatePinning(right, left);
+ return updateDiff !== 0
+ ? updateDiff
+ : compareInstalledFallback(left, right);
}
- return fallbackResult;
+ return compareInstalledFallback(left, right);
})
.map((item) => item.plugin);
};
@@ -636,9 +661,7 @@ export const useExtensionPage = () => {
const toggleShowReserved = () => {
showReserved.value = !showReserved.value;
// 保存到 localStorage
- if (typeof window !== "undefined" && window.localStorage) {
- localStorage.setItem("showReservedPlugins", showReserved.value.toString());
- }
+ writeBooleanPreference(SHOW_RESERVED_PLUGINS_STORAGE_KEY, showReserved.value);
};
const toast = (message, success) => {
@@ -1603,9 +1626,7 @@ export const useExtensionPage = () => {
// 监听显示模式变化并保存到 localStorage
watch(isListView, (newVal) => {
- if (typeof window !== "undefined" && window.localStorage) {
- localStorage.setItem("pluginListViewMode", String(newVal));
- }
+ writeBooleanPreference(PLUGIN_LIST_VIEW_MODE_STORAGE_KEY, newVal);
});
watch(
@@ -1695,6 +1716,7 @@ export const useExtensionPage = () => {
installedStatusFilter,
installedSortBy,
installedSortOrder,
+ pinUpdatesOnTop,
loading_,
currentPage,
marketCategoryFilter,
diff --git a/dashboard/tests/extensionPreferenceStorage.test.mjs b/dashboard/tests/extensionPreferenceStorage.test.mjs
new file mode 100644
index 000000000..e864f5fae
--- /dev/null
+++ b/dashboard/tests/extensionPreferenceStorage.test.mjs
@@ -0,0 +1,75 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+
+import {
+ PIN_UPDATES_ON_TOP_STORAGE_KEY,
+ readBooleanPreference,
+ writeBooleanPreference,
+} from '../src/views/extension/extensionPreferenceStorage.mjs';
+
+test("readBooleanPreference returns fallback when storage access throws", () => {
+ const storage = {
+ getItem() {
+ throw new Error("SecurityError");
+ },
+ };
+
+ assert.equal(
+ readBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, true, storage),
+ true,
+ );
+});
+
+test("readBooleanPreference parses stored boolean strings", () => {
+ const storage = {
+ getItem(key) {
+ return key === PIN_UPDATES_ON_TOP_STORAGE_KEY ? "false" : null;
+ },
+ };
+
+ assert.equal(
+ readBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, true, storage),
+ false,
+ );
+});
+
+test("readBooleanPreference treats explicit null storage as unavailable", () => {
+ assert.equal(
+ readBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, true, null),
+ true,
+ );
+});
+
+test("readBooleanPreference treats invalid storage overrides as unavailable", () => {
+ assert.equal(
+ readBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, true, {}),
+ true,
+ );
+});
+
+test("writeBooleanPreference stores boolean strings and swallows storage errors", () => {
+ const writes = [];
+ const storage = {
+ setItem(key, value) {
+ writes.push([key, value]);
+ throw new Error("QuotaExceededError");
+ },
+ };
+
+ assert.doesNotThrow(() =>
+ writeBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, true, storage),
+ );
+ assert.deepEqual(writes, [[PIN_UPDATES_ON_TOP_STORAGE_KEY, "true"]]);
+});
+
+test("writeBooleanPreference ignores explicit null storage", () => {
+ assert.doesNotThrow(() =>
+ writeBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, true, null),
+ );
+});
+
+test("writeBooleanPreference ignores invalid storage overrides", () => {
+ assert.doesNotThrow(() =>
+ writeBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, true, {}),
+ );
+});
From 406bb6c1a768b93c63a9bc89c4f4cf3ddf3c853f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8D=83=E5=B2=9A=E4=B9=8B=E5=A4=8F?=
<108566281+Blueteemo@users.noreply.github.com>
Date: Tue, 21 Apr 2026 08:24:18 +0800
Subject: [PATCH 12/24] fix: warn instead of blocking when configured model not
in hardcoded list (#7692)
* fix: change highspeed model block to warning instead of ValueError
* fix: add highspeed models + use astrbot logger (per AI review)
* style: fix ruff format (line-length, import grouping)
---
.../provider/sources/minimax_token_plan_source.py | 14 +++++++++++---
1 file changed, 11 insertions(+), 3 deletions(-)
diff --git a/astrbot/core/provider/sources/minimax_token_plan_source.py b/astrbot/core/provider/sources/minimax_token_plan_source.py
index 5a578424f..d226707fd 100644
--- a/astrbot/core/provider/sources/minimax_token_plan_source.py
+++ b/astrbot/core/provider/sources/minimax_token_plan_source.py
@@ -1,11 +1,15 @@
+from astrbot import logger
from astrbot.core.provider.sources.anthropic_source import ProviderAnthropic
from ..register import register_provider_adapter
MINIMAX_TOKEN_PLAN_MODELS = [
"MiniMax-M2.7",
+ "MiniMax-M2.7-highspeed",
"MiniMax-M2.5",
+ "MiniMax-M2.5-highspeed",
"MiniMax-M2.1",
+ "MiniMax-M2.1-highspeed",
"MiniMax-M2",
]
@@ -43,9 +47,13 @@ class ProviderMiniMaxTokenPlan(ProviderAnthropic):
configured_model = provider_config.get("model", "MiniMax-M2.7")
if configured_model not in MINIMAX_TOKEN_PLAN_MODELS:
- raise ValueError(
- f"Unsupported model: {configured_model!r}. "
- f"Supported models: {', '.join(MINIMAX_TOKEN_PLAN_MODELS)}"
+ logger.warning(
+ f"Configured model {configured_model!r} is not in the known "
+ f"Token Plan model list "
+ f"({', '.join(MINIMAX_TOKEN_PLAN_MODELS)}). "
+ f"The model may still work if your plan supports it. "
+ f"If you encounter errors, please check your plan's "
+ f"model availability."
)
self.set_model(configured_model)
From 08392c9184253154f7f08c13dd4226366e5e58d1 Mon Sep 17 00:00:00 2001
From: hjdhnx <49803097+hjdhnx@users.noreply.github.com>
Date: Tue, 21 Apr 2026 10:31:26 +0800
Subject: [PATCH 13/24] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BA=86?=
=?UTF-8?q?=E5=9B=BD=E5=86=85=E9=85=8D=E7=BD=AE=E4=B8=80=E4=BA=9B=E6=A8=A1?=
=?UTF-8?q?=E5=9E=8B=E4=B8=8D=E5=8F=AF=E7=94=A8=E9=97=AE=E9=A2=98=20(#7685?=
=?UTF-8?q?)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix: 修复了国内配置一些模型不可用问题
1. 常见的openai和anthropic协议,如 智谱的codingpan
https://open.bigmodel.cn/api/coding/paas/v4
2. 新出的一些没有模型列表的自定义模型提供商,如科大讯飞
https://maas-coding-api.cn-huabei-1.xf-yun.com/v2
* feat: 提高代码复用性
* fix(network): reuse shared SSL context
* test(network): cover proxy and header forwarding
* fix(network): support verify overrides
---------
Co-authored-by: Taois
Co-authored-by: 邹永赫 <1259085392@qq.com>
---
.../core/provider/sources/anthropic_source.py | 17 +++----
.../core/provider/sources/openai_source.py | 2 +-
astrbot/core/utils/network_utils.py | 22 ++++++--
tests/unit/test_network_utils.py | 51 +++++++++++++++++++
4 files changed, 78 insertions(+), 14 deletions(-)
create mode 100644 tests/unit/test_network_utils.py
diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py
index 764457759..d2fce17de 100644
--- a/astrbot/core/provider/sources/anthropic_source.py
+++ b/astrbot/core/provider/sources/anthropic_source.py
@@ -18,6 +18,7 @@ from astrbot.core.provider.entities import LLMResponse, TokenUsage
from astrbot.core.provider.func_tool_manager import ToolSet
from astrbot.core.utils.io import download_image_by_url
from astrbot.core.utils.network_utils import (
+ create_proxy_client,
is_connection_error,
log_connection_failure,
)
@@ -106,15 +107,13 @@ class ProviderAnthropic(Provider):
http_client=self._create_http_client(provider_config),
)
- def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient | None:
- """创建带代理的 HTTP 客户端"""
- proxy = provider_config.get("proxy", "")
- if proxy:
- logger.info(f"[Anthropic] 使用代理: {proxy}")
- return httpx.AsyncClient(proxy=proxy, headers=self.custom_headers)
- if self.custom_headers:
- return httpx.AsyncClient(headers=self.custom_headers)
- return None
+ def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient:
+ """创建带代理的 HTTP 客户端,使用系统 SSL 证书"""
+ return create_proxy_client(
+ "Anthropic",
+ provider_config.get("proxy", ""),
+ headers=self.custom_headers,
+ )
def _apply_thinking_config(self, payloads: dict) -> None:
thinking_type = self.thinking_config.get("type", "")
diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py
index b24bc0885..67971a2a9 100644
--- a/astrbot/core/provider/sources/openai_source.py
+++ b/astrbot/core/provider/sources/openai_source.py
@@ -438,7 +438,7 @@ class ProviderOpenAIOfficial(Provider):
image_fallback_used,
)
- def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient | None:
+ def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient:
"""创建带代理的 HTTP 客户端"""
proxy = provider_config.get("proxy", "")
return create_proxy_client("OpenAI", proxy)
diff --git a/astrbot/core/utils/network_utils.py b/astrbot/core/utils/network_utils.py
index 727f3762a..047529396 100644
--- a/astrbot/core/utils/network_utils.py
+++ b/astrbot/core/utils/network_utils.py
@@ -1,9 +1,13 @@
"""Network error handling utilities for providers."""
+import ssl
+
import httpx
from astrbot import logger
+_SYSTEM_SSL_CTX = ssl.create_default_context()
+
def is_connection_error(exc: BaseException) -> bool:
"""Check if an exception is a connection/network related error.
@@ -83,20 +87,30 @@ def log_connection_failure(
def create_proxy_client(
provider_label: str,
proxy: str | None = None,
-) -> httpx.AsyncClient | None:
+ headers: dict[str, str] | None = None,
+ verify: ssl.SSLContext | str | bool | None = None,
+) -> httpx.AsyncClient:
"""Create an httpx AsyncClient with proxy configuration if provided.
+ Uses the system SSL certificate store instead of certifi, which avoids
+ SSL verification failures for endpoints whose CA chain is not in certifi
+ but is trusted by the operating system.
+
Note: The caller is responsible for closing the client when done.
Consider using the client as a context manager or calling aclose() explicitly.
Args:
provider_label: The provider name for log prefix (e.g., "OpenAI", "Gemini")
proxy: The proxy address (e.g., "http://127.0.0.1:7890"), or None/empty
+ headers: Optional custom headers to include in every request
+ verify: Optional override for TLS verification. Defaults to the shared
+ system SSL context when not provided.
Returns:
- An httpx.AsyncClient configured with the proxy, or None if no proxy
+ An httpx.AsyncClient created with the shared system SSL context; the proxy is applied only if one is provided.
"""
+ resolved_verify = _SYSTEM_SSL_CTX if verify is None else verify
if proxy:
logger.info(f"[{provider_label}] 使用代理: {proxy}")
- return httpx.AsyncClient(proxy=proxy)
- return None
+ return httpx.AsyncClient(proxy=proxy, verify=resolved_verify, headers=headers)
+ return httpx.AsyncClient(verify=resolved_verify, headers=headers)
diff --git a/tests/unit/test_network_utils.py b/tests/unit/test_network_utils.py
new file mode 100644
index 000000000..ea3505e38
--- /dev/null
+++ b/tests/unit/test_network_utils.py
@@ -0,0 +1,51 @@
+import ssl
+
+import pytest
+
+from astrbot.core.utils import network_utils
+
+
+def test_create_proxy_client_reuses_shared_ssl_context(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ captured_calls: list[dict] = []
+ headers = {"X-Test-Header": "value"}
+
+ class _FakeAsyncClient:
+ def __init__(self, **kwargs):
+ captured_calls.append(kwargs)
+
+ monkeypatch.setattr(network_utils.httpx, "AsyncClient", _FakeAsyncClient)
+
+ network_utils.create_proxy_client("OpenAI")
+ network_utils.create_proxy_client("OpenAI", proxy="http://127.0.0.1:7890")
+ network_utils.create_proxy_client("OpenAI", headers=headers)
+ network_utils.create_proxy_client("OpenAI", proxy="")
+
+ assert len(captured_calls) == 4
+ assert "proxy" not in captured_calls[0]
+ assert captured_calls[1]["proxy"] == "http://127.0.0.1:7890"
+ assert captured_calls[2]["headers"] is headers
+ assert "proxy" not in captured_calls[3]
+ assert isinstance(captured_calls[0]["verify"], ssl.SSLContext)
+ assert captured_calls[0]["verify"] is captured_calls[1]["verify"]
+ assert captured_calls[1]["verify"] is captured_calls[2]["verify"]
+ assert captured_calls[2]["verify"] is captured_calls[3]["verify"]
+
+
+def test_create_proxy_client_allows_verify_override(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ captured_calls: list[dict] = []
+ custom_verify = ssl.create_default_context()
+
+ class _FakeAsyncClient:
+ def __init__(self, **kwargs):
+ captured_calls.append(kwargs)
+
+ monkeypatch.setattr(network_utils.httpx, "AsyncClient", _FakeAsyncClient)
+
+ network_utils.create_proxy_client("OpenAI", verify=custom_verify)
+
+ assert len(captured_calls) == 1
+ assert captured_calls[0]["verify"] is custom_verify
From d9ab35348ed85e8d1461e0ef7c60fd200526effd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?C=E2=82=82=E2=82=82H=E2=82=82=E2=82=85NO=E2=82=86?=
<96930391+Sisyphbaous-DT-Project@users.noreply.github.com>
Date: Tue, 21 Apr 2026 22:27:40 +0800
Subject: [PATCH 14/24] fix: drop legacy documents_fts table if exists (#7706)
* fix: recover FTS5 index from legacy documents_fts table
* fix: normalize SQL whitespace when checking contentless_delete
---
.../db/vec_db/faiss_impl/document_storage.py | 110 +++++++++++++-----
tests/unit/test_document_storage_fts.py | 28 +++++
2 files changed, 111 insertions(+), 27 deletions(-)
diff --git a/astrbot/core/db/vec_db/faiss_impl/document_storage.py b/astrbot/core/db/vec_db/faiss_impl/document_storage.py
index 116c43be1..d0310d750 100644
--- a/astrbot/core/db/vec_db/faiss_impl/document_storage.py
+++ b/astrbot/core/db/vec_db/faiss_impl/document_storage.py
@@ -96,36 +96,29 @@ class DocumentStorage:
async def _initialize_fts5(self, executor) -> None:
try:
- try:
- await executor.execute(
- text(
- f"""
- CREATE VIRTUAL TABLE IF NOT EXISTS {FTS_TABLE_NAME}
- USING fts5(
- search_text,
- content='',
- contentless_delete=1,
- tokenize='unicode61'
- )
- """,
- ),
+ await self._create_fts5_table(executor, if_not_exists=True)
+
+ is_valid_fts5, has_contentless_delete = await self._inspect_fts5_table(
+ executor,
+ )
+ if not is_valid_fts5:
+ logger.warning(
+ f"Detected incompatible legacy table `{FTS_TABLE_NAME}` in "
+ f"{self.db_path}; recreating FTS5 table.",
)
- self._fts_contentless_delete = True
- except Exception:
- await executor.execute(
- text(
- f"""
- CREATE VIRTUAL TABLE IF NOT EXISTS {FTS_TABLE_NAME}
- USING fts5(
- search_text,
- content='',
- tokenize='unicode61'
- )
- """,
- ),
+ await executor.execute(text(f"DROP TABLE IF EXISTS {FTS_TABLE_NAME}"))
+ await self._create_fts5_table(executor, if_not_exists=False)
+
+ is_valid_fts5, has_contentless_delete = await self._inspect_fts5_table(
+ executor,
)
- self._fts_contentless_delete = False
+ if not is_valid_fts5:
+ raise RuntimeError(
+ f"Failed to create a valid FTS5 table `{FTS_TABLE_NAME}`",
+ )
+
self.fts5_available = True
+ self._fts_contentless_delete = has_contentless_delete
except Exception as e:
self.fts5_available = False
self._fts_contentless_delete = False
@@ -134,6 +127,69 @@ class DocumentStorage:
f"falling back to in-memory BM25 sparse retrieval: {e}",
)
+ async def _create_fts5_table(self, executor, if_not_exists: bool) -> None:
+ create_clause = (
+ "CREATE VIRTUAL TABLE IF NOT EXISTS"
+ if if_not_exists
+ else "CREATE VIRTUAL TABLE"
+ )
+ try:
+ await executor.execute(
+ text(
+ f"""
+ {create_clause} {FTS_TABLE_NAME}
+ USING fts5(
+ search_text,
+ content='',
+ contentless_delete=1,
+ tokenize='unicode61'
+ )
+ """,
+ ),
+ )
+ except Exception:
+ await executor.execute(
+ text(
+ f"""
+ {create_clause} {FTS_TABLE_NAME}
+ USING fts5(
+ search_text,
+ content='',
+ tokenize='unicode61'
+ )
+ """,
+ ),
+ )
+
+ async def _inspect_fts5_table(self, executor) -> tuple[bool, bool]:
+ schema_result = await executor.execute(
+ text(
+ """
+ SELECT sql
+ FROM sqlite_master
+ WHERE type='table' AND name=:table_name
+ """,
+ ),
+ {"table_name": FTS_TABLE_NAME},
+ )
+ create_sql = schema_result.scalar_one_or_none()
+ if not create_sql:
+ return False, False
+
+ normalized_sql = create_sql.lower()
+ if "virtual table" not in normalized_sql or "using fts5" not in normalized_sql:
+ return False, False
+
+ pragma_result = await executor.execute(
+ text(f"PRAGMA table_info({FTS_TABLE_NAME})"),
+ )
+ columns = {row[1] for row in pragma_result.fetchall()}
+ if "search_text" not in columns:
+ return False, False
+
+ normalized_sql_no_whitespace = "".join(normalized_sql.split())
+ return True, "contentless_delete=1" in normalized_sql_no_whitespace
+
async def connect(self) -> None:
"""Connect to the SQLite database."""
if self.engine is None:
diff --git a/tests/unit/test_document_storage_fts.py b/tests/unit/test_document_storage_fts.py
index 753c371ef..a7dd32c94 100644
--- a/tests/unit/test_document_storage_fts.py
+++ b/tests/unit/test_document_storage_fts.py
@@ -1,3 +1,5 @@
+import sqlite3
+
import pytest
from astrbot.core.db.vec_db.faiss_impl.document_storage import DocumentStorage
@@ -73,3 +75,29 @@ async def test_document_storage_fts_delete_skips_missing_fts_row(tmp_path):
assert await storage.get_document_by_doc_id("legacy-chunk") is None
await storage.close()
+
+
+@pytest.mark.asyncio
+async def test_document_storage_fts_recovers_from_legacy_non_fts_table(tmp_path):
+ db_path = tmp_path / "doc.db"
+ conn = sqlite3.connect(db_path)
+ conn.execute("CREATE TABLE documents_fts (rowid INTEGER PRIMARY KEY)")
+ conn.commit()
+ conn.close()
+
+ storage = DocumentStorage(str(db_path))
+ await storage.initialize()
+
+ assert storage.fts5_available is True
+
+ await storage.insert_document(
+ doc_id="legacy-fix",
+ text="legacy fts recovery text",
+ metadata={"kb_doc_id": "doc-1", "kb_id": "kb-1", "chunk_index": 0},
+ )
+ results = await storage.search_sparse(["legacy"], limit=10)
+
+ assert results is not None
+ assert [result["doc_id"] for result in results] == ["legacy-fix"]
+
+ await storage.close()
From 03bbf0bf5ac23bd9e5bc625cb2101a23106ce317 Mon Sep 17 00:00:00 2001
From: Soulter <37870767+Soulter@users.noreply.github.com>
Date: Tue, 21 Apr 2026 22:41:31 +0800
Subject: [PATCH 15/24] feat: re-establishing /provider as a built-in command
(#7691)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat: re-establishing /provider as a built-in command
* style: format provider command
---------
Co-authored-by: 邹永赫 <1259085392@qq.com>
---
.../builtin_commands/commands/__init__.py | 2 +
.../builtin_commands/commands/provider.py | 248 ++++++++++++++++++
.../builtin_stars/builtin_commands/main.py | 13 +
3 files changed, 263 insertions(+)
create mode 100644 astrbot/builtin_stars/builtin_commands/commands/provider.py
diff --git a/astrbot/builtin_stars/builtin_commands/commands/__init__.py b/astrbot/builtin_stars/builtin_commands/commands/__init__.py
index 552ac4a8a..45447ec9c 100644
--- a/astrbot/builtin_stars/builtin_commands/commands/__init__.py
+++ b/astrbot/builtin_stars/builtin_commands/commands/__init__.py
@@ -3,6 +3,7 @@
from .admin import AdminCommands
from .conversation import ConversationCommands
from .help import HelpCommand
+from .provider import ProviderCommands
from .setunset import SetUnsetCommands
from .sid import SIDCommand
@@ -10,6 +11,7 @@ __all__ = [
"AdminCommands",
"ConversationCommands",
"HelpCommand",
+ "ProviderCommands",
"SetUnsetCommands",
"SIDCommand",
]
diff --git a/astrbot/builtin_stars/builtin_commands/commands/provider.py b/astrbot/builtin_stars/builtin_commands/commands/provider.py
new file mode 100644
index 000000000..971d6ca8a
--- /dev/null
+++ b/astrbot/builtin_stars/builtin_commands/commands/provider.py
@@ -0,0 +1,248 @@
+from __future__ import annotations
+
+import asyncio
+
+from astrbot import logger
+from astrbot.api import star
+from astrbot.api.event import AstrMessageEvent, MessageEventResult
+from astrbot.core.provider.entities import ProviderType
+from astrbot.core.utils.error_redaction import safe_error
+
+
+class ProviderCommands:
+ def __init__(self, context: star.Context) -> None:
+ self.context = context
+
+ def _log_reachability_failure(
+ self,
+ provider,
+ provider_capability_type: ProviderType | None,
+ err_code: str,
+ err_reason: str,
+ ) -> None:
+ meta = provider.meta()
+ logger.warning(
+ "Provider reachability check failed: id=%s type=%s code=%s reason=%s",
+ meta.id,
+ provider_capability_type.name if provider_capability_type else "unknown",
+ err_code,
+ err_reason,
+ )
+
+ async def _test_provider_capability(self, provider):
+ meta = provider.meta()
+ provider_capability_type = meta.provider_type
+
+ try:
+ await provider.test()
+ return True, None, None
+ except Exception as e:
+ err_code = "TEST_FAILED"
+ err_reason = safe_error("", e)
+ self._log_reachability_failure(
+ provider, provider_capability_type, err_code, err_reason
+ )
+ return False, err_code, err_reason
+
+ async def _build_provider_display_data(
+ self,
+ providers,
+ provider_type: str,
+ reachability_check_enabled: bool,
+ ) -> list[dict]:
+ if not providers:
+ return []
+
+ if reachability_check_enabled:
+ check_results = await asyncio.gather(
+ *[self._test_provider_capability(provider) for provider in providers],
+ return_exceptions=True,
+ )
+ else:
+ check_results = [None for _ in providers]
+
+ display_data = []
+ for provider, reachable in zip(providers, check_results):
+ meta = provider.meta()
+ id_ = meta.id
+ error_code = None
+
+ if isinstance(reachable, asyncio.CancelledError):
+ raise reachable
+ if isinstance(reachable, Exception):
+ self._log_reachability_failure(
+ provider,
+ None,
+ reachable.__class__.__name__,
+ safe_error("", reachable),
+ )
+ reachable_flag = False
+ error_code = reachable.__class__.__name__
+ elif isinstance(reachable, tuple):
+ reachable_flag, error_code, _ = reachable
+ else:
+ reachable_flag = reachable
+
+ if provider_type == "llm":
+ info = f"{id_} ({meta.model})"
+ else:
+ info = f"{id_}"
+
+ if reachable_flag is True:
+ mark = " ✅"
+ elif reachable_flag is False:
+ if error_code:
+ mark = f" ❌(errcode: {error_code})"
+ else:
+ mark = " ❌"
+ else:
+ mark = ""
+
+ display_data.append(
+ {
+ "info": info,
+ "mark": mark,
+ "provider": provider,
+ }
+ )
+
+ return display_data
+
+ async def provider(
+ self,
+ event: AstrMessageEvent,
+ idx: str | int | None = None,
+ idx2: int | None = None,
+ ) -> None:
+ """查看或者切换 LLM Provider"""
+ umo = event.unified_msg_origin
+ cfg = self.context.get_config(umo).get("provider_settings", {})
+ reachability_check_enabled = cfg.get("reachability_check", True)
+
+ if idx is None:
+ parts = ["## LLM Providers\n"]
+
+ llms = list(self.context.get_all_providers())
+ ttss = self.context.get_all_tts_providers()
+ stts = self.context.get_all_stt_providers()
+
+ if reachability_check_enabled and (llms or ttss or stts):
+ await event.send(
+ MessageEventResult().message("👀 Testing provider reachability...")
+ )
+
+ llm_data, tts_data, stt_data = await asyncio.gather(
+ self._build_provider_display_data(
+ llms,
+ "llm",
+ reachability_check_enabled,
+ ),
+ self._build_provider_display_data(
+ ttss,
+ "tts",
+ reachability_check_enabled,
+ ),
+ self._build_provider_display_data(
+ stts,
+ "stt",
+ reachability_check_enabled,
+ ),
+ )
+
+ provider_using = self.context.get_using_provider(umo=umo)
+ for i, d in enumerate(llm_data):
+ line = f"{i + 1}. {d['info']}{d['mark']}"
+ if (
+ provider_using
+ and provider_using.meta().id == d["provider"].meta().id
+ ):
+ line += " 👈"
+ parts.append(line + "\n")
+
+ if tts_data:
+ parts.append("\n## TTS Providers\n")
+ tts_using = self.context.get_using_tts_provider(umo=umo)
+ for i, d in enumerate(tts_data):
+ line = f"{i + 1}. {d['info']}{d['mark']}"
+ if tts_using and tts_using.meta().id == d["provider"].meta().id:
+ line += " 👈"
+ parts.append(line + "\n")
+
+ if stt_data:
+ parts.append("\n## STT Providers\n")
+ stt_using = self.context.get_using_stt_provider(umo=umo)
+ for i, d in enumerate(stt_data):
+ line = f"{i + 1}. {d['info']}{d['mark']}"
+ if stt_using and stt_using.meta().id == d["provider"].meta().id:
+ line += " 👈"
+ parts.append(line + "\n")
+
+ parts.append("\nUse /provider to switch LLM providers.")
+ ret = "".join(parts)
+
+ if ttss:
+ ret += "\nUse /provider tts to switch TTS providers."
+ if stts:
+ ret += "\nUse /provider stt to switch STT providers."
+
+ event.set_result(MessageEventResult().message(ret))
+ elif idx == "tts":
+ if idx2 is None:
+ event.set_result(
+ MessageEventResult().message("Please enter the index.")
+ )
+ return
+ if idx2 > len(self.context.get_all_tts_providers()) or idx2 < 1:
+ event.set_result(
+ MessageEventResult().message("❌ Invalid provider index.")
+ )
+ return
+ provider = self.context.get_all_tts_providers()[idx2 - 1]
+ id_ = provider.meta().id
+ await self.context.provider_manager.set_provider(
+ provider_id=id_,
+ provider_type=ProviderType.TEXT_TO_SPEECH,
+ umo=umo,
+ )
+ event.set_result(
+ MessageEventResult().message(f"✅ Successfully switched to {id_}.")
+ )
+ elif idx == "stt":
+ if idx2 is None:
+ event.set_result(
+ MessageEventResult().message("Please enter the index.")
+ )
+ return
+ if idx2 > len(self.context.get_all_stt_providers()) or idx2 < 1:
+ event.set_result(
+ MessageEventResult().message("❌ Invalid provider index.")
+ )
+ return
+ provider = self.context.get_all_stt_providers()[idx2 - 1]
+ id_ = provider.meta().id
+ await self.context.provider_manager.set_provider(
+ provider_id=id_,
+ provider_type=ProviderType.SPEECH_TO_TEXT,
+ umo=umo,
+ )
+ event.set_result(
+ MessageEventResult().message(f"✅ Successfully switched to {id_}.")
+ )
+ elif isinstance(idx, int):
+ if idx > len(self.context.get_all_providers()) or idx < 1:
+ event.set_result(
+ MessageEventResult().message("❌ Invalid provider index.")
+ )
+ return
+ provider = self.context.get_all_providers()[idx - 1]
+ id_ = provider.meta().id
+ await self.context.provider_manager.set_provider(
+ provider_id=id_,
+ provider_type=ProviderType.CHAT_COMPLETION,
+ umo=umo,
+ )
+ event.set_result(
+ MessageEventResult().message(f"✅ Successfully switched to {id_}.")
+ )
+ else:
+ event.set_result(MessageEventResult().message("❌ Invalid parameter."))
diff --git a/astrbot/builtin_stars/builtin_commands/main.py b/astrbot/builtin_stars/builtin_commands/main.py
index 8e34f582d..f2e5e26d5 100644
--- a/astrbot/builtin_stars/builtin_commands/main.py
+++ b/astrbot/builtin_stars/builtin_commands/main.py
@@ -5,6 +5,7 @@ from .commands import (
AdminCommands,
ConversationCommands,
HelpCommand,
+ ProviderCommands,
SetUnsetCommands,
SIDCommand,
)
@@ -17,6 +18,7 @@ class Main(star.Star):
self.admin_c = AdminCommands(self.context)
self.conversation_c = ConversationCommands(self.context)
self.help_c = HelpCommand(self.context)
+ self.provider_c = ProviderCommands(self.context)
self.setunset_c = SetUnsetCommands(self.context)
self.sid_c = SIDCommand(self.context)
@@ -45,6 +47,17 @@ class Main(star.Star):
"""Create new conversation"""
await self.conversation_c.new_conv(message)
+ @filter.permission_type(filter.PermissionType.ADMIN)
+ @filter.command("provider")
+ async def provider(
+ self,
+ event: AstrMessageEvent,
+ idx: str | int | None = None,
+ idx2: int | None = None,
+ ) -> None:
+ """View or switch LLM Provider"""
+ await self.provider_c.provider(event, idx, idx2)
+
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("dashboard_update")
async def update_dashboard(self, event: AstrMessageEvent) -> None:
From 6b756f666fa803909cc298863c8095062e0a18f2 Mon Sep 17 00:00:00 2001
From: Rain-0x01_ <83620631+Rain-0x01-39@users.noreply.github.com>
Date: Tue, 21 Apr 2026 22:42:27 +0800
Subject: [PATCH 16/24] docs: Unify documentation links (#7709)
astrbot.app -> docs.astrbot.app
---
astrbot/cli/commands/cmd_plug.py | 2 +-
astrbot/core/star/config.py | 2 +-
astrbot/core/utils/t2i/renderer.py | 2 +-
.../src/components/config/AstrBotCoreConfigWrapper.vue | 2 +-
.../src/layouts/full/vertical-sidebar/VerticalSidebar.vue | 6 +++---
dashboard/src/views/SessionManagementPage.vue | 2 +-
dashboard/src/views/alkaid/KnowledgeBase.vue | 4 ++--
dashboard/src/views/knowledge-base/KBList.vue | 2 +-
8 files changed, 11 insertions(+), 11 deletions(-)
diff --git a/astrbot/cli/commands/cmd_plug.py b/astrbot/cli/commands/cmd_plug.py
index 46057fc6b..462c8e8b9 100644
--- a/astrbot/cli/commands/cmd_plug.py
+++ b/astrbot/cli/commands/cmd_plug.py
@@ -84,7 +84,7 @@ def new(name: str) -> None:
# Rewrite README.md
with open(plug_path / "README.md", "w", encoding="utf-8") as f:
f.write(
- f"# {name}\n\n{desc}\n\n# Support\n\n[Documentation](https://astrbot.app)\n"
+ f"# {name}\n\n{desc}\n\n# Support\n\n[Documentation](https://docs.astrbot.app)\n"
)
# Rewrite main.py
diff --git a/astrbot/core/star/config.py b/astrbot/core/star/config.py
index 429a05d5e..8b2ba762b 100644
--- a/astrbot/core/star/config.py
+++ b/astrbot/core/star/config.py
@@ -1,4 +1,4 @@
-"""此功能已过时,参考 https://astrbot.app/dev/plugin.html#%E6%B3%A8%E5%86%8C%E6%8F%92%E4%BB%B6%E9%85%8D%E7%BD%AE-beta"""
+"""此功能已过时,参考 https://docs.astrbot.app/dev/plugin.html#%E6%B3%A8%E5%86%8C%E6%8F%92%E4%BB%B6%E9%85%8D%E7%BD%AE-beta"""
import json
import os
diff --git a/astrbot/core/utils/t2i/renderer.py b/astrbot/core/utils/t2i/renderer.py
index e3118d7e8..995c3d244 100644
--- a/astrbot/core/utils/t2i/renderer.py
+++ b/astrbot/core/utils/t2i/renderer.py
@@ -28,7 +28,7 @@ class HtmlRenderer:
@return: 图片 URL 或者文件路径,取决于 return_url 参数。
- @example: 参见 https://astrbot.app 插件开发部分。
+ @example: 参见 https://docs.astrbot.app 插件开发部分。
"""
return await self.network_strategy.render_custom_template(
tmpl_str,
diff --git a/dashboard/src/components/config/AstrBotCoreConfigWrapper.vue b/dashboard/src/components/config/AstrBotCoreConfigWrapper.vue
index 88029a25a..b485a783f 100644
--- a/dashboard/src/components/config/AstrBotCoreConfigWrapper.vue
+++ b/dashboard/src/components/config/AstrBotCoreConfigWrapper.vue
@@ -26,7 +26,7 @@
{{ tm('help.helpPrefix') }}
- {{ tm('help.documentation') }}
+ {{ tm('help.documentation') }}
{{ tm('help.helpMiddle') }}
{{ tm('help.support') }}{{ tm('help.helpSuffix') }}
diff --git a/dashboard/src/layouts/full/vertical-sidebar/VerticalSidebar.vue b/dashboard/src/layouts/full/vertical-sidebar/VerticalSidebar.vue
index e2636d211..a3c1da81e 100644
--- a/dashboard/src/layouts/full/vertical-sidebar/VerticalSidebar.vue
+++ b/dashboard/src/layouts/full/vertical-sidebar/VerticalSidebar.vue
@@ -145,7 +145,7 @@ function toggleIframe() {
function openIframeLink(url) {
if (typeof window !== 'undefined') {
- let url_ = url || "https://astrbot.app";
+ let url_ = url || "https://docs.astrbot.app";
window.open(url_, "_blank");
}
}
@@ -352,7 +352,7 @@ function openChangelogDialog() {
@@ -369,7 +369,7 @@ function openChangelogDialog() {
diff --git a/dashboard/src/views/SessionManagementPage.vue b/dashboard/src/views/SessionManagementPage.vue
index 1400f4915..39d287403 100644
--- a/dashboard/src/views/SessionManagementPage.vue
+++ b/dashboard/src/views/SessionManagementPage.vue
@@ -4,7 +4,7 @@
{{ tm('customRules.title') }}
-
+
{{ totalItems }} {{ tm('customRules.rulesCount') }}
{{ tm('notInstalled.title') }}
mdi-information-outline
+ @click="openUrl('https://docs.astrbot.app/use/knowledge-base.html')">mdi-information-outline
@@ -31,7 +31,7 @@
{{ tm('list.title') }}
mdi-information-outline
+ @click="openUrl('https://docs.astrbot.app/use/knowledge-base.html')">mdi-information-outline
diff --git a/dashboard/src/views/knowledge-base/KBList.vue b/dashboard/src/views/knowledge-base/KBList.vue
index 9428c7ff9..54fc24512 100644
--- a/dashboard/src/views/knowledge-base/KBList.vue
+++ b/dashboard/src/views/knowledge-base/KBList.vue
@@ -7,7 +7,7 @@
{{ t('list.subtitle') }}
+ href="https://docs.astrbot.app/use/knowledge-base.html" target="_blank" />
From 7778d8bb63567fd1bbff11578dc057528c9a90b7 Mon Sep 17 00:00:00 2001
From: Sebastion
Date: Tue, 21 Apr 2026 15:52:34 +0100
Subject: [PATCH 17/24] fix: prevent path traversal in backup importer (CWE-22)
(#7681)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix: prevent path traversal in backup importer (CWE-22)
Validate that all file write targets resolve within their expected
base directories before writing. This prevents crafted backup ZIP
files from writing to arbitrary filesystem locations via malicious
path values in attachment records, media file paths, or directory
entries.
* fix: use Path.is_relative_to for robust path containment check
* fix: add explicit strict=False to Path.resolve() calls
* style: format backup importer
---------
Co-authored-by: 邹永赫 <1259085392@qq.com>
---
astrbot/core/backup/importer.py | 27 +++++++++++++++++++++++++++
1 file changed, 27 insertions(+)
diff --git a/astrbot/core/backup/importer.py b/astrbot/core/backup/importer.py
index b51c7d956..e994242a8 100644
--- a/astrbot/core/backup/importer.py
+++ b/astrbot/core/backup/importer.py
@@ -59,6 +59,20 @@ def _get_major_version(version_str: str) -> str:
return "0.0"
+def _validate_path_within(target_path: Path, base_dir: Path) -> bool:
+ """Validate that target_path is within base_dir after resolving symlinks.
+
+ Prevents path traversal attacks (CWE-22) by ensuring the resolved
+ target path is relative to the resolved base directory.
+ """
+ try:
+ resolved = target_path.resolve(strict=False)
+ base_resolved = base_dir.resolve(strict=False)
+ return resolved.is_relative_to(base_resolved)
+ except (OSError, ValueError):
+ return False
+
+
CMD_CONFIG_FILE_PATH = os.path.join(get_astrbot_data_path(), "cmd_config.json")
KB_PATH = get_astrbot_knowledge_base_path()
DEFAULT_PLATFORM_STATS_INVALID_COUNT_WARN_LIMIT = 5
@@ -765,6 +779,10 @@ class AstrBotImporter:
try:
rel_path = name[len(media_prefix) :]
target_path = kb_dir / rel_path
+ # Validate path is within kb directory (CWE-22)
+ if not _validate_path_within(target_path, kb_dir):
+ logger.warning(f"媒体文件路径越界,已跳过: {target_path}")
+ continue
target_path.parent.mkdir(parents=True, exist_ok=True)
with zf.open(name) as src, open(target_path, "wb") as dst:
dst.write(src.read())
@@ -827,6 +845,11 @@ class AstrBotImporter:
else:
target_path = attachments_dir / os.path.basename(name)
+ # Validate path is within attachments directory (CWE-22)
+ if not _validate_path_within(target_path, attachments_dir):
+ logger.warning(f"附件路径越界,已跳过: {target_path}")
+ continue
+
target_path.parent.mkdir(parents=True, exist_ok=True)
with zf.open(name) as src, open(target_path, "wb") as dst:
dst.write(src.read())
@@ -904,6 +927,10 @@ class AstrBotImporter:
continue
target_path = target_dir / rel_path
+ # Validate path is within target directory (CWE-22)
+ if not _validate_path_within(target_path, target_dir):
+ result.add_warning(f"文件路径越界,已跳过: {name}")
+ continue
target_path.parent.mkdir(parents=True, exist_ok=True)
with zf.open(name) as src, open(target_path, "wb") as dst:
From 17ace9b5dbe6ff58622f964f36f446420f635b14 Mon Sep 17 00:00:00 2001
From: SaintaToken
Date: Tue, 21 Apr 2026 23:32:00 +0800
Subject: [PATCH 18/24] feat: add buffered intermediate messages for
non-streaming agent loop (#7627)
* feat: add buffered intermediate messages for non-streaming agent loop
* Refactored buffering logic into helpers to reduce inline complexity.
* feat: add buffer_intermediate_messages configuration for merging Agent intermediate messages
---------
Co-authored-by: Soulter <905617992@qq.com>
---
astrbot/core/astr_agent_run_util.py | 67 ++++++++++++++++++-
astrbot/core/config/default.py | 13 ++++
.../method/agent_sub_stages/internal.py | 7 ++
.../en-US/features/config-metadata.json | 4 ++
.../ru-RU/features/config-metadata.json | 4 ++
.../zh-CN/features/config-metadata.json | 6 +-
6 files changed, 99 insertions(+), 2 deletions(-)
diff --git a/astrbot/core/astr_agent_run_util.py b/astrbot/core/astr_agent_run_util.py
index eca24699a..62c60a436 100644
--- a/astrbot/core/astr_agent_run_util.py
+++ b/astrbot/core/astr_agent_run_util.py
@@ -87,6 +87,31 @@ def _build_tool_result_status_message(
return status_msg
+def _should_buffer_llm_result(
+ buffer_intermediate_messages: bool,
+ stream_to_general: bool,
+ agent_runner: AgentRunner,
+) -> bool:
+ return (
+ buffer_intermediate_messages
+ and not stream_to_general
+ and not agent_runner.streaming
+ )
+
+
+def _merge_buffered_llm_chains(
+ buffered_llm_chains: list[MessageChain],
+) -> MessageChain | None:
+ if not buffered_llm_chains:
+ return None
+
+ merged_chain = MessageChain()
+ for chain in buffered_llm_chains:
+ merged_chain.chain.extend(chain.chain)
+ buffered_llm_chains.clear()
+ return merged_chain
+
+
async def run_agent(
agent_runner: AgentRunner,
max_step: int = 30,
@@ -94,10 +119,17 @@ async def run_agent(
show_tool_call_result: bool = False,
stream_to_general: bool = False,
show_reasoning: bool = False,
+ buffer_intermediate_messages: bool = False,
) -> AsyncGenerator[MessageChain | None, None]:
step_idx = 0
astr_event = agent_runner.run_context.context.event
tool_name_by_call_id: dict[str, str] = {}
+ buffered_llm_chains: list[MessageChain] = []
+ can_buffer_llm_result = _should_buffer_llm_result(
+ buffer_intermediate_messages,
+ stream_to_general,
+ agent_runner,
+ )
while step_idx < max_step + 1:
step_idx += 1
@@ -126,6 +158,17 @@ async def run_agent(
agent_runner.request_stop()
if resp.type == "aborted":
+ if can_buffer_llm_result:
+ merged_chain = _merge_buffered_llm_chains(buffered_llm_chains)
+ if merged_chain:
+ astr_event.set_result(
+ MessageEventResult(
+ chain=merged_chain.chain,
+ result_content_type=ResultContentType.LLM_RESULT,
+ ),
+ )
+ yield merged_chain
+ astr_event.clear_result()
if not stop_watcher.done():
stop_watcher.cancel()
try:
@@ -197,6 +240,10 @@ async def run_agent(
continue
if stream_to_general or not agent_runner.streaming:
+ if can_buffer_llm_result and resp.type == "llm_result":
+ buffered_llm_chains.append(resp.data["chain"])
+ continue
+
content_typ = (
ResultContentType.LLM_RESULT
if resp.type == "llm_result"
@@ -208,7 +255,7 @@ async def run_agent(
result_content_type=content_typ,
),
)
- yield
+ yield resp.data["chain"]
astr_event.clear_result()
elif resp.type == "streaming_delta":
chain = resp.data["chain"]
@@ -216,6 +263,19 @@ async def run_agent(
# display the reasoning content only when configured
continue
yield resp.data["chain"] # MessageChain
+
+ if can_buffer_llm_result and agent_runner.done():
+ merged_chain = _merge_buffered_llm_chains(buffered_llm_chains)
+ if merged_chain:
+ astr_event.set_result(
+ MessageEventResult(
+ chain=merged_chain.chain,
+ result_content_type=ResultContentType.LLM_RESULT,
+ ),
+ )
+ yield merged_chain
+ astr_event.clear_result()
+
if not stop_watcher.done():
stop_watcher.cancel()
try:
@@ -288,6 +348,7 @@ async def run_live_agent(
show_tool_use: bool = True,
show_tool_call_result: bool = False,
show_reasoning: bool = False,
+ buffer_intermediate_messages: bool = False,
) -> AsyncGenerator[MessageChain | None, None]:
"""Live Mode 的 Agent 运行器,支持流式 TTS
@@ -311,6 +372,7 @@ async def run_live_agent(
show_tool_call_result=show_tool_call_result,
stream_to_general=False,
show_reasoning=show_reasoning,
+ buffer_intermediate_messages=buffer_intermediate_messages,
):
yield chain
return
@@ -343,6 +405,7 @@ async def run_live_agent(
show_tool_use,
show_tool_call_result,
show_reasoning,
+ buffer_intermediate_messages,
)
)
@@ -430,6 +493,7 @@ async def _run_agent_feeder(
show_tool_use: bool,
show_tool_call_result: bool,
show_reasoning: bool,
+ buffer_intermediate_messages: bool,
) -> None:
"""运行 Agent 并将文本输出分句放入队列"""
buffer = ""
@@ -441,6 +505,7 @@ async def _run_agent_feeder(
show_tool_call_result=show_tool_call_result,
stream_to_general=False,
show_reasoning=show_reasoning,
+ buffer_intermediate_messages=buffer_intermediate_messages,
):
if chain is None:
continue
diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py
index b7ab2951d..09976f7c4 100644
--- a/astrbot/core/config/default.py
+++ b/astrbot/core/config/default.py
@@ -134,6 +134,7 @@ DEFAULT_CONFIG = {
"streaming_response": False,
"show_tool_use_status": False,
"show_tool_call_result": False,
+ "buffer_intermediate_messages": False,
"sanitize_context_by_modalities": False,
"max_quoted_fallback_images": 20,
"quoted_message_parser": {
@@ -2777,6 +2778,9 @@ CONFIG_METADATA_2 = {
"show_tool_call_result": {
"type": "bool",
},
+ "buffer_intermediate_messages": {
+ "type": "bool",
+ },
"unsupported_streaming_strategy": {
"type": "string",
},
@@ -3543,6 +3547,15 @@ CONFIG_METADATA_3 = {
"provider_settings.show_tool_use_status": True,
},
},
+ "provider_settings.buffer_intermediate_messages": {
+ "description": "合并 Agent 中间消息",
+ "type": "bool",
+ "hint": "开启后,非流式模式下多步工具调用过程中产生的中间文本将缓冲,待 Agent 完成后合并为一条回复发送。",
+ "condition": {
+ "provider_settings.agent_runner_type": "local",
+ "provider_settings.streaming_response": False,
+ },
+ },
"provider_settings.sanitize_context_by_modalities": {
"description": "按模型能力清理历史上下文",
"type": "bool",
diff --git a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py
index e0ba2463c..0d43e4da9 100644
--- a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py
+++ b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py
@@ -66,6 +66,10 @@ class InternalAgentSubStage(Stage):
self.max_step = 30
self.show_tool_use: bool = settings.get("show_tool_use_status", True)
self.show_tool_call_result: bool = settings.get("show_tool_call_result", False)
+ self.buffer_intermediate_messages: bool = settings.get(
+ "buffer_intermediate_messages",
+ False,
+ )
self.show_reasoning = settings.get("display_reasoning_text", False)
self.sanitize_context_by_modalities: bool = settings.get(
"sanitize_context_by_modalities",
@@ -280,6 +284,7 @@ class InternalAgentSubStage(Stage):
self.show_tool_use,
self.show_tool_call_result,
show_reasoning=self.show_reasoning,
+ buffer_intermediate_messages=self.buffer_intermediate_messages,
),
),
)
@@ -310,6 +315,7 @@ class InternalAgentSubStage(Stage):
self.show_tool_use,
self.show_tool_call_result,
show_reasoning=self.show_reasoning,
+ buffer_intermediate_messages=self.buffer_intermediate_messages,
),
),
)
@@ -340,6 +346,7 @@ class InternalAgentSubStage(Stage):
self.show_tool_call_result,
stream_to_general,
show_reasoning=self.show_reasoning,
+ buffer_intermediate_messages=self.buffer_intermediate_messages,
):
yield
diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json
index ffb592ab8..6527e9576 100644
--- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json
+++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json
@@ -280,6 +280,10 @@
"description": "Output Tool Call Results",
"hint": "Only takes effect when \"Output Function Call Status\" is enabled, and shows at most 70 characters."
},
+ "buffer_intermediate_messages": {
+ "description": "Merge Agent Intermediate Messages",
+ "hint": "When enabled, intermediate text generated during multi-step tool calls in non-streaming mode will be buffered and sent as a single merged reply after the Agent finishes."
+ },
"sanitize_context_by_modalities": {
"description": "Sanitize History by Modalities",
"hint": "When enabled, sanitizes contexts before each LLM request by removing image blocks and tool-call structures that the current provider's modalities do not support (this changes what the model sees)."
diff --git a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json
index e3920940a..028c1859c 100644
--- a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json
+++ b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json
@@ -280,6 +280,10 @@
"description": "Выводить результаты работы инструментов",
"hint": "Работает только при включенном статусе вызова функций. Показывает макс. 70 символов."
},
+ "buffer_intermediate_messages": {
+ "description": "Объединять промежуточные сообщения Agent",
+ "hint": "Если включено, промежуточный текст, созданный во время многошаговых вызовов инструментов в непотоковом режиме, будет буферизован и отправлен одним объединенным ответом после завершения Agent."
+ },
"sanitize_context_by_modalities": {
"description": "Очистка истории по модальностям",
"hint": "Если включено, очищает контекст перед запросом, удаляя блоки (например, изображения), которые не поддерживаются выбранным провайдером."
diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json
index 935abb358..66c2e20b7 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json
@@ -282,6 +282,10 @@
"description": "输出函数调用返回结果",
"hint": "仅在启用“输出函数调用状态”时生效,且最多展示 70 个字符。"
},
+ "buffer_intermediate_messages": {
+ "description": "合并 Agent 中间消息",
+ "hint": "开启后,非流式模式下多步工具调用过程中产生的中间文本将缓冲,待 Agent 完成后合并为一条回复发送。"
+ },
"sanitize_context_by_modalities": {
"description": "按模型能力清理历史上下文",
"hint": "开启后,在每次请求 LLM 前会按当前模型提供商中所选择的模型能力删除对话中不支持的图片/工具调用结构(会改变模型看到的历史)"
@@ -1643,4 +1647,4 @@
"helpMiddle": "或",
"helpSuffix": "。"
}
-}
\ No newline at end of file
+}
From 662b1d36784ce6e3ba902487edd8c2b31347523a Mon Sep 17 00:00:00 2001
From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com>
Date: Tue, 21 Apr 2026 23:34:22 +0800
Subject: [PATCH 19/24] fix: accept both str and re.Pattern in RegexFilter
(#7633)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix: accept both str and re.Pattern in RegexFilter
RegexFilter.__init__ now handles compiled re.Pattern objects by
extracting .pattern for regex_str, preventing TypeError during
JSON serialization in the dashboard plugin API.
* perf: 精简代码
---
astrbot/core/star/filter/regex.py | 4 ++--
astrbot/core/star/register/star_handler.py | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/astrbot/core/star/filter/regex.py b/astrbot/core/star/filter/regex.py
index 605446282..0a64ee6a7 100644
--- a/astrbot/core/star/filter/regex.py
+++ b/astrbot/core/star/filter/regex.py
@@ -10,9 +10,9 @@ from . import HandlerFilter
class RegexFilter(HandlerFilter):
"""正则表达式过滤器"""
- def __init__(self, regex: str) -> None:
- self.regex_str = regex
+ def __init__(self, regex: str | re.Pattern) -> None:
self.regex = re.compile(regex)
+ self.regex_str = self.regex.pattern
def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
return bool(self.regex.search(event.get_message_str().strip()))
diff --git a/astrbot/core/star/register/star_handler.py b/astrbot/core/star/register/star_handler.py
index 10417c401..49a65d891 100644
--- a/astrbot/core/star/register/star_handler.py
+++ b/astrbot/core/star/register/star_handler.py
@@ -284,7 +284,7 @@ def register_platform_adapter_type(
return decorator
-def register_regex(regex: str, **kwargs):
+def register_regex(regex: str | re.Pattern, **kwargs):
"""注册一个 Regex"""
def decorator(awaitable):
From e6b68e9b098a4d2a35fd70529617d465ea21cd48 Mon Sep 17 00:00:00 2001
From: Soulter <37870767+Soulter@users.noreply.github.com>
Date: Wed, 22 Apr 2026 11:38:40 +0800
Subject: [PATCH 20/24] perf: update FileReadTool description to mention image,
PDF and docx support, and enhance modality checking in tool result case
(#7506)
* feat: update FileReadTool description to mention image and PDF support
Add explicit mention of image (OCR) and PDF (text extraction) support
to the FileReadTool description for better discoverability.
* feat: update FileReadTool description to include support for docx and epub files; change base64 decoding to utf-8
* feat: enhance ToolLoopAgentRunner to support image and audio modalities; add context sanitization logic
---
.../agent/runners/tool_loop_agent_runner.py | 88 +++++++++-
astrbot/core/astr_main_agent.py | 132 ---------------
astrbot/core/computer/file_read_utils.py | 16 +-
astrbot/core/provider/modalities.py | 158 ++++++++++++++++++
astrbot/core/tools/computer_tools/fs.py | 2 +-
tests/test_tool_loop_agent_runner.py | 113 +++++++++++++
tests/unit/test_astr_main_agent.py | 138 ---------------
7 files changed, 361 insertions(+), 286 deletions(-)
create mode 100644 astrbot/core/provider/modalities.py
diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py
index c210056e9..9d8cd2df2 100644
--- a/astrbot/core/agent/runners/tool_loop_agent_runner.py
+++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py
@@ -7,7 +7,7 @@ import typing as T
import uuid
from collections.abc import AsyncIterator
from contextlib import suppress
-from dataclasses import dataclass, field
+from dataclasses import dataclass, field, replace
from pathlib import Path
from mcp.types import (
@@ -42,6 +42,10 @@ from astrbot.core.provider.entities import (
ProviderRequest,
ToolCallsResult,
)
+from astrbot.core.provider.modalities import (
+ log_context_sanitize_stats,
+ sanitize_contexts_by_modalities,
+)
from astrbot.core.provider.provider import Provider
from ..context.compressor import ContextCompressor
@@ -300,8 +304,13 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
if isinstance(msg, dict) and msg.get("_no_save"):
m._no_save = True
messages.append(m)
- if request.prompt is not None:
- m = await request.assemble_context()
+ if (
+ request.prompt is not None
+ or request.image_urls
+ or request.audio_urls
+ or request.extra_user_content_parts
+ ):
+ m = await self._assemble_request_context_for_provider(request)
messages.append(Message.model_validate(m))
if request.system_prompt:
messages.insert(
@@ -318,6 +327,42 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
return f"`{self.read_tool.name}`"
return "the available file-read tool"
+ async def _assemble_request_context_for_provider(
+ self,
+ request: ProviderRequest,
+ ) -> dict[str, T.Any]:
+ modalities = self.provider.provider_config.get("modalities", None)
+ if not isinstance(modalities, list):
+ return await request.assemble_context()
+
+ supports_image = "image" in modalities
+ supports_audio = "audio" in modalities
+ if supports_image and supports_audio:
+ return await request.assemble_context()
+
+ adjusted_request = replace(
+ request,
+ image_urls=request.image_urls if supports_image else [],
+ audio_urls=request.audio_urls if supports_audio else [],
+ )
+ context = await adjusted_request.assemble_context()
+ content = context.get("content")
+ if isinstance(content, str):
+ content_blocks: list[dict[str, T.Any]] = [{"type": "text", "text": content}]
+ elif isinstance(content, list):
+ content_blocks = content
+ else:
+ content_blocks = []
+
+ if not supports_image:
+ for _ in request.image_urls:
+ content_blocks.append({"type": "text", "text": "[Image]"})
+ if not supports_audio:
+ for _ in request.audio_urls:
+ content_blocks.append({"type": "text", "text": "[Audio]"})
+
+ return {"role": "user", "content": content_blocks}
+
async def _write_tool_result_overflow_file(
self,
*,
@@ -415,8 +460,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
) -> T.AsyncGenerator[LLMResponse, None]:
"""Yields chunks *and* a final LLMResponse."""
payload = {
- "contexts": self.run_context.messages, # list[Message]
- "func_tool": self.req.func_tool,
+ "contexts": self._sanitize_contexts_for_provider(self.run_context.messages),
+ "func_tool": self._func_tool_for_provider(),
"session_id": self.req.session_id,
"extra_user_content_parts": self.req.extra_user_content_parts, # list[ContentPart]
"abort_signal": self._abort_signal,
@@ -532,6 +577,35 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
completion_text="All available chat models are unavailable.",
)
+ def _sanitize_contexts_for_provider(
+ self,
+ contexts: list[Message] | list[dict[str, T.Any]],
+ ) -> list[Message] | list[dict[str, T.Any]]:
+ if not self._should_fix_modalities_for_provider():
+ return contexts
+ sanitized_contexts, stats = sanitize_contexts_by_modalities(
+ contexts,
+ self.provider.provider_config.get("modalities", None),
+ )
+ log_context_sanitize_stats(stats)
+ return sanitized_contexts
+
+ def _should_fix_modalities_for_provider(self) -> bool:
+ modalities = self.provider.provider_config.get("modalities", None)
+ return isinstance(modalities, list)
+
+ def _func_tool_for_provider(self) -> ToolSet | None:
+ if not self.req.func_tool:
+ return None
+ modalities = self.provider.provider_config.get("modalities", None)
+ if isinstance(modalities, list) and "tool_use" not in modalities:
+ logger.debug(
+ "Provider %s does not support tool_use, clearing tools for request.",
+ self.provider,
+ )
+ return None
+ return self.req.func_tool
+
def _simple_print_message_role(self, tag: str = ""):
roles = []
for message in self.run_context.messages:
@@ -1196,7 +1270,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
if param_subset.tools and tool_names:
contexts = self._build_tool_requery_context(tool_names)
requery_resp = await self.provider.text_chat(
- contexts=contexts,
+ contexts=self._sanitize_contexts_for_provider(contexts),
func_tool=param_subset,
model=self.req.model,
session_id=self.req.session_id,
@@ -1222,7 +1296,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
extra_instruction=self.SKILLS_LIKE_REQUERY_REPAIR_INSTRUCTION,
)
repair_resp = await self.provider.text_chat(
- contexts=repair_contexts,
+ contexts=self._sanitize_contexts_for_provider(repair_contexts),
func_tool=param_subset,
model=self.req.model,
session_id=self.req.session_id,
diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py
index 86bbd9632..fcbd16826 100644
--- a/astrbot/core/astr_main_agent.py
+++ b/astrbot/core/astr_main_agent.py
@@ -850,136 +850,6 @@ async def _decorate_llm_request(
_apply_workspace_extra_prompt(event, req)
-def _modalities_fix(provider: Provider, req: ProviderRequest) -> None:
- if req.image_urls:
- provider_cfg = provider.provider_config.get("modalities", ["image"])
- if "image" not in provider_cfg:
- logger.debug(
- "Provider %s does not support image, using placeholder.", provider
- )
- image_count = len(req.image_urls)
- placeholder = " ".join(["[Image]"] * image_count)
- if req.prompt:
- req.prompt = f"{placeholder} {req.prompt}"
- else:
- req.prompt = placeholder
- req.image_urls = []
- if req.audio_urls:
- provider_cfg = provider.provider_config.get("modalities", ["audio"])
- if "audio" not in provider_cfg:
- logger.debug(
- "Provider %s does not support audio, using placeholder.", provider
- )
- audio_count = len(req.audio_urls)
- placeholder = " ".join(["[Audio]"] * audio_count)
- if req.prompt:
- req.prompt = f"{placeholder} {req.prompt}"
- else:
- req.prompt = placeholder
- req.audio_urls = []
- if req.func_tool:
- provider_cfg = provider.provider_config.get("modalities", ["tool_use"])
- if "tool_use" not in provider_cfg:
- logger.debug(
- "Provider %s does not support tool_use, clearing tools.", provider
- )
- req.func_tool = None
-
-
-def _sanitize_context_by_modalities(
- config: MainAgentBuildConfig,
- provider: Provider,
- req: ProviderRequest,
-) -> None:
- if not config.sanitize_context_by_modalities:
- return
- if not isinstance(req.contexts, list) or not req.contexts:
- return
- modalities = provider.provider_config.get("modalities", None)
- if not modalities or not isinstance(modalities, list):
- return
- supports_image = bool("image" in modalities)
- supports_audio = bool("audio" in modalities)
- supports_tool_use = bool("tool_use" in modalities)
- if supports_image and supports_audio and supports_tool_use:
- return
-
- sanitized_contexts: list[dict] = []
- removed_image_blocks = 0
- removed_audio_blocks = 0
- removed_tool_messages = 0
- removed_tool_calls = 0
-
- for msg in req.contexts:
- if not isinstance(msg, dict):
- continue
- role = msg.get("role")
- if not role:
- continue
-
- new_msg = msg
- if not supports_tool_use:
- if role == "tool":
- removed_tool_messages += 1
- continue
- if role == "assistant" and "tool_calls" in new_msg:
- if "tool_calls" in new_msg:
- removed_tool_calls += 1
- new_msg.pop("tool_calls", None)
- new_msg.pop("tool_call_id", None)
-
- if not supports_image or not supports_audio:
- content = new_msg.get("content")
- if isinstance(content, list):
- filtered_parts: list = []
- removed_any_multimodal = False
- for part in content:
- if isinstance(part, dict):
- part_type = str(part.get("type", "")).lower()
- if not supports_image and part_type in {"image_url", "image"}:
- removed_any_multimodal = True
- removed_image_blocks += 1
- continue
- if not supports_audio and part_type in {
- "audio_url",
- "input_audio",
- }:
- removed_any_multimodal = True
- removed_audio_blocks += 1
- continue
- filtered_parts.append(part)
- if removed_any_multimodal:
- new_msg["content"] = filtered_parts
-
- if role == "assistant":
- content = new_msg.get("content")
- has_tool_calls = bool(new_msg.get("tool_calls"))
- if not has_tool_calls:
- if not content:
- continue
- if isinstance(content, str) and not content.strip():
- continue
-
- sanitized_contexts.append(new_msg)
-
- if (
- removed_image_blocks
- or removed_audio_blocks
- or removed_tool_messages
- or removed_tool_calls
- ):
- logger.debug(
- "sanitize_context_by_modalities applied: "
- "removed_image_blocks=%s, removed_audio_blocks=%s, "
- "removed_tool_messages=%s, removed_tool_calls=%s",
- removed_image_blocks,
- removed_audio_blocks,
- removed_tool_messages,
- removed_tool_calls,
- )
- req.contexts = sanitized_contexts
-
-
def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None:
"""根据事件中的插件设置,过滤请求中的工具列表。
@@ -1424,10 +1294,8 @@ async def build_main_agent(
if not req.session_id:
req.session_id = event.unified_msg_origin
- _modalities_fix(provider, req)
_plugin_tool_fix(event, req)
await _apply_web_search_tools(event, req, plugin_context)
- _sanitize_context_by_modalities(config, provider, req)
if config.llm_safety_mode:
_apply_llm_safety_mode(config, req)
diff --git a/astrbot/core/computer/file_read_utils.py b/astrbot/core/computer/file_read_utils.py
index f22c1891f..5b5fd9fc8 100644
--- a/astrbot/core/computer/file_read_utils.py
+++ b/astrbot/core/computer/file_read_utils.py
@@ -91,7 +91,7 @@ print(
json.dumps(
{{
"size_bytes": path.stat().st_size,
- "sample_b64": base64.b64encode(sample).decode("ascii"),
+ "sample_b64": base64.b64encode(sample).decode("utf-8"),
}}
)
)
@@ -140,7 +140,7 @@ print(
json.dumps(
{{
"size_bytes": len(data),
- "base64": base64.b64encode(data).decode("ascii"),
+ "base64": base64.b64encode(data).decode("utf-8"),
}}
)
)
@@ -278,7 +278,7 @@ async def _probe_local_file(path: str) -> dict[str, str | int]:
sample = file_obj.read(_FILE_SNIFF_BYTES)
return {
"size_bytes": file_path.stat().st_size,
- "sample_b64": base64.b64encode(sample).decode("ascii"),
+ "sample_b64": base64.b64encode(sample).decode("utf-8"),
}
return await to_thread(_run)
@@ -289,7 +289,7 @@ async def _read_local_image_base64(path: str) -> dict[str, str | int]:
data = Path(path).read_bytes()
return {
"size_bytes": len(data),
- "base64": base64.b64encode(data).decode("ascii"),
+ "base64": base64.b64encode(data).decode("utf-8"),
}
return await to_thread(_run)
@@ -319,7 +319,7 @@ async def _compress_image_bytes_to_base64(data: bytes) -> dict[str, str | int]:
return {
"size_bytes": len(compressed_bytes),
- "base64": base64.b64encode(compressed_bytes).decode("ascii"),
+ "base64": base64.b64encode(compressed_bytes).decode("utf-8"),
"mime_type": "image/jpeg",
}
@@ -696,14 +696,14 @@ async def read_file_tool_result(
return "Error reading file: image payload is empty."
raw_bytes = base64.b64decode(raw_base64_data)
compressed_payload = await _compress_image_bytes_to_base64(raw_bytes)
- base64_data = str(compressed_payload.get("base64", "") or "")
- if not base64_data:
+ compressed_base64_data = str(compressed_payload.get("base64", "") or "")
+ if not compressed_base64_data:
return "Error reading file: compressed image payload is empty."
return mcp.types.CallToolResult(
content=[
mcp.types.ImageContent(
type="image",
- data=base64_data,
+ data=compressed_base64_data,
mimeType=str(
compressed_payload.get("mime_type", "") or "image/jpeg"
),
diff --git a/astrbot/core/provider/modalities.py b/astrbot/core/provider/modalities.py
new file mode 100644
index 000000000..66ac74e9b
--- /dev/null
+++ b/astrbot/core/provider/modalities.py
@@ -0,0 +1,158 @@
+from __future__ import annotations
+
+import copy
+from collections.abc import Sequence
+from dataclasses import dataclass
+from typing import Any
+
+from astrbot import logger
+from astrbot.core.agent.message import Message
+
+
+@dataclass(slots=True)
+class ContextSanitizeStats:
+ fixed_image_blocks: int = 0
+ fixed_audio_blocks: int = 0
+ fixed_tool_messages: int = 0
+ removed_tool_calls: int = 0
+
+ @property
+ def changed(self) -> bool:
+ return bool(
+ self.fixed_image_blocks
+ or self.fixed_audio_blocks
+ or self.fixed_tool_messages
+ or self.removed_tool_calls
+ )
+
+
+def _message_to_dict(message: dict[str, Any] | Message) -> dict[str, Any] | None:
+ if isinstance(message, Message):
+ return dict(message.model_dump())
+ if isinstance(message, dict):
+ return dict(copy.deepcopy(message))
+ return None
+
+
+def sanitize_contexts_by_modalities(
+ contexts: Sequence[dict[str, Any] | Message],
+ modalities: list[str] | None,
+) -> tuple[list[dict[str, Any]], ContextSanitizeStats]:
+ if not contexts:
+ return [], ContextSanitizeStats()
+ if not modalities or not isinstance(modalities, list):
+ copied_contexts = []
+ for msg in contexts:
+ copied_msg = _message_to_dict(msg)
+ if copied_msg:
+ copied_contexts.append(copied_msg)
+ return copied_contexts, ContextSanitizeStats()
+
+ supports_image = "image" in modalities
+ supports_audio = "audio" in modalities
+ supports_tool_use = "tool_use" in modalities
+ if supports_image and supports_audio and supports_tool_use:
+ copied_contexts = []
+ for msg in contexts:
+ copied_msg = _message_to_dict(msg)
+ if copied_msg:
+ copied_contexts.append(copied_msg)
+ return copied_contexts, ContextSanitizeStats()
+
+ sanitized_contexts: list[dict[str, Any]] = []
+ stats = ContextSanitizeStats()
+
+ for raw_msg in contexts:
+ msg = _message_to_dict(raw_msg)
+ if not msg:
+ continue
+ role = msg.get("role")
+ if not role:
+ continue
+
+ if not supports_tool_use:
+ if role == "tool":
+ stats.fixed_tool_messages += 1
+ fixed_msg: dict[str, Any] = {
+ "role": "user",
+ "content": _tool_result_placeholder(msg.get("content")),
+ }
+ msg = fixed_msg
+ if role == "assistant" and "tool_calls" in msg:
+ stats.removed_tool_calls += 1
+ msg.pop("tool_calls", None)
+ msg.pop("tool_call_id", None)
+
+ if not supports_image or not supports_audio:
+ content = msg.get("content")
+ if isinstance(content, list):
+ filtered_parts: list[Any] = []
+ removed_any_multimodal = False
+ for part in content:
+ if isinstance(part, dict):
+ part_type = str(part.get("type", "")).lower()
+ if not supports_image and part_type in {"image_url", "image"}:
+ removed_any_multimodal = True
+ stats.fixed_image_blocks += 1
+ filtered_parts.append({"type": "text", "text": "[Image]"})
+ continue
+ if not supports_audio and part_type in {
+ "audio_url",
+ "input_audio",
+ }:
+ removed_any_multimodal = True
+ stats.fixed_audio_blocks += 1
+ filtered_parts.append({"type": "text", "text": "[Audio]"})
+ continue
+ filtered_parts.append(part)
+ if removed_any_multimodal:
+ msg["content"] = filtered_parts
+
+ if role == "assistant":
+ content = msg.get("content")
+ has_tool_calls = bool(msg.get("tool_calls"))
+ if not has_tool_calls:
+ if not content:
+ continue
+ if isinstance(content, str) and not content.strip():
+ continue
+
+ sanitized_contexts.append(msg)
+
+ return sanitized_contexts, stats
+
+
+def _tool_result_placeholder(content: Any) -> str:
+ if isinstance(content, str):
+ content_text = content.strip()
+ elif isinstance(content, list):
+ text_parts: list[str] = []
+ for part in content:
+ if isinstance(part, dict):
+ part_type = str(part.get("type", "")).lower()
+ if part_type == "text":
+ text_parts.append(str(part.get("text", "")))
+ elif part_type in {"image_url", "image"}:
+ text_parts.append("[Image]")
+ elif part_type in {"audio_url", "input_audio"}:
+ text_parts.append("[Audio]")
+ content_text = "\n".join(part for part in text_parts if part).strip()
+ else:
+ content_text = ""
+ if not content_text:
+ return "[Tool result]"
+ return f"[Tool result]\n{content_text}"
+
+
+def log_context_sanitize_stats(stats: ContextSanitizeStats) -> None:
+ if not stats.changed:
+ return
+ logger.debug(
+ "context modality fix applied: "
+ "fixed_image_blocks=%s, fixed_audio_blocks=%s, "
+ "fixed_tool_messages=%s, removed_tool_calls=%s",
+ stats.fixed_image_blocks,
+ stats.fixed_audio_blocks,
+ stats.fixed_tool_messages,
+ stats.removed_tool_calls,
+ )
diff --git a/astrbot/core/tools/computer_tools/fs.py b/astrbot/core/tools/computer_tools/fs.py
index 8d3160ff5..60c21d949 100644
--- a/astrbot/core/tools/computer_tools/fs.py
+++ b/astrbot/core/tools/computer_tools/fs.py
@@ -171,7 +171,7 @@ def _decode_escaped_text(value: str) -> str:
@dataclass
class FileReadTool(FunctionTool):
name: str = "astrbot_file_read_tool"
- description: str = "read file content."
+ description: str = "read file content. Supports text, image, and PDF (text extraction), docx and epub files."
parameters: dict = field(
default_factory=lambda: {
"type": "object",
diff --git a/tests/test_tool_loop_agent_runner.py b/tests/test_tool_loop_agent_runner.py
index 00caed67d..74d069108 100644
--- a/tests/test_tool_loop_agent_runner.py
+++ b/tests/test_tool_loop_agent_runner.py
@@ -14,6 +14,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")
from astrbot.core.agent.agent import Agent
from astrbot.core.agent.handoff import HandoffTool
from astrbot.core.agent.hooks import BaseAgentRunHooks
+from astrbot.core.agent.message import ImageURLPart, Message, TextPart
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner
from astrbot.core.agent.tool import FunctionTool, ToolSet
@@ -156,6 +157,25 @@ class MockErrProvider(MockProvider):
)
+class CapturingProvider(MockProvider):
+ def __init__(self, modalities: list[str]):
+ super().__init__()
+ self.provider_config["modalities"] = modalities
+ self.received_contexts = []
+ self.received_func_tools = []
+ self.should_call_tools = False
+
+ async def text_chat(self, **kwargs) -> LLMResponse:
+ self.call_count += 1
+ self.received_contexts.append(kwargs.get("contexts"))
+ self.received_func_tools.append(kwargs.get("func_tool"))
+ return LLMResponse(
+ role="assistant",
+ completion_text="final",
+ usage=TokenUsage(input_other=10, output=5),
+ )
+
+
class MockEmptyOutputThenSuccessProvider(MockProvider):
def __init__(self, failures_before_success: int = 1):
super().__init__()
@@ -615,6 +635,99 @@ async def test_tool_result_includes_all_calltoolresult_content(
]
+@pytest.mark.asyncio
+async def test_runner_replaces_runtime_image_context_before_provider_call(
+ runner, provider_request, mock_hooks
+):
+ provider = CapturingProvider(modalities=["tool_use"])
+
+ await runner.reset(
+ provider=provider,
+ request=provider_request,
+ run_context=ContextWrapper(context=None),
+ tool_executor=MockToolExecutor,
+ agent_hooks=mock_hooks,
+ streaming=False,
+ )
+
+ runner.run_context.messages.append(
+ Message(
+ role="user",
+ content=[
+ TextPart(text="Review this image"),
+ ImageURLPart(
+ image_url=ImageURLPart.ImageURL(
+ url="data:image/png;base64,dGVzdA=="
+ )
+ ),
+ ],
+ )
+ )
+
+ async for _ in runner.step_until_done(1):
+ pass
+
+ assert provider.received_contexts
+ sent_context = provider.received_contexts[0]
+ assert sent_context[-1]["content"] == [
+ {"type": "text", "text": "Review this image"},
+ {"type": "text", "text": "[Image]"},
+ ]
+ assert len(runner.run_context.messages[-2].content) == 2
+
+
+@pytest.mark.asyncio
+async def test_runner_builds_placeholder_for_unsupported_request_image(
+ runner, mock_hooks, tool_set
+):
+ provider = CapturingProvider(modalities=["tool_use"])
+ request = ProviderRequest(
+ prompt="Describe it",
+ image_urls=["/path/that/should/not/be/read.jpg"],
+ func_tool=tool_set,
+ contexts=[],
+ )
+
+ await runner.reset(
+ provider=provider,
+ request=request,
+ run_context=ContextWrapper(context=None),
+ tool_executor=MockToolExecutor,
+ agent_hooks=mock_hooks,
+ streaming=False,
+ )
+
+ async for _ in runner.step_until_done(1):
+ pass
+
+ sent_context = provider.received_contexts[0]
+ assert sent_context[-1]["content"] == [
+ {"type": "text", "text": "Describe it"},
+ {"type": "text", "text": "[Image]"},
+ ]
+
+
+@pytest.mark.asyncio
+async def test_runner_clears_tools_for_provider_without_tool_use(
+ runner, provider_request, mock_hooks, mock_tool_executor
+):
+ provider = CapturingProvider(modalities=["text"])
+
+ await runner.reset(
+ provider=provider,
+ request=provider_request,
+ run_context=ContextWrapper(context=None),
+ tool_executor=mock_tool_executor,
+ agent_hooks=mock_hooks,
+ streaming=False,
+ )
+
+ async for _ in runner.step_until_done(1):
+ pass
+
+ assert provider.received_func_tools == [None]
+
+
@pytest.mark.asyncio
async def test_same_tool_consecutive_results_include_escalating_guidance(
runner, mock_tool_executor, mock_hooks
diff --git a/tests/unit/test_astr_main_agent.py b/tests/unit/test_astr_main_agent.py
index c953815a8..6ca1a3f2a 100644
--- a/tests/unit/test_astr_main_agent.py
+++ b/tests/unit/test_astr_main_agent.py
@@ -713,144 +713,6 @@ class TestDecorateLlmRequest:
assert req.prompt == "Hello"
-class TestModalitiesFix:
- """Tests for _modalities_fix function."""
-
- def test_modalities_fix_image_not_supported(self, mock_provider):
- """Test modality fix when image is not supported."""
- module = ama
- mock_provider.provider_config = {"modalities": ["text"]}
- req = ProviderRequest(prompt="Hello", image_urls=["/path/to/image.jpg"])
-
- module._modalities_fix(mock_provider, req)
-
- assert "[Image]" in req.prompt
- assert req.image_urls == []
-
- def test_modalities_fix_tool_not_supported(self, mock_provider):
- """Test modality fix when tool is not supported."""
- module = ama
- mock_provider.provider_config = {"modalities": ["text", "image"]}
- req = ProviderRequest(prompt="Hello")
- req.func_tool = ToolSet()
- req.func_tool.add_tool(
- FunctionTool(
- name="dummy_tool",
- description="dummy",
- parameters={"type": "object", "properties": {}},
- )
- )
-
- module._modalities_fix(mock_provider, req)
-
- assert req.func_tool is None
-
- def test_modalities_fix_all_supported(self, mock_provider):
- """Test modality fix when all features are supported."""
- module = ama
- mock_provider.provider_config = {"modalities": ["image", "tool_use"]}
- tool_set = ToolSet()
- tool_set.add_tool(
- FunctionTool(
- name="dummy_tool",
- description="dummy",
- parameters={"type": "object", "properties": {}},
- )
- )
- req = ProviderRequest(
- prompt="Hello",
- image_urls=["/path/to/image.jpg"],
- func_tool=tool_set,
- )
-
- module._modalities_fix(mock_provider, req)
-
- assert req.prompt == "Hello"
- assert len(req.image_urls) == 1
- assert req.func_tool is not None
-
-
-class TestSanitizeContextByModalities:
- """Tests for _sanitize_context_by_modalities function."""
-
- def test_sanitize_no_op(self, mock_provider):
- """Test sanitize when disabled or modalities support everything."""
- module = ama
- config = module.MainAgentBuildConfig(
- tool_call_timeout=60, sanitize_context_by_modalities=False
- )
- mock_provider.provider_config = {"modalities": ["image", "tool_use"]}
- req = ProviderRequest(contexts=[{"role": "user", "content": "Hello"}])
-
- module._sanitize_context_by_modalities(config, mock_provider, req)
-
- assert len(req.contexts) == 1
-
- def test_sanitize_removes_tool_messages(self, mock_provider):
- """Test sanitize removes tool messages when tool_use not supported."""
- module = ama
- config = module.MainAgentBuildConfig(
- tool_call_timeout=60, sanitize_context_by_modalities=True
- )
- mock_provider.provider_config = {"modalities": ["image"]}
- req = ProviderRequest(
- contexts=[
- {"role": "user", "content": "Hello"},
- {"role": "tool", "content": "Tool result"},
- ]
- )
-
- module._sanitize_context_by_modalities(config, mock_provider, req)
-
- assert len(req.contexts) == 1
- assert req.contexts[0]["role"] == "user"
-
- def test_sanitize_removes_tool_calls(self, mock_provider):
- """Test sanitize removes tool_calls from assistant messages."""
- module = ama
- config = module.MainAgentBuildConfig(
- tool_call_timeout=60, sanitize_context_by_modalities=True
- )
- mock_provider.provider_config = {"modalities": ["image"]}
- req = ProviderRequest(
- contexts=[
- {
- "role": "assistant",
- "content": "Response",
- "tool_calls": [{"name": "tool"}],
- }
- ]
- )
-
- module._sanitize_context_by_modalities(config, mock_provider, req)
-
- assert "tool_calls" not in req.contexts[0]
-
- def test_sanitize_removes_image_blocks(self, mock_provider):
- """Test sanitize removes image blocks when image not supported."""
- module = ama
- config = module.MainAgentBuildConfig(
- tool_call_timeout=60, sanitize_context_by_modalities=True
- )
- mock_provider.provider_config = {"modalities": ["tool_use"]}
- req = ProviderRequest(
- contexts=[
- {
- "role": "user",
- "content": [
- {"type": "text", "text": "Hello"},
- {"type": "image_url", "url": "image.jpg"},
- ],
- }
- ]
- )
-
- module._sanitize_context_by_modalities(config, mock_provider, req)
-
- assert len(req.contexts[0]["content"]) == 1
- assert req.contexts[0]["content"][0]["type"] == "text"
-
-
class TestPluginToolFix:
"""Tests for _plugin_tool_fix function."""
From 36d6f3b67ecab7cc6b473072818cde618d191bc3 Mon Sep 17 00:00:00 2001
From: Soulter <37870767+Soulter@users.noreply.github.com>
Date: Wed, 22 Apr 2026 11:51:12 +0800
Subject: [PATCH 21/24] feat: add inline message editing and regeneration
functionality for webui (#7673)
* feat: add inline message editing and regeneration functionality for webui
- Implemented inline editing for user messages in the chat component.
- Added a regenerate menu for retrying messages with different models.
- Enhanced message handling to include llm_checkpoint_id for better tracking.
- Updated localization files to include new actions for retrying and model selection.
- Introduced tests for checkpoint message handling and chat route functionality.
* feat: thread mode in webui
* feat: enhance message editing functionality to allow only the latest user message to be edited
* feat: add error handling and user feedback for thread creation in chat component
* feat: add thread count display and localization support in chat component
* feat: add RefsSidebar component and integrate reference management in chat UI
* feat: improve message editing validation and cleanup for bot messages
* feat: enhance checkpoint message handling with binding and dumping functionality
---
astrbot/core/agent/message.py | 106 +-
.../agent/runners/coze/coze_agent_runner.py | 3 +
.../agent/runners/tool_loop_agent_runner.py | 14 +-
astrbot/core/astr_main_agent.py | 11 +
astrbot/core/backup/constants.py | 2 +
astrbot/core/db/__init__.py | 79 ++
astrbot/core/db/po.py | 31 +
astrbot/core/db/sqlite.py | 189 +++
.../method/agent_sub_stages/internal.py | 20 +-
.../sources/webchat/webchat_adapter.py | 4 +
astrbot/core/platform_message_history_mgr.py | 19 +
astrbot/core/provider/entities.py | 3 +
astrbot/core/provider/provider.py | 4 +-
astrbot/dashboard/routes/chat.py | 680 +++++++++-
astrbot/dashboard/routes/live_chat.py | 21 +-
dashboard/src/components/chat/Chat.vue | 981 ++++----------
dashboard/src/components/chat/ChatInput.vue | 89 +-
.../src/components/chat/ChatMessageList.vue | 1138 +++++++++++++++++
.../src/components/chat/ProviderModelMenu.vue | 24 +-
.../src/components/chat/RegenerateMenu.vue | 237 ++++
dashboard/src/components/chat/ThreadPanel.vue | 543 ++++++++
.../chat/ThreadedMarkdownMessagePart.vue | 89 ++
.../chat/message_list_comps/ActionRef.vue | 20 +-
.../chat/message_list_comps/RefsSidebar.vue | 9 +-
.../chat/message_list_comps/ThreadNode.vue | 63 +
.../src/components/shared/StyledMenu.vue | 19 +-
dashboard/src/composables/useMessages.ts | 239 +++-
.../src/i18n/locales/en-US/features/chat.json | 12 +
.../src/i18n/locales/ru-RU/features/chat.json | 12 +
.../src/i18n/locales/zh-CN/features/chat.json | 12 +
tests/test_conversation_checkpoint.py | 110 ++
31 files changed, 3996 insertions(+), 787 deletions(-)
create mode 100644 dashboard/src/components/chat/ChatMessageList.vue
create mode 100644 dashboard/src/components/chat/RegenerateMenu.vue
create mode 100644 dashboard/src/components/chat/ThreadPanel.vue
create mode 100644 dashboard/src/components/chat/ThreadedMarkdownMessagePart.vue
create mode 100644 dashboard/src/components/chat/message_list_comps/ThreadNode.vue
create mode 100644 tests/test_conversation_checkpoint.py
diff --git a/astrbot/core/agent/message.py b/astrbot/core/agent/message.py
index bde6353ff..ad3b57cb2 100644
--- a/astrbot/core/agent/message.py
+++ b/astrbot/core/agent/message.py
@@ -7,6 +7,7 @@ from pydantic import (
BaseModel,
GetCoreSchemaHandler,
PrivateAttr,
+ ValidationError,
model_serializer,
model_validator,
)
@@ -165,6 +166,15 @@ class ToolCallPart(BaseModel):
"""A part of the arguments of the tool call."""
+class CheckpointData(BaseModel):
+ """Internal checkpoint data for linking LLM turns to platform history."""
+
+ id: str
+
+
+CHECKPOINT_ROLE = "_checkpoint"
+
+
class Message(BaseModel):
"""A message in a conversation."""
@@ -173,9 +183,10 @@ class Message(BaseModel):
"user",
"assistant",
"tool",
+ "_checkpoint",
]
- content: str | list[ContentPart] | None = None
+ content: str | list[ContentPart] | CheckpointData | None = None
"""The content of the message."""
tool_calls: list[ToolCall] | list[dict] | None = None
@@ -185,9 +196,18 @@ class Message(BaseModel):
"""The ID of the tool call."""
_no_save: bool = PrivateAttr(default=False)
+ _checkpoint_after: CheckpointData | None = PrivateAttr(default=None)
@model_validator(mode="after")
def check_content_required(self):
+ if self.role == CHECKPOINT_ROLE:
+ if not isinstance(self.content, CheckpointData):
+ raise ValueError("checkpoint message content must be CheckpointData")
+ return self
+
+ if isinstance(self.content, CheckpointData):
+ raise ValueError("CheckpointData is only allowed for role='_checkpoint'")
+
# assistant + tool_calls is not None: allow content to be None
if self.role == "assistant" and self.tool_calls is not None:
return self
@@ -231,3 +251,87 @@ class SystemMessageSegment(Message):
"""A message segment from the system."""
role: Literal["system"] = "system"
+
+
+class CheckpointMessageSegment(Message):
+ """Internal checkpoint segment for persisted conversation history."""
+
+ role: Literal["_checkpoint"] = "_checkpoint"
+ content: CheckpointData | None = None
+
+
+def is_checkpoint_message(message: Message | dict) -> bool:
+ """Return whether a message is an internal checkpoint."""
+ if isinstance(message, Message):
+ return message.role == CHECKPOINT_ROLE
+ return isinstance(message, dict) and message.get("role") == CHECKPOINT_ROLE
+
+
+def get_checkpoint_id(message: Message | dict) -> str | None:
+ """Return the checkpoint id from an internal checkpoint message."""
+ if not is_checkpoint_message(message):
+ return None
+
+ content = (
+ message.content if isinstance(message, Message) else message.get("content")
+ )
+ if isinstance(content, CheckpointData):
+ return content.id
+ if isinstance(content, dict):
+ checkpoint_id = content.get("id")
+ return (
+ checkpoint_id if isinstance(checkpoint_id, str) and checkpoint_id else None
+ )
+ return None
+
+
+def strip_checkpoint_messages(history: list[dict]) -> list[dict]:
+ """Remove internal checkpoint messages from provider-facing history."""
+ return [message for message in history if not is_checkpoint_message(message)]
+
+
+def _get_checkpoint_data(message: Message | dict) -> CheckpointData | None:
+ if not is_checkpoint_message(message):
+ return None
+
+ content = (
+ message.content if isinstance(message, Message) else message.get("content")
+ )
+ if isinstance(content, CheckpointData):
+ return content
+ if isinstance(content, dict):
+ try:
+ return CheckpointData.model_validate(content)
+ except ValidationError:
+ return None
+ return None
+
+
+def bind_checkpoint_messages(history: list[dict]) -> list[Message]:
+ """Load persisted history and bind checkpoint segments to prior messages."""
+ messages: list[Message] = []
+ for item in history:
+ if is_checkpoint_message(item):
+ checkpoint = _get_checkpoint_data(item)
+ if checkpoint is not None and messages:
+ messages[-1]._checkpoint_after = checkpoint
+ continue
+
+ message = Message.model_validate(item)
+ if item.get("_no_save"):
+ message._no_save = True
+ messages.append(message)
+
+ return messages
+
+
+def dump_messages_with_checkpoints(messages: list[Message]) -> list[dict]:
+ """Dump runtime messages and reinsert bound checkpoint segments."""
+ dumped: list[dict] = []
+ for message in messages:
+ dumped.append(message.model_dump())
+ if message._checkpoint_after is not None:
+ dumped.append(
+ CheckpointMessageSegment(content=message._checkpoint_after).model_dump()
+ )
+ return dumped
diff --git a/astrbot/core/agent/runners/coze/coze_agent_runner.py b/astrbot/core/agent/runners/coze/coze_agent_runner.py
index a8300bb71..e3e7f2c51 100644
--- a/astrbot/core/agent/runners/coze/coze_agent_runner.py
+++ b/astrbot/core/agent/runners/coze/coze_agent_runner.py
@@ -13,6 +13,7 @@ from astrbot.core.provider.entities import (
)
from ...hooks import BaseAgentRunHooks
+from ...message import is_checkpoint_message
from ...response import AgentResponseData
from ...run_context import ContextWrapper, TContext
from ..base import AgentResponse, AgentState, BaseAgentRunner
@@ -148,6 +149,8 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
# 处理历史上下文
if not self.auto_save_history and contexts:
for ctx in contexts:
+ if is_checkpoint_message(ctx):
+ continue
if isinstance(ctx, dict) and "role" in ctx and "content" in ctx:
# 处理上下文中的图片
content = ctx["content"]
diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py
index 9d8cd2df2..132ca8192 100644
--- a/astrbot/core/agent/runners/tool_loop_agent_runner.py
+++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py
@@ -53,7 +53,12 @@ from ..context.config import ContextConfig
from ..context.manager import ContextManager
from ..context.token_counter import EstimateTokenCounter, TokenCounter
from ..hooks import BaseAgentRunHooks
-from ..message import AssistantMessageSegment, Message, ToolCallMessageSegment
+from ..message import (
+ AssistantMessageSegment,
+ Message,
+ ToolCallMessageSegment,
+ bind_checkpoint_messages,
+)
from ..response import AgentResponseData, AgentStats
from ..run_context import ContextWrapper, TContext
from ..tool_executor import BaseFunctionToolExecutor
@@ -297,13 +302,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
# MODIFIE the req.func_tool to use light tool schemas
self.req.func_tool = light_set
- messages = []
# append existing messages in the run context
- for msg in request.contexts:
- m = Message.model_validate(msg)
- if isinstance(msg, dict) and msg.get("_no_save"):
- m._no_save = True
- messages.append(m)
+ messages = bind_checkpoint_messages(request.contexts or [])
if (
request.prompt is not None
or request.image_urls
diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py
index fcbd16826..a9ddb2e7b 100644
--- a/astrbot/core/astr_main_agent.py
+++ b/astrbot/core/astr_main_agent.py
@@ -1272,6 +1272,17 @@ async def build_main_agent(
if isinstance(req.contexts, str):
req.contexts = json.loads(req.contexts)
+ thread_selected_text = event.get_extra("thread_selected_text")
+ if isinstance(thread_selected_text, str) and thread_selected_text.strip():
+ req.extra_user_content_parts.append(
+ TextPart(
+ text=(
+ "The user is asking in a side thread about this selected "
+ "excerpt from the previous assistant answer:\n"
+ f"{thread_selected_text.strip()}"
+ )
+ )
+ )
req.image_urls = normalize_and_dedupe_strings(req.image_urls)
req.audio_urls = normalize_and_dedupe_strings(req.audio_urls)
diff --git a/astrbot/core/backup/constants.py b/astrbot/core/backup/constants.py
index b832a1b72..493d2670c 100644
--- a/astrbot/core/backup/constants.py
+++ b/astrbot/core/backup/constants.py
@@ -18,6 +18,7 @@ from astrbot.core.db.po import (
PlatformStat,
Preference,
SessionProjectRelation,
+ WebChatThread,
)
from astrbot.core.knowledge_base.models import (
KBDocument,
@@ -46,6 +47,7 @@ MAIN_DB_MODELS: dict[str, type[SQLModel]] = {
"preferences": Preference,
"platform_message_history": PlatformMessageHistory,
"platform_sessions": PlatformSession,
+ "webchat_threads": WebChatThread,
"chatui_projects": ChatUIProject,
"session_project_relations": SessionProjectRelation,
"attachments": Attachment,
diff --git a/astrbot/core/db/__init__.py b/astrbot/core/db/__init__.py
index 087aa625b..1800887fb 100644
--- a/astrbot/core/db/__init__.py
+++ b/astrbot/core/db/__init__.py
@@ -24,6 +24,7 @@ from astrbot.core.db.po import (
ProviderStat,
SessionProjectRelation,
Stats,
+ WebChatThread,
)
@@ -204,10 +205,26 @@ class BaseDatabase(abc.ABC):
content: dict,
sender_id: str | None = None,
sender_name: str | None = None,
+ llm_checkpoint_id: str | None = None,
) -> PlatformMessageHistory:
"""Insert a new platform message history record."""
...
+ @abc.abstractmethod
+ async def update_platform_message_history(
+ self,
+ message_id: int,
+ content: dict | None = None,
+ llm_checkpoint_id: str | None = None,
+ ) -> None:
+ """Update a platform message history record."""
+ ...
+
+ @abc.abstractmethod
+ async def delete_platform_message_history_by_id(self, message_id: int) -> None:
+ """Delete a platform message history record by its ID."""
+ ...
+
@abc.abstractmethod
async def delete_platform_message_offset(
self,
@@ -237,6 +254,68 @@ class BaseDatabase(abc.ABC):
"""Get a platform message history record by its ID."""
...
+ @abc.abstractmethod
+ async def create_webchat_thread(
+ self,
+ creator: str,
+ parent_session_id: str,
+ parent_message_id: int,
+ base_checkpoint_id: str,
+ selected_text: str,
+ ) -> WebChatThread:
+ """Create a WebChat side thread."""
+ ...
+
+ @abc.abstractmethod
+ async def get_webchat_thread_by_id(
+ self,
+ thread_id: str,
+ ) -> WebChatThread | None:
+ """Get a WebChat side thread by thread_id."""
+ ...
+
+ @abc.abstractmethod
+ async def get_webchat_threads_by_parent_session(
+ self,
+ parent_session_id: str,
+ creator: str | None = None,
+ ) -> list[WebChatThread]:
+ """Get side threads for a parent WebChat session."""
+ ...
+
+ @abc.abstractmethod
+ async def get_webchat_thread_by_parent_message_and_text(
+ self,
+ parent_session_id: str,
+ parent_message_id: int,
+ selected_text: str,
+ creator: str | None = None,
+ ) -> WebChatThread | None:
+ """Get an existing side thread for the same selected text."""
+ ...
+
+ @abc.abstractmethod
+ async def delete_webchat_thread(self, thread_id: str) -> None:
+ """Delete a WebChat side thread."""
+ ...
+
+ @abc.abstractmethod
+ async def delete_webchat_threads_by_parent_session(
+ self,
+ parent_session_id: str,
+ ) -> list[str]:
+ """Delete side threads for a parent WebChat session."""
+ ...
+
+ @abc.abstractmethod
+ async def delete_webchat_threads_by_parent_message_ids(
+ self,
+ parent_session_id: str,
+ parent_message_ids: list[int],
+ ) -> list[str]:
+ """Delete side threads linked to parent message IDs."""
+ ...
+
@abc.abstractmethod
async def insert_attachment(
self,
diff --git a/astrbot/core/db/po.py b/astrbot/core/db/po.py
index cabc3432c..0d3b9822a 100644
--- a/astrbot/core/db/po.py
+++ b/astrbot/core/db/po.py
@@ -244,6 +244,37 @@ class PlatformMessageHistory(TimestampMixin, SQLModel, table=True):
default=None,
) # Name of the sender in the platform
content: dict = Field(sa_type=JSON, nullable=False) # a message chain list
+ llm_checkpoint_id: str | None = Field(default=None, index=True)
+
+
+class WebChatThread(TimestampMixin, SQLModel, table=True):
+ """A side thread created from a selected WebChat assistant response."""
+
+ __tablename__: str = "webchat_threads"
+
+ id: int | None = Field(
+ primary_key=True,
+ sa_column_kwargs={"autoincrement": True},
+ default=None,
+ )
+ thread_id: str = Field(
+ max_length=36,
+ nullable=False,
+ unique=True,
+ default_factory=lambda: str(uuid.uuid4()),
+ )
+ creator: str = Field(nullable=False, index=True)
+ parent_session_id: str = Field(nullable=False, index=True)
+ parent_message_id: int = Field(nullable=False, index=True)
+ base_checkpoint_id: str = Field(nullable=False, index=True)
+ selected_text: str = Field(sa_type=Text, nullable=False)
+
+ __table_args__ = (
+ UniqueConstraint(
+ "thread_id",
+ name="uix_webchat_thread_id",
+ ),
+ )
class PlatformSession(TimestampMixin, SQLModel, table=True):
diff --git a/astrbot/core/db/sqlite.py b/astrbot/core/db/sqlite.py
index fd6668c0c..d79ac9d70 100644
--- a/astrbot/core/db/sqlite.py
+++ b/astrbot/core/db/sqlite.py
@@ -26,6 +26,7 @@ from astrbot.core.db.po import (
ProviderStat,
SessionProjectRelation,
SQLModel,
+ WebChatThread,
)
from astrbot.core.db.po import (
Platform as DeprecatedPlatformStat,
@@ -60,6 +61,7 @@ class SQLiteDatabase(BaseDatabase):
await self._ensure_persona_folder_columns(conn)
await self._ensure_persona_skills_column(conn)
await self._ensure_persona_custom_error_message_column(conn)
+ await self._ensure_platform_message_history_checkpoint_column(conn)
await conn.commit()
async def _ensure_persona_folder_columns(self, conn) -> None:
@@ -104,6 +106,26 @@ class SQLiteDatabase(BaseDatabase):
text("ALTER TABLE personas ADD COLUMN custom_error_message TEXT")
)
+ async def _ensure_platform_message_history_checkpoint_column(self, conn) -> None:
+ """Ensure platform_message_history has llm_checkpoint_id."""
+ result = await conn.execute(text("PRAGMA table_info(platform_message_history)"))
+ columns = {row[1] for row in result.fetchall()}
+
+ if "llm_checkpoint_id" not in columns:
+ await conn.execute(
+ text(
+ "ALTER TABLE platform_message_history "
+ "ADD COLUMN llm_checkpoint_id VARCHAR DEFAULT NULL"
+ )
+ )
+ await conn.execute(
+ text(
+ "CREATE INDEX IF NOT EXISTS "
+ "ix_platform_message_history_llm_checkpoint_id "
+ "ON platform_message_history (llm_checkpoint_id)"
+ )
+ )
+
# ====
# Platform Statistics
# ====
@@ -499,6 +521,7 @@ class SQLiteDatabase(BaseDatabase):
content,
sender_id=None,
sender_name=None,
+ llm_checkpoint_id=None,
):
"""Insert a new platform message history record."""
async with self.get_db() as session:
@@ -510,10 +533,46 @@ class SQLiteDatabase(BaseDatabase):
content=content,
sender_id=sender_id,
sender_name=sender_name,
+ llm_checkpoint_id=llm_checkpoint_id,
)
session.add(new_history)
return new_history
+ async def update_platform_message_history(
+ self,
+ message_id: int,
+ content: dict | None = None,
+ llm_checkpoint_id: str | None = None,
+ ) -> None:
+ """Update a platform message history record."""
+ values = {}
+ if content is not None:
+ values["content"] = content
+ if llm_checkpoint_id is not None:
+ values["llm_checkpoint_id"] = llm_checkpoint_id
+ if not values:
+ return
+
+ async with self.get_db() as session:
+ session: AsyncSession
+ async with session.begin():
+ await session.execute(
+ update(PlatformMessageHistory)
+ .where(PlatformMessageHistory.id == message_id)
+ .values(**values)
+ )
+
+ async def delete_platform_message_history_by_id(self, message_id: int) -> None:
+ """Delete a platform message history record by ID."""
+ async with self.get_db() as session:
+ session: AsyncSession
+ async with session.begin():
+ await session.execute(
+ delete(PlatformMessageHistory).where(
+ PlatformMessageHistory.id == message_id
+ )
+ )
+
async def delete_platform_message_offset(
self,
platform_id,
@@ -568,6 +627,136 @@ class SQLiteDatabase(BaseDatabase):
result = await session.execute(query)
return result.scalar_one_or_none()
+ async def create_webchat_thread(
+ self,
+ creator: str,
+ parent_session_id: str,
+ parent_message_id: int,
+ base_checkpoint_id: str,
+ selected_text: str,
+ ) -> WebChatThread:
+ """Create a WebChat side thread."""
+ async with self.get_db() as session:
+ session: AsyncSession
+ async with session.begin():
+ thread = WebChatThread(
+ creator=creator,
+ parent_session_id=parent_session_id,
+ parent_message_id=parent_message_id,
+ base_checkpoint_id=base_checkpoint_id,
+ selected_text=selected_text,
+ )
+ session.add(thread)
+ await session.flush()
+ await session.refresh(thread)
+ return thread
+
+ async def get_webchat_thread_by_id(
+ self,
+ thread_id: str,
+ ) -> WebChatThread | None:
+ """Get a WebChat side thread by thread_id."""
+ async with self.get_db() as session:
+ session: AsyncSession
+ result = await session.execute(
+ select(WebChatThread).where(WebChatThread.thread_id == thread_id)
+ )
+ return result.scalar_one_or_none()
+
+ async def get_webchat_threads_by_parent_session(
+ self,
+ parent_session_id: str,
+ creator: str | None = None,
+ ) -> list[WebChatThread]:
+ """Get side threads for a parent WebChat session."""
+ async with self.get_db() as session:
+ session: AsyncSession
+ query = select(WebChatThread).where(
+ WebChatThread.parent_session_id == parent_session_id
+ )
+ if creator is not None:
+ query = query.where(WebChatThread.creator == creator)
+ query = query.order_by(WebChatThread.created_at)
+ result = await session.execute(query)
+ return list(result.scalars().all())
+
+ async def get_webchat_thread_by_parent_message_and_text(
+ self,
+ parent_session_id: str,
+ parent_message_id: int,
+ selected_text: str,
+ creator: str | None = None,
+ ) -> WebChatThread | None:
+ """Get an existing side thread for the same selected text."""
+ async with self.get_db() as session:
+ session: AsyncSession
+ query = select(WebChatThread).where(
+ WebChatThread.parent_session_id == parent_session_id,
+ WebChatThread.parent_message_id == parent_message_id,
+ WebChatThread.selected_text == selected_text,
+ )
+ if creator is not None:
+ query = query.where(WebChatThread.creator == creator)
+ result = await session.execute(query)
+ return result.scalar_one_or_none()
+
+ async def delete_webchat_thread(self, thread_id: str) -> None:
+ """Delete a WebChat side thread."""
+ async with self.get_db() as session:
+ session: AsyncSession
+ async with session.begin():
+ await session.execute(
+ delete(WebChatThread).where(WebChatThread.thread_id == thread_id)
+ )
+
+ async def delete_webchat_threads_by_parent_session(
+ self,
+ parent_session_id: str,
+ ) -> list[str]:
+ """Delete side threads for a parent WebChat session."""
+ threads = await self.get_webchat_threads_by_parent_session(parent_session_id)
+ thread_ids = [thread.thread_id for thread in threads]
+ if not thread_ids:
+ return []
+ async with self.get_db() as session:
+ session: AsyncSession
+ async with session.begin():
+ await session.execute(
+ delete(WebChatThread).where(
+ col(WebChatThread.thread_id).in_(thread_ids)
+ )
+ )
+ return thread_ids
+
+ async def delete_webchat_threads_by_parent_message_ids(
+ self,
+ parent_session_id: str,
+ parent_message_ids: list[int],
+ ) -> list[str]:
+ """Delete side threads linked to parent message IDs."""
+ if not parent_message_ids:
+ return []
+ async with self.get_db() as session:
+ session: AsyncSession
+ result = await session.execute(
+ select(WebChatThread.thread_id).where(
+ WebChatThread.parent_session_id == parent_session_id,
+ col(WebChatThread.parent_message_id).in_(parent_message_ids),
+ )
+ )
+ thread_ids = list(result.scalars().all())
+ if not thread_ids:
+ return []
+ async with self.get_db() as session:
+ session: AsyncSession
+ async with session.begin():
+ await session.execute(
+ delete(WebChatThread).where(
+ col(WebChatThread.thread_id).in_(thread_ids)
+ )
+ )
+ return thread_ids
+
async def insert_attachment(self, path, type, mime_type):
"""Insert a new attachment record."""
async with self.get_db() as session:
diff --git a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py
index 0d43e4da9..c1d882656 100644
--- a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py
+++ b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py
@@ -6,7 +6,12 @@ from collections.abc import AsyncGenerator
from dataclasses import replace
from astrbot.core import db_helper, logger
-from astrbot.core.agent.message import Message
+from astrbot.core.agent.message import (
+ CheckpointData,
+ CheckpointMessageSegment,
+ Message,
+ dump_messages_with_checkpoints,
+)
from astrbot.core.agent.response import AgentStats
from astrbot.core.astr_main_agent import (
MainAgentBuildConfig,
@@ -444,7 +449,7 @@ class InternalAgentSubStage(Stage):
logger.debug("LLM 响应为空,不保存记录。")
return
- message_to_save = []
+ messages_to_save: list[Message] = []
skipped_initial_system = False
for message in all_messages:
if message.role == "system" and not skipped_initial_system:
@@ -452,7 +457,16 @@ class InternalAgentSubStage(Stage):
continue
if message.role in ["assistant", "user"] and message._no_save:
continue
- message_to_save.append(message.model_dump())
+ messages_to_save.append(message)
+
+ checkpoint_id = event.get_extra("llm_checkpoint_id")
+ message_to_save = dump_messages_with_checkpoints(messages_to_save)
+ if isinstance(checkpoint_id, str) and checkpoint_id:
+ message_to_save.append(
+ CheckpointMessageSegment(
+ content=CheckpointData(id=checkpoint_id),
+ ).model_dump()
+ )
# if user_aborted:
# message_to_save.append(
diff --git a/astrbot/core/platform/sources/webchat/webchat_adapter.py b/astrbot/core/platform/sources/webchat/webchat_adapter.py
index 26b434573..b4d494b34 100644
--- a/astrbot/core/platform/sources/webchat/webchat_adapter.py
+++ b/astrbot/core/platform/sources/webchat/webchat_adapter.py
@@ -250,6 +250,10 @@ class WebChatAdapter(Platform):
"enable_streaming", payload.get("enable_streaming", True)
)
message_event.set_extra("action_type", payload.get("action_type"))
+ message_event.set_extra("llm_checkpoint_id", payload.get("llm_checkpoint_id"))
+ message_event.set_extra(
+ "thread_selected_text", payload.get("thread_selected_text")
+ )
self.commit_event(message_event)
diff --git a/astrbot/core/platform_message_history_mgr.py b/astrbot/core/platform_message_history_mgr.py
index ad8bb44f6..3356ce99c 100644
--- a/astrbot/core/platform_message_history_mgr.py
+++ b/astrbot/core/platform_message_history_mgr.py
@@ -13,6 +13,7 @@ class PlatformMessageHistoryManager:
content: dict, # TODO: parse from message chain
sender_id: str | None = None,
sender_name: str | None = None,
+ llm_checkpoint_id: str | None = None,
) -> PlatformMessageHistory:
"""Insert a new platform message history record."""
return await self.db.insert_platform_message_history(
@@ -21,6 +22,7 @@ class PlatformMessageHistoryManager:
content=content,
sender_id=sender_id,
sender_name=sender_name,
+ llm_checkpoint_id=llm_checkpoint_id,
)
async def get(
@@ -49,3 +51,20 @@ class PlatformMessageHistoryManager:
user_id=user_id,
offset_sec=offset_sec,
)
+
+ async def update(
+ self,
+ message_id: int,
+ content: dict | None = None,
+ llm_checkpoint_id: str | None = None,
+ ) -> None:
+ """Update a platform message history record."""
+ await self.db.update_platform_message_history(
+ message_id=message_id,
+ content=content,
+ llm_checkpoint_id=llm_checkpoint_id,
+ )
+
+ async def delete_by_id(self, message_id: int) -> None:
+ """Delete a platform message history record by ID."""
+ await self.db.delete_platform_message_history_by_id(message_id)
diff --git a/astrbot/core/provider/entities.py b/astrbot/core/provider/entities.py
index b27775cdd..d4bf8814d 100644
--- a/astrbot/core/provider/entities.py
+++ b/astrbot/core/provider/entities.py
@@ -20,6 +20,7 @@ from astrbot.core.agent.message import (
ContentPart,
ToolCall,
ToolCallMessageSegment,
+ is_checkpoint_message,
)
from astrbot.core.agent.tool import ToolSet
from astrbot.core.db.po import Conversation
@@ -150,6 +151,8 @@ class ProviderRequest:
result_parts = []
for ctx in self.contexts:
+ if is_checkpoint_message(ctx):
+ continue
role = ctx.get("role", "unknown")
content = ctx.get("content", "")
diff --git a/astrbot/core/provider/provider.py b/astrbot/core/provider/provider.py
index f2571b506..efe9e2e47 100644
--- a/astrbot/core/provider/provider.py
+++ b/astrbot/core/provider/provider.py
@@ -4,7 +4,7 @@ import os
from collections.abc import AsyncGenerator
from typing import Literal, TypeAlias, Union
-from astrbot.core.agent.message import ContentPart, Message
+from astrbot.core.agent.message import ContentPart, Message, is_checkpoint_message
from astrbot.core.agent.tool import ToolSet
from astrbot.core.provider.entities import (
LLMResponse,
@@ -191,6 +191,8 @@ class Provider(AbstractProvider):
return []
dicts: list[dict] = []
for message in messages:
+ if is_checkpoint_message(message):
+ continue
if isinstance(message, Message):
dicts.append(message.model_dump())
else:
diff --git a/astrbot/dashboard/routes/chat.py b/astrbot/dashboard/routes/chat.py
index 5e6f77db9..d7d4777ac 100644
--- a/astrbot/dashboard/routes/chat.py
+++ b/astrbot/dashboard/routes/chat.py
@@ -4,12 +4,14 @@ import os
import re
import uuid
from contextlib import asynccontextmanager
+from copy import deepcopy
from typing import cast
from quart import Response as QuartResponse
from quart import g, make_response, request, send_file
from astrbot.core import logger, sp
+from astrbot.core.agent.message import get_checkpoint_id, is_checkpoint_message
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db import BaseDatabase
from astrbot.core.platform.message_type import MessageType
@@ -76,6 +78,12 @@ class ChatRoute(Route):
"POST",
self.update_session_display_name,
),
+ "/chat/message/edit": ("POST", self.update_message),
+ "/chat/message/regenerate": ("POST", self.regenerate_message),
+ "/chat/thread/create": ("POST", self.create_thread),
+ "/chat/thread/get": ("GET", self.get_thread),
+ "/chat/thread/send": ("POST", self.send_thread_message),
+ "/chat/thread/delete": ("POST", self.delete_thread),
"/chat/get_file": ("GET", self.get_file),
"/chat/get_attachment": ("GET", self.get_attachment),
"/chat/post_file": ("POST", self.post_file),
@@ -276,6 +284,238 @@ class ChatRoute(Route):
return {"used": used_refs} if used_refs else {}
+ def _sanitize_message_content(self, content: dict) -> dict:
+ """Normalize editable WebChat message content before persisting."""
+ if not isinstance(content, dict):
+ raise ValueError("Missing key: content")
+
+ normalized = deepcopy(content)
+ message_type = normalized.get("type")
+ if message_type not in {"user", "bot"}:
+ raise ValueError("Invalid key: content.type")
+
+ message_parts = normalized.get("message")
+ if not isinstance(message_parts, list):
+ raise ValueError("Missing key: content.message")
+ normalized["message"] = strip_message_parts_path_fields(message_parts)
+ return normalized
+
+ def _extract_platform_message_text(self, content: dict | None) -> str:
+ if not isinstance(content, dict):
+ return ""
+ message_parts = content.get("message")
+ if not isinstance(message_parts, list):
+ return ""
+ texts: list[str] = []
+ for part in message_parts:
+ if isinstance(part, dict) and part.get("type") == "plain":
+ text = part.get("text")
+ if isinstance(text, str):
+ texts.append(text)
+ return "".join(texts)
+
+ def _build_webchat_unified_msg_origin(self, session) -> str:
+ message_type = (
+ MessageType.GROUP_MESSAGE.value
+ if session.is_group
+ else MessageType.FRIEND_MESSAGE.value
+ )
+ return (
+ f"{session.platform_id}:{message_type}:"
+ f"{session.platform_id}!{session.creator}!{session.session_id}"
+ )
+
+ def _build_thread_unified_msg_origin(self, creator: str, thread_id: str) -> str:
+ return (
+ f"webchat:{MessageType.FRIEND_MESSAGE.value}:webchat!{creator}!{thread_id}"
+ )
+
+ def _serialize_thread(self, thread) -> dict:
+ return {
+ "thread_id": thread.thread_id,
+ "parent_session_id": thread.parent_session_id,
+ "parent_message_id": thread.parent_message_id,
+ "base_checkpoint_id": thread.base_checkpoint_id,
+ "selected_text": thread.selected_text,
+ "created_at": to_utc_isoformat(thread.created_at),
+ "updated_at": to_utc_isoformat(thread.updated_at),
+ }
+
+ async def _delete_threads_by_ids(self, thread_ids: list[str], creator: str) -> None:
+ for thread_id in thread_ids:
+ unified_msg_origin = self._build_thread_unified_msg_origin(
+ creator, thread_id
+ )
+ active_event_registry.request_agent_stop_all(unified_msg_origin)
+ await self.conv_mgr.delete_conversations_by_user_id(unified_msg_origin)
+ await self.platform_history_mgr.delete(
+ platform_id="webchat_thread",
+ user_id=thread_id,
+ offset_sec=99999999,
+ )
+ webchat_queue_mgr.remove_queues(thread_id)
+ self.running_convs.pop(thread_id, None)
+
+ async def _load_current_conversation_history(self, session) -> tuple[str, list]:
+ unified_msg_origin = self._build_webchat_unified_msg_origin(session)
+ conversation_id = await self.conv_mgr.get_curr_conversation_id(
+ unified_msg_origin
+ )
+ if not conversation_id:
+ return "", []
+
+ conversation = await self.conv_mgr.get_conversation(
+ unified_msg_origin=unified_msg_origin,
+ conversation_id=conversation_id,
+ )
+ if not conversation:
+ return "", []
+
+ try:
+ history = json.loads(conversation.history or "[]")
+ except json.JSONDecodeError:
+ return "", []
+ return conversation_id, history if isinstance(history, list) else []
+
+ def _find_checkpoint_index(
+ self, history: list[dict], checkpoint_id: str
+ ) -> int | None:
+ for index, message in enumerate(history):
+ if get_checkpoint_id(message) == checkpoint_id:
+ return index
+ return None
+
+ def _find_turn_range(
+ self, history: list[dict], checkpoint_id: str
+ ) -> tuple[int, int] | None:
+ checkpoint_index = self._find_checkpoint_index(history, checkpoint_id)
+ if checkpoint_index is None:
+ return None
+
+ start = 0
+ for index in range(checkpoint_index - 1, -1, -1):
+ if is_checkpoint_message(history[index]):
+ start = index + 1
+ break
+ return start, checkpoint_index
+
+ def _is_latest_checkpoint(self, history: list[dict], checkpoint_id: str) -> bool:
+ for message in reversed(history):
+ current_checkpoint_id = get_checkpoint_id(message)
+ if current_checkpoint_id:
+ return current_checkpoint_id == checkpoint_id
+ return False
+
+ def _replace_user_conversation_content(self, original_content, edited_text: str):
+ if isinstance(original_content, str):
+ return edited_text
+ if not isinstance(original_content, list):
+ return edited_text
+
+ result: list[dict] = []
+ inserted_text = False
+ for part in original_content:
+ if not isinstance(part, dict):
+ result.append(part)
+ continue
+ if part.get("type") != "text":
+ result.append(part)
+ continue
+ text = part.get("text")
+ if isinstance(text, str) and text.startswith(""):
+ result.append(part)
+ continue
+ if not inserted_text and edited_text:
+ result.append({"type": "text", "text": edited_text})
+ inserted_text = True
+
+ if not inserted_text and edited_text:
+ result.insert(0, {"type": "text", "text": edited_text})
+ return result
+
+ def _replace_assistant_conversation_content(
+ self,
+ original_content,
+ edited_text: str,
+ reasoning: str,
+ ):
+ if isinstance(original_content, str):
+ return edited_text
+ if not isinstance(original_content, list):
+ return [{"type": "text", "text": edited_text}] if edited_text else []
+
+ result: list[dict] = []
+ inserted_text = False
+ inserted_think = False
+ for part in original_content:
+ if not isinstance(part, dict):
+ result.append(part)
+ continue
+ if part.get("type") == "text":
+ if not inserted_text and edited_text:
+ result.append({"type": "text", "text": edited_text})
+ inserted_text = True
+ continue
+ if part.get("type") == "think":
+ if not inserted_think and reasoning:
+ result.append({"type": "think", "think": reasoning})
+ inserted_think = True
+ continue
+ result.append(part)
+
+ if reasoning and not inserted_think:
+ result.insert(0, {"type": "think", "think": reasoning})
+ if edited_text and not inserted_text:
+ result.append({"type": "text", "text": edited_text})
+ return result
+
+ def _find_turn_user_index(
+ self, history: list[dict], start: int, end: int
+ ) -> int | None:
+ for index in range(start, end):
+ message = history[index]
+ if isinstance(message, dict) and message.get("role") == "user":
+ return index
+ return None
+
+ def _find_turn_final_assistant_index(
+ self, history: list[dict], start: int, end: int
+ ) -> int | None:
+ for index in range(end - 1, start - 1, -1):
+ message = history[index]
+ if not isinstance(message, dict) or message.get("role") != "assistant":
+ continue
+ if message.get("tool_calls") and not message.get("content"):
+ continue
+ return index
+ return None
+
+ async def _get_sorted_platform_history(self, session) -> list:
+ history_list = await self.platform_history_mgr.get(
+ platform_id=session.platform_id,
+ user_id=session.session_id,
+ page=1,
+ page_size=100000,
+ )
+ history_list.sort(key=lambda item: (item.created_at, item.id))
+ return history_list
+
+ async def _delete_platform_history_after(
+ self, session, message_id: int
+ ) -> list[int]:
+ history_list = await self._get_sorted_platform_history(session)
+ should_delete = False
+ deleted_ids: list[int] = []
+ for item in history_list:
+ if should_delete:
+ if item.id is not None:
+ deleted_ids.append(item.id)
+ await self.platform_history_mgr.delete_by_id(item.id)
+ continue
+ if item.id == message_id:
+ should_delete = True
+ return deleted_ids
+
async def _save_bot_message(
self,
webchat_conv_id: str,
@@ -284,6 +524,8 @@ class ChatRoute(Route):
reasoning: str,
agent_stats: dict,
refs: dict,
+ llm_checkpoint_id: str | None = None,
+ platform_history_id: str = "webchat",
):
"""保存 bot 消息到历史记录,返回保存的记录"""
bot_message_parts = []
@@ -300,11 +542,12 @@ class ChatRoute(Route):
new_his["refs"] = refs
record = await self.platform_history_mgr.insert(
- platform_id="webchat",
+ platform_id=platform_history_id,
user_id=webchat_conv_id,
content=new_his,
sender_id="bot",
sender_name="bot",
+ llm_checkpoint_id=llm_checkpoint_id,
)
return record
@@ -328,6 +571,8 @@ class ChatRoute(Route):
selected_provider = post_data.get("selected_provider")
selected_model = post_data.get("selected_model")
enable_streaming = post_data.get("enable_streaming", True)
+ platform_history_id = post_data.get("_platform_history_id") or "webchat"
+ thread_selected_text = post_data.get("_thread_selected_text")
if not session_id:
return Response().error("session_id is empty").__dict__
@@ -344,10 +589,13 @@ class ChatRoute(Route):
)
message_id = str(uuid.uuid4())
+ llm_checkpoint_id = post_data.get("_llm_checkpoint_id") or str(uuid.uuid4())
+ skip_user_history = bool(post_data.get("_skip_user_history"))
back_queue = webchat_queue_mgr.get_or_create_back_queue(
message_id,
webchat_conv_id,
)
+ saved_user_record = None
async def stream():
client_disconnected = False
@@ -365,6 +613,18 @@ class ChatRoute(Route):
"session_id": webchat_conv_id,
}
yield f"data: {json.dumps(session_info, ensure_ascii=False)}\n\n"
+ if saved_user_record and not client_disconnected:
+ user_saved_info = {
+ "type": "user_message_saved",
+ "data": {
+ "id": saved_user_record.id,
+ "created_at": to_utc_isoformat(
+ saved_user_record.created_at
+ ),
+ "llm_checkpoint_id": llm_checkpoint_id,
+ },
+ }
+ yield f"data: {json.dumps(user_saved_info, ensure_ascii=False)}\n\n"
async with track_conversation(self.running_convs, webchat_conv_id):
while True:
@@ -507,6 +767,8 @@ class ChatRoute(Route):
accumulated_reasoning,
agent_stats,
refs,
+ llm_checkpoint_id,
+ platform_history_id,
)
# 发送保存的消息信息给前端
if saved_record and not client_disconnected:
@@ -517,6 +779,7 @@ class ChatRoute(Route):
"created_at": to_utc_isoformat(
saved_record.created_at
),
+ "llm_checkpoint_id": llm_checkpoint_id,
},
}
try:
@@ -546,19 +809,23 @@ class ChatRoute(Route):
"selected_model": selected_model,
"enable_streaming": enable_streaming,
"message_id": message_id,
+ "llm_checkpoint_id": llm_checkpoint_id,
+ "thread_selected_text": thread_selected_text,
},
),
)
message_parts_for_storage = strip_message_parts_path_fields(message_parts)
- await self.platform_history_mgr.insert(
- platform_id="webchat",
- user_id=webchat_conv_id,
- content={"type": "user", "message": message_parts_for_storage},
- sender_id=username,
- sender_name=username,
- )
+ if not skip_user_history:
+ saved_user_record = await self.platform_history_mgr.insert(
+ platform_id=platform_history_id,
+ user_id=webchat_conv_id,
+ content={"type": "user", "message": message_parts_for_storage},
+ sender_id=username,
+ sender_name=username,
+ llm_checkpoint_id=llm_checkpoint_id,
+ )
response = cast(
QuartResponse,
@@ -631,6 +898,8 @@ class ChatRoute(Route):
user_id=session_id,
offset_sec=99999999,
)
+ thread_ids = await self.db.delete_webchat_threads_by_parent_session(session_id)
+ await self._delete_threads_by_ids(thread_ids, username)
# 删除与会话关联的配置路由
try:
@@ -832,9 +1101,14 @@ class ChatRoute(Route):
)
history_res = [history.model_dump() for history in history_ls]
+ threads = await self.db.get_webchat_threads_by_parent_session(
+ parent_session_id=session_id,
+ creator=username,
+ )
response_data = {
"history": history_res,
+ "threads": [self._serialize_thread(thread) for thread in threads],
"is_running": self.running_convs.get(session_id, False),
}
@@ -848,6 +1122,396 @@ class ChatRoute(Route):
return Response().ok(data=response_data).__dict__
+ async def create_thread(self):
+ """Create or reuse a side thread from a selected assistant message."""
+ post_data = await request.json
+ if post_data is None:
+ return Response().error("Missing JSON body").__dict__
+
+ session_id = post_data.get("session_id")
+ parent_message_id = post_data.get("parent_message_id")
+ selected_text = str(post_data.get("selected_text") or "").strip()
+ if not session_id:
+ return Response().error("Missing key: session_id").__dict__
+ if parent_message_id is None:
+ return Response().error("Missing key: parent_message_id").__dict__
+ if not selected_text:
+ return Response().error("Missing key: selected_text").__dict__
+
+ try:
+ parent_message_id = int(parent_message_id)
+ except (TypeError, ValueError):
+ return Response().error("Invalid key: parent_message_id").__dict__
+
+ username = g.get("username", "guest")
+ session = await self.db.get_platform_session_by_id(session_id)
+ if not session:
+ return Response().error(f"Session {session_id} not found").__dict__
+ if session.creator != username:
+ return Response().error("Permission denied").__dict__
+
+ parent_record = await self.db.get_platform_message_history_by_id(
+ parent_message_id
+ )
+ if (
+ not parent_record
+ or parent_record.platform_id != session.platform_id
+ or parent_record.user_id != session_id
+ ):
+ return Response().error("Parent message not found").__dict__
+ if not isinstance(parent_record.content, dict):
+ return Response().error("Invalid parent message content").__dict__
+ if parent_record.content.get("type") != "bot":
+ return Response().error("Only bot messages can create threads").__dict__
+
+ checkpoint_id = parent_record.llm_checkpoint_id
+ if not checkpoint_id:
+ return (
+ Response().error("Parent message is not linked to LLM history").__dict__
+ )
+
+ existing = await self.db.get_webchat_thread_by_parent_message_and_text(
+ parent_session_id=session_id,
+ parent_message_id=parent_message_id,
+ selected_text=selected_text,
+ creator=username,
+ )
+ if existing:
+ return Response().ok(data=self._serialize_thread(existing)).__dict__
+
+ conversation_id, history = await self._load_current_conversation_history(
+ session
+ )
+ turn_range = self._find_turn_range(history, checkpoint_id)
+ if not conversation_id or not turn_range:
+ return Response().error("Linked checkpoint not found").__dict__
+
+ _start, end = turn_range
+ base_history = history[: end + 1]
+ thread = await self.db.create_webchat_thread(
+ creator=username,
+ parent_session_id=session_id,
+ parent_message_id=parent_message_id,
+ base_checkpoint_id=checkpoint_id,
+ selected_text=selected_text,
+ )
+ await self.conv_mgr.new_conversation(
+ unified_msg_origin=self._build_thread_unified_msg_origin(
+ username,
+ thread.thread_id,
+ ),
+ platform_id="webchat",
+ content=base_history,
+ )
+ return Response().ok(data=self._serialize_thread(thread)).__dict__
+
+ async def get_thread(self):
+ """Get a side thread and its message history."""
+ thread_id = request.args.get("thread_id")
+ if not thread_id:
+ return Response().error("Missing key: thread_id").__dict__
+
+ username = g.get("username", "guest")
+ thread = await self.db.get_webchat_thread_by_id(thread_id)
+ if not thread:
+ return Response().error(f"Thread {thread_id} not found").__dict__
+ if thread.creator != username:
+ return Response().error("Permission denied").__dict__
+
+ history_ls = await self.platform_history_mgr.get(
+ platform_id="webchat_thread",
+ user_id=thread_id,
+ page=1,
+ page_size=1000,
+ )
+ return (
+ Response()
+ .ok(
+ data={
+ "thread": self._serialize_thread(thread),
+ "history": [history.model_dump() for history in history_ls],
+ "is_running": self.running_convs.get(thread_id, False),
+ }
+ )
+ .__dict__
+ )
+
+ async def send_thread_message(self):
+ """Send a message inside a WebChat side thread."""
+ post_data = await request.json
+ if post_data is None:
+ return Response().error("Missing JSON body").__dict__
+
+ thread_id = post_data.get("thread_id")
+ if not thread_id:
+ return Response().error("Missing key: thread_id").__dict__
+
+ username = g.get("username", "guest")
+ thread = await self.db.get_webchat_thread_by_id(thread_id)
+ if not thread:
+ return Response().error(f"Thread {thread_id} not found").__dict__
+ if thread.creator != username:
+ return Response().error("Permission denied").__dict__
+
+ return await self.chat(
+ {
+ "session_id": thread.thread_id,
+ "message": post_data.get("message", []),
+ "enable_streaming": post_data.get("enable_streaming", True),
+ "selected_provider": post_data.get("selected_provider"),
+ "selected_model": post_data.get("selected_model"),
+ "_platform_history_id": "webchat_thread",
+ "_thread_selected_text": thread.selected_text,
+ }
+ )
+
+ async def delete_thread(self):
+ """Delete a WebChat side thread and its isolated history."""
+ post_data = await request.json
+ if post_data is None:
+ return Response().error("Missing JSON body").__dict__
+
+ thread_id = post_data.get("thread_id")
+ if not thread_id:
+ return Response().error("Missing key: thread_id").__dict__
+
+ username = g.get("username", "guest")
+ thread = await self.db.get_webchat_thread_by_id(thread_id)
+ if not thread:
+ return Response().error(f"Thread {thread_id} not found").__dict__
+ if thread.creator != username:
+ return Response().error("Permission denied").__dict__
+
+ await self.db.delete_webchat_thread(thread_id)
+ await self._delete_threads_by_ids([thread_id], username)
+ return Response().ok(data={"thread_id": thread_id}).__dict__
+
+ async def update_message(self):
+ """Update a persisted WebChat message and its linked LLM turn."""
+ post_data = await request.json
+ if post_data is None:
+ return Response().error("Missing JSON body").__dict__
+
+ session_id = post_data.get("session_id")
+ message_id = post_data.get("message_id")
+ content = post_data.get("content")
+ if not session_id:
+ return Response().error("Missing key: session_id").__dict__
+ if message_id is None:
+ return Response().error("Missing key: message_id").__dict__
+
+ try:
+ message_id = int(message_id)
+ content = self._sanitize_message_content(content)
+ except (TypeError, ValueError) as exc:
+ return Response().error(str(exc)).__dict__
+
+ username = g.get("username", "guest")
+ session = await self.db.get_platform_session_by_id(session_id)
+ if not session:
+ return Response().error(f"Session {session_id} not found").__dict__
+ if session.creator != username:
+ return Response().error("Permission denied").__dict__
+
+ record = await self.db.get_platform_message_history_by_id(message_id)
+ if not record:
+ return Response().error(f"Message {message_id} not found").__dict__
+ if record.platform_id != session.platform_id or record.user_id != session_id:
+ return Response().error("Message does not belong to the session").__dict__
+ if not isinstance(record.content, dict):
+ return Response().error("Invalid message content").__dict__
+ if record.content.get("type") != content.get("type"):
+ return Response().error("Message type cannot be changed").__dict__
+ if content.get("type") != "user":
+ return Response().error("Only user messages can be edited").__dict__
+
+ platform_history = await self._get_sorted_platform_history(session)
+ latest_user_record = next(
+ (
+ item
+ for item in reversed(platform_history)
+ if isinstance(item.content, dict) and item.content.get("type") == "user"
+ ),
+ None,
+ )
+ if not latest_user_record or latest_user_record.id != message_id:
+ return (
+ Response().error("Only the latest user message can be edited").__dict__
+ )
+
+ checkpoint_id = record.llm_checkpoint_id
+ if not checkpoint_id:
+ return (
+ Response()
+ .error("This message is not linked to LLM history and cannot be edited")
+ .__dict__
+ )
+
+ conversation_id, history = await self._load_current_conversation_history(
+ session
+ )
+ turn_range = self._find_turn_range(history, checkpoint_id)
+ if not conversation_id or not turn_range:
+ return Response().error("Linked checkpoint not found").__dict__
+ if not self._is_latest_checkpoint(history, checkpoint_id):
+ return Response().error("Only the latest turn can be edited").__dict__
+
+ start, _end = turn_range
+
+ target_index = self._find_turn_user_index(history, start, _end)
+ if target_index is None:
+ return Response().error("Linked user message not found").__dict__
+
+ new_checkpoint_id = str(uuid.uuid4())
+ truncated_history = history[:start]
+ await self.platform_history_mgr.update(
+ message_id=message_id,
+ content=content,
+ llm_checkpoint_id=new_checkpoint_id,
+ )
+ deleted_message_ids = await self._delete_platform_history_after(
+ session, message_id
+ )
+ thread_ids = await self.db.delete_webchat_threads_by_parent_message_ids(
+ session_id,
+ deleted_message_ids,
+ )
+ await self._delete_threads_by_ids(thread_ids, username)
+ await self.conv_mgr.update_conversation(
+ unified_msg_origin=self._build_webchat_unified_msg_origin(session),
+ conversation_id=conversation_id,
+ history=truncated_history,
+ )
+ await self.db.update_platform_session(session_id=session_id)
+ updated = await self.db.get_platform_message_history_by_id(message_id)
+ return (
+ Response()
+ .ok(
+ data={
+ "message": updated.model_dump() if updated else None,
+ "needs_regenerate": True,
+ "truncated_after_message": True,
+ }
+ )
+ .__dict__
+ )
+
+ async def regenerate_message(self):
+ """Regenerate the latest bot message linked to an LLM checkpoint."""
+ post_data = await request.json
+ if post_data is None:
+ return Response().error("Missing JSON body").__dict__
+
+ session_id = post_data.get("session_id")
+ message_id = post_data.get("message_id")
+ if not session_id:
+ return Response().error("Missing key: session_id").__dict__
+ if message_id is None:
+ return Response().error("Missing key: message_id").__dict__
+
+ try:
+ message_id = int(message_id)
+ except (TypeError, ValueError):
+ return Response().error("Invalid key: message_id").__dict__
+
+ username = g.get("username", "guest")
+ session = await self.db.get_platform_session_by_id(session_id)
+ if not session:
+ return Response().error(f"Session {session_id} not found").__dict__
+ if session.creator != username:
+ return Response().error("Permission denied").__dict__
+
+ target_record = await self.db.get_platform_message_history_by_id(message_id)
+ if not target_record:
+ return Response().error(f"Message {message_id} not found").__dict__
+ if (
+ target_record.platform_id != session.platform_id
+ or target_record.user_id != session_id
+ ):
+ return Response().error("Message does not belong to the session").__dict__
+ if not isinstance(target_record.content, dict):
+ return Response().error("Invalid message content").__dict__
+ if target_record.content.get("type") != "bot":
+ return Response().error("Only bot messages can be regenerated").__dict__
+
+ checkpoint_id = target_record.llm_checkpoint_id
+ if not checkpoint_id:
+ return Response().error("Message is not linked to LLM history").__dict__
+
+ conversation_id, history = await self._load_current_conversation_history(
+ session
+ )
+ turn_range = self._find_turn_range(history, checkpoint_id)
+ if not conversation_id or not turn_range:
+ return Response().error("Linked checkpoint not found").__dict__
+ if not self._is_latest_checkpoint(history, checkpoint_id):
+ return (
+ Response().error("Regenerating older turns requires branching").__dict__
+ )
+
+ start, end = turn_range
+ user_index = self._find_turn_user_index(history, start, end)
+ if user_index is None:
+ return Response().error("Linked user message not found").__dict__
+
+ platform_history = await self._get_sorted_platform_history(session)
+ source_user_record = next(
+ (
+ item
+ for item in reversed(platform_history)
+ if item.llm_checkpoint_id == checkpoint_id
+ and isinstance(item.content, dict)
+ and item.content.get("type") == "user"
+ ),
+ None,
+ )
+ if not source_user_record:
+ return Response().error("Linked user display message not found").__dict__
+
+ old_bot_record_ids = [
+ item.id
+ for item in platform_history
+ if item.id is not None
+ and item.llm_checkpoint_id == checkpoint_id
+ and isinstance(item.content, dict)
+ and item.content.get("type") == "bot"
+ ]
+ if not old_bot_record_ids:
+ return Response().error("Linked bot display message not found").__dict__
+
+ new_checkpoint_id = str(uuid.uuid4())
+ # The WebChat send path adds the current user message from the prompt.
+ # Remove the whole old turn here to avoid duplicating that user message.
+ new_history = history[:start] + history[end + 1 :]
+ await self.conv_mgr.update_conversation(
+ unified_msg_origin=self._build_webchat_unified_msg_origin(session),
+ conversation_id=conversation_id,
+ history=new_history,
+ )
+ thread_ids = await self.db.delete_webchat_threads_by_parent_message_ids(
+ session_id,
+ old_bot_record_ids,
+ )
+ await self._delete_threads_by_ids(thread_ids, username)
+ for old_bot_record_id in old_bot_record_ids:
+ await self.platform_history_mgr.delete_by_id(old_bot_record_id)
+ await self.platform_history_mgr.update(
+ message_id=source_user_record.id,
+ llm_checkpoint_id=new_checkpoint_id,
+ )
+
+ return await self.chat(
+ {
+ "session_id": session_id,
+ "message": source_user_record.content.get("message", []),
+ "enable_streaming": post_data.get("enable_streaming", True),
+ "selected_provider": post_data.get("selected_provider"),
+ "selected_model": post_data.get("selected_model"),
+ "_skip_user_history": True,
+ "_llm_checkpoint_id": new_checkpoint_id,
+ }
+ )
+
async def update_session_display_name(self):
"""Update a Platform session's display name."""
post_data = await request.json
diff --git a/astrbot/dashboard/routes/live_chat.py b/astrbot/dashboard/routes/live_chat.py
index dafb3c2f8..16c605848 100644
--- a/astrbot/dashboard/routes/live_chat.py
+++ b/astrbot/dashboard/routes/live_chat.py
@@ -255,6 +255,7 @@ class LiveChatRoute(Route):
reasoning: str,
agent_stats: dict,
refs: dict,
+ llm_checkpoint_id: str | None = None,
):
"""保存 bot 消息到历史记录。"""
bot_message_parts = []
@@ -276,6 +277,7 @@ class LiveChatRoute(Route):
content=new_his,
sender_id="bot",
sender_name="bot",
+ llm_checkpoint_id=llm_checkpoint_id,
)
async def _send_chat_payload(self, session: LiveChatSession, payload: dict) -> None:
@@ -452,6 +454,7 @@ class LiveChatRoute(Route):
session.is_processing = True
session.should_interrupt = False
back_queue = webchat_queue_mgr.get_or_create_back_queue(message_id, session_id)
+ llm_checkpoint_id = str(uuid.uuid4())
try:
chat_queue = webchat_queue_mgr.get_or_create_queue(session_id)
@@ -469,17 +472,31 @@ class LiveChatRoute(Route):
"show_reasoning": show_reasoning,
"enable_streaming": enable_streaming,
"message_id": message_id,
+ "llm_checkpoint_id": llm_checkpoint_id,
},
),
)
message_parts_for_storage = strip_message_parts_path_fields(message_parts)
- await self.platform_history_mgr.insert(
+ saved_user_record = await self.platform_history_mgr.insert(
platform_id="webchat",
user_id=session_id,
content={"type": "user", "message": message_parts_for_storage},
sender_id=session.username,
sender_name=session.username,
+ llm_checkpoint_id=llm_checkpoint_id,
+ )
+ await self._send_chat_payload(
+ session,
+ {
+ "ct": "chat",
+ "type": "user_message_saved",
+ "data": {
+ "id": saved_user_record.id,
+ "created_at": to_utc_isoformat(saved_user_record.created_at),
+ "llm_checkpoint_id": llm_checkpoint_id,
+ },
+ },
)
accumulated_parts = []
@@ -618,6 +635,7 @@ class LiveChatRoute(Route):
accumulated_reasoning,
agent_stats,
refs,
+ llm_checkpoint_id,
)
if saved_record:
await self._send_chat_payload(
@@ -630,6 +648,7 @@ class LiveChatRoute(Route):
"created_at": to_utc_isoformat(
saved_record.created_at
),
+ "llm_checkpoint_id": llm_checkpoint_id,
},
},
)
diff --git a/dashboard/src/components/chat/Chat.vue b/dashboard/src/components/chat/Chat.vue
index b24aeed04..ecf61a937 100644
--- a/dashboard/src/components/chat/Chat.vue
+++ b/dashboard/src/components/chat/Chat.vue
@@ -344,236 +344,32 @@
-
-
-
-
- ✦
-
-
-
-
-
-
- {{ tm("message.loading") }}
-
-
-
-
-
-
-
-
-
- {{ part.text || "" }}
-
-
-
-
-
-
-
-
-
-
-
- mdi-file-document-outline
- {{ part.filename || "file" }}
-
-
-
-
-
-
-
- mdi-code-json
- {{ tool.name || "python" }}
-
- {{ toolCallStatusText(tool) }}
-
-
-
-
-
-
-
-
-
-
-
- {{ formatJson(part) }}
-
-
-
-
-
-
-
-
+
@@ -610,6 +406,23 @@
+
+
+
+
+
-
-
-
-
@@ -678,8 +485,6 @@ import {
import { useRoute, useRouter } from "vue-router";
import { useDisplay } from "vuetify";
import axios from "axios";
-import { setCustomComponents } from "markstream-vue";
-import "markstream-vue/index.css";
import StyledMenu from "@/components/shared/StyledMenu.vue";
import ProviderConfigDialog from "@/components/chat/ProviderConfigDialog.vue";
import ProjectDialog, {
@@ -688,19 +493,15 @@ import ProjectDialog, {
import ProjectList, { type Project } from "@/components/chat/ProjectList.vue";
import ProjectView from "@/components/chat/ProjectView.vue";
import ChatInput from "@/components/chat/ChatInput.vue";
-import ReasoningBlock from "@/components/chat/message_list_comps/ReasoningBlock.vue";
-import ToolCallCard from "@/components/chat/message_list_comps/ToolCallCard.vue";
-import ToolCallItem from "@/components/chat/message_list_comps/ToolCallItem.vue";
-import IPythonToolBlock from "@/components/chat/message_list_comps/IPythonToolBlock.vue";
+import ChatMessageList from "@/components/chat/ChatMessageList.vue";
+import type { RegenerateModelSelection } from "@/components/chat/RegenerateMenu.vue";
+import ThreadPanel from "@/components/chat/ThreadPanel.vue";
import RefsSidebar from "@/components/chat/message_list_comps/RefsSidebar.vue";
-import RefNode from "@/components/chat/message_list_comps/RefNode.vue";
-import ActionRef from "@/components/chat/message_list_comps/ActionRef.vue";
-import MarkdownMessagePart from "@/components/chat/message_list_comps/MarkdownMessagePart.vue";
-import ThemeAwareMarkdownCodeBlock from "@/components/shared/ThemeAwareMarkdownCodeBlock.vue";
import { useSessions, type Session } from "@/composables/useSessions";
import {
useMessages,
type ChatRecord,
+ type ChatThread,
type MessagePart,
type TransportMode,
} from "@/composables/useMessages";
@@ -714,16 +515,12 @@ import {
} from "@/i18n/composables";
import type { Locale } from "@/i18n/types";
import { askForConfirmation, useConfirmDialog } from "@/utils/confirmDialog";
+import { useToast } from "@/utils/toast";
const props = withDefaults(defineProps<{ chatboxMode?: boolean }>(), {
chatboxMode: false,
});
-setCustomComponents("chat-message", {
- ref: RefNode,
- code_block: ThemeAwareMarkdownCodeBlock,
-});
-
const route = useRoute();
const router = useRouter();
const { lgAndUp } = useDisplay();
@@ -731,6 +528,7 @@ const customizer = useCustomizerStore();
const { t } = useI18n();
const { tm } = useModuleI18n("features/chat");
const confirmDialog = useConfirmDialog();
+const toast = useToast();
const { languageOptions, currentLanguage, switchLanguage, locale } =
useLanguageSwitcher();
const {
@@ -777,17 +575,34 @@ const sessionTitleDraft = ref("");
const editingSessionTitleId = ref("");
const refreshProjectSessionsAfterTitleSave = ref(false);
const savingSessionTitle = ref(false);
+const messageEditDraft = ref("");
+const editingMessage = ref(null);
+const savingMessageEdit = ref(false);
const projectSessions = ref([]);
const loadingSessions = ref(false);
const draft = ref("");
-const downloadingFiles = ref(new Set());
const messagesContainer = ref(null);
const inputRef = ref | null>(null);
const shouldStickToBottom = ref(true);
const replyTarget = ref(null);
-const imagePreview = reactive({ visible: false, url: "" });
+const threadPanelOpen = ref(false);
+const activeThread = ref(null);
+const deletingThread = ref(false);
const refsSidebarOpen = ref(false);
const selectedRefs = ref | null>(null);
+const threadSelection = reactive<{
+ visible: boolean;
+ left: number;
+ top: number;
+ message: ChatRecord | null;
+ selectedText: string;
+}>({
+ visible: false,
+ left: 0,
+ top: 0,
+ message: null,
+ selectedText: "",
+});
const enableStreaming = ref(true);
const isRecording = ref(false);
const sendShortcut = ref<"enter" | "shift_enter">("enter");
@@ -811,12 +626,13 @@ const {
activeMessages,
isSessionRunning,
isUserMessage,
- isMessageStreaming,
- messageContent,
messageParts,
loadSessionMessages,
createLocalExchange,
sendMessageStream,
+ editMessage,
+ continueEditedMessage,
+ regenerateMessage,
stopSession,
} = useMessages({
currentSessionId: currSessionId,
@@ -853,7 +669,6 @@ const canSend = computed(
() =>
Boolean(draft.value.trim() || stagedFiles.value.length) && !sending.value,
);
-const customMarkdownTags = ["ref"];
const currentSession = computed(
() =>
sessions.value.find(
@@ -1109,7 +924,7 @@ async function sendCurrentMessage() {
const messageId = crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`;
const outgoingParts = buildOutgoingParts(text);
const selection = inputRef.value?.getCurrentSelection();
- const { botRecord } = createLocalExchange({
+ const { userRecord, botRecord } = createLocalExchange({
sessionId,
messageId,
parts: outgoingParts,
@@ -1129,6 +944,7 @@ async function sendCurrentMessage() {
enableStreaming: enableStreaming.value,
selectedProvider: selection?.providerId || "",
selectedModel: selection?.modelName || "",
+ userRecord,
botRecord,
});
} catch (error) {
@@ -1161,45 +977,12 @@ function buildOutgoingParts(text: string): MessagePart[] {
return parts;
}
-function hasNonReasoningContent(message: ChatRecord) {
- return messageParts(message).some((part) => {
- if (part.type === "reply") return false;
- if (part.type === "plain") return Boolean(String(part.text || "").trim());
- return true;
- });
-}
-
function updateTitleFromText(sessionId: string, text: string) {
const session = sessions.value.find((item) => item.session_id === sessionId);
if (!session || session.display_name || !text) return;
updateSessionTitle(sessionId, text.slice(0, 40));
}
-function partUrl(part: MessagePart) {
- if (part.embedded_url) return part.embedded_url;
- if (part.embedded_file?.url) return part.embedded_file.url;
- if (part.attachment_id)
- return `/api/chat/get_attachment?attachment_id=${encodeURIComponent(
- part.attachment_id,
- )}`;
- if (part.filename)
- return `/api/chat/get_file?filename=${encodeURIComponent(part.filename)}`;
- return "";
-}
-
-function formatJson(value: unknown) {
- if (typeof value === "string") {
- const parsed = parseJsonSafe(value);
- if (parsed !== value) return JSON.stringify(parsed, null, 2);
- return value;
- }
- try {
- return JSON.stringify(value, null, 2);
- } catch {
- return String(value ?? "");
- }
-}
-
function replyPreview(messageId?: string | number, fallback?: string) {
if (fallback) return truncate(fallback, 80);
const found = activeMessages.value.find(
@@ -1230,48 +1013,141 @@ function scrollToMessage(messageId?: string | number) {
rows?.[index]?.scrollIntoView({ behavior: "smooth", block: "center" });
}
-function setReplyTarget(message: ChatRecord) {
- replyTarget.value = message;
- nextTick(() => inputRef.value?.focusInput?.());
+function openMessageEdit(message: ChatRecord) {
+ messageEditDraft.value = plainTextFromMessage(message);
+ editingMessage.value = message;
+ nextTick(() => scrollToMessage(message.id));
}
-function showMessageMeta(message: ChatRecord, msgIndex: number) {
- return (
- !messageContent(message).isLoading && !isMessageStreaming(message, msgIndex)
+function cancelMessageEdit() {
+ editingMessage.value = null;
+ messageEditDraft.value = "";
+}
+
+async function saveMessageEdit() {
+ if (!currSessionId.value || !editingMessage.value) return;
+ savingMessageEdit.value = true;
+ try {
+ const target = editingMessage.value;
+ const result = await editMessage(
+ currSessionId.value,
+ target,
+ messageEditDraft.value,
+ );
+ cancelMessageEdit();
+
+ if (result.needsRegenerate && result.truncatedAfterMessage) {
+ const selection = inputRef.value?.getCurrentSelection();
+ continueEditedMessage({
+ sessionId: currSessionId.value,
+ sourceRecord: target,
+ enableStreaming: enableStreaming.value,
+ selectedProvider: selection?.providerId || "",
+ selectedModel: selection?.modelName || "",
+ });
+ scrollToBottom();
+ } else if (result.needsRegenerate) {
+ const index = activeMessages.value.findIndex(
+ (message) => String(message.id) === String(target.id),
+ );
+ const nextBot = activeMessages.value
+ .slice(index + 1)
+ .find((message) => !isUserMessage(message));
+ if (nextBot) {
+ await handleRegenerateMessage(nextBot);
+ }
+ }
+ } catch (error) {
+ console.error("Failed to edit message:", error);
+ } finally {
+ savingMessageEdit.value = false;
+ }
+}
+
+async function handleRegenerateMessage(
+ message: ChatRecord,
+ selection?: RegenerateModelSelection,
+) {
+ if (!currSessionId.value || isUserMessage(message)) return;
+ message.threads = [];
+ await regenerateMessage(
+ currSessionId.value,
+ message,
+ selection?.providerId || "",
+ selection?.modelName || "",
);
}
-function messageRefs(message: ChatRecord) {
- return resolvedMessageRefs(message).used;
+function handleBotTextSelection(event: MouseEvent, message: ChatRecord) {
+ if (message.id == null || String(message.id).startsWith("local-")) return;
+ const container = event.currentTarget as HTMLElement | null;
+ window.setTimeout(() => {
+ const selection = window.getSelection();
+ const selectedText = selection?.toString().trim() || "";
+ if (!selection || !selectedText) {
+ threadSelection.visible = false;
+ return;
+ }
+ if (
+ !container ||
+ !container.contains(selection.anchorNode) ||
+ !container.contains(selection.focusNode)
+ ) {
+ threadSelection.visible = false;
+ return;
+ }
+ const range = selection.getRangeAt(0);
+ const rect = range.getBoundingClientRect();
+ threadSelection.message = message;
+ threadSelection.selectedText = selectedText;
+ threadSelection.left = Math.min(
+ window.innerWidth - 180,
+ Math.max(12, rect.left + rect.width / 2 - 70),
+ );
+ threadSelection.top = Math.max(12, rect.top - 42);
+ threadSelection.visible = true;
+ }, 0);
}
-function resolvedMessageRefs(message: ChatRecord) {
- return normalizeRefs(messageContent(message).refs);
+async function createThreadFromSelection() {
+ const message = threadSelection.message;
+ if (!currSessionId.value || !message?.id || !threadSelection.selectedText) return;
+ try {
+ const response = await axios.post("/api/chat/thread/create", {
+ session_id: currSessionId.value,
+ parent_message_id: message.id,
+ selected_text: threadSelection.selectedText,
+ });
+ if (response.data?.status !== "ok") {
+ toast.error(response.data?.message || tm("thread.createFailed"));
+ return;
+ }
+ const thread = response.data?.data as ChatThread | undefined;
+ if (!thread) {
+ toast.error(tm("thread.createFailed"));
+ return;
+ }
+ message.threads = message.threads || [];
+ if (!message.threads.some((item) => item.thread_id === thread.thread_id)) {
+ message.threads.push(thread);
+ }
+ openThreadPanel(thread);
+ window.getSelection()?.removeAllRanges();
+ } catch (error) {
+ toast.error(
+ axios.isAxiosError(error)
+ ? error.response?.data?.message || error.message
+ : tm("thread.createFailed"),
+ );
+ console.error("Failed to create thread:", error);
+ } finally {
+ threadSelection.visible = false;
+ }
}
-function normalizeRefs(refs: unknown) {
- if (!refs) return { used: [] as Array> };
- const used = Array.isArray((refs as any)?.used)
- ? (refs as any).used
- : Array.isArray(refs)
- ? refs
- : [];
-
- return {
- used: normalizeRefItems(used),
- };
-}
-
-function normalizeRefItems(items: unknown[]) {
- return items
- .map((item: any) => ({
- index: item?.index,
- title: item?.title || item?.url || tm("refs.title"),
- url: item?.url,
- snippet: item?.snippet,
- favicon: item?.favicon,
- }))
- .filter((item) => item.url);
+function openThreadPanel(thread: ChatThread) {
+ activeThread.value = thread;
+ threadPanelOpen.value = true;
}
function openRefsSidebar(refs: unknown) {
@@ -1280,68 +1156,33 @@ function openRefsSidebar(refs: unknown) {
refsSidebarOpen.value = true;
}
-function normalizeToolCall(tool: Record) {
- const normalized = { ...tool };
- normalized.args = normalized.args ?? normalized.arguments ?? {};
- normalized.ts = normalized.ts ?? Date.now() / 1000;
- if (normalized.result && typeof normalized.result === "object") {
- normalized.result = JSON.stringify(normalized.result, null, 2);
- }
- return normalized;
-}
-
-function isIPythonToolCall(tool: Record) {
- const name = String(tool.name || "").toLowerCase();
- return name.includes("python") || name.includes("ipython");
-}
-
-function toolCallStatusText(tool: Record) {
- if (tool.finished_ts) return tm("toolStatus.done");
- return tm("toolStatus.running");
-}
-
-function parseJsonSafe(value: unknown) {
- if (typeof value !== "string") return value;
+async function deleteThread(thread: ChatThread) {
+ if (deletingThread.value) return;
+ if (!(await askForConfirmation(tm("thread.confirmDelete"), confirmDialog))) return;
+ deletingThread.value = true;
try {
- return JSON.parse(value);
- } catch {
- return value;
- }
-}
-
-async function copyMessage(message: ChatRecord) {
- const text = plainTextFromMessage(message);
- if (!text) return;
- await navigator.clipboard?.writeText(text);
-}
-
-async function downloadPart(part: MessagePart) {
- const key = part.attachment_id || part.filename || "";
- if (!key) return;
- downloadingFiles.value = new Set(downloadingFiles.value).add(key);
- try {
- const response = await axios.get(partUrl(part), { responseType: "blob" });
- const url = URL.createObjectURL(response.data);
- const anchor = document.createElement("a");
- anchor.href = url;
- anchor.download = part.filename || "file";
- anchor.click();
- URL.revokeObjectURL(url);
+ await axios.post("/api/chat/thread/delete", {
+ thread_id: thread.thread_id,
+ });
+ removeThreadFromMessages(thread.thread_id);
+ if (activeThread.value?.thread_id === thread.thread_id) {
+ threadPanelOpen.value = false;
+ activeThread.value = null;
+ }
+ } catch (error) {
+ console.error("Failed to delete thread:", error);
} finally {
- const next = new Set(downloadingFiles.value);
- next.delete(key);
- downloadingFiles.value = next;
+ deletingThread.value = false;
}
}
-function openImage(url: string) {
- imagePreview.url = url;
- imagePreview.visible = true;
-}
-
-function closeImage() {
- imagePreview.visible = false;
- imagePreview.url = "";
+function removeThreadFromMessages(threadId: string) {
+ for (const message of activeMessages.value) {
+ if (!message.threads?.length) continue;
+ message.threads = message.threads.filter(
+ (thread) => thread.thread_id !== threadId,
+ );
+ }
}
async function handleFilesSelected(files: FileList) {
@@ -1368,6 +1209,7 @@ function stopRecording() {
}
function handleMessagesScroll() {
+ threadSelection.visible = false;
const container = messagesContainer.value;
if (!container) return;
const distance =
@@ -1396,60 +1238,6 @@ async function stopCurrentSession() {
function toggleTheme() {
customizer.SET_UI_THEME(isDark.value ? "PurpleTheme" : "PurpleThemeDark");
}
-
-function formatTime(value: string) {
- const date = new Date(value);
- if (Number.isNaN(date.getTime())) return "";
- return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
-}
-
-function inputTokens(stats: any) {
- const usage = stats?.token_usage || {};
- return (usage.input_other || 0) + (usage.input_cached || 0);
-}
-
-function outputTokens(stats: any) {
- return stats?.token_usage?.output || 0;
-}
-
-function agentDuration(stats: any) {
- const directDuration = readPositiveNumber(stats, [
- "duration",
- "total_duration",
- ]);
- if (directDuration !== null) return formatDuration(directDuration);
-
- const startTime = readPositiveNumber(stats, ["start_time"]);
- const endTime = readPositiveNumber(stats, ["end_time"]);
- if (startTime === null || endTime === null || endTime < startTime) return "-";
- return formatDuration(endTime - startTime);
-}
-
-function agentTtft(stats: any) {
- const ttft = readPositiveNumber(stats, [
- "time_to_first_token",
- "ttft",
- "first_token_latency",
- ]);
- if (ttft === null) return "";
- return formatDuration(ttft);
-}
-
-function readPositiveNumber(source: any, keys: string[]) {
- for (const key of keys) {
- const value = Number(source?.[key]);
- if (Number.isFinite(value) && value > 0) return value;
- }
- return null;
-}
-
-function formatDuration(seconds: number) {
- if (seconds < 1) return `${Math.round(seconds * 1000)}ms`;
- if (seconds < 60) return `${seconds.toFixed(1)}s`;
- const minutes = Math.floor(seconds / 60);
- const restSeconds = Math.round(seconds % 60);
- return `${minutes}m ${restSeconds}s`;
-}
diff --git a/dashboard/src/components/chat/ChatInput.vue b/dashboard/src/components/chat/ChatInput.vue
index 7f2117377..0b0aeb3d2 100644
--- a/dashboard/src/components/chat/ChatInput.vue
+++ b/dashboard/src/components/chat/ChatInput.vue
@@ -102,8 +102,8 @@
@@ -193,8 +193,7 @@
@click="handleRecordClick"
icon
variant="text"
- :color="isRecording ? 'error' : 'primary'"
- class="record-btn"
+ class="record-btn input-icon-btn"
>
@@ -222,10 +221,10 @@
@@ -601,6 +600,43 @@ defineExpose({
background: #e7e7e7;
}
+.input-action-btn {
+ background: #5594c6 !important;
+ color: #fff !important;
+}
+
+.input-action-btn:hover {
+ background: #4c86b3 !important;
+}
+
+.input-action-btn:disabled {
+ background: rgba(85, 148, 198, 0.24) !important;
+ color: rgba(255, 255, 255, 0.72) !important;
+}
+
+.input-icon-btn {
+ background: transparent !important;
+ color: rgb(var(--v-theme-on-surface)) !important;
+ margin-right: 8px;
+}
+
+.input-icon-btn:hover {
+ background: rgba(var(--v-theme-on-surface), 0.04) !important;
+}
+
+.input-outline-control {
+ width: 36px !important;
+ height: 36px !important;
+ min-width: 36px !important;
+ border-color: rgba(var(--v-theme-on-surface), 0.18) !important;
+ background: transparent !important;
+}
+
+.input-outline-control:hover {
+ border-color: rgba(var(--v-theme-on-surface), 0.34) !important;
+ background: rgba(var(--v-theme-on-surface), 0.04) !important;
+}
+
.input-area.is-dark .input-neutral-btn {
color: rgba(255, 255, 255, 0.78) !important;
}
@@ -610,6 +646,30 @@ defineExpose({
background: rgba(255, 255, 255, 0.1);
}
+.input-area.is-dark .input-outline-control {
+ border-color: rgba(255, 255, 255, 0.22) !important;
+ background: transparent !important;
+}
+
+.input-area.is-dark .input-outline-control:hover {
+ border-color: rgba(255, 255, 255, 0.42) !important;
+ background: rgba(255, 255, 255, 0.06) !important;
+}
+
+.input-area.is-dark .input-action-btn {
+ background: rgb(var(--v-theme-on-surface)) !important;
+ color: rgb(var(--v-theme-surface)) !important;
+}
+
+.input-area.is-dark .input-action-btn:hover {
+ background: rgba(var(--v-theme-on-surface), 0.86) !important;
+}
+
+.input-area.is-dark .input-action-btn:disabled {
+ background: rgba(var(--v-theme-on-surface), 0.14) !important;
+ color: rgba(var(--v-theme-on-surface), 0.4) !important;
+}
+
/* 拖拽上传遮罩 */
.drop-overlay {
position: absolute;
@@ -811,14 +871,23 @@ defineExpose({
.input-container {
width: 100% !important;
max-width: 100% !important;
+ border-bottom-left-radius: 0 !important;
+ border-bottom-right-radius: 0 !important;
+ }
+
+ .input-outline-control {
+ width: 32px !important;
+ height: 32px !important;
+ min-width: 32px !important;
}
.input-area textarea,
.chat-textarea {
- min-height: 30px !important;
- max-height: 160px !important;
+ min-height: 28px !important;
+ max-height: 140px !important;
font-size: 16px !important;
- padding: 12px 16px 10px 16px !important;
+ line-height: 20px !important;
+ padding: 8px 14px 7px !important;
}
}
diff --git a/dashboard/src/components/chat/ChatMessageList.vue b/dashboard/src/components/chat/ChatMessageList.vue
new file mode 100644
index 000000000..1962ac404
--- /dev/null
+++ b/dashboard/src/components/chat/ChatMessageList.vue
@@ -0,0 +1,1138 @@
+
+
+
+
+
+
+ ✦
+
+
+
+
+
+ {{ tm("message.loading") }}
+
+
+
+
+
+
+
+ {{ t("core.common.cancel") }}
+
+
+ {{ t("core.common.save") }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ part.text || "" }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ mdi-file-document-outline
+ {{ part.filename || "file" }}
+
+
+
+
+
+
+
+ mdi-code-json
+ {{ tool.name || "python" }}
+
+ {{ toolCallStatusText(tool) }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatJson(part) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/components/chat/ProviderModelMenu.vue b/dashboard/src/components/chat/ProviderModelMenu.vue
index ced65dd55..e9a2f4f76 100644
--- a/dashboard/src/components/chat/ProviderModelMenu.vue
+++ b/dashboard/src/components/chat/ProviderModelMenu.vue
@@ -1,7 +1,7 @@
-
+
mdi-creation
{{ selectedProviderId }}
@@ -64,7 +64,6 @@
+
+
diff --git a/dashboard/src/components/chat/ThreadPanel.vue b/dashboard/src/components/chat/ThreadPanel.vue
new file mode 100644
index 000000000..cd0431309
--- /dev/null
+++ b/dashboard/src/components/chat/ThreadPanel.vue
@@ -0,0 +1,543 @@
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/components/chat/ThreadedMarkdownMessagePart.vue b/dashboard/src/components/chat/ThreadedMarkdownMessagePart.vue
new file mode 100644
index 000000000..96e25087c
--- /dev/null
+++ b/dashboard/src/components/chat/ThreadedMarkdownMessagePart.vue
@@ -0,0 +1,89 @@
+
+
+
+
+
diff --git a/dashboard/src/components/chat/message_list_comps/ActionRef.vue b/dashboard/src/components/chat/message_list_comps/ActionRef.vue
index d83443f3c..ed810842d 100644
--- a/dashboard/src/components/chat/message_list_comps/ActionRef.vue
+++ b/dashboard/src/components/chat/message_list_comps/ActionRef.vue
@@ -22,7 +22,7 @@
+{{ refs.used.length - 3 }}
-
+
{{ tm("refs.sources") }}
@@ -64,10 +64,13 @@ export default {
.refs-container {
display: flex;
align-items: center;
- margin-left: 8px;
- padding: 4px 8px;
- border-radius: 12px;
+ min-height: 24px;
+ padding: 0 6px;
+ border-radius: 8px;
+ color: inherit;
cursor: pointer;
+ font-size: 12px;
+ line-height: 24px;
transition: background-color;
}
@@ -79,6 +82,7 @@ export default {
display: flex;
align-items: center;
position: relative;
+ min-height: 24px;
}
.ref-avatar {
@@ -86,7 +90,6 @@ export default {
height: 20px;
border-radius: 50%;
opacity: 0.9;
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
@@ -118,4 +121,11 @@ export default {
opacity: 0.7;
font-weight: 500;
}
+
+.refs-label {
+ margin-left: 6px;
+ color: inherit;
+ font-size: 12px;
+ line-height: 24px;
+}
diff --git a/dashboard/src/components/chat/message_list_comps/RefsSidebar.vue b/dashboard/src/components/chat/message_list_comps/RefsSidebar.vue
index d622ee7f7..4fa31b400 100644
--- a/dashboard/src/components/chat/message_list_comps/RefsSidebar.vue
+++ b/dashboard/src/components/chat/message_list_comps/RefsSidebar.vue
@@ -151,14 +151,16 @@ export default {
display: flex;
justify-content: space-between;
align-items: center;
- padding: 16px 20px;
+ padding: 14px 16px 8px;
flex-shrink: 0;
}
.sidebar-title {
- font-size: 18px;
+ font-size: 16px;
font-weight: 600;
- color: var(--v-theme-primaryText);
+ color: rgb(var(--v-theme-on-surface));
+ line-height: 1.4;
+ margin: 0;
}
.refs-list {
@@ -194,7 +196,6 @@ export default {
display: flex;
align-items: center;
justify-content: center;
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.ref-item-favicon {
diff --git a/dashboard/src/components/chat/message_list_comps/ThreadNode.vue b/dashboard/src/components/chat/message_list_comps/ThreadNode.vue
new file mode 100644
index 000000000..6e1aad62b
--- /dev/null
+++ b/dashboard/src/components/chat/message_list_comps/ThreadNode.vue
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
diff --git a/dashboard/src/components/shared/StyledMenu.vue b/dashboard/src/components/shared/StyledMenu.vue
index 9f755422a..d52e96c8e 100644
--- a/dashboard/src/components/shared/StyledMenu.vue
+++ b/dashboard/src/components/shared/StyledMenu.vue
@@ -4,7 +4,12 @@
-
@@ -397,6 +400,72 @@ const canSend = computed(() => {
);
});
+const hasStagedAttachments = computed(() => {
+ return (
+ props.stagedImagesUrl.length > 0 ||
+ props.stagedAudioUrl ||
+ (props.stagedFiles && props.stagedFiles.length > 0)
+ );
+});
+
+const fileTypeStyles: Record<
+ string,
+ { color: string; icon: string; label: string }
+> = {
+ pdf: { color: "#d32f2f", icon: "mdi-file-pdf-box", label: "PDF" },
+ txt: { color: "#1976d2", icon: "mdi-file-document-outline", label: "TXT" },
+ md: { color: "#1976d2", icon: "mdi-language-markdown-outline", label: "MD" },
+ markdown: {
+ color: "#1976d2",
+ icon: "mdi-language-markdown-outline",
+ label: "MD",
+ },
+ doc: { color: "#2b579a", icon: "mdi-file-word-box", label: "DOC" },
+ docx: { color: "#2b579a", icon: "mdi-file-word-box", label: "DOCX" },
+ xls: { color: "#217346", icon: "mdi-file-excel-box", label: "XLS" },
+ xlsx: { color: "#217346", icon: "mdi-file-excel-box", label: "XLSX" },
+ csv: { color: "#217346", icon: "mdi-file-delimited-outline", label: "CSV" },
+ ppt: { color: "#d24726", icon: "mdi-file-powerpoint-box", label: "PPT" },
+ pptx: { color: "#d24726", icon: "mdi-file-powerpoint-box", label: "PPTX" },
+ zip: { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "ZIP" },
+ rar: { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "RAR" },
+ "7z": { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "7Z" },
+ tar: { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "TAR" },
+ gz: { color: "#7b5e00", icon: "mdi-folder-zip-outline", label: "GZ" },
+ json: { color: "#6a1b9a", icon: "mdi-code-json", label: "JSON" },
+ yaml: { color: "#6a1b9a", icon: "mdi-code-braces", label: "YAML" },
+ yml: { color: "#6a1b9a", icon: "mdi-code-braces", label: "YML" },
+ js: { color: "#b8860b", icon: "mdi-language-javascript", label: "JS" },
+ ts: { color: "#3178c6", icon: "mdi-language-typescript", label: "TS" },
+ html: { color: "#e34c26", icon: "mdi-language-html5", label: "HTML" },
+ css: { color: "#264de4", icon: "mdi-language-css3", label: "CSS" },
+ py: { color: "#3776ab", icon: "mdi-language-python", label: "PY" },
+ java: { color: "#b07219", icon: "mdi-language-java", label: "JAVA" },
+ mp3: { color: "#00897b", icon: "mdi-file-music-outline", label: "MP3" },
+ wav: { color: "#00897b", icon: "mdi-file-music-outline", label: "WAV" },
+ flac: { color: "#00897b", icon: "mdi-file-music-outline", label: "FLAC" },
+ mp4: { color: "#5e35b1", icon: "mdi-file-video-outline", label: "MP4" },
+ mov: { color: "#5e35b1", icon: "mdi-file-video-outline", label: "MOV" },
+ webm: { color: "#5e35b1", icon: "mdi-file-video-outline", label: "WEBM" },
+};
+
+function fileExtension(file: StagedFileInfo) {
+ const name = file.original_name || file.filename || "";
+ const extension = name.split(".").pop()?.toLowerCase() || "";
+ return extension === name.toLowerCase() ? "" : extension;
+}
+
+function filePresentation(file: StagedFileInfo) {
+ const extension = fileExtension(file);
+ return (
+ fileTypeStyles[extension] || {
+ color: "#607d8b",
+ icon: "mdi-file-document-outline",
+ label: extension ? extension.slice(0, 4).toUpperCase() : "FILE",
+ }
+ );
+}
+
// Ctrl+B 长按录音相关
const ctrlKeyDown = ref(false);
const ctrlKeyTimer = ref(null);
@@ -800,45 +869,89 @@ defineExpose({
.attachments-preview {
display: flex;
- gap: 8px;
- margin-top: 8px;
- max-width: 900px;
- margin: 8px auto 0;
- flex-wrap: wrap;
+ gap: 10px;
+ margin: 10px 12px 0;
+ padding: 2px 2px 4px;
+ flex-wrap: nowrap;
+ align-items: center;
+ overflow-x: auto;
+ overflow-y: hidden;
+ scrollbar-width: thin;
+ max-height: 72px;
}
-.image-preview,
-.audio-preview,
-.file-preview {
+.attachment-card {
position: relative;
display: inline-flex;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 8px;
+ width: 220px;
+ height: 64px;
+ flex: 0 0 auto;
+ min-width: 0;
+ padding: 8px 34px 8px 10px;
+ overflow: hidden;
+ color: rgb(var(--v-theme-on-surface));
+ background: rgba(var(--v-theme-on-surface), 0.04);
+ border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
+ border-radius: 12px;
+}
+
+.image-preview {
+ width: 64px;
+ flex-basis: 64px;
+ padding: 0;
+ background: rgba(var(--v-theme-on-surface), 0.06);
}
.preview-image {
- width: 60px;
- height: 60px;
+ width: 100%;
+ height: 100%;
object-fit: cover;
- border-radius: 8px;
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+ border-radius: 11px;
}
-.audio-chip,
-.file-chip {
- height: 36px;
- border-radius: 18px;
+.attachment-icon {
+ display: inline-flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 1px;
+ flex-shrink: 0;
+ min-width: 34px;
}
-.file-name-preview {
- max-width: 120px;
+.attachment-icon--audio {
+ color: #00897b;
+}
+
+.attachment-ext {
+ max-width: 58px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
+ font-size: 10px;
+ font-weight: 700;
+ line-height: 12px;
+}
+
+.attachment-name {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 13px;
+ line-height: 18px;
}
.remove-attachment-btn {
position: absolute;
- top: -8px;
- right: -8px;
+ top: 4px;
+ right: 4px;
+ width: 22px !important;
+ height: 22px !important;
+ min-width: 22px !important;
opacity: 0.8;
transition: opacity 0.2s;
}
@@ -851,6 +964,27 @@ defineExpose({
animation: fadeIn 0.3s ease-in-out;
}
+.attachments-enter-active,
+.attachments-leave-active {
+ overflow: hidden;
+ transition:
+ max-height 0.2s ease,
+ margin 0.2s ease,
+ padding 0.2s ease,
+ opacity 0.16s ease,
+ transform 0.2s ease;
+}
+
+.attachments-enter-from,
+.attachments-leave-to {
+ max-height: 0;
+ margin-top: 0;
+ padding-top: 0;
+ padding-bottom: 0;
+ opacity: 0;
+ transform: translateY(6px);
+}
+
@keyframes fadeIn {
from {
opacity: 0;
@@ -889,5 +1023,20 @@ defineExpose({
line-height: 20px !important;
padding: 8px 14px 7px !important;
}
+
+ .attachments-preview {
+ margin: 8px 10px 0;
+ gap: 8px;
+ }
+
+ .attachment-card {
+ width: min(220px, calc(100vw - 28px));
+ height: 58px;
+ }
+
+ .image-preview {
+ width: 58px;
+ flex-basis: 58px;
+ }
}
diff --git a/dashboard/src/components/chat/ChatMessageList.vue b/dashboard/src/components/chat/ChatMessageList.vue
index 1962ac404..a0e70d65f 100644
--- a/dashboard/src/components/chat/ChatMessageList.vue
+++ b/dashboard/src/components/chat/ChatMessageList.vue
@@ -24,6 +24,54 @@
+
+
+
+
+
+
+
+ {{ attachmentPresentation(part).label }}
+
+
+
+ {{ attachmentName(part) }}
+
+
+
+
+
+
+