diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 143f9f7e0..3bc8f1867 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -71,20 +71,20 @@ jobs: echo "build_date=$build_date" >> $GITHUB_OUTPUT - name: Set QEMU - uses: docker/setup-qemu-action@v4.1.0 + uses: docker/setup-qemu-action@v4.2.0 - name: Set Docker Buildx - uses: docker/setup-buildx-action@v4.1.0 + uses: docker/setup-buildx-action@v4.2.0 - name: Log in to DockerHub - uses: docker/login-action@v4.2.0 + uses: docker/login-action@v4.4.0 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_PASSWORD }} - name: Login to GitHub Container Registry if: env.HAS_GHCR_TOKEN == 'true' - uses: docker/login-action@v4.2.0 + uses: docker/login-action@v4.4.0 with: registry: ghcr.io username: ${{ env.GHCR_OWNER }} @@ -105,7 +105,7 @@ jobs: echo "EOF" >> $GITHUB_OUTPUT - name: Build and Push Nightly Image - uses: docker/build-push-action@v7.2.0 + uses: docker/build-push-action@v7.3.0 with: context: . platforms: linux/amd64,linux/arm64 @@ -171,27 +171,27 @@ jobs: cp -r dashboard/dist astrbot/dashboard/dist - name: Set QEMU - uses: docker/setup-qemu-action@v4.1.0 + uses: docker/setup-qemu-action@v4.2.0 - name: Set Docker Buildx - uses: docker/setup-buildx-action@v4.1.0 + uses: docker/setup-buildx-action@v4.2.0 - name: Log in to DockerHub - uses: docker/login-action@v4.2.0 + uses: docker/login-action@v4.4.0 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_PASSWORD }} - name: Login to GitHub Container Registry if: env.HAS_GHCR_TOKEN == 'true' - uses: docker/login-action@v4.2.0 + uses: docker/login-action@v4.4.0 with: registry: ghcr.io username: ${{ env.GHCR_OWNER }} password: ${{ secrets.GHCR_GITHUB_TOKEN }} - name: Build and Push Release Image - uses: docker/build-push-action@v7.2.0 + uses: docker/build-push-action@v7.3.0 with: context: . platforms: linux/amd64,linux/arm64 diff --git a/README.md b/README.md index 7c0bc7160..96d2d305e 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,10 @@ AstrBot is an open-source all-in-one Agent chatbot platform that integrates with ### One-Click Deployment +> [!NOTE] +> Requires [uv](https://docs.astral.sh/uv/) to be installed. +> For macOS users: due to macOS security checks, the first run of the `astrbot` command may take longer (about 10-20s). + For users who want to quickly experience AstrBot, are familiar with command-line usage, and can install a `uv` environment on their own, we recommend the `uv` one-click deployment method ⚡️: ```bash @@ -83,20 +87,17 @@ astrbot init # Only execute this command for the first time to initialize the en astrbot run ``` -> Requires [uv](https://docs.astral.sh/uv/) to be installed. -> AstrBot requires Python 3.12 or later. The `--python 3.12` option ensures that `uv` creates the tool environment with Python 3.12. - -> [!NOTE] -> For macOS users: due to macOS security checks, the first run of the `astrbot` command may take longer (about 10-20s). - Update `astrbot`: ```bash uv tool upgrade astrbot --python 3.12 ``` -> [!WARNING] -> AstrBot deployed via `uv` **does not support upgrading through the WebUI**. To update, please run the command above from the command line. +### One-Click Cloud Deployment (RainYun) + +For users who want one-click deployment and do not want to manage servers themselves, we recommend RainYun's one-click cloud deployment service ☁️: + +[![Deploy on RainYun](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-en.svg)](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0) ### Docker Deployment @@ -104,12 +105,6 @@ For users familiar with containers and looking for a more stable, production-rea Please refer to the official documentation: [Deploy AstrBot with Docker](https://docs.astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot). -### Deploy on RainYun - -For users who want one-click deployment and do not want to manage servers themselves, we recommend RainYun's one-click cloud deployment service ☁️: - -[![Deploy on RainYun](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-en.svg)](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0) - ### Desktop Application Deployment For users who want to use AstrBot on desktop and mainly use ChatUI, we recommend AstrBot App. @@ -142,6 +137,18 @@ yay -S astrbot-git If you need panel-based management or deeper customization, see [BT-Panel Deployment](https://docs.astrbot.app/deploy/astrbot/btpanel.html) for BT Panel app-store setup, [1Panel Deployment](https://docs.astrbot.app/deploy/astrbot/1panel.html) for 1Panel app-market deployment, [CasaOS Deployment](https://docs.astrbot.app/deploy/astrbot/casaos.html) for NAS/home-server visual deployment, and [Manual Deployment](https://docs.astrbot.app/deploy/astrbot/cli.html) for fully custom source-based installation with `uv`. + +## ❤️ Sponsors + +Welcome to sponsor us via [Afdian](https://afdian.com/a/astrbot_team) or [contact us](mailto:community@astrbot.app). + +

+ + sponsors + +

+ + ## Supported Messaging Platforms Connect AstrBot to your favorite chat platform. @@ -205,13 +212,6 @@ Connect AstrBot to your favorite chat platform. | Xiaomi MiMo TTS | Text-to-Speech Services | | Volcano Engine TTS | Text-to-Speech Services | -## ❤️ Sponsors - -

- sponsors -

- - ## ❤️ Contributing Issues and Pull Requests are always welcome! Feel free to submit your changes to this project :) @@ -235,22 +235,7 @@ pre-commit install ### QQ Groups -- Group 1: 322154837 (Full) -- Group 3: 630166526 (Full) -- Group 4: 1077826412 (Full) -- Group 5: 822130018 (Full) -- Group 6: 753075035 (Full) -- Group 7: 743746109 (Full) -- Group 8: 1030353265 (Full) -- Group 9: 1076659624 (Full) -- Group 10: 1078079676 (Full) -- Group 11: 704659519 (Full) -- Group 12: 916228568 (Full) -- Group 13: 1092185289 -- Group 14: 1103419483 - -- Developer Group(Chit-chat): 975206796 -- Developer Group(Formal): 1039761811 +We have 15+ chat groups, please see: [Community](https://docs.astrbot.app/community.html) for details. ### Discord Server @@ -264,9 +249,10 @@ Special thanks to all Contributors and plugin developers for their contributions -Additionally, the birth of this project would not have been possible without the help of the following open-source projects: +Open Source Friends ❤️ - [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - The amazing cat framework +- [Mai-with-u/MaiBot](https://github.com/Mai-with-u/MaiBot) - The powerful "digital life" in your QQ! ## ⭐ Star History diff --git a/astrbot/__init__.py b/astrbot/__init__.py index a3af79fec..d73f7f0a8 100644 --- a/astrbot/__init__.py +++ b/astrbot/__init__.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any try: __version__ = _pkg_version("astrbot") except PackageNotFoundError: - __version__ = "4.26.4" + __version__ = "4.26.6" if TYPE_CHECKING: diff --git a/astrbot/core/agent/response.py b/astrbot/core/agent/response.py index 9e61fa8c7..887f8941a 100644 --- a/astrbot/core/agent/response.py +++ b/astrbot/core/agent/response.py @@ -18,6 +18,8 @@ class AgentResponse: @dataclass class AgentStats: token_usage: TokenUsage = field(default_factory=TokenUsage) + current_context_tokens: int = 0 + """Input tokens sent in the most recent LLM request.""" start_time: float = 0.0 end_time: float = 0.0 time_to_first_token: float = 0.0 @@ -28,7 +30,8 @@ class AgentStats: def to_dict(self) -> dict: return { - "token_usage": self.token_usage.__dict__, + "token_usage": self.token_usage.__dict__.copy(), + "current_context_tokens": self.current_context_tokens, "start_time": self.start_time, "end_time": self.end_time, "time_to_first_token": self.time_to_first_token, diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 1ce6fae32..294d375ac 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -765,11 +765,25 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]): continue llm_resp_result = llm_response - if not llm_response.is_chunk and llm_response.usage: - # only count the token usage of the final response for computation purpose + # Chunk responses have already continued above. A missing usage report + # means the latest context occupancy is unknown. + self.stats.current_context_tokens = 0 + if llm_response.usage: + # Keep cumulative usage for billing and expose the latest request + # input separately for context-window occupancy displays. self.stats.token_usage += llm_response.usage + self.stats.current_context_tokens = llm_response.usage.input if self.req.conversation: self.req.conversation.token_usage = llm_response.usage.total + yield AgentResponse( + type="agent_stats", + data=AgentResponseData( + chain=MessageChain( + type="agent_stats", + chain=[Json(data=self.stats.to_dict())], + ) + ), + ) break # got final response if not llm_resp_result: diff --git a/astrbot/core/astr_agent_run_util.py b/astrbot/core/astr_agent_run_util.py index 2f1a4199e..d909bed17 100644 --- a/astrbot/core/astr_agent_run_util.py +++ b/astrbot/core/astr_agent_run_util.py @@ -186,6 +186,11 @@ async def run_agent( if _should_stop_agent(astr_event): continue + if resp.type == "agent_stats": + if astr_event.get_platform_name() == "webchat": + await astr_event.send(resp.data["chain"]) + continue + if resp.type == "tool_call_result": msg_chain = resp.data["chain"] @@ -250,6 +255,24 @@ async def run_agent( if stream_to_general and resp.type == "streaming_delta": continue + if ( + resp.type == "err" + and agent_runner.streaming + and not stream_to_general + ): + chain = ( + resp.data.get("chain") if isinstance(resp.data, dict) else None + ) + if not isinstance(chain, MessageChain): + logger.error( + "Agent runner returned an error response without a message chain." + ) + chain = MessageChain().message( + "Error occurred during AI execution." + ) + yield chain + 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"]) @@ -294,15 +317,6 @@ async def run_agent( except asyncio.CancelledError: pass if agent_runner.done(): - # send agent stats to webchat - if astr_event.get_platform_name() == "webchat": - await astr_event.send( - MessageChain( - type="agent_stats", - chain=[Json(data=agent_runner.stats.to_dict())], - ), - ) - break except Exception as e: diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 615660124..52e703632 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -1839,6 +1839,7 @@ CONFIG_METADATA_2 = { "embedding_api_base": "", "embedding_model": "", "embedding_dimensions": 1024, + "embedding_dimensions_mode": "auto", "timeout": 20, "proxy": "", }, @@ -1934,6 +1935,20 @@ CONFIG_METADATA_2 = { "timeout": 20, "nvidia_rerank_truncate": "", }, + "TEI Rerank": { + "id": "tei_rerank", + "type": "tei_rerank", + "provider": "huggingface", + "provider_type": "rerank", + "enable": True, + "rerank_api_key": "", + "rerank_api_base": "http://127.0.0.1:8080", + "timeout": 20, + "tei_rerank_truncate": False, + "tei_rerank_truncation_direction": "Right", + "tei_rerank_raw_scores": False, + "tei_rerank_return_text": False, + }, "Xinference STT": { "id": "xinference_stt", "type": "xinference_stt", @@ -2030,6 +2045,27 @@ CONFIG_METADATA_2 = { "END", ], }, + "tei_rerank_truncate": { + "description": "截断超长文本", + "type": "bool", + "hint": "当输入超过模型最大上下文长度时,是否自动截断。启用后需配合 截断方向 使用。", + }, + "tei_rerank_truncation_direction": { + "description": "截断方向", + "type": "string", + "options": ["left", "right"], + "hint": "选择从文本的左侧(left)还是右侧(right)开始截断。仅在 截断超长文本 为 True 时生效。", + }, + "tei_rerank_raw_scores": { + "description": "返回原始分数", + "type": "bool", + "hint": "如果为 True,返回模型原始 logit 分数(可能为负值),不经过 sigmoid 归一化。默认 False。", + }, + "tei_rerank_return_text": { + "description": "返回排序结果中的文档原文", + "type": "bool", + "hint": "如果为 True,每个排序结果将附带原始文本。默认 False 以减少网络传输。", + }, "modalities": { "description": "模型能力", "type": "list", @@ -2211,6 +2247,12 @@ CONFIG_METADATA_2 = { "hint": "嵌入向量的维度。根据模型不同,可能需要调整,请参考具体模型的文档。此配置项请务必填写正确,否则将导致向量数据库无法正常工作。", "_special": "get_embedding_dim", }, + "embedding_dimensions_mode": { + "description": "嵌入维度参数发送模式", + "type": "string", + "options": ["auto", "always", "never"], + "hint": "控制是否在 OpenAI 兼容 Embedding 请求中发送 dimensions 参数。auto 会仅对官方 OpenAI embedding-3 模型自动发送;第三方兼容 API 如需该参数可改为 always,报错时改为 never。", + }, "embedding_model": { "description": "嵌入模型", "type": "string", diff --git a/astrbot/core/core_lifecycle.py b/astrbot/core/core_lifecycle.py index 6198e33da..025ef6980 100644 --- a/astrbot/core/core_lifecycle.py +++ b/astrbot/core/core_lifecycle.py @@ -36,6 +36,9 @@ from astrbot.core.star.star_manager import PluginManager from astrbot.core.subagent_orchestrator import SubAgentOrchestrator from astrbot.core.umop_config_router import UmopConfigRouter from astrbot.core.updator import AstrBotUpdator +from astrbot.core.utils.event_loop_diagnostics import ( + create_event_loop_diagnostic_tasks, +) from astrbot.core.utils.llm_metadata import update_llm_metadata from astrbot.core.utils.migra_helper import migra from astrbot.core.utils.temp_dir_cleaner import TempDirCleaner @@ -605,6 +608,7 @@ class AstrBotCoreLifecycle: self.temp_dir_cleaner.run(), name="temp_dir_cleaner", ) + diagnostic_tasks = create_event_loop_diagnostic_tasks() # 把插件中注册的所有协程函数注册到事件总线中并执行 extra_tasks = [] @@ -614,6 +618,7 @@ class AstrBotCoreLifecycle: tasks_ = [] if event_bus_task: tasks_.append(event_bus_task) + tasks_.extend(diagnostic_tasks) tasks_.extend(extra_tasks or []) if cron_task: tasks_.append(cron_task) diff --git a/astrbot/core/db/vec_db/faiss_impl/vec_db.py b/astrbot/core/db/vec_db/faiss_impl/vec_db.py index 64a749aa3..b06a5208e 100644 --- a/astrbot/core/db/vec_db/faiss_impl/vec_db.py +++ b/astrbot/core/db/vec_db/faiss_impl/vec_db.py @@ -5,6 +5,7 @@ import numpy as np from astrbot import logger from astrbot.core.db.vec_db.base import BaseVecDB, Result +from astrbot.core.exceptions import KnowledgeBaseUploadError from astrbot.core.provider.provider import EmbeddingProvider, RerankProvider from .document_storage import DocumentStorage @@ -80,6 +81,32 @@ class FaissVecDB(BaseVecDB): ) return [] + content_count = len(contents) + if len(metadatas) != content_count: + raise KnowledgeBaseUploadError( + stage="storage", + user_message=( + f"存储失败:文本分块数量与元数据数量不一致(期望 {content_count}," + f"实际 {len(metadatas)})。" + ), + details={ + "expected_contents": content_count, + "actual_metadatas": len(metadatas), + }, + ) + if len(ids) != content_count: + raise KnowledgeBaseUploadError( + stage="storage", + user_message=( + f"存储失败:文本分块数量与文档 ID 数量不一致(期望 {content_count}," + f"实际 {len(ids)})。" + ), + details={ + "expected_contents": content_count, + "actual_ids": len(ids), + }, + ) + start = time.time() logger.debug(f"Generating embeddings for {len(contents)} contents...") vectors = await self.embedding_provider.get_embeddings_batch( @@ -93,17 +120,80 @@ class FaissVecDB(BaseVecDB): logger.debug( f"Generated embeddings for {len(contents)} contents in {end - start:.2f} seconds.", ) + if len(vectors) != content_count: + raise KnowledgeBaseUploadError( + stage="embedding", + user_message=( + "向量化失败:嵌入模型返回的向量数量与文本分块数量不一致" + f"(期望 {content_count},实际 {len(vectors)})。" + "这通常说明当前 Embedding 接口未完整返回批量结果," + "或该服务不兼容当前批量请求格式。" + ), + details={ + "expected_contents": content_count, + "actual_vectors": len(vectors), + }, + ) - # 使用 DocumentStorage 的批量插入方法 + # Validate vector format/dimension before any local write so validation + # failures never leave a half-written document store. + try: + vectors_array = np.asarray(vectors, dtype=np.float32) + except (TypeError, ValueError) as exc: + raise KnowledgeBaseUploadError( + stage="embedding", + user_message=( + "向量化失败:嵌入模型返回的向量格式不正确," + "无法转换为统一的浮点向量矩阵。" + ), + details={"vector_count": len(vectors)}, + ) from exc + if vectors_array.ndim != 2: + raise KnowledgeBaseUploadError( + stage="embedding", + user_message=( + "向量化失败:嵌入模型返回的向量格式不正确,无法构造成二维向量矩阵。" + ), + details={"actual_ndim": int(vectors_array.ndim)}, + ) + if vectors_array.shape[1] != self.embedding_storage.dimension: + raise KnowledgeBaseUploadError( + stage="embedding", + user_message=( + "向量化失败:返回向量维度与当前知识库索引维度不一致" + f"(期望 {self.embedding_storage.dimension}," + f"实际 {vectors_array.shape[1]})。" + ), + details={ + "expected_dimension": self.embedding_storage.dimension, + "actual_dimension": int(vectors_array.shape[1]), + }, + ) + + # DocumentStorage and FAISS cannot share a transaction. If FAISS + # write fails after documents are committed, compensate by deleting. int_ids = await self.document_storage.insert_documents_batch( ids, contents, metadatas, ) - - # 批量插入向量到 FAISS - vectors_array = np.array(vectors).astype("float32") - await self.embedding_storage.insert_batch(vectors_array, int_ids) + try: + if len(int_ids) != content_count: + raise KnowledgeBaseUploadError( + stage="storage", + user_message=( + f"存储失败:写入文档索引后返回的内部 ID 数量与文本分块数量不一致" + f"(期望 {content_count},实际 {len(int_ids)})。" + ), + details={ + "expected_contents": content_count, + "actual_int_ids": len(int_ids), + }, + ) + await self.embedding_storage.insert_batch(vectors_array, int_ids) + except Exception: + await self._rollback_partial_insert(ids=ids, int_ids=int_ids) + raise return int_ids async def retrieve( @@ -171,6 +261,39 @@ class FaissVecDB(BaseVecDB): return top_k_results + async def _rollback_partial_insert( + self, + ids: list[str], + int_ids: list[int], + ) -> None: + """Best-effort cleanup after a partial ``insert_batch`` write. + + DocumentStorage commits independently from FAISS. When FAISS insertion + fails after documents are written — or after vectors were added in + memory but not flushed — remove those rows/ids so the two stores do + not diverge. + + Args: + ids: Chunk UUID strings written to DocumentStorage. + int_ids: Internal integer ids returned by DocumentStorage, also + used as FAISS ids when any vectors may have been added. + """ + if int_ids: + try: + await self.embedding_storage.delete(int_ids) + except Exception as exc: + logger.warning( + f"Failed to roll back FAISS vectors for partial insert: {exc}", + ) + + for doc_id in ids: + try: + await self.document_storage.delete_document_by_doc_id(doc_id) + except Exception as exc: + logger.warning( + f"Failed to roll back document storage entry {doc_id}: {exc}", + ) + async def delete(self, doc_id: str) -> bool: """删除一条文档块(chunk)""" # 获得对应的 int id diff --git a/astrbot/core/knowledge_base/kb_helper.py b/astrbot/core/knowledge_base/kb_helper.py index 7111c611c..403c2f831 100644 --- a/astrbot/core/knowledge_base/kb_helper.py +++ b/astrbot/core/knowledge_base/kb_helper.py @@ -244,12 +244,20 @@ class KBHelper: - current: 当前进度 - total: 总数 + Multi-store writes cannot share a transaction. Failures before metadata + commit best-effort roll back written chunks/vectors/media. After + metadata is committed, only report stats-refresh errors and keep the + document. """ await self._ensure_vec_db() doc_id = str(uuid.uuid4()) media_paths: list[Path] = [] saved_file_path: Path | None = None file_size = 0 + # Only roll back chunks/vectors/media when metadata has not been + # committed yet. After commit (e.g. stats refresh failure) the document + # is already user-visible and must not be fully undone. + metadata_committed = False try: chunks_text: list[str] = [] @@ -276,10 +284,31 @@ class KBHelper: if progress_callback: await progress_callback("parsing", 0, 100) - parser = await select_parser(f".{file_type}") - parse_result = await parser.parse(file_content, file_name) + try: + parser = await select_parser(f".{file_type}") + parse_result = await parser.parse(file_content, file_name) + except KnowledgeBaseUploadError: + raise + except Exception as exc: + raise KnowledgeBaseUploadError( + stage="parsing", + user_message=( + "文档解析失败:无法读取或解析上传文件。" + "请确认文件格式受支持且文件内容未损坏。" + ), + details={"file_name": file_name}, + ) from exc text_content = parse_result.text media_items = parse_result.media + if not text_content or not text_content.strip(): + raise KnowledgeBaseUploadError( + stage="parsing", + user_message=( + "文档解析失败:未能从文件中提取可索引文本。" + "该文件可能是扫描件、纯图片 PDF,或格式暂不受支持。" + ), + details={"file_name": file_name}, + ) if progress_callback: await progress_callback("parsing", 100, 100) @@ -364,14 +393,27 @@ class KBHelper: if progress_callback: await progress_callback("embedding", current, total) - await self._get_vec_db().insert_batch( - contents=contents, - metadatas=metadatas, - batch_size=batch_size, - tasks_limit=tasks_limit, - max_retries=max_retries, - progress_callback=embedding_progress_callback, - ) + try: + await self._get_vec_db().insert_batch( + contents=contents, + metadatas=metadatas, + batch_size=batch_size, + tasks_limit=tasks_limit, + max_retries=max_retries, + progress_callback=embedding_progress_callback, + ) + except KnowledgeBaseUploadError: + raise + except Exception as exc: + raise KnowledgeBaseUploadError( + stage="storage", + user_message=("存储失败:文本块已生成,但写入知识库索引时出错。"), + details={ + "file_name": file_name, + "doc_id": doc_id, + "cause": str(exc), + }, + ) from exc # 保存文档的元数据 doc = KBDocument( @@ -384,39 +426,143 @@ class KBHelper: chunk_count=len(chunks_text), media_count=len(saved_media), ) - async with self.kb_db.get_db() as session: - async with session.begin(): - session.add(doc) - for media in saved_media: - session.add(media) - - await session.refresh(doc) + try: + async with self.kb_db.get_db() as session: + async with session.begin(): + session.add(doc) + for media in saved_media: + session.add(media) + await session.commit() + # Mark committed immediately after commit succeeds. A later + # refresh failure must not trigger full upload rollback. + metadata_committed = True + await session.refresh(doc) + except KnowledgeBaseUploadError: + raise + except Exception as exc: + if metadata_committed: + raise KnowledgeBaseUploadError( + stage="metadata", + user_message=( + "元数据更新失败:文档已上传,但文档记录刷新失败。" + ), + details={"file_name": file_name, "doc_id": doc_id}, + ) from exc + raise KnowledgeBaseUploadError( + stage="metadata", + user_message=( + "元数据保存失败:文本块已写入知识库,但文档记录保存失败。" + ), + details={"file_name": file_name, "doc_id": doc_id}, + ) from exc vec_db = self._get_vec_db() - await self.kb_db.update_kb_stats(kb_id=self.kb.kb_id, vec_db=vec_db) - await self.refresh_kb() - await self.refresh_document(doc_id) + try: + await self.kb_db.update_kb_stats(kb_id=self.kb.kb_id, vec_db=vec_db) + await self.refresh_kb() + await self.refresh_document(doc_id) + except KnowledgeBaseUploadError: + raise + except Exception as exc: + raise KnowledgeBaseUploadError( + stage="metadata", + user_message=( + "元数据更新失败:文档已上传,但知识库统计信息刷新失败。" + ), + details={"file_name": file_name, "doc_id": doc_id}, + ) from exc return doc except Exception as e: - logger.error(f"上传文档失败: {e}") + if isinstance(e, KnowledgeBaseUploadError): + logger.warning(f"上传文档失败: {e}", extra={"details": e.details}) + else: + logger.error(f"上传文档失败: {e}", exc_info=True) - if saved_file_path and saved_file_path.exists(): - try: - saved_file_path.unlink() - except Exception as file_error: - logger.warning( - f"清理原始文档文件失败 {saved_file_path}: {file_error}", - ) - - for media_path in media_paths: - try: - if media_path.exists(): - media_path.unlink() - except Exception as me: - logger.warning(f"清理多媒体文件失败 {media_path}: {me}") + if not metadata_committed: + if saved_file_path and saved_file_path.exists(): + try: + saved_file_path.unlink() + except Exception as file_error: + logger.warning( + f"清理原始文档文件失败 {saved_file_path}: {file_error}", + ) + await self._cleanup_failed_upload( + doc_id=doc_id, + media_paths=media_paths, + ) raise + async def _cleanup_failed_upload( + self, + doc_id: str, + media_paths: list[Path], + ) -> None: + """Best-effort compensating cleanup after a failed upload. + + Multi-store writes (media files, chunk/FTS rows, FAISS vectors, KB + metadata) cannot share a single transaction. On failure before the KB + document row is committed, remove any partial state keyed by ``doc_id``. + + Cleanup order intentionally differs from user-facing document deletion: + chunk/vector data is removed first, then residual KB metadata rows. + This avoids the "metadata gone, orphans remain" window that + ``delete_document_by_id`` can leave when vector deletion fails. + + Args: + doc_id: Pre-generated document id used for this upload attempt. + media_paths: Media files written to disk during this attempt. + """ + from sqlalchemy import delete + from sqlmodel import col + + # 1) chunks + vectors first (most common orphan after partial insert) + vec_db = getattr(self, "vec_db", None) + if vec_db is not None: + try: + await vec_db.delete_documents( + metadata_filters={"kb_doc_id": doc_id}, + ) + except Exception as ve: + logger.warning( + f"Failed to roll back chunks/vectors for failed upload " + f"(doc_id={doc_id}): {ve}", + ) + + # 2) residual KBDocument / KBMedia rows only (normally none yet) + try: + async with self.kb_db.get_db() as session, session.begin(): + await session.execute( + delete(KBMedia).where(col(KBMedia.doc_id) == doc_id), + ) + await session.execute( + delete(KBDocument).where(col(KBDocument.doc_id) == doc_id), + ) + except Exception as exc: + logger.warning( + f"Failed to roll back document metadata for failed upload " + f"(doc_id={doc_id}): {exc}", + ) + + # 3) media files on disk + for media_path in media_paths: + try: + if media_path.exists(): + media_path.unlink() + except Exception as me: + logger.warning(f"Failed to clean up media file {media_path}: {me}") + + # 4) empty media directory for this doc + try: + media_dir = self.kb_medias_dir / doc_id + if media_dir.exists() and media_dir.is_dir(): + media_dir.rmdir() + except Exception as de: + logger.warning( + f"Failed to remove media directory after failed upload " + f"(doc_id={doc_id}): {de}", + ) + async def list_documents( self, offset: int = 0, diff --git a/astrbot/core/pipeline/result_decorate/stage.py b/astrbot/core/pipeline/result_decorate/stage.py index 54290ef69..f4af92811 100644 --- a/astrbot/core/pipeline/result_decorate/stage.py +++ b/astrbot/core/pipeline/result_decorate/stage.py @@ -247,7 +247,13 @@ class ResultDecorateStage(Stage): continue for seg in split_response: if self.content_cleanup_rule: - seg = re.sub(self.content_cleanup_rule, "", seg) + try: + seg = re.sub(self.content_cleanup_rule, "", seg) + except re.error: + logger.error( + f"分段回复过滤表达式失败,无法成功过滤:{traceback.format_exc()}" + ) + self.content_cleanup_rule = None seg = seg.strip() if seg: new_chain.append(Plain(seg)) diff --git a/astrbot/core/pipeline/waking_check/stage.py b/astrbot/core/pipeline/waking_check/stage.py index 21c4174ab..491dd489a 100644 --- a/astrbot/core/pipeline/waking_check/stage.py +++ b/astrbot/core/pipeline/waking_check/stage.py @@ -138,7 +138,10 @@ class WakingCheckStage(Stage): event.is_at_or_wake_command = True break # 检查是否是私聊 - if event.is_private_chat() and not self.friend_message_needs_wake_prefix: + if event.is_private_chat() and ( + not self.friend_message_needs_wake_prefix + or event.get_platform_name() == "webchat" + ): is_wake = True event.is_wake = True event.is_at_or_wake_command = True diff --git a/astrbot/core/platform/sources/line/line_adapter.py b/astrbot/core/platform/sources/line/line_adapter.py index 3ed40dbc0..df431e17f 100644 --- a/astrbot/core/platform/sources/line/line_adapter.py +++ b/astrbot/core/platform/sources/line/line_adapter.py @@ -126,7 +126,7 @@ class LinePlatformAdapter(Platform): logger.warning("[LINE] invalid webhook signature") return ("invalid signature", 400) try: - payload = await request.get_json(force=True, silent=False) + payload = await request.get_json(silent=False) except Exception as e: logger.warning("[LINE] invalid webhook body: %s", e) return ("bad request", 400) diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index 79d0bc06d..49cca650b 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -76,6 +76,15 @@ def _qqofficial_retry(max_attempts: int = 5): ) +_QQOFFICIAL_SEND_API_ERRORS = ( + botpy.errors.ForbiddenError, + botpy.errors.MethodNotAllowedError, + botpy.errors.NotFoundError, + botpy.errors.SequenceNumberError, + botpy.errors.ServerError, +) + + class QQOfficialMessageEvent(AstrMessageEvent): MARKDOWN_NOT_ALLOWED_ERROR = "不允许发送原生 markdown" IMAGE_FILE_TYPE = 1 @@ -414,7 +423,24 @@ class QQOfficialMessageEvent(AstrMessageEvent): ): try: return await send_func(payload) - except botpy.errors.ServerError as err: + except _QQOFFICIAL_SEND_API_ERRORS as err: + logger.info("[QQOfficial] 回复消息失败: %s, 尝试使用主动发送接口。", err) + if payload.get("msg_id"): + fallback_payload = payload.copy() + fallback_payload.pop("msg_id", None) + try: + ret = await send_func(fallback_payload) + logger.info("[QQOfficial] 使用主动发送接口发送成功。") + return ret + except _QQOFFICIAL_SEND_API_ERRORS as fallback_err: + err = fallback_err + payload = fallback_payload + + if not isinstance(err, botpy.errors.ServerError): + raise + + # QQ 流式 markdown 分片校验:内容必须以换行结尾。 + # 某些边界场景服务端仍可能判定失败,这里做一次修正重试。 if stream and self.STREAM_MARKDOWN_NEWLINE_ERROR in str(err): retry_payload = payload.copy() markdown_payload = retry_payload.get("markdown") diff --git a/astrbot/core/platform/sources/webchat/webchat_event.py b/astrbot/core/platform/sources/webchat/webchat_event.py index 0d12d42bf..bb7ad6600 100644 --- a/astrbot/core/platform/sources/webchat/webchat_event.py +++ b/astrbot/core/platform/sources/webchat/webchat_event.py @@ -20,15 +20,6 @@ from .webchat_queue_mgr import webchat_queue_mgr attachments_dir = os.path.join(get_astrbot_data_path(), "attachments") -def _extract_conversation_id(session_id: str) -> str: - """Extract raw webchat conversation id from event/session id.""" - if session_id.startswith("webchat!"): - parts = session_id.split("!", 2) - if len(parts) == 3: - return parts[2] - return session_id - - class WebChatMessageEvent(AstrMessageEvent): def __init__(self, message_str, message_obj, platform_meta, session_id) -> None: super().__init__(message_str, message_obj, platform_meta, session_id) @@ -43,13 +34,9 @@ class WebChatMessageEvent(AstrMessageEvent): emit_complete: bool = False, ) -> str | None: request_id = str(message_id) - conversation_id = _extract_conversation_id(session_id) - web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue( - request_id, - conversation_id, - ) if not message: - await web_chat_back_queue.put( + await webchat_queue_mgr.put_back_queue( + request_id, { "type": "end", "data": "", @@ -63,7 +50,8 @@ class WebChatMessageEvent(AstrMessageEvent): for comp in message.chain: if isinstance(comp, Plain): data = comp.text - await web_chat_back_queue.put( + accepted = await webchat_queue_mgr.put_back_queue( + request_id, { "type": "plain", "data": data, @@ -72,8 +60,11 @@ class WebChatMessageEvent(AstrMessageEvent): "message_id": message_id, }, ) + if not accepted: + return None elif isinstance(comp, Json): - await web_chat_back_queue.put( + accepted = await webchat_queue_mgr.put_back_queue( + request_id, { "type": "plain", "data": json.dumps(comp.data, ensure_ascii=False), @@ -82,6 +73,8 @@ class WebChatMessageEvent(AstrMessageEvent): "message_id": message_id, }, ) + if not accepted: + return None elif isinstance(comp, Image): # save image to local image_base64 = await comp.convert_to_base64() @@ -95,7 +88,8 @@ class WebChatMessageEvent(AstrMessageEvent): path = os.path.join(attachments_dir, filename) await asyncio.to_thread(Path(path).write_bytes, image_bytes) data = f"[IMAGE]{filename}" - await web_chat_back_queue.put( + accepted = await webchat_queue_mgr.put_back_queue( + request_id, { "type": "image", "data": data, @@ -103,6 +97,8 @@ class WebChatMessageEvent(AstrMessageEvent): "message_id": message_id, }, ) + if not accepted: + return None elif isinstance(comp, Record): # save record to local filename = f"{uuid.uuid4()!s}.wav" @@ -111,7 +107,8 @@ class WebChatMessageEvent(AstrMessageEvent): record_bytes = base64.b64decode(record_base64) await asyncio.to_thread(Path(path).write_bytes, record_bytes) data = f"[RECORD]{filename}" - await web_chat_back_queue.put( + accepted = await webchat_queue_mgr.put_back_queue( + request_id, { "type": "record", "data": data, @@ -119,6 +116,8 @@ class WebChatMessageEvent(AstrMessageEvent): "message_id": message_id, }, ) + if not accepted: + return None elif isinstance(comp, File): # save file to local file_path = await comp.get_file() @@ -135,7 +134,8 @@ class WebChatMessageEvent(AstrMessageEvent): dest_path = os.path.join(attachments_dir, filename) shutil.copy2(file_path, dest_path) data = f"[FILE]{filename}|{original_name}" - await web_chat_back_queue.put( + accepted = await webchat_queue_mgr.put_back_queue( + request_id, { "type": "file", "data": data, @@ -143,11 +143,14 @@ class WebChatMessageEvent(AstrMessageEvent): "message_id": message_id, }, ) + if not accepted: + return None else: logger.debug(f"webchat 忽略: {comp.type}") if emit_complete: - await web_chat_back_queue.put( + await webchat_queue_mgr.put_back_queue( + request_id, { "type": "complete", "data": data, @@ -169,11 +172,6 @@ class WebChatMessageEvent(AstrMessageEvent): reasoning_content = "" message_id = self.message_obj.message_id request_id = str(message_id) - conversation_id = _extract_conversation_id(self.session_id) - web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue( - request_id, - conversation_id, - ) async for chain in generator: # 处理音频流(Live Mode) if chain.type == "audio_chunk": @@ -196,7 +194,9 @@ class WebChatMessageEvent(AstrMessageEvent): if text: payload["text"] = text - await web_chat_back_queue.put(payload) + accepted = await webchat_queue_mgr.put_back_queue(request_id, payload) + if not accepted: + return continue # if chain.type == "break" and final_data: @@ -224,7 +224,8 @@ class WebChatMessageEvent(AstrMessageEvent): else: final_data += r - await web_chat_back_queue.put( + await webchat_queue_mgr.put_back_queue( + request_id, { "type": "complete", # complete means we return the final result "data": final_data, diff --git a/astrbot/core/platform/sources/webchat/webchat_queue_mgr.py b/astrbot/core/platform/sources/webchat/webchat_queue_mgr.py index 1f7a178fc..a9c5509bf 100644 --- a/astrbot/core/platform/sources/webchat/webchat_queue_mgr.py +++ b/astrbot/core/platform/sources/webchat/webchat_queue_mgr.py @@ -10,6 +10,7 @@ class WebChatQueueMgr: """Conversation ID to asyncio.Queue mapping""" self.back_queues: dict[str, asyncio.Queue] = {} """Request ID to asyncio.Queue mapping for responses""" + self._back_queue_close_events: dict[str, asyncio.Event] = {} self._conversation_back_requests: dict[str, set[str]] = {} self._request_conversation: dict[str, str] = {} self._queue_close_events: dict[str, asyncio.Event] = {} @@ -36,6 +37,7 @@ class WebChatQueueMgr: self.back_queues[request_id] = asyncio.Queue( maxsize=self.back_queue_maxsize, ) + self._back_queue_close_events[request_id] = asyncio.Event() if conversation_id: self._request_conversation[request_id] = conversation_id if conversation_id not in self._conversation_back_requests: @@ -43,8 +45,49 @@ class WebChatQueueMgr: self._conversation_back_requests[conversation_id].add(request_id) return self.back_queues[request_id] + async def put_back_queue(self, request_id: str, data: object) -> bool: + """Write a response while the request queue remains active. + + Args: + request_id: Response request identifier. + data: Payload to enqueue. + + Returns: + Whether an active response queue accepted the payload. + """ + queue = self.back_queues.get(request_id) + close_event = self._back_queue_close_events.get(request_id) + if queue is None or close_event is None or close_event.is_set(): + return False + + try: + queue.put_nowait(data) + return True + except asyncio.QueueFull: + pass + + put_task = asyncio.create_task(queue.put(data)) + close_task = asyncio.create_task(close_event.wait()) + try: + done, _ = await asyncio.wait( + {put_task, close_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + if close_task in done: + return False + await put_task + return True + finally: + for task in (put_task, close_task): + if not task.done(): + task.cancel() + await asyncio.gather(put_task, close_task, return_exceptions=True) + def remove_back_queue(self, request_id: str): """Remove back queue for the given request ID""" + close_event = self._back_queue_close_events.pop(request_id, None) + if close_event is not None: + close_event.set() self.back_queues.pop(request_id, None) conversation_id = self._request_conversation.pop(request_id, None) if conversation_id: diff --git a/astrbot/core/provider/manager.py b/astrbot/core/provider/manager.py index 1764646d9..24e0265bc 100644 --- a/astrbot/core/provider/manager.py +++ b/astrbot/core/provider/manager.py @@ -518,6 +518,10 @@ class ProviderManager: from .sources.nvidia_rerank_source import ( NvidiaRerankProvider as NvidiaRerankProvider, ) + case "tei_rerank": + from .sources.tei_rerank_source import ( + TEIRerankProvider as TEIRerankProvider, + ) def get_merged_provider_config(self, provider_config: dict) -> dict: """获取 provider 配置和 provider_source 配置合并后的结果 diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index ecf9d2ab1..355df1e12 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -42,6 +42,8 @@ from astrbot.core.utils.network_utils import ( "Anthropic Claude API 提供商适配器", ) class ProviderAnthropic(Provider): + _PROMPT_CACHE_CONTROL = {"type": "ephemeral"} + @staticmethod def _ensure_usable_response( llm_response: LLMResponse, @@ -488,6 +490,16 @@ class ProviderAnthropic(Provider): logger.warning(f"未知的 tool_choice 值: {tool_choice},已回退为 'auto'") return {"type": "auto"} + @classmethod + def _apply_explicit_prompt_cache_breakpoints(cls, payloads: dict) -> None: + system_blocks = payloads.get("system") + if not isinstance(system_blocks, list) or not system_blocks: + return + + last_block = system_blocks[-1] + if isinstance(last_block, dict) and "cache_control" not in last_block: + last_block["cache_control"] = dict(cls._PROMPT_CACHE_CONTROL) + async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: if tools: if tool_list := tools.get_func_desc_anthropic_style(): @@ -500,6 +512,7 @@ class ProviderAnthropic(Provider): if "max_tokens" not in payloads: payloads["max_tokens"] = 65536 + self._apply_explicit_prompt_cache_breakpoints(payloads) self._apply_thinking_config(payloads) self._sanitize_assistant_messages(payloads) @@ -597,6 +610,7 @@ class ProviderAnthropic(Provider): if "max_tokens" not in payloads: payloads["max_tokens"] = 65536 + self._apply_explicit_prompt_cache_breakpoints(payloads) self._apply_thinking_config(payloads) self._sanitize_assistant_messages(payloads) diff --git a/astrbot/core/provider/sources/openai_embedding_source.py b/astrbot/core/provider/sources/openai_embedding_source.py index 73a6c71b0..3eeb0262c 100644 --- a/astrbot/core/provider/sources/openai_embedding_source.py +++ b/astrbot/core/provider/sources/openai_embedding_source.py @@ -1,4 +1,5 @@ import re +from urllib.parse import urlparse import httpx from openai import AsyncOpenAI @@ -65,32 +66,47 @@ class OpenAIEmbeddingProvider(EmbeddingProvider): return [item.embedding for item in embeddings.data] def _embedding_kwargs(self) -> dict: - """构建嵌入请求的可选参数""" + """Build optional embedding request parameters.""" kwargs = {} - if "embedding_dimensions" in self.provider_config: + dimensions_mode = self.provider_config.get("embedding_dimensions_mode", "auto") + if dimensions_mode not in {"auto", "always", "never"}: + logger.warning( + f"Unknown embedding_dimensions_mode in embedding configs: '{dimensions_mode}', fallback to 'auto'." + ) + dimensions_mode = "auto" + send_dimensions = dimensions_mode == "always" + if dimensions_mode == "auto": + api_base = _normalize_api_base( + self.provider_config.get( + "embedding_api_base", "https://api.openai.com/v1" + ) + or "https://api.openai.com/v1" + ) + parsed_api_base = urlparse(api_base) + model = ( + getattr(self, "model", None) + or self.provider_config.get("embedding_model") + or "text-embedding-3-small" + ) + model_lower = str(model).lower() + model_name = model_lower.rsplit("/", 1)[-1] + send_dimensions = ( + parsed_api_base.scheme == "https" + and parsed_api_base.hostname == "api.openai.com" + and parsed_api_base.path.rstrip("/") == "/v1" + and model_name.startswith("text-embedding-3") + ) or ( + parsed_api_base.scheme == "https" + and parsed_api_base.hostname == "api.siliconflow.cn" + and model_name.startswith("qwen") + ) + if send_dimensions and "embedding_dimensions" in self.provider_config: try: kwargs["dimensions"] = int(self.provider_config["embedding_dimensions"]) except (ValueError, TypeError): logger.warning( f"embedding_dimensions in embedding configs is not a valid integer: '{self.provider_config['embedding_dimensions']}', ignored.", ) - - # Fix: SiliconFlow provider does not support dimensions parameter, except for Qwen models. - provider_api_base = self.provider_config.get("embedding_api_base") - provider_id = self.provider_config.get("id", "unknown_id") - if ( - provider_api_base - # Hard-code SiliconFlow API Base Prefix and Model Name, as it's just a temporary workaround. - and provider_api_base.strip().startswith("https://api.siliconflow.cn") - and not self.model.lower().startswith("qwen") - ): - # For SiliconFlow and Non-Qwen models, dimensions parameter is not supported. so remove it. - removed_dimensions = kwargs.pop("dimensions", None) - if removed_dimensions is not None: - # Log a warning message if dimensions parameter is removed. - logger.warning( - f"dimensions not supported for model '{self.model}' of provider '{provider_id}' as SiliconFlow does not support this parameter for non-Qwen models: '{removed_dimensions}'." - ) return kwargs def get_dim(self) -> int: diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 5b39657c7..271ff335c 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -546,11 +546,26 @@ class ProviderOpenAIOfficial(Provider): return value.strip().lower() in {"1", "true", "yes", "on"} return bool(value) - def _apply_provider_specific_extra_body_overrides( + def _apply_provider_specific_request_overrides( self, + payloads: dict[str, Any], extra_body: dict[str, Any], ) -> None: - if self.provider_config.get("provider") != "ollama": + provider = self.provider_config.get("provider") + model = str(payloads.get("model", "")).lower() + + # NVIDIA's hosted MiniMax M3 endpoint can return empty choices when + # max_tokens is omitted (#9206). Scope the compatibility default to + # that model; other NVIDIA models have different token limits. + if ( + provider == "nvidia" + and model == "minimaxai/minimax-m3" + and "max_tokens" not in payloads + and "max_tokens" not in extra_body + ): + payloads["max_tokens"] = 8192 + + if provider != "ollama": return if not self._ollama_disable_thinking_enabled(): return @@ -681,7 +696,7 @@ class ProviderOpenAIOfficial(Provider): custom_extra_body = self.provider_config.get("custom_extra_body", {}) if isinstance(custom_extra_body, dict): extra_body.update(custom_extra_body) - self._apply_provider_specific_extra_body_overrides(extra_body) + self._apply_provider_specific_request_overrides(payloads, extra_body) model = payloads.get("model", "").lower() @@ -735,7 +750,7 @@ class ProviderOpenAIOfficial(Provider): to_del.append(key) for key in to_del: del payloads[key] - self._apply_provider_specific_extra_body_overrides(extra_body) + self._apply_provider_specific_request_overrides(payloads, extra_body) self._sanitize_assistant_messages(payloads) diff --git a/astrbot/core/provider/sources/tei_rerank_source.py b/astrbot/core/provider/sources/tei_rerank_source.py new file mode 100644 index 000000000..9a5d58aaf --- /dev/null +++ b/astrbot/core/provider/sources/tei_rerank_source.py @@ -0,0 +1,151 @@ +import aiohttp + +from astrbot import logger + +from ..entities import ProviderType, RerankResult +from ..provider import RerankProvider +from ..register import register_provider_adapter + + +@register_provider_adapter( + "tei_rerank", + "HuggingFace TEI Rerank 适配器", + provider_type=ProviderType.RERANK, +) +class TEIRerankProvider(RerankProvider): + """HuggingFace Text Embeddings Inference (TEI) Rerank 适配器。""" + + def __init__(self, provider_config: dict, provider_settings: dict) -> None: + super().__init__(provider_config, provider_settings) + self.provider_config = provider_config + self.provider_settings = provider_settings + self.api_key = provider_config.get("rerank_api_key", "") + self.base_url = provider_config.get( + "rerank_api_base", "http://127.0.0.1:8080" + ).rstrip("/") + self.timeout = provider_config.get("timeout", 20) + self.truncate = provider_config.get("tei_rerank_truncate", False) + self.truncation_direction = provider_config.get( + "tei_rerank_truncation_direction", "right" + ).lower() + self.raw_scores = provider_config.get("tei_rerank_raw_scores", False) + self.return_text = provider_config.get("tei_rerank_return_text", False) + + h = {} + if self.api_key: + h["Authorization"] = f"Bearer {self.api_key}" + self.client = aiohttp.ClientSession( + headers=h, + timeout=aiohttp.ClientTimeout(total=self.timeout), + ) + + async def rerank( + self, + query: str, + documents: list[str], + top_n: int | None = None, + ) -> list[RerankResult]: + if not self.client or self.client.closed: + logger.error("[TEI Rerank] Client session is not initialized or closed") + return [] + if not documents: + logger.warning( + "[TEI Rerank] Document list is empty, returning empty results" + ) + return [] + if not query.strip(): + logger.warning("[TEI Rerank] Query is empty, returning empty results") + return [] + payload: dict = { + "query": query, + "texts": documents, + } + + if self.truncate: + payload["truncate"] = True + payload["truncation_direction"] = self.truncation_direction + if self.raw_scores: + payload["raw_scores"] = True + if self.return_text: + payload["return_text"] = True + + try: + rerank_url = f"{self.base_url}/rerank" + logger.debug( + f"[TEI Rerank] Request: query='{query[:50]}...', " + f"doc_count={len(documents)}" + ) + async with self.client.post(rerank_url, json=payload) as response: + if response.status != 200: + try: + error_data = await response.json() + error_msg = error_data.get( + "error", error_data.get("message", "Unknown error") + ) + except Exception: + error_msg = await response.text() + logger.error( + f"[TEI Rerank] API returned HTTP {response.status}: {error_msg}" + ) + raise Exception(f"TEI Rerank HTTP {response.status}: {error_msg}") + + response_data = await response.json() + + if not response_data: + logger.warning( + f"[TEI Rerank] API returned empty results. " + f"Response: {response_data}" + ) + return [] + + results = [] + for rank_item in response_data: + results.append( + RerankResult( + index=rank_item["index"], + relevance_score=rank_item["score"], + ) + ) + + if top_n is not None and top_n > 0: + results = results[:top_n] + + logger.debug( + f"[TEI Rerank] Successfully returned {len(results)} results" + ) + return results + + except aiohttp.ClientError as e: + logger.error(f"[TEI Rerank] Network error: {e}") + raise Exception(f"TEI Rerank network error: {e}") from e + except Exception as e: + logger.error(f"[TEI Rerank] Error: {e}") + raise + + async def test(self) -> None: + if not self.client or self.client.closed: + raise Exception("TEI Rerank client session is not initialized") + + health_url = f"{self.base_url}/health" + try: + async with self.client.get(health_url) as response: + if response.status != 200: + raise Exception( + f"TEI service health check failed at {self.base_url}: " + f"HTTP {response.status}" + ) + except aiohttp.ClientError as e: + raise Exception( + f"TEI service is not reachable at {self.base_url}: {e}" + ) from e + result = await self.rerank("Apple", documents=["apple", "banana"]) + if not result: + raise Exception( + "TEI Rerank provider test failed: no results returned. " + "Please ensure a reranker model is loaded in TEI." + ) + + async def terminate(self) -> None: + if self.client and not self.client.closed: + await self.client.close() + self.client = None diff --git a/astrbot/core/utils/event_loop_diagnostics.py b/astrbot/core/utils/event_loop_diagnostics.py new file mode 100644 index 000000000..cf2892643 --- /dev/null +++ b/astrbot/core/utils/event_loop_diagnostics.py @@ -0,0 +1,216 @@ +import asyncio +import faulthandler +from dataclasses import dataclass +from pathlib import Path +from typing import TextIO + +from astrbot import logger +from astrbot.core.utils.astrbot_path import get_astrbot_data_path + +DEFAULT_LAG_MONITOR_ENABLED = True +DEFAULT_LAG_MONITOR_INTERVAL = 5.0 +DEFAULT_LAG_MONITOR_THRESHOLD = 15.0 +DEFAULT_WATCHDOG_ENABLED = True +DEFAULT_WATCHDOG_INTERVAL = 5.0 +DEFAULT_WATCHDOG_TIMEOUT = 30.0 +DEFAULT_WATCHDOG_LOG_RELATIVE_PATH = Path("logs") / "event_loop_watchdog.log" +DEFAULT_WATCHDOG_LOG_MAX_BYTES = 1024 * 1024 + + +@dataclass(frozen=True) +class EventLoopDiagnosticSettings: + """Settings for event loop lag and blockage diagnostics. + + Args: + lag_monitor_enabled: Whether to log event loop scheduling lag. + lag_monitor_interval: Seconds between lag monitor wakeups. + lag_monitor_threshold: Minimum lag seconds before logging a warning. + watchdog_enabled: Whether to arm the faulthandler watchdog. + watchdog_interval: Seconds between faulthandler watchdog refreshes. + watchdog_timeout: Seconds without event loop progress before dumping stacks. + watchdog_log_path: File that receives faulthandler watchdog output. + watchdog_log_max_bytes: Maximum watchdog log bytes before rotation. + """ + + lag_monitor_enabled: bool + lag_monitor_interval: float + lag_monitor_threshold: float + watchdog_enabled: bool + watchdog_interval: float + watchdog_timeout: float + watchdog_log_path: Path + watchdog_log_max_bytes: int + + +def _watchdog_log_path() -> Path: + """Resolve the watchdog stack dump log path. + + Returns: + Absolute path for watchdog stack dump output. + """ + return Path(get_astrbot_data_path()) / DEFAULT_WATCHDOG_LOG_RELATIVE_PATH + + +def load_event_loop_diagnostic_settings() -> EventLoopDiagnosticSettings: + """Load fixed event loop diagnostic settings. + + Returns: + Event loop diagnostic settings. + """ + return EventLoopDiagnosticSettings( + lag_monitor_enabled=DEFAULT_LAG_MONITOR_ENABLED, + lag_monitor_interval=DEFAULT_LAG_MONITOR_INTERVAL, + lag_monitor_threshold=DEFAULT_LAG_MONITOR_THRESHOLD, + watchdog_enabled=DEFAULT_WATCHDOG_ENABLED, + watchdog_interval=DEFAULT_WATCHDOG_INTERVAL, + watchdog_timeout=DEFAULT_WATCHDOG_TIMEOUT, + watchdog_log_path=_watchdog_log_path(), + watchdog_log_max_bytes=DEFAULT_WATCHDOG_LOG_MAX_BYTES, + ) + + +async def monitor_event_loop_lag( + *, + interval: float = DEFAULT_LAG_MONITOR_INTERVAL, + warn_after: float = DEFAULT_LAG_MONITOR_THRESHOLD, +) -> None: + """Log a warning when the event loop wakes significantly later than expected. + + Args: + interval: Seconds between monitor wakeups. + warn_after: Minimum lag seconds before logging a warning. + """ + loop = asyncio.get_running_loop() + expected = loop.time() + interval + while True: + await asyncio.sleep(interval) + now = loop.time() + lag = now - expected + if lag > warn_after: + logger.warning( + "Event loop lag detected: %.3fs (threshold %.3fs).", + lag, + warn_after, + ) + expected = now + interval + + +def _rotate_watchdog_log_file(log_path: Path, max_bytes: int) -> None: + """Rotate the watchdog log when it reaches the configured size limit. + + Args: + log_path: Current watchdog log path. + max_bytes: Maximum current log size before rotation. + """ + try: + if not log_path.exists() or log_path.stat().st_size < max_bytes: + return + rotated_path = log_path.with_name(f"{log_path.name}.1") + if rotated_path.exists(): + rotated_path.unlink() + log_path.replace(rotated_path) + except OSError as e: + logger.warning("Failed to rotate event loop watchdog log %s: %s", log_path, e) + + +def _open_watchdog_log_file(log_path: Path, max_bytes: int) -> TextIO: + """Open the watchdog log file after applying size-based rotation. + + Args: + log_path: Current watchdog log path. + max_bytes: Maximum current log size before rotation. + + Returns: + Writable text file object for faulthandler output. + """ + log_path.parent.mkdir(parents=True, exist_ok=True) + _rotate_watchdog_log_file(log_path, max_bytes) + return log_path.open("a", encoding="utf-8") + + +async def faulthandler_event_loop_watchdog( + *, + timeout: float = DEFAULT_WATCHDOG_TIMEOUT, + interval: float = DEFAULT_WATCHDOG_INTERVAL, + dump_file: TextIO | None = None, + dump_path: Path | None = None, + max_bytes: int = DEFAULT_WATCHDOG_LOG_MAX_BYTES, +) -> None: + """Dump all thread stacks if the event loop is blocked for too long. + + Args: + timeout: Seconds without watchdog refresh before faulthandler dumps stacks. + interval: Seconds between watchdog refreshes while the event loop is healthy. + dump_file: File object that receives faulthandler output. + dump_path: Path that receives faulthandler output when dump_file is unset. + max_bytes: Maximum current log size before rotation. + """ + log_path = dump_path or _watchdog_log_path() + try: + while True: + faulthandler.cancel_dump_traceback_later() + output: TextIO | None = None + should_close = False + try: + output = dump_file or _open_watchdog_log_file(log_path, max_bytes) + should_close = dump_file is None + faulthandler.dump_traceback_later( + timeout, + repeat=False, + file=output, + ) + await asyncio.sleep(interval) + except Exception as e: + logger.warning("Event loop faulthandler watchdog failed: %s", e) + await asyncio.sleep(interval) + finally: + faulthandler.cancel_dump_traceback_later() + if should_close and output is not None: + output.close() + finally: + faulthandler.cancel_dump_traceback_later() + + +def create_event_loop_diagnostic_tasks() -> list[asyncio.Task]: + """Create enabled event loop diagnostic tasks for the current loop. + + Returns: + A list of created asyncio tasks. + """ + settings = load_event_loop_diagnostic_settings() + tasks: list[asyncio.Task] = [] + + if settings.lag_monitor_enabled: + tasks.append( + asyncio.create_task( + monitor_event_loop_lag( + interval=settings.lag_monitor_interval, + warn_after=settings.lag_monitor_threshold, + ), + name="event_loop_lag_monitor", + ) + ) + + if settings.watchdog_enabled: + logger.info( + "Event loop faulthandler watchdog enabled: timeout=%.3fs interval=%.3fs. " + "If the loop is blocked, Python thread stacks will be written to %s " + "(rotates at %d bytes).", + settings.watchdog_timeout, + settings.watchdog_interval, + settings.watchdog_log_path, + settings.watchdog_log_max_bytes, + ) + tasks.append( + asyncio.create_task( + faulthandler_event_loop_watchdog( + timeout=settings.watchdog_timeout, + interval=settings.watchdog_interval, + dump_path=settings.watchdog_log_path, + max_bytes=settings.watchdog_log_max_bytes, + ), + name="event_loop_faulthandler_watchdog", + ) + ) + + return tasks diff --git a/astrbot/dashboard/api/app.py b/astrbot/dashboard/api/app.py index 00ceb11cf..91ef71ab2 100644 --- a/astrbot/dashboard/api/app.py +++ b/astrbot/dashboard/api/app.py @@ -2,12 +2,14 @@ from __future__ import annotations from types import SimpleNamespace -from fastapi import FastAPI, HTTPException, Request +from fastapi import FastAPI, Request from fastapi.responses import JSONResponse +from starlette.exceptions import HTTPException as StarletteHTTPException from astrbot.core import LogBroker from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.db import BaseDatabase +from astrbot.core.log import LogManager from astrbot.dashboard.responses import ApiError, error from astrbot.dashboard.services.api_key_service import ApiKeyService from astrbot.dashboard.services.auth_service import AuthService @@ -157,10 +159,30 @@ def create_dashboard_asgi_app( async def value_error_handler(_request: Request, exc: ValueError): return JSONResponse(error(str(exc)), status_code=400) - @app.exception_handler(HTTPException) - async def http_error_handler(_request: Request, exc: HTTPException): - detail = exc.detail if isinstance(exc.detail, str) else "Request failed" - return JSONResponse(error(detail), status_code=exc.status_code) + @app.exception_handler(StarletteHTTPException) + async def starlette_http_error_handler( + _request: Request, exc: StarletteHTTPException + ): + if isinstance(exc.detail, str): + return JSONResponse( + error(exc.detail), status_code=exc.status_code, headers=exc.headers + ) + return JSONResponse( + error("Request failed", exc.detail), + status_code=exc.status_code, + headers=exc.headers, + ) + + @app.exception_handler(Exception) + async def catch_all_handler(_request: Request, exc: Exception): + LogManager.GetLogger("astrbot.dashboard").error( + "Unhandled exception in dashboard API", + exc_info=exc, + ) + return JSONResponse( + error("Internal server error"), + status_code=500, + ) # Legacy dashboard routes keep old /api/* callers working without entering OpenAPI. app.include_router(legacy_api_keys_router) diff --git a/astrbot/dashboard/api/chat.py b/astrbot/dashboard/api/chat.py index 15e17cece..910cbdea8 100644 --- a/astrbot/dashboard/api/chat.py +++ b/astrbot/dashboard/api/chat.py @@ -178,6 +178,28 @@ async def stop_chat_session( return await _run(lambda: service.stop_session(auth.username, session_id)) +@router.get("/chat/runs/{run_id}/stream") +async def resume_chat_run( + run_id: str, + auth: AuthContext = Depends(require_chat_scope), + service: ChatService = Depends(get_service), +): + try: + stream = await service.build_chat_run_stream(auth.username, run_id) + except ChatServiceError: + return JSONResponse(error("Chat run is unavailable")) + + return StreamingResponse( + stream, + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Transfer-Encoding": "chunked", + "Connection": "keep-alive", + }, + ) + + @router.patch("/chat/sessions/{session_id}/messages/{message_id}") async def update_chat_message( session_id: str, diff --git a/astrbot/dashboard/api/skills.py b/astrbot/dashboard/api/skills.py index c8925c8e4..ebc784a71 100644 --- a/astrbot/dashboard/api/skills.py +++ b/astrbot/dashboard/api/skills.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import Any -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import FileResponse from astrbot.core import logger @@ -94,10 +94,14 @@ async def _download_skill(service: SkillsService, name: str): try: return _archive_response(service.prepare_skill_archive(name)) except SkillsServiceError as exc: - return error(str(exc)) + message = str(exc) + raise HTTPException(status_code=exc.status_code, detail=message) from exc except Exception as exc: logger.error(str(exc), exc_info=True) - return error(str(exc)) + raise HTTPException( + status_code=500, + detail="Failed to prepare skill archive", + ) from exc @router.get("/skills") diff --git a/astrbot/dashboard/services/auth_service.py b/astrbot/dashboard/services/auth_service.py index 8a5c3494f..c5e333144 100644 --- a/astrbot/dashboard/services/auth_service.py +++ b/astrbot/dashboard/services/auth_service.py @@ -369,6 +369,12 @@ class AuthService: if not new_pwd and not new_username: return self.error("新用户名和新密码不能同时为空") + username_to_save = None + if new_username is not None and new_username != "": + if not isinstance(new_username, str) or len(new_username.strip()) < 3: + return self.error("用户名长度至少3位") + username_to_save = new_username.strip() + if new_pwd: if not isinstance(new_pwd, str): return self.error("新密码无效") @@ -384,8 +390,8 @@ class AuthService: await set_password_change_required(self.db, self.config, False) if is_totp_enabled(self.config): await revoke_user_trusted_devices(self.db) - if new_username: - self.config["dashboard"]["username"] = new_username + if username_to_save: + self.config["dashboard"]["username"] = username_to_save self.config.save_config() diff --git a/astrbot/dashboard/services/chat_service.py b/astrbot/dashboard/services/chat_service.py index 7c8db7ab3..3db7dcd7c 100644 --- a/astrbot/dashboard/services/chat_service.py +++ b/astrbot/dashboard/services/chat_service.py @@ -8,6 +8,7 @@ import uuid from collections.abc import AsyncIterator from contextlib import asynccontextmanager from copy import deepcopy +from dataclasses import dataclass, field from pathlib import Path, PurePosixPath from typing import Any @@ -32,6 +33,7 @@ from astrbot.core.utils.media_utils import ( ) SSE_HEARTBEAT = ": heartbeat\n\n" +CHAT_RUN_SUBSCRIBER_QUEUE_SIZE = 256 def sanitize_upload_filename(filename: str | None) -> str: @@ -492,6 +494,25 @@ class ChatServiceError(Exception): pass +@dataclass(slots=True) +class ChatRunState: + """State owned by a WebChat generation independently of its subscribers.""" + + run_id: str + username: str + session_id: str + llm_checkpoint_id: str + platform_history_id: str + back_queue: asyncio.Queue + subscribers: set[asyncio.Queue] = field(default_factory=set) + message_parts: list[dict] = field(default_factory=list) + agent_stats: dict = field(default_factory=dict) + refs: dict = field(default_factory=dict) + revision: int = 0 + status: str = "running" + task: asyncio.Task[None] | None = None + + class ChatService: def __init__( self, @@ -509,6 +530,8 @@ class ChatService: self.platform_history_mgr = core_lifecycle.platform_message_history_manager self.umop_config_router = core_lifecycle.umop_config_router self.running_convs: dict[str, bool] = {} + self.chat_runs: dict[str, ChatRunState] = {} + self.chat_runs_by_session: dict[str, set[str]] = {} async def build_user_message_parts(self, message: str | list) -> list[dict]: return await build_webchat_message_parts( @@ -646,6 +669,14 @@ class ChatService: for thread_id in thread_ids: unified_msg_origin = build_thread_unified_msg_origin(creator, thread_id) active_event_registry.request_agent_stop_all(unified_msg_origin) + tasks = [] + for run_id in list(self.chat_runs_by_session.get(thread_id, set())): + run = self.chat_runs.get(run_id) + if run and run.task and not run.task.done(): + run.task.cancel() + tasks.append(run.task) + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) await self.conv_mgr.delete_conversations_by_user_id(unified_msg_origin) await self.platform_history_mgr.delete( platform_id="webchat_thread", @@ -724,6 +755,352 @@ class ChatService: llm_checkpoint_id=llm_checkpoint_id, ) + def get_active_chat_runs(self, username: str, session_id: str) -> list[dict]: + """Return resumable runs owned by a user in one chat session. + + Args: + username: Authenticated run owner. + session_id: WebChat session or thread identifier. + + Returns: + Active run snapshots in creation order. + """ + snapshots = [] + for run in self.chat_runs.values(): + if run.username != username or run.session_id != session_id: + continue + snapshots.append( + { + "run_id": run.run_id, + "session_id": run.session_id, + "llm_checkpoint_id": run.llm_checkpoint_id, + "status": run.status, + "revision": run.revision, + "content": build_bot_history_content( + deepcopy(run.message_parts), + agent_stats=deepcopy(run.agent_stats), + refs=deepcopy(run.refs), + ), + } + ) + return snapshots + + @staticmethod + def _publish_chat_run(run: ChatRunState, payload: dict) -> None: + """Publish one output event without coupling the run to subscribers. + + Args: + run: Chat run producing the event. + payload: Existing WebChat event payload. + """ + run.revision += 1 + item = (run.revision, payload) + for subscriber in list(run.subscribers): + try: + subscriber.put_nowait(item) + except asyncio.QueueFull: + # End slow streams so they can reconnect from a fresh snapshot. + run.subscribers.discard(subscriber) + while not subscriber.empty(): + subscriber.get_nowait() + subscriber.put_nowait(None) + + def _subscribe_chat_run( + self, + run: ChatRunState, + *, + include_snapshot: bool, + saved_user_record=None, + ) -> AsyncIterator[str]: + """Create an SSE subscriber for a running chat generation. + + Args: + run: Chat run to observe. + include_snapshot: Whether to begin with accumulated run state. + saved_user_record: Newly persisted user record for the legacy stream. + + Returns: + SSE iterator detached from the generation task lifecycle. + """ + subscriber: asyncio.Queue = asyncio.Queue( + maxsize=CHAT_RUN_SUBSCRIBER_QUEUE_SIZE + ) + run.subscribers.add(subscriber) + snapshot = None + if include_snapshot: + snapshot = { + "run_id": run.run_id, + "session_id": run.session_id, + "llm_checkpoint_id": run.llm_checkpoint_id, + "status": run.status, + "revision": run.revision, + "content": build_bot_history_content( + deepcopy(run.message_parts), + agent_stats=deepcopy(run.agent_stats), + refs=deepcopy(run.refs), + ), + } + snapshot_revision = run.revision + + async def stream(): + try: + if snapshot is not None: + payload = {"type": "run_snapshot", "data": snapshot} + yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" + else: + session_info = { + "type": "session_id", + "data": None, + "session_id": run.session_id, + } + yield f"data: {json.dumps(session_info, ensure_ascii=False)}\n\n" + if saved_user_record: + 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": run.llm_checkpoint_id, + }, + } + yield f"data: {json.dumps(user_saved_info, ensure_ascii=False)}\n\n" + + while True: + try: + item = await asyncio.wait_for(subscriber.get(), timeout=1) + except TimeoutError: + yield SSE_HEARTBEAT + continue + if item is None: + break + revision, payload = item + if include_snapshot and revision <= snapshot_revision: + continue + yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" + finally: + run.subscribers.discard(subscriber) + + return stream() + + async def build_chat_run_stream( + self, + username: str, + run_id: str, + ) -> AsyncIterator[str]: + """Attach a new SSE subscriber to an active chat run. + + Args: + username: Authenticated run owner. + run_id: Active run identifier. + + Returns: + SSE iterator beginning with a full accumulated snapshot. + + Raises: + ChatServiceError: If the run is absent or owned by another user. + """ + run = self.chat_runs.get(run_id) + if run is None: + raise ChatServiceError(f"Chat run {run_id} not found") + if run.username != username: + raise ChatServiceError("Permission denied") + return self._subscribe_chat_run(run, include_snapshot=True) + + async def _consume_chat_run(self, run: ChatRunState) -> None: + """Drain runner output, persist it, and fan it out to subscribers. + + Args: + run: Chat run owning the producer queue and durable state. + """ + pending_accumulator = BotMessageAccumulator() + display_accumulator = BotMessageAccumulator() + pending_agent_stats = {} + pending_refs = {} + + async def flush_pending_bot_message(): + nonlocal pending_accumulator, pending_agent_stats, pending_refs + if not ( + pending_accumulator.has_content() or pending_refs or pending_agent_stats + ): + return None + + message_parts_to_save = pending_accumulator.build_message_parts( + include_pending_tool_calls=True + ) + plain_text = collect_plain_text_from_message_parts(message_parts_to_save) + try: + extracted_refs = extract_web_search_refs( + plain_text, + message_parts_to_save, + ) + except Exception as exc: + logger.exception( + f"Failed to extract web search refs: {exc}", + exc_info=True, + ) + extracted_refs = pending_refs + + run.refs = extracted_refs + saved_record = await self.save_bot_message( + run.session_id, + message_parts_to_save, + pending_agent_stats, + extracted_refs, + run.llm_checkpoint_id, + run.platform_history_id, + ) + pending_accumulator = BotMessageAccumulator() + pending_agent_stats = {} + pending_refs = {} + return saved_record + + self.running_convs[run.session_id] = True + try: + while True: + result = await run.back_queue.get() + if not result: + continue + if result.get("message_id") and str(result["message_id"]) != run.run_id: + logger.warning("webchat stream message_id mismatch") + continue + + result_text = result.get("data", "") + msg_type = result.get("type") + streaming = result.get("streaming", False) + chain_type = result.get("chain_type") + + if chain_type == "agent_stats": + try: + run.agent_stats = json.loads(result_text) + except (TypeError, json.JSONDecodeError): + run.agent_stats = {} + pending_agent_stats = run.agent_stats + self._publish_chat_run( + run, + {"type": "agent_stats", "data": run.agent_stats}, + ) + continue + + attachment_saved_payload = None + if msg_type == "plain": + for accumulator in (pending_accumulator, display_accumulator): + accumulator.add_plain( + result_text, + chain_type=chain_type, + streaming=streaming, + ) + elif msg_type in {"image", "record", "file", "video"}: + prefix = { + "image": "[IMAGE]", + "record": "[RECORD]", + "file": "[FILE]", + "video": "[VIDEO]", + }[msg_type] + filename = str(result_text).replace(prefix, "", 1) + display_name = None + if msg_type in {"file", "video"} and "|" in filename: + filename, display_name = filename.split("|", 1) + part = await self.create_attachment_from_file( + filename, + msg_type, + display_name=display_name, + ) + for accumulator in (pending_accumulator, display_accumulator): + accumulator.add_attachment(part) + if part and part.get("attachment_id") and part.get("type"): + attachment_saved_payload = { + "type": "attachment_saved", + "data": { + "id": part["attachment_id"], + "type": part["type"], + }, + } + + snapshot_accumulator = deepcopy(display_accumulator) + run.message_parts = snapshot_accumulator.build_message_parts( + include_pending_tool_calls=True + ) + self._publish_chat_run(run, result) + if attachment_saved_payload: + self._publish_chat_run(run, attachment_saved_payload) + + should_save = False + if msg_type == "end": + should_save = bool( + pending_accumulator.has_content() + or pending_refs + or pending_agent_stats + ) + elif (streaming and msg_type == "complete") or not streaming: + if chain_type not in ("tool_call", "tool_call_result"): + should_save = True + + if should_save: + saved_record = await flush_pending_bot_message() + if saved_record: + self._publish_chat_run( + run, + { + "type": "message_saved", + "data": { + "id": saved_record.id, + "created_at": to_utc_isoformat( + saved_record.created_at + ), + "llm_checkpoint_id": run.llm_checkpoint_id, + }, + }, + ) + if msg_type == "end": + run.status = "completed" + break + except asyncio.CancelledError: + run.status = "stopped" + except Exception as exc: + run.status = "failed" + logger.exception(f"WebChat run unexpected error: {exc}", exc_info=True) + self._publish_chat_run( + run, + {"type": "error", "data": "WebChat run failed"}, + ) + finally: + try: + saved_record = await asyncio.shield(flush_pending_bot_message()) + if saved_record: + self._publish_chat_run( + run, + { + "type": "message_saved", + "data": { + "id": saved_record.id, + "created_at": to_utc_isoformat(saved_record.created_at), + "llm_checkpoint_id": run.llm_checkpoint_id, + }, + }, + ) + except Exception as exc: + logger.exception( + f"Failed to persist pending webchat message: {exc}", + exc_info=True, + ) + + webchat_queue_mgr.remove_back_queue(run.run_id) + if self.chat_runs.get(run.run_id) is run: + self.chat_runs.pop(run.run_id, None) + run_ids = self.chat_runs_by_session.get(run.session_id) + if run_ids is not None: + run_ids.discard(run.run_id) + if not run_ids: + self.chat_runs_by_session.pop(run.session_id, None) + self.running_convs.pop(run.session_id, None) + for subscriber in list(run.subscribers): + while not subscriber.empty(): + subscriber.get_nowait() + subscriber.put_nowait(None) + run.subscribers.clear() + async def build_chat_stream( self, username: str, @@ -755,248 +1132,8 @@ class ChatService: 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 - message_accumulator = BotMessageAccumulator() - agent_stats = {} - refs = {} - - async def flush_pending_bot_message(): - nonlocal message_accumulator, agent_stats, refs - if not (message_accumulator.has_content() or refs or agent_stats): - return None - - message_parts_to_save = message_accumulator.build_message_parts( - include_pending_tool_calls=True - ) - plain_text = collect_plain_text_from_message_parts( - message_parts_to_save - ) - - try: - extracted_refs = extract_web_search_refs( - plain_text, - message_parts_to_save, - ) - except Exception as e: - logger.exception( - f"Failed to extract web search refs: {e}", - exc_info=True, - ) - extracted_refs = refs - - saved_record = await self.save_bot_message( - webchat_conv_id, - message_parts_to_save, - agent_stats, - extracted_refs, - llm_checkpoint_id, - platform_history_id, - ) - message_accumulator = BotMessageAccumulator() - agent_stats = {} - refs = {} - return saved_record - - def build_attachment_saved_event(part: dict | None) -> str | None: - if not part or not part.get("attachment_id") or not part.get("type"): - return None - - payload = { - "type": "attachment_saved", - "data": { - "id": part["attachment_id"], - "type": part["type"], - }, - } - return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" - - try: - session_info = { - "type": "session_id", - "data": None, - "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: - result, should_break = await poll_webchat_stream_result( - back_queue, username - ) - if should_break: - client_disconnected = True - break - if not result: - if not client_disconnected: - yield SSE_HEARTBEAT - continue - - if ( - "message_id" in result - and result["message_id"] != message_id - ): - logger.warning("webchat stream message_id mismatch") - continue - - result_text = result["data"] - msg_type = result.get("type") - streaming = result.get("streaming", False) - chain_type = result.get("chain_type") - - if chain_type == "agent_stats": - stats_info = { - "type": "agent_stats", - "data": json.loads(result_text), - } - yield f"data: {json.dumps(stats_info, ensure_ascii=False)}\n\n" - agent_stats = stats_info["data"] - continue - - try: - if not client_disconnected: - yield f"data: {json.dumps(result, ensure_ascii=False)}\n\n" - except Exception as e: - if not client_disconnected: - logger.debug( - f"[WebChat] 用户 {username} 断开聊天长连接。 {e}" - ) - client_disconnected = True - - try: - if not client_disconnected: - await asyncio.sleep(0.05) - except asyncio.CancelledError: - logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。") - client_disconnected = True - - if msg_type == "plain": - message_accumulator.add_plain( - result_text, - chain_type=chain_type, - streaming=streaming, - ) - elif msg_type == "image": - filename = result_text.replace("[IMAGE]", "") - part = await self.create_attachment_from_file( - filename, "image" - ) - message_accumulator.add_attachment(part) - if attachment_saved_event := build_attachment_saved_event( - part - ): - yield attachment_saved_event - elif msg_type == "record": - filename = result_text.replace("[RECORD]", "") - part = await self.create_attachment_from_file( - filename, "record" - ) - message_accumulator.add_attachment(part) - if attachment_saved_event := build_attachment_saved_event( - part - ): - yield attachment_saved_event - elif msg_type == "file": - filename = result_text.replace("[FILE]", "", 1) - display_name = None - if "|" in filename: - filename, display_name = filename.split("|", 1) - part = await self.create_attachment_from_file( - filename, - "file", - display_name=display_name, - ) - message_accumulator.add_attachment(part) - if attachment_saved_event := build_attachment_saved_event( - part - ): - yield attachment_saved_event - elif msg_type == "video": - filename = result_text.replace("[VIDEO]", "") - part = await self.create_attachment_from_file( - filename, "video" - ) - message_accumulator.add_attachment(part) - if attachment_saved_event := build_attachment_saved_event( - part - ): - yield attachment_saved_event - - should_save = False - if msg_type == "end": - should_save = message_accumulator.has_content() or bool( - refs or agent_stats - ) - elif (streaming and msg_type == "complete") or not streaming: - if chain_type not in ("tool_call", "tool_call_result"): - should_save = True - - if should_save: - saved_record = await flush_pending_bot_message() - if saved_record and not client_disconnected: - saved_info = { - "type": "message_saved", - "data": { - "id": saved_record.id, - "created_at": to_utc_isoformat( - saved_record.created_at - ), - "llm_checkpoint_id": llm_checkpoint_id, - }, - } - try: - yield f"data: {json.dumps(saved_info, ensure_ascii=False)}\n\n" - except Exception: - pass - if msg_type == "end": - break - except BaseException as e: - logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True) - finally: - try: - await flush_pending_bot_message() - except Exception as e: - logger.exception( - f"Failed to persist pending webchat message: {e}", - exc_info=True, - ) - webchat_queue_mgr.remove_back_queue(message_id) - - chat_queue = webchat_queue_mgr.get_or_create_queue(webchat_conv_id) - await chat_queue.put( - ( - username, - webchat_conv_id, - { - "message": message_parts, - "selected_provider": selected_provider, - "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) if not skip_user_history: saved_user_record = await self.platform_history_mgr.insert( @@ -1008,7 +1145,53 @@ class ChatService: llm_checkpoint_id=llm_checkpoint_id, ) - return stream() + back_queue = webchat_queue_mgr.get_or_create_back_queue( + message_id, + webchat_conv_id, + ) + run = ChatRunState( + run_id=message_id, + username=username, + session_id=webchat_conv_id, + llm_checkpoint_id=llm_checkpoint_id, + platform_history_id=platform_history_id, + back_queue=back_queue, + ) + self.chat_runs[message_id] = run + self.chat_runs_by_session.setdefault(webchat_conv_id, set()).add(message_id) + stream = self._subscribe_chat_run( + run, + include_snapshot=False, + saved_user_record=saved_user_record, + ) + run.task = asyncio.create_task( + self._consume_chat_run(run), + name=f"webchat_run_{message_id}", + ) + + try: + chat_queue = webchat_queue_mgr.get_or_create_queue(webchat_conv_id) + await chat_queue.put( + ( + username, + webchat_conv_id, + { + "message": message_parts, + "selected_provider": selected_provider, + "selected_model": selected_model, + "enable_streaming": enable_streaming, + "message_id": message_id, + "llm_checkpoint_id": llm_checkpoint_id, + "thread_selected_text": thread_selected_text, + }, + ), + ) + except BaseException: + run.task.cancel() + await asyncio.gather(run.task, return_exceptions=True) + raise + + return stream async def stop_session(self, username: str, session_id: str) -> dict: session = await self.db.get_platform_session_by_id(session_id) @@ -1039,6 +1222,15 @@ class ChatService: f"{session.platform_id}:{message_type}:" f"{session.platform_id}!{username}!{session_id}" ) + active_event_registry.request_agent_stop_all(unified_msg_origin) + tasks = [] + for run_id in list(self.chat_runs_by_session.get(session_id, set())): + run = self.chat_runs.get(run_id) + if run and run.task and not run.task.done(): + run.task.cancel() + tasks.append(run.task) + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) await self.conv_mgr.delete_conversations_by_user_id(unified_msg_origin) history_list = await self.platform_history_mgr.get( @@ -1237,6 +1429,7 @@ class ChatService: "history": [serialize_history_entry(history) for history in history_ls], "threads": [serialize_thread(thread) for thread in threads], "is_running": self.running_convs.get(session_id, False), + "active_runs": self.get_active_chat_runs(username, session_id), } if project_info: response_data["project"] = { @@ -1352,6 +1545,7 @@ class ChatService: "thread": serialize_thread(thread), "history": [serialize_history_entry(history) for history in history_ls], "is_running": self.running_convs.get(thread_id, False), + "active_runs": self.get_active_chat_runs(username, thread_id), } async def get_thread_from_dashboard_query( diff --git a/astrbot/dashboard/services/skills_service.py b/astrbot/dashboard/services/skills_service.py index 31c5c9afd..aa2cbc406 100644 --- a/astrbot/dashboard/services/skills_service.py +++ b/astrbot/dashboard/services/skills_service.py @@ -39,7 +39,9 @@ _EDITABLE_SKILL_FILENAMES = {"Dockerfile", "Makefile"} class SkillsServiceError(Exception): - pass + def __init__(self, message: str, *, status_code: int = 400) -> None: + super().__init__(message) + self.status_code = status_code @dataclass @@ -436,12 +438,16 @@ class SkillsService: skill_dir = Path(skill_mgr.skills_root) / skill_name skill_md = skill_dir / "SKILL.md" if not skill_dir.is_dir() or not skill_md.exists(): - raise SkillsServiceError("Local skill not found") + raise SkillsServiceError("Local skill not found", status_code=404) export_dir = Path(get_astrbot_temp_path()) / "skill_exports" export_dir.mkdir(parents=True, exist_ok=True) zip_base = export_dir / skill_name - zip_path = zip_base.with_suffix(".zip") + # with_suffix(".zip") replaces only the last suffix (e.g. ".0" in + # "skill-writing-1.0.0"), producing "skill-writing-1.0.zip" — + # which mismatches shutil.make_archive's "skill-writing-1.0.0.zip". + # Append ".zip" to the full name instead. + zip_path = zip_base.with_name(zip_base.name + ".zip") if zip_path.exists(): zip_path.unlink() diff --git a/changelogs/v4.26.5.md b/changelogs/v4.26.5.md new file mode 100644 index 000000000..c04d01bac --- /dev/null +++ b/changelogs/v4.26.5.md @@ -0,0 +1,41 @@ +## [4.26.5] - 2026-07-07 + +### Added + +- Added support for setting a custom bot ID while creating a platform by scanning a QR code. (#9067) +- Added ChatUI project workspaces. (#9066) +- Added improved ChatUI attachment display. (#9134) +- Added malformed tool-call name sanitation in `ToolLoopAgentRunner`. (#9144) +- Added model metadata in selectors and a chat token usage indicator. (#9161) +- Added event-loop diagnostics and expanded diagnostics documentation. (#9168) +- Added documentation for cross-platform compatibility and Python version support. +- Added test coverage for POSIX file URI root preservation. (#8906) + +### Changed + +- Log event-loop watchdog startup at the info level. +- Keep model metadata separate from providers. (#9161) +- Polish snackbar styling in the WebUI. (#9173) + +### Fixed + +- Enabled only synced ModelScope MCP servers. (#9084) +- Improved MCP client startup and shutdown handling to avoid idle spinning and clean up on errors, timeouts, and cancellation. (#9070) +- Replaced fragile knowledge-base duplicate-name error matching with a pre-check. (#9121) +- Improved knowledge-base retrieval resilience on per-knowledge-base failures and rerank selection. (#9122) +- Cleaned up `KBMedia` records on document deletion and fixed knowledge-base stats updates. (#9120) +- Aligned the knowledge-base CRUD API contract with `kb_name`, canonical payload fields, and OpenAPI types. (#9000) +- Constrained custom workspace paths and preserved absolute custom workspace paths. +- Improved web search API key failover. +- Enforced ownership when reading ChatUI sessions. (#9141) +- Secured project update temporary staging. (#9083) +- Improved QQ Official retry behavior when websocket send returns `None`. (#8977) +- Preserved QQ Official quoted message context. (#9164) +- Removed QQ Official reply IDs on proactive fallback so oversized passive replies can succeed via proactive sending. (#9169) +- Adapted MiMo STT to V2.5 models and rejected non-WAV audio payloads. (#9118) +- Serialized webchat history timestamps as UTC-aware ISO strings. (#9159) +- Synced plugin activation status on enable in the WebUI. (#9156) +- Exited MCP `anyio` contexts in the lifecycle task on disable. (#9132) +- Fixed leaked SSE exit stack handling that could cause MCP epoll busy-wait. (#8307) + +[4.26.5]: https://github.com/AstrBotDevs/AstrBot/compare/v4.26.4...v4.26.5 diff --git a/changelogs/v4.26.6.md b/changelogs/v4.26.6.md new file mode 100644 index 000000000..1b19a2cda --- /dev/null +++ b/changelogs/v4.26.6.md @@ -0,0 +1,27 @@ +## [4.26.6] - 2026-07-14 + +### Added + +- Added persona import and export in the WebUI. Exported personas intentionally exclude tools and Skills configuration. (#4532) + +### Changed + +- Enabled ephemeral cache control for Anthropic providers to improve cache hit rates. (#9197) +- Refined README documentation and corrected the Satori and Mattermost tutorial links. (#9205) +- Updated grouped GitHub Actions dependencies. (#9170) + +### Fixed + +- Validated dashboard account username updates. (#9175) +- Removed an unsupported `force` argument from LINE response parsing. (#9187) +- Handled invalid response cleanup regular expressions without interrupting message delivery. (#9183) +- Fixed the mobile chat input freeze and multiline attachment button alignment. (#9202) +- Made ChatUI model selection theme-aware. (#9200) +- Corrected the i18n callback path to Settings General. (#9201) +- Returned appropriate HTTP errors when skill downloads fail. (#9213) +- Exempted WebChat from the friend wake prefix. (#9215) +- Handled `max_tokens` correctly for NVIDIA MiniMax M3. (#9209) +- Reported current context usage accurately in the token indicator. (#9255) +- Resumed active ChatUI streams after a page refresh. (#9259) + +[4.26.6]: https://github.com/AstrBotDevs/AstrBot/compare/v4.26.5...v4.26.6 diff --git a/dashboard/src/App.vue b/dashboard/src/App.vue index e5ffa4328..383241024 100644 --- a/dashboard/src/App.vue +++ b/dashboard/src/App.vue @@ -12,9 +12,18 @@ :location="snackbarLocation" close-on-back > - {{ toastStore.current.message }} +
+ + {{ toastStore.current.message }} +
@@ -57,6 +66,17 @@ const snackbarLocation = computed( () => toastStore.current?.location as SnackAnchor | undefined, ); +const toastIcon = computed( + () => + ({ + success: "mdi-check-circle-outline", + error: "mdi-alert-circle-outline", + warning: "mdi-alert-outline", + info: "mdi-information-outline", + primary: "mdi-information-outline", + })[toastStore.current?.color ?? ""] ?? "", +); + onMounted(() => { const desktopBridge = window.astrbotDesktop; if (!desktopBridge?.onTrayRestartBackend) { diff --git a/dashboard/src/api/generated/openapi-v1/sdk.gen.ts b/dashboard/src/api/generated/openapi-v1/sdk.gen.ts index 3a7d40cdd..a07789be2 100644 --- a/dashboard/src/api/generated/openapi-v1/sdk.gen.ts +++ b/dashboard/src/api/generated/openapi-v1/sdk.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts import { createClient, createConfig, type OptionsLegacyParser, formDataBodySerializer } from '@hey-api/client-axios'; -import type { LoginData, LoginError, LoginResponse, LogoutError, LogoutResponse, GetAuthSetupStatusError, GetAuthSetupStatusResponse, SetupAuthData, SetupAuthError, SetupAuthResponse, SetupTotpData, SetupTotpError, SetupTotpResponse, RecoverTotpError, RecoverTotpResponse, UpdateAuthAccountData, UpdateAuthAccountError, UpdateAuthAccountResponse, ListApiKeysError, ListApiKeysResponse, CreateApiKeyData, CreateApiKeyError, CreateApiKeyResponse, RevokeApiKeyData, RevokeApiKeyError, RevokeApiKeyResponse, DeleteApiKeyData, DeleteApiKeyError, DeleteApiKeyResponse, GetSystemConfigSchemaError, GetSystemConfigSchemaResponse, GetSystemConfigError, GetSystemConfigResponse, UpdateSystemConfigData, UpdateSystemConfigError, UpdateSystemConfigResponse, GetSystemConfigRuntimeError, GetSystemConfigRuntimeResponse, GetConfigProfileSchemaError, GetConfigProfileSchemaResponse, ListConfigProfilesError, ListConfigProfilesResponse, CreateConfigProfileData, CreateConfigProfileError, CreateConfigProfileResponse, GetConfigProfileData, GetConfigProfileError, GetConfigProfileResponse, UpdateConfigProfileContentData, UpdateConfigProfileContentError, UpdateConfigProfileContentResponse, RenameConfigProfileData, RenameConfigProfileError, RenameConfigProfileResponse, DeleteConfigProfileData, DeleteConfigProfileError, DeleteConfigProfileResponse, ListConfigRoutesError, ListConfigRoutesResponse, ReplaceConfigRoutesData, ReplaceConfigRoutesError, ReplaceConfigRoutesResponse, UpsertConfigRouteData, UpsertConfigRouteError, UpsertConfigRouteResponse, DeleteConfigRouteData, DeleteConfigRouteError, DeleteConfigRouteResponse, ListBotTypesError, ListBotTypesResponse, RegisterBotTypeData, RegisterBotTypeError, RegisterBotTypeResponse, ListBotsData, ListBotsError, ListBotsResponse, CreateBotData, CreateBotError, CreateBotResponse, ListBotStatsError, ListBotStatsResponse, GetBotByIdData, GetBotByIdError, GetBotByIdResponse, UpdateBotByIdData, UpdateBotByIdError, UpdateBotByIdResponse, DeleteBotByIdData, DeleteBotByIdError, DeleteBotByIdResponse, SetBotEnabledByIdData, SetBotEnabledByIdError, SetBotEnabledByIdResponse, TestBotByIdData, TestBotByIdError, TestBotByIdResponse, GetBotData, GetBotError, GetBotResponse, UpdateBotData, UpdateBotError, UpdateBotResponse, DeleteBotData, DeleteBotError, DeleteBotResponse, SetBotEnabledData, SetBotEnabledError, SetBotEnabledResponse, TestBotData, TestBotError, TestBotResponse, GetProviderSchemaError, GetProviderSchemaResponse, ListProviderSourcesError, ListProviderSourcesResponse, CreateProviderSourceData, CreateProviderSourceError, CreateProviderSourceResponse, GetProviderSourceByIdData, GetProviderSourceByIdError, GetProviderSourceByIdResponse, UpsertProviderSourceByIdData, UpsertProviderSourceByIdError, UpsertProviderSourceByIdResponse, DeleteProviderSourceByIdData, DeleteProviderSourceByIdError, DeleteProviderSourceByIdResponse, ListProviderSourceModelsByIdData, ListProviderSourceModelsByIdError, ListProviderSourceModelsByIdResponse, ListProvidersBySourceIdData, ListProvidersBySourceIdError, ListProvidersBySourceIdResponse, CreateProviderInSourceByIdData, CreateProviderInSourceByIdError, CreateProviderInSourceByIdResponse, GetProviderSourceData, GetProviderSourceError, GetProviderSourceResponse, UpsertProviderSourceData, UpsertProviderSourceError, UpsertProviderSourceResponse, DeleteProviderSourceData, DeleteProviderSourceError, DeleteProviderSourceResponse, ListProviderSourceModelsData, ListProviderSourceModelsError, ListProviderSourceModelsResponse, ListProvidersBySourceData, ListProvidersBySourceError, ListProvidersBySourceResponse, CreateProviderInSourceData, CreateProviderInSourceError, CreateProviderInSourceResponse, ListProvidersData, ListProvidersError, ListProvidersResponse, CreateProviderData, CreateProviderError, CreateProviderResponse, GetProviderByIdData, GetProviderByIdError, GetProviderByIdResponse, UpdateProviderByIdData, UpdateProviderByIdError, UpdateProviderByIdResponse, DeleteProviderByIdData, DeleteProviderByIdError, DeleteProviderByIdResponse, SetProviderEnabledByIdData, SetProviderEnabledByIdError, SetProviderEnabledByIdResponse, TestProviderByIdData, TestProviderByIdError, TestProviderByIdResponse, GetProviderEmbeddingDimensionByIdData, GetProviderEmbeddingDimensionByIdError, GetProviderEmbeddingDimensionByIdResponse, GetProviderData, GetProviderError, GetProviderResponse, UpdateProviderData, UpdateProviderError, UpdateProviderResponse, DeleteProviderData, DeleteProviderError, DeleteProviderResponse, SetProviderEnabledData, SetProviderEnabledError, SetProviderEnabledResponse, TestProviderData, TestProviderError, TestProviderResponse, GetProviderEmbeddingDimensionData, GetProviderEmbeddingDimensionError, GetProviderEmbeddingDimensionResponse, SendChatMessageData, SendChatMessageError, SendChatMessageResponse, OpenChatWebSocketData, OpenLiveChatWebSocketData, OpenUnifiedChatWebSocketData, ListChatSessionsData, ListChatSessionsError, ListChatSessionsResponse, CreateChatSessionData, CreateChatSessionError, CreateChatSessionResponse, BatchDeleteChatSessionsData, BatchDeleteChatSessionsError, BatchDeleteChatSessionsResponse, GetChatSessionData, GetChatSessionError, GetChatSessionResponse, UpdateChatSessionData, UpdateChatSessionError, UpdateChatSessionResponse, DeleteChatSessionData, DeleteChatSessionError, DeleteChatSessionResponse, StopChatSessionData, StopChatSessionError, StopChatSessionResponse, UpdateChatMessageData, UpdateChatMessageError, UpdateChatMessageResponse, RegenerateChatMessageData, RegenerateChatMessageError, RegenerateChatMessageResponse, ListChatConfigsError, ListChatConfigsResponse, CreateChatThreadData, CreateChatThreadError, CreateChatThreadResponse, GetChatThreadData, GetChatThreadError, GetChatThreadResponse, DeleteChatThreadData, DeleteChatThreadError, DeleteChatThreadResponse, SendChatThreadMessageData, SendChatThreadMessageError, SendChatThreadMessageResponse, ListChatProjectsError, ListChatProjectsResponse, CreateChatProjectData, CreateChatProjectError, CreateChatProjectResponse, GetChatProjectData, GetChatProjectError, GetChatProjectResponse, UpdateChatProjectData, UpdateChatProjectError, UpdateChatProjectResponse, DeleteChatProjectData, DeleteChatProjectError, DeleteChatProjectResponse, ListChatProjectSessionsData, ListChatProjectSessionsError, ListChatProjectSessionsResponse, AddChatProjectSessionData, AddChatProjectSessionError, AddChatProjectSessionResponse, RemoveChatProjectSessionData, RemoveChatProjectSessionError, RemoveChatProjectSessionResponse, SendImMessageData, SendImMessageError, SendImMessageResponse, ListImBotsError, ListImBotsResponse, UploadFileData, UploadFileError, UploadFileResponse, UploadOpenApiFileData, UploadOpenApiFileError, UploadOpenApiFileResponse, DownloadOpenApiFileData, DownloadOpenApiFileError, DownloadOpenApiFileResponse, GetFileByNameData, GetFileByNameError, GetFileByNameResponse, GetTokenFileData, GetTokenFileError, GetTokenFileResponse, GetAttachmentData, GetAttachmentError, GetAttachmentResponse, DeleteAttachmentData, DeleteAttachmentError, DeleteAttachmentResponse, DownloadAttachmentData, DownloadAttachmentError, DownloadAttachmentResponse, ListPluginsData, ListPluginsError, ListPluginsResponse, GetPluginByIdData, GetPluginByIdError, GetPluginByIdResponse, UninstallPluginByIdData, UninstallPluginByIdError, UninstallPluginByIdResponse, GetPluginConfigByIdData, GetPluginConfigByIdError, GetPluginConfigByIdResponse, UpdatePluginConfigByIdData, UpdatePluginConfigByIdError, UpdatePluginConfigByIdResponse, GetPluginConfigSchemaByIdData, GetPluginConfigSchemaByIdError, GetPluginConfigSchemaByIdResponse, ListPluginConfigFilesByIdData, ListPluginConfigFilesByIdError, ListPluginConfigFilesByIdResponse, UploadPluginConfigFilesByIdData, UploadPluginConfigFilesByIdError, UploadPluginConfigFilesByIdResponse, DeletePluginConfigFileByIdData, DeletePluginConfigFileByIdError, DeletePluginConfigFileByIdResponse, GetPluginReadmeByIdData, GetPluginReadmeByIdError, GetPluginReadmeByIdResponse, GetPluginChangelogByIdData, GetPluginChangelogByIdError, GetPluginChangelogByIdResponse, ReloadPluginByIdData, ReloadPluginByIdError, ReloadPluginByIdResponse, SetPluginEnabledByIdData, SetPluginEnabledByIdError, SetPluginEnabledByIdResponse, ListPluginPagesByIdData, ListPluginPagesByIdError, ListPluginPagesByIdResponse, GetPluginPageByIdData, GetPluginPageByIdError, GetPluginPageByIdResponse, GetPluginPageAssetByIdData, GetPluginPageAssetByIdError, GetPluginPageAssetByIdResponse, GetPluginData, GetPluginError, GetPluginResponse, UninstallPluginData, UninstallPluginError, UninstallPluginResponse, GetPluginConfigData, GetPluginConfigError, GetPluginConfigResponse, UpdatePluginConfigData, UpdatePluginConfigError, UpdatePluginConfigResponse, GetPluginConfigSchemaData, GetPluginConfigSchemaError, GetPluginConfigSchemaResponse, ListPluginConfigFilesData, ListPluginConfigFilesError, ListPluginConfigFilesResponse, UploadPluginConfigFilesData, UploadPluginConfigFilesError, UploadPluginConfigFilesResponse, DeletePluginConfigFileData, DeletePluginConfigFileError, DeletePluginConfigFileResponse, GetPluginReadmeData, GetPluginReadmeError, GetPluginReadmeResponse, GetPluginChangelogData, GetPluginChangelogError, GetPluginChangelogResponse, ReloadPluginData, ReloadPluginError, ReloadPluginResponse, BindPluginSourceData, BindPluginSourceError, BindPluginSourceResponse, SetPluginEnabledData, SetPluginEnabledError, SetPluginEnabledResponse, UpdatePluginData, UpdatePluginError, UpdatePluginResponse, UpdatePluginsData, UpdatePluginsError, UpdatePluginsResponse, CheckPluginVersionSupportData, CheckPluginVersionSupportError, CheckPluginVersionSupportResponse, ValidatePluginRepoData, ValidatePluginRepoError, ValidatePluginRepoResponse, ListFailedPluginsError, ListFailedPluginsResponse, UninstallFailedPluginData, UninstallFailedPluginError, UninstallFailedPluginResponse, ReloadFailedPluginData, ReloadFailedPluginError, ReloadFailedPluginResponse, InstallPluginFromGithubData, InstallPluginFromGithubError, InstallPluginFromGithubResponse, InstallPluginFromUrlData, InstallPluginFromUrlError, InstallPluginFromUrlResponse, InstallPluginFromUploadData, InstallPluginFromUploadError, InstallPluginFromUploadResponse, ListPluginMarketData, ListPluginMarketError, ListPluginMarketResponse, ListPluginMarketCategoriesError, ListPluginMarketCategoriesResponse, ListPluginSourcesError, ListPluginSourcesResponse, CreatePluginSourceData, CreatePluginSourceError, CreatePluginSourceResponse, ReplacePluginSourcesData, ReplacePluginSourcesError, ReplacePluginSourcesResponse, DeletePluginSourceData, DeletePluginSourceError, DeletePluginSourceResponse, DeletePluginSourceByIdData, DeletePluginSourceByIdError, DeletePluginSourceByIdResponse, ListPluginPagesData, ListPluginPagesError, ListPluginPagesResponse, GetPluginPageData, GetPluginPageError, GetPluginPageResponse, GetPluginPageAssetData, GetPluginPageAssetError, GetPluginPageAssetResponse, GetPluginPageBridgeSdkError, GetPluginPageBridgeSdkResponse, GetPluginExtensionRouteData, GetPluginExtensionRouteError, GetPluginExtensionRouteResponse, PostPluginExtensionRouteData, PostPluginExtensionRouteError, PostPluginExtensionRouteResponse, PutPluginExtensionRouteData, PutPluginExtensionRouteError, PutPluginExtensionRouteResponse, PatchPluginExtensionRouteData, PatchPluginExtensionRouteError, PatchPluginExtensionRouteResponse, DeletePluginExtensionRouteData, DeletePluginExtensionRouteError, DeletePluginExtensionRouteResponse, ListCommandsData, ListCommandsError, ListCommandsResponse, UpdateCommandData, UpdateCommandError, UpdateCommandResponse, ListCommandConflictsError, ListCommandConflictsResponse, ListToolsData, ListToolsError, ListToolsResponse, SetToolEnabledData, SetToolEnabledError, SetToolEnabledResponse, SetToolPermissionData, SetToolPermissionError, SetToolPermissionResponse, ListMcpServersError, ListMcpServersResponse, CreateMcpServerData, CreateMcpServerError, CreateMcpServerResponse, UpdateMcpServerByNameData, UpdateMcpServerByNameError, UpdateMcpServerByNameResponse, DeleteMcpServerByNameData, DeleteMcpServerByNameError, DeleteMcpServerByNameResponse, SetMcpServerEnabledByNameData, SetMcpServerEnabledByNameError, SetMcpServerEnabledByNameResponse, TestMcpServerByNameData, TestMcpServerByNameError, TestMcpServerByNameResponse, UpdateMcpServerData, UpdateMcpServerError, UpdateMcpServerResponse, DeleteMcpServerData, DeleteMcpServerError, DeleteMcpServerResponse, SetMcpServerEnabledData, SetMcpServerEnabledError, SetMcpServerEnabledResponse, TestMcpServerData, TestMcpServerError, TestMcpServerResponse, SyncModelScopeMcpServersData, SyncModelScopeMcpServersError, SyncModelScopeMcpServersResponse, ListSkillsData, ListSkillsError, ListSkillsResponse, UploadSkillData, UploadSkillError, UploadSkillResponse, UploadSkillsBatchData, UploadSkillsBatchError, UploadSkillsBatchResponse, UpdateSkillByNameData, UpdateSkillByNameError, UpdateSkillByNameResponse, DeleteSkillByNameData, DeleteSkillByNameError, DeleteSkillByNameResponse, DownloadSkillByNameData, DownloadSkillByNameError, DownloadSkillByNameResponse, ListSkillFilesByNameData, ListSkillFilesByNameError, ListSkillFilesByNameResponse, GetSkillFileByNameData, GetSkillFileByNameError, GetSkillFileByNameResponse, UpdateSkillFileByNameData, UpdateSkillFileByNameError, UpdateSkillFileByNameResponse, UpdateSkillData, UpdateSkillError, UpdateSkillResponse, DeleteSkillData, DeleteSkillError, DeleteSkillResponse, DownloadSkillData, DownloadSkillError, DownloadSkillResponse, ListSkillFilesData, ListSkillFilesError, ListSkillFilesResponse, GetSkillFileData, GetSkillFileError, GetSkillFileResponse, UpdateSkillFileData, UpdateSkillFileError, UpdateSkillFileResponse, ListNeoSkillCandidatesData, ListNeoSkillCandidatesError, ListNeoSkillCandidatesResponse, ListNeoSkillReleasesData, ListNeoSkillReleasesError, ListNeoSkillReleasesResponse, GetNeoSkillPayloadData, GetNeoSkillPayloadError, GetNeoSkillPayloadResponse, EvaluateNeoSkillCandidateData, EvaluateNeoSkillCandidateError, EvaluateNeoSkillCandidateResponse, PromoteNeoSkillCandidateData, PromoteNeoSkillCandidateError, PromoteNeoSkillCandidateResponse, RollbackNeoSkillReleaseData, RollbackNeoSkillReleaseError, RollbackNeoSkillReleaseResponse, SyncNeoSkillReleaseData, SyncNeoSkillReleaseError, SyncNeoSkillReleaseResponse, DeleteNeoSkillCandidateData, DeleteNeoSkillCandidateError, DeleteNeoSkillCandidateResponse, DeleteNeoSkillReleaseData, DeleteNeoSkillReleaseError, DeleteNeoSkillReleaseResponse, ListKnowledgeBasesData, ListKnowledgeBasesError, ListKnowledgeBasesResponse, CreateKnowledgeBaseData, CreateKnowledgeBaseError, CreateKnowledgeBaseResponse, GetKnowledgeBaseData, GetKnowledgeBaseError, GetKnowledgeBaseResponse, UpdateKnowledgeBaseData, UpdateKnowledgeBaseError, UpdateKnowledgeBaseResponse, DeleteKnowledgeBaseData, DeleteKnowledgeBaseError, DeleteKnowledgeBaseResponse, GetKnowledgeBaseStatsData, GetKnowledgeBaseStatsError, GetKnowledgeBaseStatsResponse, ListKnowledgeDocumentsData, ListKnowledgeDocumentsError, ListKnowledgeDocumentsResponse, UploadKnowledgeDocumentData, UploadKnowledgeDocumentError, UploadKnowledgeDocumentResponse, ImportKnowledgeDocumentsData, ImportKnowledgeDocumentsError, ImportKnowledgeDocumentsResponse, ImportKnowledgeDocumentFromUrlData, ImportKnowledgeDocumentFromUrlError, ImportKnowledgeDocumentFromUrlResponse, GetKnowledgeDocumentData, GetKnowledgeDocumentError, GetKnowledgeDocumentResponse, DeleteKnowledgeDocumentData, DeleteKnowledgeDocumentError, DeleteKnowledgeDocumentResponse, ListKnowledgeChunksData, ListKnowledgeChunksError, ListKnowledgeChunksResponse, DeleteKnowledgeChunkData, DeleteKnowledgeChunkError, DeleteKnowledgeChunkResponse, RetrieveKnowledgeBaseData, RetrieveKnowledgeBaseError, RetrieveKnowledgeBaseResponse, GetKnowledgeTaskData, GetKnowledgeTaskError, GetKnowledgeTaskResponse, GetPersonaTreeError, GetPersonaTreeResponse, ListPersonasData, ListPersonasError, ListPersonasResponse, CreatePersonaData, CreatePersonaError, CreatePersonaResponse, GetPersonaByIdData, GetPersonaByIdError, GetPersonaByIdResponse, UpdatePersonaByIdData, UpdatePersonaByIdError, UpdatePersonaByIdResponse, DeletePersonaByIdData, DeletePersonaByIdError, DeletePersonaByIdResponse, GetPersonaData, GetPersonaError, GetPersonaResponse, UpdatePersonaData, UpdatePersonaError, UpdatePersonaResponse, DeletePersonaData, DeletePersonaError, DeletePersonaResponse, ListPersonaFoldersData, ListPersonaFoldersError, ListPersonaFoldersResponse, CreatePersonaFolderData, CreatePersonaFolderError, CreatePersonaFolderResponse, UpdatePersonaFolderData, UpdatePersonaFolderError, UpdatePersonaFolderResponse, DeletePersonaFolderData, DeletePersonaFolderError, DeletePersonaFolderResponse, MovePersonaItemData, MovePersonaItemError, MovePersonaItemResponse, ReorderPersonaItemsData, ReorderPersonaItemsError, ReorderPersonaItemsResponse, ListSessionsData, ListSessionsError, ListSessionsResponse, ListActiveUmosError, ListActiveUmosResponse, ListSessionRulesData, ListSessionRulesError, ListSessionRulesResponse, UpsertSessionRuleData, UpsertSessionRuleError, UpsertSessionRuleResponse, DeleteSessionRulesData, DeleteSessionRulesError, DeleteSessionRulesResponse, BatchUpdateSessionProviderData, BatchUpdateSessionProviderError, BatchUpdateSessionProviderResponse, BatchUpdateSessionServiceData, BatchUpdateSessionServiceError, BatchUpdateSessionServiceResponse, ListSessionGroupsError, ListSessionGroupsResponse, CreateSessionGroupData, CreateSessionGroupError, CreateSessionGroupResponse, UpdateSessionGroupData, UpdateSessionGroupError, UpdateSessionGroupResponse, DeleteSessionGroupData, DeleteSessionGroupError, DeleteSessionGroupResponse, ListConversationsData, ListConversationsError, ListConversationsResponse, BatchDeleteConversationsData, BatchDeleteConversationsError, BatchDeleteConversationsResponse, GetConversationData, GetConversationError, GetConversationResponse, UpdateConversationData, UpdateConversationError, UpdateConversationResponse, DeleteConversationData, DeleteConversationError, DeleteConversationResponse, ReplaceConversationMessagesData, ReplaceConversationMessagesError, ReplaceConversationMessagesResponse, ExportConversationsData, ExportConversationsError, ExportConversationsResponse, GetStatsData, GetStatsError, GetStatsResponse, GetProviderTokenStatsData, GetProviderTokenStatsError, GetProviderTokenStatsResponse, GetVersionError, GetVersionResponse, GetPublicVersionsError, GetPublicVersionsResponse, GetFirstNoticeData, GetFirstNoticeError, GetFirstNoticeResponse, TestGhproxyConnectionData, TestGhproxyConnectionError, TestGhproxyConnectionResponse, ListChangelogVersionsError, ListChangelogVersionsResponse, GetChangelogData, GetChangelogError, GetChangelogResponse, GetStartTimeError, GetStartTimeResponse, GetStorageStatusError, GetStorageStatusResponse, CleanupStorageData, CleanupStorageError, CleanupStorageResponse, RestartCoreError, RestartCoreResponse, ListBackupsData, ListBackupsError, ListBackupsResponse, CreateBackupData, CreateBackupError, CreateBackupResponse, UploadBackupData, UploadBackupError, UploadBackupResponse, InitBackupUploadData, InitBackupUploadError, InitBackupUploadResponse, UploadBackupChunkData, UploadBackupChunkError, UploadBackupChunkResponse, CompleteBackupUploadData, CompleteBackupUploadError, CompleteBackupUploadResponse, AbortBackupUploadData, AbortBackupUploadError, AbortBackupUploadResponse, GetBackupProgressData, GetBackupProgressError, GetBackupProgressResponse, DownloadBackupData, DownloadBackupError, DownloadBackupResponse, RenameBackupData, RenameBackupError, RenameBackupResponse, DeleteBackupData, DeleteBackupError, DeleteBackupResponse, CheckBackupData, CheckBackupError, CheckBackupResponse, ImportBackupData, ImportBackupError, ImportBackupResponse, CheckUpdateError, CheckUpdateResponse, ListReleasesData, ListReleasesError, ListReleasesResponse, UpdateCoreData, UpdateCoreError, UpdateCoreResponse, UpdateDashboardData, UpdateDashboardError, UpdateDashboardResponse, GetUpdateProgressData, GetUpdateProgressError, GetUpdateProgressResponse, InstallPipPackageData, InstallPipPackageError, InstallPipPackageResponse, ListCronJobsData, ListCronJobsError, ListCronJobsResponse, CreateCronJobData, CreateCronJobError, CreateCronJobResponse, UpdateCronJobData, UpdateCronJobError, UpdateCronJobResponse, DeleteCronJobData, DeleteCronJobError, DeleteCronJobResponse, RunCronJobData, RunCronJobError, RunCronJobResponse, StreamLiveLogsError, StreamLiveLogsResponse, GetLogHistoryError, GetLogHistoryResponse, GetTraceSettingsError, GetTraceSettingsResponse, UpdateTraceSettingsData, UpdateTraceSettingsError, UpdateTraceSettingsResponse, ListT2iTemplatesError, ListT2iTemplatesResponse, CreateT2iTemplateData, CreateT2iTemplateError, CreateT2iTemplateResponse, GetActiveT2iTemplateError, GetActiveT2iTemplateResponse, SetActiveT2iTemplateData, SetActiveT2iTemplateError, SetActiveT2iTemplateResponse, ResetDefaultT2iTemplateError, ResetDefaultT2iTemplateResponse, GetT2iTemplateData, GetT2iTemplateError, GetT2iTemplateResponse, UpdateT2iTemplateData, UpdateT2iTemplateError, UpdateT2iTemplateResponse, DeleteT2iTemplateData, DeleteT2iTemplateError, DeleteT2iTemplateResponse, GetSubagentConfigError, GetSubagentConfigResponse, UpdateSubagentConfigData, UpdateSubagentConfigError, UpdateSubagentConfigResponse, ListSubagentAvailableToolsError, ListSubagentAvailableToolsResponse, VerifyPlatformWebhookData, VerifyPlatformWebhookError, VerifyPlatformWebhookResponse, ReceivePlatformWebhookData, ReceivePlatformWebhookError, ReceivePlatformWebhookResponse } from './types.gen'; +import type { LoginData, LoginError, LoginResponse, LogoutError, LogoutResponse, GetAuthSetupStatusError, GetAuthSetupStatusResponse, SetupAuthData, SetupAuthError, SetupAuthResponse, SetupTotpData, SetupTotpError, SetupTotpResponse, RecoverTotpError, RecoverTotpResponse, UpdateAuthAccountData, UpdateAuthAccountError, UpdateAuthAccountResponse, ListApiKeysError, ListApiKeysResponse, CreateApiKeyData, CreateApiKeyError, CreateApiKeyResponse, RevokeApiKeyData, RevokeApiKeyError, RevokeApiKeyResponse, DeleteApiKeyData, DeleteApiKeyError, DeleteApiKeyResponse, GetSystemConfigSchemaError, GetSystemConfigSchemaResponse, GetSystemConfigError, GetSystemConfigResponse, UpdateSystemConfigData, UpdateSystemConfigError, UpdateSystemConfigResponse, GetSystemConfigRuntimeError, GetSystemConfigRuntimeResponse, GetConfigProfileSchemaError, GetConfigProfileSchemaResponse, ListConfigProfilesError, ListConfigProfilesResponse, CreateConfigProfileData, CreateConfigProfileError, CreateConfigProfileResponse, GetConfigProfileData, GetConfigProfileError, GetConfigProfileResponse, UpdateConfigProfileContentData, UpdateConfigProfileContentError, UpdateConfigProfileContentResponse, RenameConfigProfileData, RenameConfigProfileError, RenameConfigProfileResponse, DeleteConfigProfileData, DeleteConfigProfileError, DeleteConfigProfileResponse, ListConfigRoutesError, ListConfigRoutesResponse, ReplaceConfigRoutesData, ReplaceConfigRoutesError, ReplaceConfigRoutesResponse, UpsertConfigRouteData, UpsertConfigRouteError, UpsertConfigRouteResponse, DeleteConfigRouteData, DeleteConfigRouteError, DeleteConfigRouteResponse, ListBotTypesError, ListBotTypesResponse, RegisterBotTypeData, RegisterBotTypeError, RegisterBotTypeResponse, ListBotsData, ListBotsError, ListBotsResponse, CreateBotData, CreateBotError, CreateBotResponse, ListBotStatsError, ListBotStatsResponse, GetBotByIdData, GetBotByIdError, GetBotByIdResponse, UpdateBotByIdData, UpdateBotByIdError, UpdateBotByIdResponse, DeleteBotByIdData, DeleteBotByIdError, DeleteBotByIdResponse, SetBotEnabledByIdData, SetBotEnabledByIdError, SetBotEnabledByIdResponse, TestBotByIdData, TestBotByIdError, TestBotByIdResponse, GetBotData, GetBotError, GetBotResponse, UpdateBotData, UpdateBotError, UpdateBotResponse, DeleteBotData, DeleteBotError, DeleteBotResponse, SetBotEnabledData, SetBotEnabledError, SetBotEnabledResponse, TestBotData, TestBotError, TestBotResponse, GetProviderSchemaError, GetProviderSchemaResponse, ListProviderSourcesError, ListProviderSourcesResponse, CreateProviderSourceData, CreateProviderSourceError, CreateProviderSourceResponse, GetProviderSourceByIdData, GetProviderSourceByIdError, GetProviderSourceByIdResponse, UpsertProviderSourceByIdData, UpsertProviderSourceByIdError, UpsertProviderSourceByIdResponse, DeleteProviderSourceByIdData, DeleteProviderSourceByIdError, DeleteProviderSourceByIdResponse, ListProviderSourceModelsByIdData, ListProviderSourceModelsByIdError, ListProviderSourceModelsByIdResponse, ListProvidersBySourceIdData, ListProvidersBySourceIdError, ListProvidersBySourceIdResponse, CreateProviderInSourceByIdData, CreateProviderInSourceByIdError, CreateProviderInSourceByIdResponse, GetProviderSourceData, GetProviderSourceError, GetProviderSourceResponse, UpsertProviderSourceData, UpsertProviderSourceError, UpsertProviderSourceResponse, DeleteProviderSourceData, DeleteProviderSourceError, DeleteProviderSourceResponse, ListProviderSourceModelsData, ListProviderSourceModelsError, ListProviderSourceModelsResponse, ListProvidersBySourceData, ListProvidersBySourceError, ListProvidersBySourceResponse, CreateProviderInSourceData, CreateProviderInSourceError, CreateProviderInSourceResponse, ListProvidersData, ListProvidersError, ListProvidersResponse, CreateProviderData, CreateProviderError, CreateProviderResponse, GetProviderByIdData, GetProviderByIdError, GetProviderByIdResponse, UpdateProviderByIdData, UpdateProviderByIdError, UpdateProviderByIdResponse, DeleteProviderByIdData, DeleteProviderByIdError, DeleteProviderByIdResponse, SetProviderEnabledByIdData, SetProviderEnabledByIdError, SetProviderEnabledByIdResponse, TestProviderByIdData, TestProviderByIdError, TestProviderByIdResponse, GetProviderEmbeddingDimensionByIdData, GetProviderEmbeddingDimensionByIdError, GetProviderEmbeddingDimensionByIdResponse, GetProviderData, GetProviderError, GetProviderResponse, UpdateProviderData, UpdateProviderError, UpdateProviderResponse, DeleteProviderData, DeleteProviderError, DeleteProviderResponse, SetProviderEnabledData, SetProviderEnabledError, SetProviderEnabledResponse, TestProviderData, TestProviderError, TestProviderResponse, GetProviderEmbeddingDimensionData, GetProviderEmbeddingDimensionError, GetProviderEmbeddingDimensionResponse, SendChatMessageData, SendChatMessageError, SendChatMessageResponse, OpenChatWebSocketData, OpenLiveChatWebSocketData, OpenUnifiedChatWebSocketData, ListChatSessionsData, ListChatSessionsError, ListChatSessionsResponse, CreateChatSessionData, CreateChatSessionError, CreateChatSessionResponse, BatchDeleteChatSessionsData, BatchDeleteChatSessionsError, BatchDeleteChatSessionsResponse, GetChatSessionData, GetChatSessionError, GetChatSessionResponse, UpdateChatSessionData, UpdateChatSessionError, UpdateChatSessionResponse, DeleteChatSessionData, DeleteChatSessionError, DeleteChatSessionResponse, StopChatSessionData, StopChatSessionError, StopChatSessionResponse, ResumeChatRunData, ResumeChatRunError, ResumeChatRunResponse, UpdateChatMessageData, UpdateChatMessageError, UpdateChatMessageResponse, RegenerateChatMessageData, RegenerateChatMessageError, RegenerateChatMessageResponse, ListChatConfigsError, ListChatConfigsResponse, CreateChatThreadData, CreateChatThreadError, CreateChatThreadResponse, GetChatThreadData, GetChatThreadError, GetChatThreadResponse, DeleteChatThreadData, DeleteChatThreadError, DeleteChatThreadResponse, SendChatThreadMessageData, SendChatThreadMessageError, SendChatThreadMessageResponse, ListChatProjectsError, ListChatProjectsResponse, CreateChatProjectData, CreateChatProjectError, CreateChatProjectResponse, GetChatProjectData, GetChatProjectError, GetChatProjectResponse, UpdateChatProjectData, UpdateChatProjectError, UpdateChatProjectResponse, DeleteChatProjectData, DeleteChatProjectError, DeleteChatProjectResponse, ListChatProjectSessionsData, ListChatProjectSessionsError, ListChatProjectSessionsResponse, AddChatProjectSessionData, AddChatProjectSessionError, AddChatProjectSessionResponse, RemoveChatProjectSessionData, RemoveChatProjectSessionError, RemoveChatProjectSessionResponse, SendImMessageData, SendImMessageError, SendImMessageResponse, ListImBotsError, ListImBotsResponse, UploadFileData, UploadFileError, UploadFileResponse, UploadOpenApiFileData, UploadOpenApiFileError, UploadOpenApiFileResponse, DownloadOpenApiFileData, DownloadOpenApiFileError, DownloadOpenApiFileResponse, GetFileByNameData, GetFileByNameError, GetFileByNameResponse, GetTokenFileData, GetTokenFileError, GetTokenFileResponse, GetAttachmentData, GetAttachmentError, GetAttachmentResponse, DeleteAttachmentData, DeleteAttachmentError, DeleteAttachmentResponse, DownloadAttachmentData, DownloadAttachmentError, DownloadAttachmentResponse, ListPluginsData, ListPluginsError, ListPluginsResponse, GetPluginByIdData, GetPluginByIdError, GetPluginByIdResponse, UninstallPluginByIdData, UninstallPluginByIdError, UninstallPluginByIdResponse, GetPluginConfigByIdData, GetPluginConfigByIdError, GetPluginConfigByIdResponse, UpdatePluginConfigByIdData, UpdatePluginConfigByIdError, UpdatePluginConfigByIdResponse, GetPluginConfigSchemaByIdData, GetPluginConfigSchemaByIdError, GetPluginConfigSchemaByIdResponse, ListPluginConfigFilesByIdData, ListPluginConfigFilesByIdError, ListPluginConfigFilesByIdResponse, UploadPluginConfigFilesByIdData, UploadPluginConfigFilesByIdError, UploadPluginConfigFilesByIdResponse, DeletePluginConfigFileByIdData, DeletePluginConfigFileByIdError, DeletePluginConfigFileByIdResponse, GetPluginReadmeByIdData, GetPluginReadmeByIdError, GetPluginReadmeByIdResponse, GetPluginChangelogByIdData, GetPluginChangelogByIdError, GetPluginChangelogByIdResponse, ReloadPluginByIdData, ReloadPluginByIdError, ReloadPluginByIdResponse, SetPluginEnabledByIdData, SetPluginEnabledByIdError, SetPluginEnabledByIdResponse, ListPluginPagesByIdData, ListPluginPagesByIdError, ListPluginPagesByIdResponse, GetPluginPageByIdData, GetPluginPageByIdError, GetPluginPageByIdResponse, GetPluginPageAssetByIdData, GetPluginPageAssetByIdError, GetPluginPageAssetByIdResponse, GetPluginData, GetPluginError, GetPluginResponse, UninstallPluginData, UninstallPluginError, UninstallPluginResponse, GetPluginConfigData, GetPluginConfigError, GetPluginConfigResponse, UpdatePluginConfigData, UpdatePluginConfigError, UpdatePluginConfigResponse, GetPluginConfigSchemaData, GetPluginConfigSchemaError, GetPluginConfigSchemaResponse, ListPluginConfigFilesData, ListPluginConfigFilesError, ListPluginConfigFilesResponse, UploadPluginConfigFilesData, UploadPluginConfigFilesError, UploadPluginConfigFilesResponse, DeletePluginConfigFileData, DeletePluginConfigFileError, DeletePluginConfigFileResponse, GetPluginReadmeData, GetPluginReadmeError, GetPluginReadmeResponse, GetPluginChangelogData, GetPluginChangelogError, GetPluginChangelogResponse, ReloadPluginData, ReloadPluginError, ReloadPluginResponse, BindPluginSourceData, BindPluginSourceError, BindPluginSourceResponse, SetPluginEnabledData, SetPluginEnabledError, SetPluginEnabledResponse, UpdatePluginData, UpdatePluginError, UpdatePluginResponse, UpdatePluginsData, UpdatePluginsError, UpdatePluginsResponse, CheckPluginVersionSupportData, CheckPluginVersionSupportError, CheckPluginVersionSupportResponse, ValidatePluginRepoData, ValidatePluginRepoError, ValidatePluginRepoResponse, ListFailedPluginsError, ListFailedPluginsResponse, UninstallFailedPluginData, UninstallFailedPluginError, UninstallFailedPluginResponse, ReloadFailedPluginData, ReloadFailedPluginError, ReloadFailedPluginResponse, InstallPluginFromGithubData, InstallPluginFromGithubError, InstallPluginFromGithubResponse, InstallPluginFromUrlData, InstallPluginFromUrlError, InstallPluginFromUrlResponse, InstallPluginFromUploadData, InstallPluginFromUploadError, InstallPluginFromUploadResponse, ListPluginMarketData, ListPluginMarketError, ListPluginMarketResponse, ListPluginMarketCategoriesError, ListPluginMarketCategoriesResponse, ListPluginSourcesError, ListPluginSourcesResponse, CreatePluginSourceData, CreatePluginSourceError, CreatePluginSourceResponse, ReplacePluginSourcesData, ReplacePluginSourcesError, ReplacePluginSourcesResponse, DeletePluginSourceData, DeletePluginSourceError, DeletePluginSourceResponse, DeletePluginSourceByIdData, DeletePluginSourceByIdError, DeletePluginSourceByIdResponse, ListPluginPagesData, ListPluginPagesError, ListPluginPagesResponse, GetPluginPageData, GetPluginPageError, GetPluginPageResponse, GetPluginPageAssetData, GetPluginPageAssetError, GetPluginPageAssetResponse, GetPluginPageBridgeSdkError, GetPluginPageBridgeSdkResponse, GetPluginExtensionRouteData, GetPluginExtensionRouteError, GetPluginExtensionRouteResponse, PostPluginExtensionRouteData, PostPluginExtensionRouteError, PostPluginExtensionRouteResponse, PutPluginExtensionRouteData, PutPluginExtensionRouteError, PutPluginExtensionRouteResponse, PatchPluginExtensionRouteData, PatchPluginExtensionRouteError, PatchPluginExtensionRouteResponse, DeletePluginExtensionRouteData, DeletePluginExtensionRouteError, DeletePluginExtensionRouteResponse, ListCommandsData, ListCommandsError, ListCommandsResponse, UpdateCommandData, UpdateCommandError, UpdateCommandResponse, ListCommandConflictsError, ListCommandConflictsResponse, ListToolsData, ListToolsError, ListToolsResponse, SetToolEnabledData, SetToolEnabledError, SetToolEnabledResponse, SetToolPermissionData, SetToolPermissionError, SetToolPermissionResponse, ListMcpServersError, ListMcpServersResponse, CreateMcpServerData, CreateMcpServerError, CreateMcpServerResponse, UpdateMcpServerByNameData, UpdateMcpServerByNameError, UpdateMcpServerByNameResponse, DeleteMcpServerByNameData, DeleteMcpServerByNameError, DeleteMcpServerByNameResponse, SetMcpServerEnabledByNameData, SetMcpServerEnabledByNameError, SetMcpServerEnabledByNameResponse, TestMcpServerByNameData, TestMcpServerByNameError, TestMcpServerByNameResponse, UpdateMcpServerData, UpdateMcpServerError, UpdateMcpServerResponse, DeleteMcpServerData, DeleteMcpServerError, DeleteMcpServerResponse, SetMcpServerEnabledData, SetMcpServerEnabledError, SetMcpServerEnabledResponse, TestMcpServerData, TestMcpServerError, TestMcpServerResponse, SyncModelScopeMcpServersData, SyncModelScopeMcpServersError, SyncModelScopeMcpServersResponse, ListSkillsData, ListSkillsError, ListSkillsResponse, UploadSkillData, UploadSkillError, UploadSkillResponse, UploadSkillsBatchData, UploadSkillsBatchError, UploadSkillsBatchResponse, UpdateSkillByNameData, UpdateSkillByNameError, UpdateSkillByNameResponse, DeleteSkillByNameData, DeleteSkillByNameError, DeleteSkillByNameResponse, DownloadSkillByNameData, DownloadSkillByNameError, DownloadSkillByNameResponse, ListSkillFilesByNameData, ListSkillFilesByNameError, ListSkillFilesByNameResponse, GetSkillFileByNameData, GetSkillFileByNameError, GetSkillFileByNameResponse, UpdateSkillFileByNameData, UpdateSkillFileByNameError, UpdateSkillFileByNameResponse, UpdateSkillData, UpdateSkillError, UpdateSkillResponse, DeleteSkillData, DeleteSkillError, DeleteSkillResponse, DownloadSkillData, DownloadSkillError, DownloadSkillResponse, ListSkillFilesData, ListSkillFilesError, ListSkillFilesResponse, GetSkillFileData, GetSkillFileError, GetSkillFileResponse, UpdateSkillFileData, UpdateSkillFileError, UpdateSkillFileResponse, ListNeoSkillCandidatesData, ListNeoSkillCandidatesError, ListNeoSkillCandidatesResponse, ListNeoSkillReleasesData, ListNeoSkillReleasesError, ListNeoSkillReleasesResponse, GetNeoSkillPayloadData, GetNeoSkillPayloadError, GetNeoSkillPayloadResponse, EvaluateNeoSkillCandidateData, EvaluateNeoSkillCandidateError, EvaluateNeoSkillCandidateResponse, PromoteNeoSkillCandidateData, PromoteNeoSkillCandidateError, PromoteNeoSkillCandidateResponse, RollbackNeoSkillReleaseData, RollbackNeoSkillReleaseError, RollbackNeoSkillReleaseResponse, SyncNeoSkillReleaseData, SyncNeoSkillReleaseError, SyncNeoSkillReleaseResponse, DeleteNeoSkillCandidateData, DeleteNeoSkillCandidateError, DeleteNeoSkillCandidateResponse, DeleteNeoSkillReleaseData, DeleteNeoSkillReleaseError, DeleteNeoSkillReleaseResponse, ListKnowledgeBasesData, ListKnowledgeBasesError, ListKnowledgeBasesResponse, CreateKnowledgeBaseData, CreateKnowledgeBaseError, CreateKnowledgeBaseResponse, GetKnowledgeBaseData, GetKnowledgeBaseError, GetKnowledgeBaseResponse, UpdateKnowledgeBaseData, UpdateKnowledgeBaseError, UpdateKnowledgeBaseResponse, DeleteKnowledgeBaseData, DeleteKnowledgeBaseError, DeleteKnowledgeBaseResponse, GetKnowledgeBaseStatsData, GetKnowledgeBaseStatsError, GetKnowledgeBaseStatsResponse, ListKnowledgeDocumentsData, ListKnowledgeDocumentsError, ListKnowledgeDocumentsResponse, UploadKnowledgeDocumentData, UploadKnowledgeDocumentError, UploadKnowledgeDocumentResponse, ImportKnowledgeDocumentsData, ImportKnowledgeDocumentsError, ImportKnowledgeDocumentsResponse, ImportKnowledgeDocumentFromUrlData, ImportKnowledgeDocumentFromUrlError, ImportKnowledgeDocumentFromUrlResponse, GetKnowledgeDocumentData, GetKnowledgeDocumentError, GetKnowledgeDocumentResponse, DeleteKnowledgeDocumentData, DeleteKnowledgeDocumentError, DeleteKnowledgeDocumentResponse, ListKnowledgeChunksData, ListKnowledgeChunksError, ListKnowledgeChunksResponse, DeleteKnowledgeChunkData, DeleteKnowledgeChunkError, DeleteKnowledgeChunkResponse, RetrieveKnowledgeBaseData, RetrieveKnowledgeBaseError, RetrieveKnowledgeBaseResponse, GetKnowledgeTaskData, GetKnowledgeTaskError, GetKnowledgeTaskResponse, GetPersonaTreeError, GetPersonaTreeResponse, ListPersonasData, ListPersonasError, ListPersonasResponse, CreatePersonaData, CreatePersonaError, CreatePersonaResponse, GetPersonaByIdData, GetPersonaByIdError, GetPersonaByIdResponse, UpdatePersonaByIdData, UpdatePersonaByIdError, UpdatePersonaByIdResponse, DeletePersonaByIdData, DeletePersonaByIdError, DeletePersonaByIdResponse, GetPersonaData, GetPersonaError, GetPersonaResponse, UpdatePersonaData, UpdatePersonaError, UpdatePersonaResponse, DeletePersonaData, DeletePersonaError, DeletePersonaResponse, ListPersonaFoldersData, ListPersonaFoldersError, ListPersonaFoldersResponse, CreatePersonaFolderData, CreatePersonaFolderError, CreatePersonaFolderResponse, UpdatePersonaFolderData, UpdatePersonaFolderError, UpdatePersonaFolderResponse, DeletePersonaFolderData, DeletePersonaFolderError, DeletePersonaFolderResponse, MovePersonaItemData, MovePersonaItemError, MovePersonaItemResponse, ReorderPersonaItemsData, ReorderPersonaItemsError, ReorderPersonaItemsResponse, ListSessionsData, ListSessionsError, ListSessionsResponse, ListActiveUmosError, ListActiveUmosResponse, ListSessionRulesData, ListSessionRulesError, ListSessionRulesResponse, UpsertSessionRuleData, UpsertSessionRuleError, UpsertSessionRuleResponse, DeleteSessionRulesData, DeleteSessionRulesError, DeleteSessionRulesResponse, BatchUpdateSessionProviderData, BatchUpdateSessionProviderError, BatchUpdateSessionProviderResponse, BatchUpdateSessionServiceData, BatchUpdateSessionServiceError, BatchUpdateSessionServiceResponse, ListSessionGroupsError, ListSessionGroupsResponse, CreateSessionGroupData, CreateSessionGroupError, CreateSessionGroupResponse, UpdateSessionGroupData, UpdateSessionGroupError, UpdateSessionGroupResponse, DeleteSessionGroupData, DeleteSessionGroupError, DeleteSessionGroupResponse, ListConversationsData, ListConversationsError, ListConversationsResponse, BatchDeleteConversationsData, BatchDeleteConversationsError, BatchDeleteConversationsResponse, GetConversationData, GetConversationError, GetConversationResponse, UpdateConversationData, UpdateConversationError, UpdateConversationResponse, DeleteConversationData, DeleteConversationError, DeleteConversationResponse, ReplaceConversationMessagesData, ReplaceConversationMessagesError, ReplaceConversationMessagesResponse, ExportConversationsData, ExportConversationsError, ExportConversationsResponse, GetStatsData, GetStatsError, GetStatsResponse, GetProviderTokenStatsData, GetProviderTokenStatsError, GetProviderTokenStatsResponse, GetVersionError, GetVersionResponse, GetPublicVersionsError, GetPublicVersionsResponse, GetFirstNoticeData, GetFirstNoticeError, GetFirstNoticeResponse, TestGhproxyConnectionData, TestGhproxyConnectionError, TestGhproxyConnectionResponse, ListChangelogVersionsError, ListChangelogVersionsResponse, GetChangelogData, GetChangelogError, GetChangelogResponse, GetStartTimeError, GetStartTimeResponse, GetStorageStatusError, GetStorageStatusResponse, CleanupStorageData, CleanupStorageError, CleanupStorageResponse, RestartCoreError, RestartCoreResponse, ListBackupsData, ListBackupsError, ListBackupsResponse, CreateBackupData, CreateBackupError, CreateBackupResponse, UploadBackupData, UploadBackupError, UploadBackupResponse, InitBackupUploadData, InitBackupUploadError, InitBackupUploadResponse, UploadBackupChunkData, UploadBackupChunkError, UploadBackupChunkResponse, CompleteBackupUploadData, CompleteBackupUploadError, CompleteBackupUploadResponse, AbortBackupUploadData, AbortBackupUploadError, AbortBackupUploadResponse, GetBackupProgressData, GetBackupProgressError, GetBackupProgressResponse, DownloadBackupData, DownloadBackupError, DownloadBackupResponse, RenameBackupData, RenameBackupError, RenameBackupResponse, DeleteBackupData, DeleteBackupError, DeleteBackupResponse, CheckBackupData, CheckBackupError, CheckBackupResponse, ImportBackupData, ImportBackupError, ImportBackupResponse, CheckUpdateError, CheckUpdateResponse, ListReleasesData, ListReleasesError, ListReleasesResponse, UpdateCoreData, UpdateCoreError, UpdateCoreResponse, UpdateDashboardData, UpdateDashboardError, UpdateDashboardResponse, GetUpdateProgressData, GetUpdateProgressError, GetUpdateProgressResponse, InstallPipPackageData, InstallPipPackageError, InstallPipPackageResponse, ListCronJobsData, ListCronJobsError, ListCronJobsResponse, CreateCronJobData, CreateCronJobError, CreateCronJobResponse, UpdateCronJobData, UpdateCronJobError, UpdateCronJobResponse, DeleteCronJobData, DeleteCronJobError, DeleteCronJobResponse, RunCronJobData, RunCronJobError, RunCronJobResponse, StreamLiveLogsError, StreamLiveLogsResponse, GetLogHistoryError, GetLogHistoryResponse, GetTraceSettingsError, GetTraceSettingsResponse, UpdateTraceSettingsData, UpdateTraceSettingsError, UpdateTraceSettingsResponse, ListT2iTemplatesError, ListT2iTemplatesResponse, CreateT2iTemplateData, CreateT2iTemplateError, CreateT2iTemplateResponse, GetActiveT2iTemplateError, GetActiveT2iTemplateResponse, SetActiveT2iTemplateData, SetActiveT2iTemplateError, SetActiveT2iTemplateResponse, ResetDefaultT2iTemplateError, ResetDefaultT2iTemplateResponse, GetT2iTemplateData, GetT2iTemplateError, GetT2iTemplateResponse, UpdateT2iTemplateData, UpdateT2iTemplateError, UpdateT2iTemplateResponse, DeleteT2iTemplateData, DeleteT2iTemplateError, DeleteT2iTemplateResponse, GetSubagentConfigError, GetSubagentConfigResponse, UpdateSubagentConfigData, UpdateSubagentConfigError, UpdateSubagentConfigResponse, ListSubagentAvailableToolsError, ListSubagentAvailableToolsResponse, VerifyPlatformWebhookData, VerifyPlatformWebhookError, VerifyPlatformWebhookResponse, ReceivePlatformWebhookData, ReceivePlatformWebhookError, ReceivePlatformWebhookResponse } from './types.gen'; export const client = createClient(createConfig()); @@ -819,6 +819,16 @@ export const stopChatSession = (options: O }); }; +/** + * Resume an active webchat run as an SSE stream + */ +export const resumeChatRun = (options: OpenApiClientOptions) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/chat/runs/{run_id}/stream' + }); +}; + /** * Update the latest user message in a webchat session */ diff --git a/dashboard/src/api/generated/openapi-v1/types.gen.ts b/dashboard/src/api/generated/openapi-v1/types.gen.ts index 3b2314e4d..cef5033e2 100644 --- a/dashboard/src/api/generated/openapi-v1/types.gen.ts +++ b/dashboard/src/api/generated/openapi-v1/types.gen.ts @@ -1371,6 +1371,16 @@ export type StopChatSessionResponse = (SuccessEnvelope); export type StopChatSessionError = unknown; +export type ResumeChatRunData = { + path: { + run_id: string; + }; +}; + +export type ResumeChatRunResponse = (unknown); + +export type ResumeChatRunError = unknown; + export type UpdateChatMessageData = { body: ChatMessagePatchRequest; path: { diff --git a/dashboard/src/api/v1.ts b/dashboard/src/api/v1.ts index 37022d332..5df8c5856 100644 --- a/dashboard/src/api/v1.ts +++ b/dashboard/src/api/v1.ts @@ -795,6 +795,9 @@ export const chatApi = { sendStreamUrl() { return '/api/v1/chat'; }, + resumeRunStreamUrl(runId: string) { + return `/api/v1/chat/runs/${encodeURIComponent(runId)}/stream`; + }, liveWebSocketUrl(token: string, host = window.location.host) { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; return `${protocol}//${host}/api/v1/live-chat/ws?token=${encodeURIComponent(token)}`; diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css index 681266010..c838d7a0f 100644 --- a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css +++ b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css @@ -1,4 +1,4 @@ -/* Auto-generated MDI subset – 281 icons */ +/* Auto-generated MDI subset – 280 icons */ /* Do not edit manually. Run: pnpm run subset-icons */ @font-face { @@ -324,10 +324,6 @@ content: "\F0CFD"; } -.mdi-cursor-move::before { - content: "\F01BE"; -} - .mdi-database::before { content: "\F01BC"; } diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff index cdbe3df6e..a3c75f85e 100644 Binary files a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff and b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff differ diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 index bf8da1b2d..42e382e36 100644 Binary files a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 and b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 differ diff --git a/dashboard/src/components/chat/Chat.vue b/dashboard/src/components/chat/Chat.vue index 4624c3e37..87ab57f31 100644 --- a/dashboard/src/components/chat/Chat.vue +++ b/dashboard/src/components/chat/Chat.vue @@ -785,11 +785,16 @@ const currentTokenMetadata = computed(() => { const model = currentTokenProvider.value?.model; return model ? tokenModelMetadata.value[model] || null : null; }); -const latestTokenUsageTotal = computed(() => { +const latestContextTokens = computed(() => { for (let index = activeMessages.value.length - 1; index >= 0; index -= 1) { const message = activeMessages.value[index]; if (isUserMessage(message)) continue; - const usage = message.content?.agentStats?.token_usage; + const stats = message.content?.agentStats; + if (!stats) continue; + if (stats.current_context_tokens != null) { + return readTokenCount(stats.current_context_tokens); + } + const usage = stats.token_usage; if (!usage) continue; return ( readTokenCount(usage.input_other) + @@ -800,7 +805,7 @@ const latestTokenUsageTotal = computed(() => { return 0; }); const tokenUsageIndicator = computed(() => { - const used = latestTokenUsageTotal.value; + const used = latestContextTokens.value; const limit = contextLimit(currentTokenProvider.value, currentTokenMetadata.value); if (used <= 0 || limit <= 0) return null; diff --git a/dashboard/src/components/chat/ProviderModelMenu.vue b/dashboard/src/components/chat/ProviderModelMenu.vue index 30d4b8d25..53f75d662 100644 --- a/dashboard/src/components/chat/ProviderModelMenu.vue +++ b/dashboard/src/components/chat/ProviderModelMenu.vue @@ -442,7 +442,7 @@ defineExpose({ } .provider-menu-item.v-list-item--active { - background: #f2f2f2; + background: rgba(var(--v-theme-primary), 0.08); color: rgb(var(--v-theme-on-surface)); } @@ -517,10 +517,6 @@ defineExpose({ text-align: center; } -:global(.v-theme--dark) .provider-menu-item.v-list-item--active { - background: rgba(255, 255, 255, 0.1); -} - @media (max-width: 768px) { .provider-trigger--header .provider-trigger-title { font-size: 16px; diff --git a/dashboard/src/components/extension/McpServersSection.vue b/dashboard/src/components/extension/McpServersSection.vue index 5e481c8ab..ccff62b79 100644 --- a/dashboard/src/components/extension/McpServersSection.vue +++ b/dashboard/src/components/extension/McpServersSection.vue @@ -380,7 +380,7 @@ diff --git a/dashboard/src/components/extension/SkillsSection.vue b/dashboard/src/components/extension/SkillsSection.vue index 35b8ff61e..9a141d22b 100644 --- a/dashboard/src/components/extension/SkillsSection.vue +++ b/dashboard/src/components/extension/SkillsSection.vue @@ -760,7 +760,7 @@ v-model="snackbar.show" :timeout="3500" :color="snackbar.color" - elevation="24" + elevation="6" > {{ snackbar.message }} diff --git a/dashboard/src/components/extension/componentPanel/index.vue b/dashboard/src/components/extension/componentPanel/index.vue index 8c6268284..36b054e51 100644 --- a/dashboard/src/components/extension/componentPanel/index.vue +++ b/dashboard/src/components/extension/componentPanel/index.vue @@ -345,7 +345,7 @@ watch(viewMode, async (mode) => { {{ snackbar.message }} diff --git a/dashboard/src/composables/useMessages.ts b/dashboard/src/composables/useMessages.ts index a48f4a953..29fbe2426 100644 --- a/dashboard/src/composables/useMessages.ts +++ b/dashboard/src/composables/useMessages.ts @@ -1,5 +1,7 @@ import axios from "axios"; import { computed, onBeforeUnmount, type Ref, reactive, ref } from "vue"; +import { fetchWithAuth } from "@/api/http"; +import { resolveApiUrl } from "@/utils/request"; export type TransportMode = "sse" | "websocket"; @@ -68,12 +70,22 @@ export interface ChatSessionProject { emoji?: string; } +interface ActiveChatRun { + run_id: string; + session_id: string; + llm_checkpoint_id?: string | null; + status?: string; + revision?: number; + content?: ChatContent; +} + interface ActiveConnection { sessionId: string; messageId: string; transport: TransportMode; abort?: AbortController; ws?: WebSocket; + botRecord?: ChatRecord; } interface SendMessageStreamOptions { @@ -203,9 +215,13 @@ export function useMessages(options: UseMessagesOptions) { await Promise.all(tasks); } - async function loadSessionMessages(sessionId: string) { + async function loadSessionMessages( + sessionId: string, + resumeRuns = true, + showLoading = true, + ) { if (!sessionId) return; - loadingMessages.value = true; + if (showLoading) loadingMessages.value = true; try { const response = await axios.get("/api/chat/get_session", { params: { session_id: sessionId }, @@ -218,14 +234,47 @@ export function useMessages(options: UseMessagesOptions) { messagesBySession[sessionId] = records; sessionProjects[sessionId] = normalizeSessionProject(payload.project); loadedSessions[sessionId] = true; + if (resumeRuns && Array.isArray(payload.active_runs)) { + await restoreNextActiveRun(sessionId, payload.active_runs); + } } catch (error) { console.error("Failed to load session messages:", error); messagesBySession[sessionId] = messagesBySession[sessionId] || []; } finally { - loadingMessages.value = false; + if (showLoading) loadingMessages.value = false; } } + async function restoreNextActiveRun( + sessionId: string, + activeRuns: ActiveChatRun[], + ) { + const run = activeRuns[0]; + if (!run?.run_id || activeConnections[sessionId]) return; + + const checkpointId = run.llm_checkpoint_id || null; + const records = (messagesBySession[sessionId] || []).filter((record) => { + return !( + checkpointId && + record.llm_checkpoint_id === checkpointId && + messageContent(record).type === "bot" + ); + }); + const botRecord = normalizeHistoryRecord({ + id: `active-run-${run.run_id}`, + content: run.content || { type: "bot", message: [] }, + llm_checkpoint_id: checkpointId, + created_at: new Date().toISOString(), + }); + botRecord.content.isLoading = botRecord.content.message.length === 0; + records.push(botRecord); + messagesBySession[sessionId] = records; + const restoredRecords = messagesBySession[sessionId]; + const reactiveBotRecord = restoredRecords[restoredRecords.length - 1]; + await resolveRecordMedia([reactiveBotRecord]); + startResumeStream(sessionId, run.run_id, reactiveBotRecord); + } + function createLocalExchange({ sessionId, messageId, parts }: CreateLocalExchangeOptions) { loadedSessions[sessionId] = true; messagesBySession[sessionId] = messagesBySession[sessionId] || []; @@ -532,6 +581,90 @@ export function useMessages(options: UseMessagesOptions) { }); } + function startResumeStream( + sessionId: string, + runId: string, + botRecord: ChatRecord, + ) { + const abort = new AbortController(); + activeConnections[sessionId] = { + sessionId, + messageId: runId, + transport: "sse", + abort, + botRecord, + }; + + void (async () => { + let receivedEnd = false; + let lastError: unknown = null; + + for ( + let attempt = 0; + attempt < 5 && !abort.signal.aborted; + attempt += 1 + ) { + let retryable = true; + try { + const response = await fetchWithAuth( + resolveApiUrl( + `/api/v1/chat/runs/${encodeURIComponent(runId)}/stream`, + ), + { + headers: { Accept: "text/event-stream" }, + signal: abort.signal, + }, + ); + const contentType = response.headers.get("content-type") || ""; + if ( + !response.ok || + !response.body || + !contentType.includes("text/event-stream") + ) { + retryable = response.status >= 500; + throw new Error(`Resume stream failed: ${response.status}`); + } + + await readSseStream(response.body, (payload) => { + processStreamPayload(botRecord, payload); + options.onStreamUpdate?.(sessionId); + const payloadType = payload?.type || payload?.t; + if (payloadType === "end") receivedEnd = true; + }); + if (receivedEnd) break; + lastError = new Error("Resume stream closed before completion."); + } catch (error) { + if (abort.signal.aborted) return; + lastError = error; + } + + if (!retryable || attempt === 4 || abort.signal.aborted) break; + await new Promise((resolve) => { + const timeout = window.setTimeout(resolve, 250 * 2 ** attempt); + abort.signal.addEventListener( + "abort", + () => { + window.clearTimeout(timeout); + resolve(); + }, + { once: true }, + ); + }); + } + + if (!receivedEnd && lastError && !abort.signal.aborted) { + console.error("Resume chat stream failed:", lastError); + } + + const ownsConnection = activeConnections[sessionId]?.abort === abort; + if (ownsConnection) delete activeConnections[sessionId]; + if (!abort.signal.aborted && ownsConnection) { + await loadSessionMessages(sessionId, true, false); + await options.onSessionsChanged?.(); + } + })(); + } + function startWebSocketStream( sessionId: string, messageId: string, @@ -595,6 +728,21 @@ export function useMessages(options: UseMessagesOptions) { const data = normalized?.data ?? ""; if (msgType === "session_id" || msgType === "session_bound") return; + if (msgType === "run_snapshot") { + const snapshot = data && typeof data === "object" ? data : {}; + const snapshotRecord = normalizeHistoryRecord({ + id: `active-run-${snapshot.run_id || "unknown"}`, + content: snapshot.content || { type: "bot", message: [] }, + llm_checkpoint_id: snapshot.llm_checkpoint_id || null, + }); + snapshotRecord.content.isLoading = + snapshot.status === "running" && + snapshotRecord.content.message.length === 0; + botRecord.content = snapshotRecord.content; + botRecord.llm_checkpoint_id = snapshotRecord.llm_checkpoint_id; + void resolveRecordMedia([botRecord]); + return; + } if (msgType === "user_message_saved") { if (userRecord) { userRecord.id = data?.id || userRecord.id; diff --git a/dashboard/src/composables/useProviderSources.ts b/dashboard/src/composables/useProviderSources.ts index 5584b9c70..8ab476a9c 100644 --- a/dashboard/src/composables/useProviderSources.ts +++ b/dashboard/src/composables/useProviderSources.ts @@ -70,6 +70,7 @@ export function useProviderSources(options: UseProviderSourcesOptions) { const modelSearch = ref(""); let suppressSourceWatch = false; + const unsavedProviderSourceMarker = Symbol("unsavedProviderSource"); const providerTypes = computed(() => [ { @@ -459,6 +460,29 @@ export function useProviderSources(options: UseProviderSourcesOptions) { return candidate; } + function removeProviderSourceFromLocalState(sourceId: string) { + providers.value = providers.value.filter( + (provider) => + provider.provider_source_id == null || + String(provider.provider_source_id) !== sourceId, + ); + providerSources.value = providerSources.value.filter( + (source) => source.id == null || String(source.id) !== sourceId, + ); + + if ( + selectedProviderSource.value?.id != null && + String(selectedProviderSource.value.id) === sourceId + ) { + selectedProviderSource.value = null; + selectedProviderSourceOriginalId.value = null; + editableProviderSource.value = null; + availableModels.value = []; + modelMetadata.value = {}; + isSourceModified.value = false; + } + } + function addProviderSource(templateKey: string) { const template = providerTemplates.value[templateKey]; if (!template) { @@ -476,6 +500,7 @@ export function useProviderSources(options: UseProviderSourcesOptions) { enable: true, }); + newSource[unsavedProviderSourceMarker] = true; providerSources.value.push(newSource); selectedProviderSource.value = newSource; selectedProviderSourceOriginalId.value = newId; @@ -489,20 +514,18 @@ export function useProviderSources(options: UseProviderSourcesOptions) { const confirmed = await askForConfirmation(tm("providerSources.deleteConfirm", { id: source.id })); if (!confirmed) return; + const sourceId = String(source.id); + if (source[unsavedProviderSourceMarker]) { + removeProviderSourceFromLocalState(sourceId); + showMessage(tm("providerSources.deleteSuccess")); + return; + } + try { await axios.post("/api/config/provider_sources/delete", { - id: source.id, + id: sourceId, }); - - providers.value = providers.value.filter((p) => p.provider_source_id !== source.id); - providerSources.value = providerSources.value.filter((s) => s.id !== source.id); - - if (selectedProviderSource.value?.id === source.id) { - selectedProviderSource.value = null; - selectedProviderSourceOriginalId.value = null; - editableProviderSource.value = null; - } - + removeProviderSourceFromLocalState(sourceId); showMessage(tm("providerSources.deleteSuccess")); } catch (error: any) { showMessage(error.message || tm("providerSources.deleteError"), "error"); @@ -515,7 +538,8 @@ export function useProviderSources(options: UseProviderSourcesOptions) { if (!selectedProviderSource.value) return; savingSource.value = true; - const originalId = selectedProviderSourceOriginalId.value || selectedProviderSource.value.id; + const sourceBeingSaved = selectedProviderSource.value; + const originalId = selectedProviderSourceOriginalId.value || sourceBeingSaved.id; try { const response = await axios.post("/api/config/provider_sources/update", { config: editableProviderSource.value, @@ -545,6 +569,7 @@ export function useProviderSources(options: UseProviderSourcesOptions) { suppressSourceWatch = false; }); + delete sourceBeingSaved[unsavedProviderSourceMarker]; isSourceModified.value = false; showMessage(response.data.message || tm("providerSources.saveSuccess")); return true; 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 85adbaaad..5f28db00d 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -1229,6 +1229,22 @@ "description": "Text Truncation Strategy", "hint": "Whether to truncate the input to fit the model's maximum context length when the input text is too long." }, + "tei_rerank_truncate": { + "description": "Truncate Long Text", + "hint": "Whether to automatically truncate input when it exceeds the model's maximum context length. Requires Truncation Direction when enabled." + }, + "tei_rerank_truncation_direction": { + "description": "Truncation Direction", + "hint": "Choose to truncate from the left or right side of the text. Only takes effect when Truncate Long Text is True." + }, + "tei_rerank_raw_scores": { + "description": "Return Raw Scores", + "hint": "If True, returns raw model logit scores (which may be negative) without sigmoid normalization. Default is False." + }, + "tei_rerank_return_text": { + "description": "Return Document Text in Results", + "hint": "If True, each result will include the original document text. Default is False to reduce network overhead." + }, "launch_model_if_not_running": { "description": "Auto-start model if not running", "hint": "If the model is not running in Xinference, attempt to start it automatically. Recommended to disable in production." @@ -1359,6 +1375,10 @@ "description": "Embedding dimensions", "hint": "Embedding vector dimensions. May need adjustment per model; see model documentation. This must be correct or the vector database will not work." }, + "embedding_dimensions_mode": { + "description": "Embedding dimensions mode", + "hint": "Controls whether to send the dimensions parameter in OpenAI-compatible embedding requests. auto sends it only for official OpenAI embedding-3 models; use always when a compatible API supports it, or never when it rejects the parameter." + }, "embedding_model": { "description": "Embedding model", "hint": "Embedding model name." diff --git a/dashboard/src/i18n/locales/en-US/features/persona.json b/dashboard/src/i18n/locales/en-US/features/persona.json index 395b26286..d53d5abb6 100644 --- a/dashboard/src/i18n/locales/en-US/features/persona.json +++ b/dashboard/src/i18n/locales/en-US/features/persona.json @@ -12,7 +12,8 @@ "move": "Move", "clone": "Clone", "addDialogPair": "Add Dialog Pair", - "import": "Import" + "import": "Import Persona", + "export": "Export" }, "labels": { "presetDialogs": "Preset Dialogs ({count} pairs)", @@ -86,7 +87,15 @@ "deleteConfirm": "Are you sure you want to delete persona \"{id}\"? This action cannot be undone.", "deleteSuccess": "Deleted successfully", "deleteError": "Delete failed", - "importExists": "Persona with ID {id} already exists." + "importExists": "Persona with ID {id} already exists.", + "exportConfirm": "Export only includes system prompt and preset dialogs, not tools and Skills config. Continue?", + "exportSuccess": "Exported successfully", + "exportError": "Export failed: {error}", + "importSuccess": "Persona imported successfully, please configure tools and Skills manually", + "importError": "Import failed: {error}", + "importFormatError": "Invalid file format, please select a valid persona export file", + "importMissingPrompt": "Incomplete file content, missing system prompt", + "importIdExists": "Persona name already exists, renamed to \"{id}\"" }, "persona": { "personasTitle": "Personas", diff --git a/dashboard/src/i18n/locales/en-US/features/platform.json b/dashboard/src/i18n/locales/en-US/features/platform.json index fd49f0639..9faaed029 100644 --- a/dashboard/src/i18n/locales/en-US/features/platform.json +++ b/dashboard/src/i18n/locales/en-US/features/platform.json @@ -9,7 +9,7 @@ "webhookCopyFailed": "Copy failed, please copy manually", "webhookDialog": { "title": "Webhook Callback URL", - "description": "The callback address is as follows, please ensure that the network environment can be accessed. You can also view the callback address information in the logs.", + "description": "The callback address is as follows, please ensure that the network environment can be publicly accessible. You can also view the callback address information in the logs. It is recommended to fill in the \"Externally Accessible Callback API Address\" in Settings -> General.", "close": "Close" }, "details": { @@ -17,11 +17,6 @@ "token": "Token", "description": "Description" }, - "logs": { - "title": "Platform Logs", - "expand": "Expand", - "collapse": "Collapse" - }, "dialog": { "add": "Add", "edit": "Edit", diff --git a/dashboard/src/i18n/locales/en-US/features/settings.json b/dashboard/src/i18n/locales/en-US/features/settings.json index 0e2454716..ad5cf5c7c 100644 --- a/dashboard/src/i18n/locales/en-US/features/settings.json +++ b/dashboard/src/i18n/locales/en-US/features/settings.json @@ -1,4 +1,23 @@ { + "sections": { + "resources": { + "title": "About" + } + }, + "resources": { + "changelog": { + "subtitle": "Review changes in the current and previous versions." + }, + "documentation": { + "subtitle": "Open the official AstrBot documentation." + }, + "faq": { + "subtitle": "Read common questions and troubleshooting notes." + }, + "github": { + "subtitle": "Visit the AstrBot GitHub repository." + } + }, "network": { "title": "Network", "proxy": { 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 c04a11d35..d396bc207 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json +++ b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json @@ -1222,6 +1222,22 @@ "description": "Стратегия усечения текста", "hint": "Определяет, следует ли усекать входной текст, если он слишком длинный и не помещается в максимальную длину контекста модели." }, + "tei_rerank_truncate": { + "description": "Усекать длинный текст", + "hint": "Автоматически усекать входной текст, если он превышает максимальную длину контекста модели. При включении необходимо указать направление усечения." + }, + "tei_rerank_truncation_direction": { + "description": "Направление усечения", + "hint": "Выберите усечение текста слева или справа. Действует только при включенном усечении длинного текста." + }, + "tei_rerank_raw_scores": { + "description": "Возвращать необработанные оценки", + "hint": "Если включено, возвращает исходные логиты модели, которые могут быть отрицательными, без сигмоидальной нормализации. По умолчанию выключено." + }, + "tei_rerank_return_text": { + "description": "Возвращать текст документа в результатах", + "hint": "Если включено, каждый результат содержит исходный текст документа. По умолчанию выключено для снижения сетевой нагрузки." + }, "launch_model_if_not_running": { "description": "Автозапуск модели", "hint": "Если модель не запущена в Xinference, попытаться запустить её автоматически. Рекомендуется отключать в продакшене." @@ -1348,6 +1364,10 @@ "description": "Размерность эмбеддингов", "hint": "Размерность векторов эмбеддингов. Зависит от модели. Должно быть указано верно для работы векторной базы данных." }, + "embedding_dimensions_mode": { + "description": "Режим передачи размерности эмбеддингов", + "hint": "Управляет передачей параметра dimensions в OpenAI-совместимых запросах эмбеддингов. auto передает его только для официальных моделей OpenAI embedding-3; always используйте для совместимых API, а never — если API отклоняет этот параметр." + }, "embedding_model": { "description": "Модель эмбеддингов", "hint": "Имя модели эмбеддингов." diff --git a/dashboard/src/i18n/locales/ru-RU/features/persona.json b/dashboard/src/i18n/locales/ru-RU/features/persona.json index 7aa750468..4959fc6ab 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/persona.json +++ b/dashboard/src/i18n/locales/ru-RU/features/persona.json @@ -11,7 +11,9 @@ "save": "Сохранить", "move": "Переместить", "clone": "Клонировать", - "addDialogPair": "Добавить пример диалога" + "addDialogPair": "Добавить пример диалога", + "export": "Экспорт", + "import": "Импорт персонажа" }, "labels": { "presetDialogs": "Примеры диалогов ({count})", @@ -84,18 +86,31 @@ "saveError": "Ошибка сохранения", "deleteConfirm": "Вы уверены, что хотите удалить персонажа «{id}»? Это действие необратимо.", "deleteSuccess": "Удалено", - "deleteError": "Ошибка удаления" + "deleteError": "Ошибка удаления", + "exportConfirm": "Экспорт содержит только системный промпт и примеры диалогов, без инструментов и навыков. Продолжить?", + "exportSuccess": "Экспортировано", + "exportError": "Ошибка экспорта: {error}", + "importSuccess": "Персонаж импортирован, настройте инструменты и навыки вручную", + "importError": "Ошибка импорта: {error}", + "importFormatError": "Неверный формат файла, выберите корректный файл экспорта", + "importMissingPrompt": "Неполное содержимое файла, отсутствует системный промпт", + "importIdExists": "Имя персонажа уже существует, переименовано в «{id}»" }, "persona": { "personasTitle": "Персонаж", "toolsCount": "инстр.", "skillsCount": "навыков", "contextMenu": { - "moveTo": "Переместить в..." + "moveTo": "Переместить в...", + "export": "Экспорт" }, "messages": { "moveSuccess": "Персонаж перемещен", - "moveError": "Не удалось переместить персонажа" + "moveError": "Не удалось переместить персонажа", + "exportSuccess": "Экспортировано", + "exportError": "Ошибка экспорта персонажа", + "importSuccess": "Импортировано", + "importError": "Ошибка импорта персонажа" } }, "folder": { diff --git a/dashboard/src/i18n/locales/ru-RU/features/platform.json b/dashboard/src/i18n/locales/ru-RU/features/platform.json index 43ae40209..2d841640f 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/platform.json +++ b/dashboard/src/i18n/locales/ru-RU/features/platform.json @@ -9,7 +9,7 @@ "webhookCopyFailed": "Не удалось скопировать, сделайте это вручную", "webhookDialog": { "title": "Адрес Webhook", - "description": "Используйте этот адрес для обратных вызовов. Убедитесь, что ваш AstrBot доступен из интернета. Рекомендуется указать «Внешний URL для Webhook» в Конфигурация -> Система.", + "description": "Используйте этот адрес для обратных вызовов. Убедитесь, что ваш AstrBot доступен из интернета. Рекомендуется указать «Внешний URL для Webhook» в Настройки -> Общие.", "close": "Закрыть" }, "details": { @@ -17,11 +17,6 @@ "token": "Токен", "description": "Описание" }, - "logs": { - "title": "Логи платформы", - "expand": "Развернуть", - "collapse": "Свернуть" - }, "dialog": { "add": "Добавить", "edit": "Изменить", diff --git a/dashboard/src/i18n/locales/ru-RU/features/settings.json b/dashboard/src/i18n/locales/ru-RU/features/settings.json index 7ebcc0fac..e8e2e1634 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/settings.json +++ b/dashboard/src/i18n/locales/ru-RU/features/settings.json @@ -1,4 +1,23 @@ { + "sections": { + "resources": { + "title": "О программе" + } + }, + "resources": { + "changelog": { + "subtitle": "Просмотреть изменения в текущей и предыдущих версиях." + }, + "documentation": { + "subtitle": "Открыть официальную документацию AstrBot." + }, + "faq": { + "subtitle": "Прочитать ответы на частые вопросы и советы по устранению неполадок." + }, + "github": { + "subtitle": "Открыть репозиторий AstrBot на GitHub." + } + }, "network": { "title": "Сеть", "githubProxy": { 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 05dde88df..9299de699 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -1231,6 +1231,22 @@ "description": "文本截断策略", "hint": "当输入文本过长时,是否截断输入以适应模型的最大上下文长度。" }, + "tei_rerank_truncate": { + "description": "截断超长文本", + "hint": "当输入超过模型最大上下文长度时,是否自动截断。启用后需配合 截断方向 使用。" + }, + "tei_rerank_truncation_direction": { + "description": "截断方向", + "hint": "选择从文本的左侧(left)还是右侧(right)开始截断。仅在 截断超长文本 为 True 时生效。" + }, + "tei_rerank_raw_scores": { + "description": "返回原始分数", + "hint": "如果为 True,返回模型原始 logit 分数(可能为负值),不经过 sigmoid 归一化。默认 False。" + }, + "tei_rerank_return_text": { + "description": "返回排序结果中的文档原文", + "hint": "如果为 True,每个排序结果将附带原始文本。默认 False 以减少网络传输。" + }, "launch_model_if_not_running": { "description": "模型未运行时自动启动", "hint": "如果模型当前未在 Xinference 服务中运行,是否尝试自动启动它。在生产环境中建议关闭。" @@ -1361,6 +1377,10 @@ "description": "嵌入维度", "hint": "嵌入向量的维度。根据模型不同,可能需要调整,请参考具体模型的文档。此配置项请务必填写正确,否则将导致向量数据库无法正常工作。" }, + "embedding_dimensions_mode": { + "description": "嵌入维度参数发送模式", + "hint": "控制是否在 OpenAI 兼容 Embedding 请求中发送 dimensions 参数。auto 会仅对官方 OpenAI embedding-3 模型自动发送;第三方兼容 API 如需该参数可改为 always,报错时改为 never。" + }, "embedding_model": { "description": "嵌入模型", "hint": "嵌入模型名称。" diff --git a/dashboard/src/i18n/locales/zh-CN/features/persona.json b/dashboard/src/i18n/locales/zh-CN/features/persona.json index 46f46ae4a..5fcea734f 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/persona.json +++ b/dashboard/src/i18n/locales/zh-CN/features/persona.json @@ -12,7 +12,8 @@ "move": "移动", "clone": "克隆", "addDialogPair": "添加对话对", - "import": "导入" + "import": "导入人格", + "export": "导出" }, "labels": { "presetDialogs": "预设对话 ({count} 对)", @@ -86,7 +87,15 @@ "deleteConfirm": "确定要删除人格 \"{id}\" 吗?此操作不可撤销。", "deleteSuccess": "删除成功", "deleteError": "删除失败", - "importExists": "人格 ID {id} 已存在" + "importExists": "人格 ID {id} 已存在", + "exportConfirm": "导出仅包含系统提示词和预设对话,不包含工具和 Skills 配置。是否继续导出?", + "exportSuccess": "导出成功", + "exportError": "导出失败:{error}", + "importSuccess": "人格导入成功,请手动配置工具和 Skills", + "importError": "导入失败:{error}", + "importFormatError": "文件格式错误,请选择有效的人格导出文件", + "importMissingPrompt": "文件内容不完整,缺少系统提示词", + "importIdExists": "人格名称已存在,已重命名为 \"{id}\"" }, "persona": { "personasTitle": "人格", diff --git a/dashboard/src/i18n/locales/zh-CN/features/platform.json b/dashboard/src/i18n/locales/zh-CN/features/platform.json index ba349684d..18eba1032 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/platform.json +++ b/dashboard/src/i18n/locales/zh-CN/features/platform.json @@ -9,7 +9,7 @@ "webhookCopyFailed": "复制失败,请手动复制", "webhookDialog": { "title": "Webhook 回调地址", - "description": "回调地址如下,请确保网络环境可以公网访问。也可以在日志中查看回调地址信息。建议填写 配置文件 -> 系统 中的「对外可达的回调接口地址」配置项。", + "description": "回调地址如下,请确保网络环境可以公网访问。也可以在日志中查看回调地址信息。建议填写 设置 -> 常规 中的「对外可达的回调接口地址」配置项。", "close": "关闭" }, "details": { @@ -17,11 +17,6 @@ "token": "Token", "description": "描述" }, - "logs": { - "title": "平台日志", - "expand": "展开", - "collapse": "收起" - }, "dialog": { "add": "新增", "edit": "编辑", diff --git a/dashboard/src/i18n/locales/zh-CN/features/settings.json b/dashboard/src/i18n/locales/zh-CN/features/settings.json index 59f10f7ac..ead925d6b 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/settings.json +++ b/dashboard/src/i18n/locales/zh-CN/features/settings.json @@ -1,4 +1,23 @@ { + "sections": { + "resources": { + "title": "关于" + } + }, + "resources": { + "changelog": { + "subtitle": "查看当前版本和历史版本的变更。" + }, + "documentation": { + "subtitle": "打开 AstrBot 官方文档。" + }, + "faq": { + "subtitle": "查看常见问题和故障排查说明。" + }, + "github": { + "subtitle": "访问 AstrBot GitHub 仓库。" + } + }, "network": { "title": "网络", "proxy": { diff --git a/dashboard/src/layouts/full/vertical-sidebar/NavItem.vue b/dashboard/src/layouts/full/vertical-sidebar/NavItem.vue index 2cffd3a15..c37f73857 100644 --- a/dashboard/src/layouts/full/vertical-sidebar/NavItem.vue +++ b/dashboard/src/layouts/full/vertical-sidebar/NavItem.vue @@ -1,117 +1,102 @@ -