Compare commits

..

1 Commits

Author SHA1 Message Date
Soulter
96a4b31144 feat: integrate Monaco Editor workers for enhanced code editing support
fixes: #5587
2026-04-01 01:17:48 +08:00
45 changed files with 256 additions and 1561 deletions

View File

@@ -12,7 +12,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
bash \
ffmpeg \
libavcodec-extra \
curl \
gnupg \
git \

View File

@@ -92,9 +92,6 @@ Update `astrbot`:
uv tool upgrade astrbot
```
> [!WARNING]
> AstrBot deployed via `uv` **does not support upgrading through the WebUI**. To update, please run the command above from the command line.
### Docker Deployment
For users familiar with containers and looking for a more stable, production-ready deployment method, we recommend deploying AstrBot with Docker / Docker Compose.
@@ -187,7 +184,6 @@ Connect AstrBot to your favorite chat platform.
| Coze | LLMOps Platforms |
| OpenAI Whisper | Speech-to-Text Services |
| SenseVoice | Speech-to-Text Services |
| Xiaomi MiMo Omni | Speech-to-Text Services |
| OpenAI TTS | Text-to-Speech Services |
| Gemini TTS | Text-to-Speech Services |
| GPT-Sovits-Inference | Text-to-Speech Services |
@@ -197,7 +193,6 @@ Connect AstrBot to your favorite chat platform.
| Alibaba Cloud Bailian TTS | Text-to-Speech Services |
| Azure TTS | Text-to-Speech Services |
| Minimax TTS | Text-to-Speech Services |
| Xiaomi MiMo TTS | Text-to-Speech Services |
| Volcano Engine TTS | Text-to-Speech Services |
## ❤️ Sponsors

View File

@@ -92,9 +92,6 @@ Mettre à jour `astrbot` :
uv tool upgrade astrbot
```
> [!WARNING]
> AstrBot déployé via `uv` **ne prend pas en charge la mise à jour via le WebUI**. Pour mettre à jour, exécutez la commande ci-dessus depuis le terminal.
### Déploiement Docker
Pour les utilisateurs familiers avec les conteneurs et qui souhaitent une méthode plus stable et adaptée à la production, nous recommandons de déployer AstrBot avec Docker / Docker Compose.
@@ -187,7 +184,6 @@ Connectez AstrBot à vos plateformes de chat préférées.
| Coze | Plateformes LLMOps |
| OpenAI Whisper | Services de reconnaissance vocale |
| SenseVoice | Services de reconnaissance vocale |
| Xiaomi MiMo Omni | Services de reconnaissance vocale |
| OpenAI TTS | Services de synthèse vocale |
| Gemini TTS | Services de synthèse vocale |
| GPT-Sovits-Inference | Services de synthèse vocale |
@@ -197,7 +193,6 @@ Connectez AstrBot à vos plateformes de chat préférées.
| Alibaba Cloud Bailian TTS | Services de synthèse vocale |
| Azure TTS | Services de synthèse vocale |
| Minimax TTS | Services de synthèse vocale |
| Xiaomi MiMo TTS | Services de synthèse vocale |
| Volcano Engine TTS | Services de synthèse vocale |
## ❤️ Contribuer

View File

@@ -92,9 +92,6 @@ astrbot run
uv tool upgrade astrbot
```
> [!WARNING]
> `uv` 経由でデプロイした AstrBot は、**WebUI からのバージョンアップグレードに対応していません**。更新するには、上記のコマンドをコマンドラインで実行してください。
### Docker デプロイ
コンテナ運用に慣れており、より安定した本番向けのデプロイ方法を求めるユーザーには、Docker / Docker Compose での AstrBot デプロイをおすすめします。
@@ -188,7 +185,6 @@ AstrBot をよく使うチャットプラットフォームに接続できます
| Coze | LLMOps プラットフォーム |
| OpenAI Whisper | 音声認識サービス |
| SenseVoice | 音声認識サービス |
| Xiaomi MiMo Omni | 音声認識サービス |
| OpenAI TTS | 音声合成サービス |
| Gemini TTS | 音声合成サービス |
| GPT-Sovits-Inference | 音声合成サービス |
@@ -198,7 +194,6 @@ AstrBot をよく使うチャットプラットフォームに接続できます
| Alibaba Cloud 百炼 TTS | 音声合成サービス |
| Azure TTS | 音声合成サービス |
| Minimax TTS | 音声合成サービス |
| Xiaomi MiMo TTS | 音声合成サービス |
| Volcano Engine TTS | 音声合成サービス |
## ❤️ コントリビューション

View File

@@ -92,9 +92,6 @@ astrbot run
uv tool upgrade astrbot
```
> [!WARNING]
> AstrBot, развёрнутый через `uv`, **не поддерживает обновление через WebUI**. Для обновления выполните указанную выше команду из командной строки.
### Развёртывание Docker
Для пользователей, знакомых с контейнерами и которым нужен более стабильный и подходящий для production способ, мы рекомендуем разворачивать AstrBot через Docker / Docker Compose.
@@ -187,7 +184,6 @@ yay -S astrbot-git
| Coze | Платформы LLMOps |
| OpenAI Whisper | Сервисы распознавания речи |
| SenseVoice | Сервисы распознавания речи |
| Xiaomi MiMo Omni | Сервисы распознавания речи |
| OpenAI TTS | Сервисы синтеза речи |
| Gemini TTS | Сервисы синтеза речи |
| GPT-Sovits-Inference | Сервисы синтеза речи |
@@ -197,7 +193,6 @@ yay -S astrbot-git
| Alibaba Cloud Bailian TTS | Сервисы синтеза речи |
| Azure TTS | Сервисы синтеза речи |
| Minimax TTS | Сервисы синтеза речи |
| Xiaomi MiMo TTS | Сервисы синтеза речи |
| Volcano Engine TTS | Сервисы синтеза речи |
## ❤️ Вклад в проект

View File

@@ -92,9 +92,6 @@ astrbot run
uv tool upgrade astrbot
```
> [!WARNING]
> 透過 `uv` 部署的 AstrBot **不支援在 WebUI 中進行版本升級**。如需更新,請透過命令列執行上述命令。
### Docker 部署
對於熟悉容器、希望獲得更穩定且更適合正式環境部署方式的使用者,我們推薦使用 Docker / Docker Compose 部署 AstrBot。
@@ -187,7 +184,6 @@ yay -S astrbot-git
| Coze | LLMOps 平台 |
| OpenAI Whisper | 語音轉文字服務 |
| SenseVoice | 語音轉文字服務 |
| Xiaomi MiMo Omni | 語音轉文字服務 |
| OpenAI TTS | 文字轉語音服務 |
| Gemini TTS | 文字轉語音服務 |
| GPT-Sovits-Inference | 文字轉語音服務 |
@@ -197,7 +193,6 @@ yay -S astrbot-git
| 阿里雲百煉 TTS | 文字轉語音服務 |
| Azure TTS | 文字轉語音服務 |
| Minimax TTS | 文字轉語音服務 |
| Xiaomi MiMo TTS | 文字轉語音服務 |
| 火山引擎 TTS | 文字轉語音服務 |
## ❤️ 貢獻

View File

@@ -92,9 +92,6 @@ astrbot run
uv tool upgrade astrbot
```
> [!WARNING]
> 通过 `uv` 部署的 AstrBot **不支持在 WebUI 中进行版本升级**。如需更新,请通过命令行执行上述命令。
### Docker 部署
对于熟悉容器、希望获得更稳定且更适合生产环境部署方式的用户,我们推荐使用 Docker / Docker Compose 部署 AstrBot。
@@ -188,7 +185,6 @@ yay -S astrbot-git
| Coze | LLMOps 平台 |
| OpenAI Whisper | 语音转文本 |
| SenseVoice | 语音转文本 |
| Xiaomi MiMo Omni | 语音转文本 |
| OpenAI TTS | 文本转语音 |
| Gemini TTS | 文本转语音 |
| GPT-Sovits-Inference | 文本转语音 |
@@ -198,7 +194,6 @@ yay -S astrbot-git
| 阿里云百炼 TTS | 文本转语音 |
| Azure TTS | 文本转语音 |
| Minimax TTS | 文本转语音 |
| Xiaomi MiMo TTS | 文本转语音 |
| 火山引擎 TTS | 文本转语音 |
## ❤️ 贡献

View File

@@ -15,6 +15,7 @@ class HandoffTool(FunctionTool, Generic[TContext]):
tool_description: str | None = None,
**kwargs,
) -> None:
# Avoid passing duplicate `description` to the FunctionTool dataclass.
# Some call sites (e.g. SubAgentOrchestrator) pass `description` via kwargs
# to override what the main agent sees, while we also compute a default

View File

@@ -4040,9 +4040,9 @@ CONFIG_METADATA_3_SYSTEM = {
"hint": "时区设置。请填写 IANA 时区名称, 如 Asia/Shanghai, 为空时使用系统默认时区。所有时区请查看: https://data.iana.org/time-zones/tzdb-2021a/zone1970.tab",
},
"http_proxy": {
"description": "代理",
"description": "HTTP 代理",
"type": "string",
"hint": "启用后,会以添加环境变量的方式设置代理。支持 http://、https://、socks5:// 格式例如http://127.0.0.1:7890 或 socks5://127.0.0.1:7891",
"hint": "启用后,会以添加环境变量的方式设置代理。格式为 `http://ip:port`",
},
"no_proxy": {
"description": "直连地址列表",

View File

@@ -108,7 +108,6 @@ Text chunk to process:
class KBHelper:
vec_db: BaseVecDB
kb: KnowledgeBase
init_error: str | None
def __init__(
self,
@@ -123,7 +122,6 @@ class KBHelper:
self.prov_mgr = provider_manager
self.kb_root_dir = kb_root_dir
self.chunker = chunker
self.init_error = None
self.kb_dir = Path(self.kb_root_dir) / self.kb.kb_id
self.kb_medias_dir = Path(self.kb_dir) / "medias" / self.kb.kb_id
@@ -150,14 +148,13 @@ class KBHelper:
async def get_rp(self) -> RerankProvider | None:
if not self.kb.rerank_provider_id:
return None
rp: RerankProvider | None = await self.prov_mgr.get_provider_by_id(
rp: RerankProvider = await self.prov_mgr.get_provider_by_id(
self.kb.rerank_provider_id,
) # type: ignore
if not rp:
logger.warning(
f"知识库 {self.kb.kb_name}({self.kb.kb_id}) 的 Rerank Provider({self.kb.rerank_provider_id}) 不可用,将跳过重排序。",
raise ValueError(
f"无法找到 ID 为 {self.kb.rerank_provider_id} 的 Rerank Provider",
)
return None
return rp
async def _ensure_vec_db(self) -> FaissVecDB:
@@ -165,13 +162,7 @@ class KBHelper:
raise ValueError(f"知识库 {self.kb.kb_name} 未配置 Embedding Provider")
ep = await self.get_ep()
rp: RerankProvider | None = None
try:
rp = await self.get_rp()
except Exception as e:
logger.warning(
f"知识库 {self.kb.kb_name}({self.kb.kb_id}) 初始化重排序能力失败,将跳过重排序: {e}",
)
rp = await self.get_rp()
vec_db = FaissVecDB(
doc_store_path=str(self.kb_dir / "doc.db"),
@@ -181,8 +172,6 @@ class KBHelper:
)
await vec_db.initialize()
self.vec_db = vec_db
# Clear stale init_error once initialization succeeds.
self.init_error = None
return vec_db
async def delete_vec_db(self) -> None:
@@ -194,7 +183,7 @@ class KBHelper:
shutil.rmtree(self.kb_dir)
async def terminate(self) -> None:
if hasattr(self, "vec_db") and self.vec_db:
if self.vec_db:
await self.vec_db.close()
async def upload_document(

View File

@@ -1,3 +1,4 @@
import traceback
from pathlib import Path
from astrbot.core import logger
@@ -55,7 +56,8 @@ class KnowledgeBaseManager:
logger.error(f"知识库模块导入失败: {e}")
logger.warning("请确保已安装所需依赖: pypdf, aiofiles, Pillow, rank-bm25")
except Exception as e:
logger.error(f"知识库模块初始化失败: {e}", exc_info=True)
logger.error(f"知识库模块初始化失败: {e}")
logger.error(traceback.format_exc())
async def _init_kb_database(self) -> None:
self.kb_db = KBSQLiteDatabase(DB_PATH.as_posix())
@@ -74,14 +76,7 @@ class KnowledgeBaseManager:
kb_root_dir=FILES_PATH,
chunker=CHUNKER,
)
try:
await kb_helper.initialize()
except Exception as e:
kb_helper.init_error = str(e)
logger.error(
f"知识库 {record.kb_name}({record.kb_id}) 初始化失败: {e}",
exc_info=True,
)
await kb_helper.initialize()
self.kb_insts[record.kb_id] = kb_helper
async def create_kb(
@@ -184,20 +179,6 @@ class KnowledgeBaseManager:
return None
kb = kb_helper.kb
previous_state = {
"kb_name": kb.kb_name,
"description": kb.description,
"emoji": kb.emoji,
"embedding_provider_id": kb.embedding_provider_id,
"rerank_provider_id": kb.rerank_provider_id,
"chunk_size": kb.chunk_size,
"chunk_overlap": kb.chunk_overlap,
"top_k_dense": kb.top_k_dense,
"top_k_sparse": kb.top_k_sparse,
"top_m_final": kb.top_m_final,
}
previous_init_error = kb_helper.init_error
if kb_name is not None:
kb.kb_name = kb_name
if description is not None:
@@ -217,47 +198,12 @@ class KnowledgeBaseManager:
kb.top_k_sparse = top_k_sparse
if top_m_final is not None:
kb.top_m_final = top_m_final
# Build a new helper first. Keep current vec_db alive until new init succeeds.
new_helper = KBHelper(
kb_db=self.kb_db,
kb=kb,
provider_manager=self.provider_manager,
kb_root_dir=FILES_PATH,
chunker=CHUNKER,
)
try:
await new_helper.initialize()
except Exception as e:
# Roll back in-memory settings and keep current helper available.
kb.kb_name = previous_state["kb_name"]
kb.description = previous_state["description"]
kb.emoji = previous_state["emoji"]
kb.embedding_provider_id = previous_state["embedding_provider_id"]
kb.rerank_provider_id = previous_state["rerank_provider_id"]
kb.chunk_size = previous_state["chunk_size"]
kb.chunk_overlap = previous_state["chunk_overlap"]
kb.top_k_dense = previous_state["top_k_dense"]
kb.top_k_sparse = previous_state["top_k_sparse"]
kb.top_m_final = previous_state["top_m_final"]
kb_helper.init_error = previous_init_error
logger.error(
f"知识库 {kb.kb_name}({kb.kb_id}) 重新初始化失败,继续使用旧实例: {e}",
exc_info=True,
)
return kb_helper
async with self.kb_db.get_db() as session:
session.add(kb)
await session.commit()
await session.refresh(kb)
old_helper = kb_helper
self.kb_insts[kb_id] = new_helper
await old_helper.terminate()
new_helper.init_error = None
return new_helper
return kb_helper
async def retrieve(
self,
@@ -269,21 +215,11 @@ class KnowledgeBaseManager:
"""从指定知识库中检索相关内容"""
kb_ids = []
kb_id_helper_map = {}
unavailable_kbs = []
for kb_name in kb_names:
if kb_helper := await self.get_kb_by_name(kb_name):
if kb_helper.init_error:
unavailable_kbs.append((kb_name, kb_helper.init_error))
logger.warning(f"知识库 {kb_name} 不可用: {kb_helper.init_error}")
continue
kb_ids.append(kb_helper.kb.kb_id)
kb_id_helper_map[kb_helper.kb.kb_id] = kb_helper
# all requested KBs are unavailable
if not kb_ids and unavailable_kbs:
errors = "; ".join(f"{n}: {e}" for n, e in unavailable_kbs)
raise ValueError(f"所有请求的知识库均不可用: {errors}")
if not kb_ids:
return {}

View File

@@ -184,15 +184,12 @@ class RetrievalManager:
first_rerank = vec_db.rerank_provider
break
if first_rerank and retrieval_results:
try:
retrieval_results = await self._rerank(
query=query,
results=retrieval_results,
top_k=top_m_final,
rerank_provider=first_rerank,
)
except Exception as e:
logger.warning(f"Rerank 执行失败,已跳过重排序并使用融合结果: {e}")
retrieval_results = await self._rerank(
query=query,
results=retrieval_results,
top_k=top_m_final,
rerank_provider=first_rerank,
)
return retrieval_results[:top_m_final]
@@ -232,10 +229,10 @@ class RetrievalManager:
all_results.extend(vec_results)
except Exception as e:
logger.error(f"知识库 {kb_id} 稠密检索失败: {e}", exc_info=True)
if len(kb_ids) == 1:
raise RuntimeError(f"知识库 {kb_id} 稠密检索失败: {e}") from e
# multi-KB: skip the faulty KB and continue
from astrbot.core import logger
logger.warning(f"知识库 {kb_id} 稠密检索失败: {e}")
continue
# 按相似度排序并返回 top_k
all_results.sort(key=lambda x: x.similarity, reverse=True)

View File

@@ -35,11 +35,11 @@ class DiscordBotClient(discord.Bot):
async def on_ready(self) -> None:
"""当机器人成功连接并准备就绪时触发"""
if self.user is None:
logger.error("[Discord] Bot user not loaded correctly (self.user is None)")
logger.error("[Discord] 客户端未正确加载用户信息 (self.user is None)")
return
logger.info(f"[Discord] Logged in as {self.user} (ID: {self.user.id})")
logger.info("[Discord] Client is ready.")
logger.info(f"[Discord] 已作为 {self.user} (ID: {self.user.id}) 登录")
logger.info("[Discord] 客户端已准备就绪。")
if self.on_ready_once_callback and not self._ready_once_fired:
self._ready_once_fired = True
@@ -47,7 +47,7 @@ class DiscordBotClient(discord.Bot):
await self.on_ready_once_callback()
except Exception as e:
logger.error(
f"[Discord] Failed to execute on_ready_once_callback: {e}",
f"[Discord] on_ready_once_callback 执行失败: {e}",
exc_info=True,
)
@@ -99,7 +99,7 @@ class DiscordBotClient(discord.Bot):
return
logger.debug(
f"[Discord] Received raw message from {message.author.name}: {message.content}",
f"[Discord] 收到原始消息 from {message.author.name}: {message.content}",
)
if self.on_message_received:

View File

@@ -46,7 +46,7 @@ class DiscordPlatformAdapter(Platform):
) -> None:
super().__init__(platform_config, event_queue)
self.settings = platform_settings
self.bot_self_id: str | None = None
self.client_self_id: str | None = None
self.registered_handlers = []
# 指令注册相关
self.enable_command_register = self.config.get("discord_command_register", True)
@@ -64,7 +64,7 @@ class DiscordPlatformAdapter(Platform):
"""通过会话发送消息"""
if self.client.user is None:
logger.error(
"[Discord] Client is not ready (self.client.user is None); message send skipped"
"[Discord] 客户端未就绪 (self.client.user is None),无法发送消息"
)
return
@@ -92,10 +92,10 @@ class DiscordPlatformAdapter(Platform):
message_obj.message_str = message_chain.get_plain_text()
message_obj.sender = MessageMember(
user_id=str(self.bot_self_id),
user_id=str(self.client_self_id),
nickname=self.client.user.display_name,
)
message_obj.self_id = cast(str, self.bot_self_id)
message_obj.self_id = cast(str, self.client_self_id)
message_obj.session_id = session.session_id
message_obj.message = message_chain.chain
@@ -115,7 +115,7 @@ class DiscordPlatformAdapter(Platform):
"""返回平台元数据"""
return PlatformMetadata(
"discord",
"Discord Adapter",
"Discord 适配器",
id=cast(str, self.config.get("id")),
default_config_tmpl=self.config,
support_streaming_message=False,
@@ -127,18 +127,16 @@ class DiscordPlatformAdapter(Platform):
# 初始化回调函数
async def on_received(message_data) -> None:
logger.debug(f"[Discord] Message received: {message_data}")
if self.bot_self_id is None:
self.bot_self_id = message_data.get("bot_id")
logger.debug(f"[Discord] 收到消息: {message_data}")
if self.client_self_id is None:
self.client_self_id = message_data.get("bot_id")
abm = await self.convert_message(data=message_data)
await self.handle_msg(abm)
# 初始化 Discord 客户端
token = str(self.config.get("discord_token"))
if not token:
logger.error(
"[Discord] Bot token is not configured. Please set a valid token in the config file."
)
logger.error("[Discord] Bot Token 未配置。请在配置文件中正确设置 token。")
return
proxy = self.config.get("discord_proxy") or None
@@ -146,17 +144,12 @@ class DiscordPlatformAdapter(Platform):
self.client.on_message_received = on_received
async def callback() -> None:
try:
if self.enable_command_register:
await self._collect_and_register_commands()
if self.activity_name:
await self.client.change_presence(
status=discord.Status.online,
activity=discord.CustomActivity(name=self.activity_name),
)
except Exception as e:
logger.error(
f"[Discord] on_ready_once_callback err: {e}", exc_info=True
if self.enable_command_register:
await self._collect_and_register_commands()
if self.activity_name:
await self.client.change_presence(
status=discord.Status.online,
activity=discord.CustomActivity(name=self.activity_name),
)
self.client.on_ready_once_callback = callback
@@ -165,16 +158,11 @@ class DiscordPlatformAdapter(Platform):
self._polling_task = asyncio.create_task(self.client.start_polling())
await self.shutdown_event.wait()
except discord.errors.LoginFailure:
logger.error(
"[Discord] Login failed. Please check whether the bot token is correct."
)
logger.error("[Discord] 登录失败。请检查你的 Bot Token 是否正确。")
except discord.errors.ConnectionClosed:
logger.warning("[Discord] Connection with Discord has been closed.")
logger.warning("[Discord] 与 Discord 的连接已关闭。")
except Exception as e:
logger.error(
f"[Discord] Unexpected error while adapter is running: {e}",
exc_info=True,
)
logger.error(f"[Discord] 适配器运行时发生意外错误: {e}", exc_info=True)
def _get_message_type(
self,
@@ -253,7 +241,7 @@ class DiscordPlatformAdapter(Platform):
)
abm.message = message_chain
abm.raw_message = message
abm.self_id = cast(str, self.bot_self_id)
abm.self_id = cast(str, self.client_self_id)
abm.session_id = str(message.channel.id)
abm.message_id = str(message.id)
return abm
@@ -276,7 +264,7 @@ class DiscordPlatformAdapter(Platform):
if self.client.user is None:
logger.error(
"[Discord] Client is not ready (self.client.user is None); message handling skipped"
"[Discord] 客户端未就绪 (self.client.user is None),无法处理消息"
)
return
@@ -295,7 +283,7 @@ class DiscordPlatformAdapter(Platform):
raw_message = message.raw_message
if not isinstance(raw_message, discord.Message):
logger.warning(
f"[Discord] Non-Message type received and ignored: {type(raw_message)}"
f"[Discord] 收到非 Message 类型的消息: {type(raw_message)},已忽略。"
)
return
@@ -336,9 +324,20 @@ class DiscordPlatformAdapter(Platform):
@override
async def terminate(self) -> None:
logger.info("[Discord] Shutting down adapter...")
"""终止适配器"""
logger.info("[Discord] 正在终止适配器... (step 1: cancel polling task)")
self.shutdown_event.set()
logger.info("[Discord] Cleaning up commands...")
# 优先 cancel polling_task
if self._polling_task:
self._polling_task.cancel()
try:
await asyncio.wait_for(self._polling_task, timeout=10)
except asyncio.CancelledError:
logger.info("[Discord] polling_task 已取消。")
except Exception as e:
logger.warning(f"[Discord] polling_task 取消异常: {e}")
logger.info("[Discord] 正在清理已注册的斜杠指令... (step 2)")
# 清理指令
if self.enable_command_register and self.client:
try:
await asyncio.wait_for(
@@ -348,29 +347,16 @@ class DiscordPlatformAdapter(Platform):
),
timeout=10,
)
logger.info("[Discord] Commands cleaned up successfully.")
logger.info("[Discord] 指令清理完成。")
except Exception as e:
logger.warning(
f"[Discord] Error occurred while cleaning up commands: {e}"
)
if self._polling_task:
self._polling_task.cancel()
try:
await asyncio.wait_for(self._polling_task, timeout=10)
except asyncio.CancelledError:
logger.info("[Discord] Polling task cancelled successfully.")
except Exception as e:
logger.warning(
f"[Discord] Error occurred while cancelling polling task: {e}"
)
logger.info("[Discord] Closing client connection...")
logger.error(f"[Discord] 清理指令时发生错误: {e}", exc_info=True)
logger.info("[Discord] 正在关闭 Discord 客户端... (step 3)")
if self.client and hasattr(self.client, "close"):
try:
await asyncio.wait_for(self.client.close(), timeout=10)
except Exception as e:
logger.warning(f"[Discord] Error occurred while closing client: {e}")
logger.info("[Discord] Adapter shutdown complete.")
logger.warning(f"[Discord] 客户端关闭异常: {e}")
logger.info("[Discord] 适配器已终止。")
def register_handler(self, handler_info) -> None:
"""注册处理器信息"""
@@ -378,7 +364,7 @@ class DiscordPlatformAdapter(Platform):
async def _collect_and_register_commands(self) -> None:
"""收集所有指令并注册到Discord"""
logger.info("[Discord] Collecting and registering slash commands...")
logger.info("[Discord] 开始收集并注册斜杠指令...")
registered_commands = []
for handler_md in star_handlers_registry:
@@ -419,15 +405,15 @@ class DiscordPlatformAdapter(Platform):
if registered_commands:
logger.info(
f"[Discord] Ready to sync {len(registered_commands)} commands: {', '.join(registered_commands)}",
f"[Discord] 准备同步 {len(registered_commands)} 个指令: {', '.join(registered_commands)}",
)
else:
logger.info("[Discord] No commands found for registration.")
logger.info("[Discord] 没有发现可注册的指令。")
# 使用 Pycord 的方法同步指令
# 注意:这可能需要一些时间,并且有频率限制
await self.client.sync_commands()
logger.info("[Discord] Command synchronization completed.")
logger.info("[Discord] 指令同步完成。")
def _create_dynamic_callback(self, cmd_name: str):
"""为每个指令动态创建一个异步回调函数"""
@@ -436,17 +422,17 @@ class DiscordPlatformAdapter(Platform):
ctx: discord.ApplicationContext, params: str | None = None
) -> None:
# 将平台特定的前缀'/'剥离以适配通用的CommandFilter
logger.debug(f"[Discord] Callback triggered: {cmd_name}")
logger.debug(f"[Discord] Callback context: {ctx}")
logger.debug(f"[Discord] Callback params: {params}")
logger.debug(f"[Discord] 回调函数触发: {cmd_name}")
logger.debug(f"[Discord] 回调函数参数: {ctx}")
logger.debug(f"[Discord] 回调函数参数: {params}")
message_str_for_filter = cmd_name
if params:
message_str_for_filter += f" {params}"
logger.debug(
f"[Discord] Slash command '{cmd_name}' triggered. "
f"Raw params: '{params}'. "
f"Built command string: '{message_str_for_filter}'",
f"[Discord] 斜杠指令 '{cmd_name}' 被触发。 "
f"原始参数: '{params}'. "
f"构建的指令字符串: '{message_str_for_filter}'",
)
# 尝试立即响应,防止超时
@@ -455,7 +441,7 @@ class DiscordPlatformAdapter(Platform):
await ctx.defer()
followup_webhook = ctx.followup
except Exception as e:
logger.warning(f"[Discord] Failed to defer command '{cmd_name}': {e}")
logger.warning(f"[Discord] 指令 '{cmd_name}' defer 失败: {e}")
# 2. 构建 AstrBotMessage
channel = ctx.channel
@@ -479,7 +465,7 @@ class DiscordPlatformAdapter(Platform):
)
abm.message = [Plain(text=message_str_for_filter)]
abm.raw_message = ctx.interaction
abm.self_id = cast(str, self.bot_self_id)
abm.self_id = cast(str, self.client_self_id)
abm.session_id = str(ctx.channel_id)
abm.message_id = str(ctx.interaction.id)
@@ -517,10 +503,10 @@ class DiscordPlatformAdapter(Platform):
# Discord 斜杠指令名称规范
if not re.match(r"^[a-z0-9_-]{1,32}$", cmd_name):
logger.debug(f"[Discord] Skipping invalid slash command format: {cmd_name}")
logger.debug(f"[Discord] 跳过不符合规范的指令: {cmd_name}")
return None
description = handler_metadata.desc or f"Command: {cmd_name}"
description = handler_metadata.desc or f"指令: {cmd_name}"
if len(description) > 100:
description = f"{description[:97]}..."

View File

@@ -93,7 +93,7 @@ class MisskeyPlatformAdapter(Platform):
self.api: MisskeyAPI | None = None
self._running = False
self.bot_self_id = ""
self.client_self_id = ""
self._bot_username = ""
self._user_cache = {}
@@ -138,10 +138,10 @@ class MisskeyPlatformAdapter(Platform):
try:
user_info = await self.api.get_current_user()
self.bot_self_id = str(user_info.get("id", ""))
self.client_self_id = str(user_info.get("id", ""))
self._bot_username = user_info.get("username", "")
logger.info(
f"[Misskey] 已连接用户: {self._bot_username} (ID: {self.bot_self_id})",
f"[Misskey] 已连接用户: {self._bot_username} (ID: {self.client_self_id})",
)
except Exception as e:
logger.error(f"[Misskey] 获取用户信息失败: {e}")
@@ -312,9 +312,9 @@ class MisskeyPlatformAdapter(Platform):
)
room_id = data.get("toRoomId")
logger.debug(
f"[Misskey] 收到聊天事件: sender_id={sender_id}, room_id={room_id}, is_self={sender_id == self.bot_self_id}",
f"[Misskey] 收到聊天事件: sender_id={sender_id}, room_id={room_id}, is_self={sender_id == self.client_self_id}",
)
if sender_id == self.bot_self_id:
if sender_id == self.client_self_id:
return
if room_id:
@@ -354,13 +354,13 @@ class MisskeyPlatformAdapter(Platform):
mentions = note.get("mentions", [])
if self._bot_username and f"@{self._bot_username}" in text:
return True
if self.bot_self_id in [str(uid) for uid in mentions]:
if self.client_self_id in [str(uid) for uid in mentions]:
return True
reply = note.get("reply")
if reply and isinstance(reply, dict):
reply_user_id = str(reply.get("user", {}).get("id", ""))
if reply_user_id == self.bot_self_id:
if reply_user_id == self.client_self_id:
return bool(self._bot_username and f"@{self._bot_username}" in text)
return False
@@ -598,7 +598,7 @@ class MisskeyPlatformAdapter(Platform):
visibility, visible_user_ids = resolve_message_visibility(
user_id=user_id_for_cache,
user_cache=self._user_cache,
self_id=self.bot_self_id,
self_id=self.client_self_id,
default_visibility=self.default_visibility,
)
logger.debug(
@@ -637,14 +637,14 @@ class MisskeyPlatformAdapter(Platform):
message = create_base_message(
raw_data,
sender_info,
self.bot_self_id,
self.client_self_id,
is_chat=False,
)
cache_user_info(
self._user_cache,
sender_info,
raw_data,
self.bot_self_id,
self.client_self_id,
is_chat=False,
)
@@ -656,7 +656,7 @@ class MisskeyPlatformAdapter(Platform):
message,
raw_text,
self._bot_username,
self.bot_self_id,
self.client_self_id,
)
message_parts.extend(text_parts)
@@ -685,14 +685,14 @@ class MisskeyPlatformAdapter(Platform):
message = create_base_message(
raw_data,
sender_info,
self.bot_self_id,
self.client_self_id,
is_chat=True,
)
cache_user_info(
self._user_cache,
sender_info,
raw_data,
self.bot_self_id,
self.client_self_id,
is_chat=True,
)
@@ -713,7 +713,7 @@ class MisskeyPlatformAdapter(Platform):
message = create_base_message(
raw_data,
sender_info,
self.bot_self_id,
self.client_self_id,
is_chat=False,
room_id=room_id,
)
@@ -722,10 +722,10 @@ class MisskeyPlatformAdapter(Platform):
self._user_cache,
sender_info,
raw_data,
self.bot_self_id,
self.client_self_id,
is_chat=False,
)
cache_room_info(self._user_cache, raw_data, self.bot_self_id)
cache_room_info(self._user_cache, raw_data, self.client_self_id)
raw_text = raw_data.get("text", "")
message_parts = []
@@ -736,7 +736,7 @@ class MisskeyPlatformAdapter(Platform):
message,
raw_text,
self._bot_username,
self.bot_self_id,
self.client_self_id,
)
message_parts.extend(text_parts)
else:

View File

@@ -335,7 +335,7 @@ def extract_sender_info(
def create_base_message(
raw_data: dict[str, Any],
sender_info: dict[str, Any],
bot_self_id: str,
client_self_id: str,
is_chat: bool = False,
room_id: str | None = None,
) -> AstrBotMessage:
@@ -367,7 +367,7 @@ def create_base_message(
session_id if sender_info["sender_id"] else f"{session_prefix}%unknown"
)
message.message_id = str(raw_data.get("id", ""))
message.self_id = bot_self_id
message.self_id = client_self_id
return message
@@ -376,7 +376,7 @@ def process_at_mention(
message: AstrBotMessage,
raw_text: str,
bot_username: str,
bot_self_id: str,
client_self_id: str,
) -> tuple[list[str], str]:
"""处理@提及逻辑,返回消息部分列表和处理后的文本"""
message_parts = []
@@ -386,7 +386,7 @@ def process_at_mention(
if bot_username and raw_text.startswith(f"@{bot_username}"):
at_mention = f"@{bot_username}"
message.message.append(Comp.At(qq=bot_self_id))
message.message.append(Comp.At(qq=client_self_id))
remaining_text = raw_text[len(at_mention) :].strip()
if remaining_text:
message.message.append(Comp.Plain(remaining_text))
@@ -401,7 +401,7 @@ def cache_user_info(
user_cache: dict[str, Any],
sender_info: dict[str, Any],
raw_data: dict[str, Any],
bot_self_id: str,
client_self_id: str,
is_chat: bool = False,
) -> None:
"""缓存用户信息"""
@@ -410,7 +410,7 @@ def cache_user_info(
"username": sender_info["username"],
"nickname": sender_info["nickname"],
"visibility": "specified",
"visible_user_ids": [bot_self_id, sender_info["sender_id"]],
"visible_user_ids": [client_self_id, sender_info["sender_id"]],
}
else:
user_cache_data = {
@@ -428,7 +428,7 @@ def cache_user_info(
def cache_room_info(
user_cache: dict[str, Any],
raw_data: dict[str, Any],
bot_self_id: str,
client_self_id: str,
) -> None:
"""缓存房间信息"""
room_data = raw_data.get("toRoom")
@@ -442,7 +442,7 @@ def cache_room_info(
"room_description": room_data.get("description", ""),
"owner_id": room_data.get("ownerId", ""),
"visibility": "specified",
"visible_user_ids": [bot_self_id],
"visible_user_ids": [client_self_id],
}

View File

@@ -50,6 +50,7 @@ class TelegramPlatformAdapter(Platform):
) -> None:
super().__init__(platform_config, event_queue)
self.settings = platform_settings
self.client_self_id = uuid.uuid4().hex[:8]
base_url = self.config.get(
"telegram_api_base_url",
@@ -335,8 +336,6 @@ class TelegramPlatformAdapter(Platform):
return None
def _apply_caption() -> None:
if not update.message:
return
if update.message.caption:
message.message_str = update.message.caption
message.message.append(Comp.Plain(message.message_str))

View File

@@ -148,6 +148,7 @@ class WecomPlatformAdapter(Platform):
) -> None:
super().__init__(platform_config, event_queue)
self.settingss = platform_settings
self.client_self_id = uuid.uuid4().hex[:8]
self.api_base_url = platform_config.get(
"api_base_url",
"https://qyapi.weixin.qq.com/cgi-bin/",

View File

@@ -2,6 +2,7 @@ import asyncio
import os
import sys
import time
import uuid
from collections.abc import Callable, Coroutine
from typing import Any, cast
@@ -323,6 +324,7 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
) -> None:
super().__init__(platform_config, event_queue)
self.settingss = platform_settings
self.client_self_id = uuid.uuid4().hex[:8]
self.api_base_url = platform_config.get(
"api_base_url",
"https://api.weixin.qq.com/cgi-bin/",

View File

@@ -142,8 +142,7 @@ class BailianRerankProvider(RerankProvider):
f"百炼 API 错误: {data.get('code')} {data.get('message', '')}"
)
# 兼容旧版 API (output.results) 和新版 compatible API (results)
results = (data.get("output") or {}).get("results") or data.get("results") or []
results = data.get("output", {}).get("results", [])
if not results:
logger.warning(f"百炼 Rerank 返回空结果: {data}")
return []

View File

@@ -457,27 +457,6 @@ class ProviderOpenAIOfficial(Provider):
model = payloads.get("model", "").lower()
if "messages" in payloads and isinstance(payloads["messages"], list):
cleaned_messages = []
for idx, msg in enumerate(payloads["messages"]):
# 过滤空的 assistant 消息,防止严格 API如 Moonshot返回 400 错误
if msg.get("role") == "assistant":
content = msg.get("content")
tool_calls = msg.get("tool_calls")
# 情况1: 空/null content 且无 tool_calls -> 过滤掉
if not tool_calls and (content == "" or content is None):
logger.warning(f"过滤第 {idx} 条空 assistant 消息 (无工具调用)")
continue
# 情况2: 空 content 但有 tool_calls -> 设为 None (符合 OpenAI 规范)
if content == "" and tool_calls:
msg["content"] = None
cleaned_messages.append(msg)
payloads["messages"] = cleaned_messages
completion = await self.client.chat.completions.create(
**payloads,
stream=False,
@@ -610,10 +589,7 @@ class ProviderOpenAIOfficial(Provider):
def _extract_usage(self, usage: CompletionUsage | dict) -> TokenUsage:
ptd = getattr(usage, "prompt_tokens_details", None)
cached = getattr(ptd, "cached_tokens", 0) if ptd else 0
cached = (
cached if isinstance(cached, int) else 0
) # ptd.cached_tokens 可能为None
prompt_tokens = getattr(usage, "prompt_tokens", 0) or 0 # 安全
prompt_tokens = getattr(usage, "prompt_tokens", 0) or 0
completion_tokens = getattr(usage, "completion_tokens", 0) or 0
cached = cached or 0
prompt_tokens = prompt_tokens or 0

View File

@@ -394,6 +394,7 @@ def find_missing_requirements(requirements_path: str) -> set[str] | None:
def find_missing_requirements_from_lines(
requirement_lines: Sequence[str],
) -> set[str] | None:
required = list(iter_requirements(lines=requirement_lines))
if not required:
return set()

View File

@@ -314,12 +314,7 @@ class KnowledgeBaseRoute(Route):
# 转换为字典列表
kb_list = []
for kb in kbs:
kb_dict = kb.model_dump()
# include init_error from KBHelper if present
kb_helper = await kb_manager.get_kb(kb.kb_id)
if kb_helper and kb_helper.init_error:
kb_dict["init_error"] = kb_helper.init_error
kb_list.append(kb_dict)
kb_list.append(kb.model_dump())
return (
Response()

View File

@@ -1,4 +1,4 @@
/* Auto-generated MDI subset 255 icons */
/* Auto-generated MDI subset 256 icons */
/* Do not edit manually. Run: pnpm run subset-icons */
@font-face {
@@ -132,6 +132,10 @@
content: "\F0B66";
}
.mdi-calendar-clock-outline::before {
content: "\F16E1";
}
.mdi-calendar-edit::before {
content: "\F08A7";
}
@@ -292,10 +296,6 @@
content: "\F0193";
}
.mdi-content-save-outline::before {
content: "\F0818";
}
.mdi-creation::before {
content: "\F0674";
}
@@ -844,6 +844,10 @@
content: "\F048A";
}
.mdi-send-clock-outline::before {
content: "\F1164";
}
.mdi-server::before {
content: "\F048B";
}

View File

@@ -65,26 +65,18 @@ const authorDisplay = computed(() => {
>
<v-menu offset-y>
<template #activator="{ props: menuProps }">
<div class="d-flex flex-column align-center" style="cursor: pointer; width: 80px;">
<v-avatar
v-bind="menuProps"
size="72"
class="pinned-avatar activator-avatar mb-1"
:title="plugin.display_name || plugin.name"
>
<img
:src="(typeof plugin.logo === 'string' && plugin.logo.trim()) ? plugin.logo : defaultPluginIcon"
:alt="plugin.name"
@error="handlePinnedImgError"
/>
</v-avatar>
<span
class="text-caption text-center text-truncate"
style="width: 100%; font-size: 0.75rem; opacity: 0.9; line-height: 1.2;"
>
{{ plugin.display_name || plugin.name }}
</span>
</div>
<v-avatar
v-bind="menuProps"
size="72"
class="pinned-avatar activator-avatar"
:title="plugin.display_name || plugin.name"
>
<img
:src="(typeof plugin.logo === 'string' && plugin.logo.trim()) ? plugin.logo : defaultPluginIcon"
:alt="plugin.name"
@error="handlePinnedImgError"
/>
</v-avatar>
</template>
<v-card>
@@ -190,7 +182,8 @@ const authorDisplay = computed(() => {
.pinned-card-wrapper {
position: relative;
display: inline-block;
width: 80px;
width: 72px;
height: 72px;
}
.pinned-item {

View File

@@ -1,10 +1,8 @@
<template>
<div class="provider-models-panel">
<div class="provider-models-head">
<div class="provider-models-title-wrap">
<h3 class="provider-models-title">{{ tm('models.configured') }}</h3>
<small v-if="availableCount" class="provider-models-subtitle">{{ tm('models.available') }} {{ availableCount }}</small>
</div>
<div class="mt-4">
<div class="d-flex align-center ga-2 mb-2">
<h3 class="text-h5 font-weight-bold mb-0">{{ tm('models.configured') }}</h3>
<small style="color: grey;" v-if="availableCount">{{ tm('models.available') }} {{ availableCount }}</small>
<v-text-field
v-model="modelSearchProxy"
density="compact"
@@ -13,35 +11,37 @@
hide-details
variant="solo-filled"
flat
class="provider-models-search"
class="ml-1"
style="max-width: 240px;"
:placeholder="tm('models.searchPlaceholder')"
/>
<div class="provider-models-actions">
<v-btn
color="primary"
prepend-icon="mdi-download"
:loading="loadingModels"
@click="emit('fetch-models')"
variant="tonal"
size="small"
>
{{ isSourceModified ? tm('providerSources.saveAndFetchModels') : tm('providerSources.fetchModels') }}
</v-btn>
<v-btn
color="primary"
prepend-icon="mdi-pencil-plus"
variant="text"
size="small"
@click="emit('open-manual-model')"
>
{{ tm('models.manualAddButton') }}
</v-btn>
</div>
<v-spacer></v-spacer>
<v-btn
color="primary"
prepend-icon="mdi-download"
:loading="loadingModels"
@click="emit('fetch-models')"
variant="tonal"
size="small"
>
{{ isSourceModified ? tm('providerSources.saveAndFetchModels') : tm('providerSources.fetchModels') }}
</v-btn>
<v-btn
color="primary"
prepend-icon="mdi-pencil-plus"
variant="text"
size="small"
class="ml-1"
@click="emit('open-manual-model')"
>
{{ tm('models.manualAddButton') }}
</v-btn>
</div>
<v-list
density="compact"
class="provider-models-list"
class="rounded-lg border"
style="max-height: 520px; overflow-y: auto; font-family:system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;"
>
<template v-if="entries.length > 0">
<template v-for="entry in entries" :key="entry.type === 'configured' ? `provider-${entry.provider.id}` : `model-${entry.model}`">
@@ -55,7 +55,7 @@
<v-list-item-title class="font-weight-medium text-truncate">
{{ entry.provider.id }}
</v-list-item-title>
<v-list-item-subtitle class="provider-model-subtitle d-flex align-center ga-1">
<v-list-item-subtitle class="text-caption text-grey d-flex align-center ga-1" style="font-family: monospace;">
<span>{{ entry.provider.model }}</span>
<v-icon v-if="supportsImageInput(entry.metadata)" size="14" color="grey">
mdi-eye-outline
@@ -124,7 +124,7 @@
<template #activator="{ props }">
<v-list-item v-bind="props" class="cursor-pointer" @click="emit('add-model-provider', entry.model)">
<v-list-item-title>{{ entry.model }}</v-list-item-title>
<v-list-item-subtitle class="provider-model-subtitle d-flex align-center ga-1">
<v-list-item-subtitle class="text-caption text-grey d-flex align-center ga-1">
<span>{{ entry.model }}</span>
<v-icon v-if="supportsImageInput(entry.metadata)" size="14" color="grey">
mdi-eye-outline
@@ -231,87 +231,11 @@ const isProviderTesting = (providerId) => props.testingProviders.includes(provid
</script>
<style scoped>
.provider-models-panel {
display: grid;
gap: 14px;
}
.provider-models-head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.provider-models-title-wrap {
min-width: 0;
}
.provider-models-title {
margin: 0;
font-size: 18px;
line-height: 1.3;
font-weight: 650;
}
.provider-models-subtitle {
display: block;
margin-top: 6px;
color: rgba(var(--v-theme-on-surface), 0.6);
font-size: 12px;
}
.provider-models-search {
max-width: 240px;
}
.provider-models-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.provider-models-list {
max-height: 520px;
overflow-y: auto;
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: 14px;
background: rgb(var(--v-theme-surface));
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
.provider-compact-item {
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.08);
}
.provider-models-list :deep(.v-list-item:last-child) {
border-bottom: 0;
}
.provider-model-subtitle {
color: rgba(var(--v-theme-on-surface), 0.62);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
.border {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.cursor-pointer {
cursor: pointer;
}
@media (max-width: 900px) {
.provider-models-head {
align-items: stretch;
}
.provider-models-search {
max-width: none;
width: 100%;
}
.provider-models-actions {
margin-left: 0;
width: 100%;
}
}
</style>

View File

@@ -1,13 +1,8 @@
<template>
<v-card class="provider-sources-panel h-100" elevation="0">
<div class="provider-sources-head">
<div class="provider-sources-title-wrap">
<div class="provider-sources-title-row">
<h3 class="provider-sources-title">{{ tm('providerSources.title') }}</h3>
<v-chip size="x-small" variant="tonal" label>
{{ displayedProviderSources.length }}
</v-chip>
</div>
<div class="d-flex align-center justify-space-between px-4 pt-4 pb-2">
<div class="d-flex align-center ga-2">
<h3 class="mb-0">{{ tm('providerSources.title') }}</h3>
</div>
<StyledMenu>
<template #activator="{ props }">
@@ -16,6 +11,7 @@
prepend-icon="mdi-plus"
color="primary"
variant="tonal"
rounded="xl"
size="small"
>
{{ tm('providerSources.add') }}
@@ -38,7 +34,7 @@
</StyledMenu>
</div>
<div v-if="isMobile && displayedProviderSources.length > 0" class="provider-sources-mobile">
<div v-if="isMobile && displayedProviderSources.length > 0" class="px-4 pb-3">
<div class="d-flex align-center ga-2">
<v-select
:model-value="selectedId"
@@ -75,7 +71,7 @@
</div>
</div>
<div v-else-if="displayedProviderSources.length > 0" class="provider-sources-list-wrap">
<div v-else-if="displayedProviderSources.length > 0">
<v-list class="provider-source-list" nav density="compact" lines="two">
<v-list-item
v-for="source in displayedProviderSources"
@@ -87,13 +83,13 @@
@click="emitSelectSource(source)"
>
<template #prepend>
<v-avatar size="28" class="provider-source-avatar" rounded="0">
<v-avatar size="32" class="bg-grey-lighten-4" rounded="0">
<v-img v-if="source?.provider" :src="resolveSourceIcon(source)" alt="logo" cover></v-img>
<v-icon v-else size="20">mdi-creation</v-icon>
<v-icon v-else size="32">mdi-creation</v-icon>
</v-avatar>
</template>
<v-list-item-title class="provider-source-title">{{ getSourceDisplayName(source) }}</v-list-item-title>
<v-list-item-subtitle class="provider-source-subtitle text-truncate">{{ source.api_base || 'N/A' }}</v-list-item-subtitle>
<v-list-item-title class="font-weight-bold mb-1" style="font-family: Arial, Helvetica, sans-serif; font-size: 16px;">{{ getSourceDisplayName(source) }}</v-list-item-title>
<v-list-item-subtitle class="text-truncate">{{ source.api_base || 'N/A' }}</v-list-item-subtitle>
<template #append>
<div class="d-flex align-center ga-1">
<v-btn
@@ -102,7 +98,6 @@
variant="text"
size="x-small"
color="error"
:ripple="false"
@click.stop="emitDeleteSource(source)"
></v-btn>
</div>
@@ -186,98 +181,22 @@ const emitDeleteSource = (source) => emit('delete-provider-source', source)
<style scoped>
.provider-sources-panel {
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: 16px;
background: rgb(var(--v-theme-surface));
min-height: 320px;
overflow: hidden;
}
.provider-sources-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
padding: 18px 18px 12px;
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.1);
}
.provider-sources-title-row {
display: flex;
align-items: center;
gap: 6px;
}
.provider-sources-title {
margin: 0;
font-size: 17px;
line-height: 1.2;
font-weight: 650;
}
.provider-sources-mobile {
padding: 14px 18px 0;
}
.provider-sources-list-wrap {
padding: 8px 8px 10px;
}
.provider-source-list {
max-height: calc(100vh - 335px);
overflow-y: auto;
padding: 0;
background: transparent;
padding: 6px 8px;
}
.provider-source-list-item {
margin-bottom: 2px;
border: 1px solid transparent;
transition: background-color 0.15s ease, border-color 0.15s ease;
background: transparent;
}
.provider-source-list-item--active {
background-color: rgba(var(--v-theme-primary), 0.06);
border: 1px solid transparent;
}
.provider-source-avatar {
background: transparent !important;
}
.provider-source-title {
font-size: 15px;
font-weight: 650;
line-height: 1.4;
}
.provider-source-subtitle {
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 12px;
line-height: 1.5;
}
.provider-source-list :deep(.v-list-item__prepend) {
margin-inline-end: 10px;
}
.provider-source-list :deep(.v-list-item__content) {
min-width: 0;
}
.provider-source-list :deep(.v-list-item__append) {
opacity: 0;
transition: opacity 0.15s ease;
}
.provider-source-list-item:hover {
background-color: rgba(var(--v-theme-on-surface), 0.025);
}
.provider-source-list-item:hover :deep(.v-list-item__append),
.provider-source-list-item--active :deep(.v-list-item__append) {
opacity: 1;
background-color: #E8F0FE;
border: 1px solid rgba(var(--v-theme-primary), 0.25);
}
@media (max-width: 960px) {
@@ -293,7 +212,7 @@ const emitDeleteSource = (source) => emit('delete-provider-source', source)
<style>
.v-theme--PurpleThemeDark .provider-source-list-item--active {
background-color: rgba(var(--v-theme-primary), 0.1);
border: 1px solid transparent;
background-color: #2d2d2d;
border: none;
}
</style>

View File

@@ -5,7 +5,7 @@ import { EventSourcePolyfill } from 'event-source-polyfill';
</script>
<template>
<div class="console-displayer-wrapper" id="console-wrapper">
<div>
<div class="filter-controls mb-2" v-if="showLevelBtns">
<v-chip-group v-model="selectedLevels" column multiple>
<v-chip v-for="level in logLevels" :key="level" :color="getLevelColor(level)" filter variant="flat" size="small"
@@ -13,14 +13,6 @@ import { EventSourcePolyfill } from 'event-source-polyfill';
{{ level }}
</v-chip>
</v-chip-group>
<v-spacer></v-spacer>
<v-btn
:icon="isFullscreen ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'"
variant="text"
density="compact"
class="me-4 fullscreen-btn"
@click="toggleFullscreen"
></v-btn>
</div>
<div id="term" style="background-color: #1e1e1e; padding: 16px; border-radius: 8px; overflow-y:auto; height: 100%">
@@ -34,7 +26,6 @@ export default {
data() {
return {
autoScroll: true,
isFullscreen: false,
logColorAnsiMap: {
'\u001b[1;34m': 'color: #39C5BB; font-weight: bold;',
'\u001b[1;36m': 'color: #00FFFF; font-weight: bold;',
@@ -89,10 +80,8 @@ export default {
async mounted() {
await this.fetchLogHistory();
this.connectSSE();
document.addEventListener('fullscreenchange', this.handleFullscreenChange);
},
beforeUnmount() {
document.removeEventListener('fullscreenchange', this.handleFullscreenChange);
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
@@ -264,21 +253,6 @@ export default {
this.autoScroll = !this.autoScroll;
},
toggleFullscreen() {
const container = document.getElementById('console-wrapper');
if (!document.fullscreenElement) {
container.requestFullscreen().catch(err => {
console.error(`Error attempting to enable full-screen mode: ${err.message}`);
});
} else {
document.exitFullscreen();
}
},
handleFullscreenChange() {
this.isFullscreen = !!document.fullscreenElement;
},
printLog(log) {
let ele = document.getElementById('term')
if (!ele) {
@@ -308,30 +282,14 @@ export default {
</script>
<style scoped>
.console-displayer-wrapper {
height: 100%;
display: flex;
flex-direction: column;
}
#console-wrapper:fullscreen {
background-color: #1e1e1e;
padding: 20px;
}
.filter-controls {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
margin-left: 20px;
}
.fullscreen-btn {
color: rgba(255, 255, 255, 0.7) !important; /* 提高在深色背景下的对比度 */
}
:deep(.console-log-line) {
display: block;
margin-bottom: 2px;

View File

@@ -10,8 +10,7 @@
"loading": "Loading...",
"documents": "Documents",
"chunks": "Chunks",
"sessionConfig": "Session Config",
"initError": "Initialization Failed"
"sessionConfig": "Session Config"
},
"card": {
"edit": "Edit",

View File

@@ -10,8 +10,7 @@
"loading": "Загрузка...",
"documents": "док.",
"chunks": "фрагм.",
"sessionConfig": "Профиль",
"initError": "Ошибка инициализации"
"sessionConfig": "Профиль"
},
"card": {
"edit": "Изменить",

View File

@@ -10,8 +10,7 @@
"loading": "正在加载...",
"documents": "文档",
"chunks": "分块",
"sessionConfig": "会话配置",
"initError": "初始化失败"
"sessionConfig": "会话配置"
},
"card": {
"edit": "编辑",

View File

@@ -25,47 +25,12 @@ export const useAuthStore = defineStore({
localStorage.setItem('user', this.username);
localStorage.setItem('token', res.data.data.token);
localStorage.setItem('change_pwd_hint', res.data.data?.change_pwd_hint);
const onboardingCompleted = await this.checkOnboardingCompleted();
this.returnUrl = null;
if (onboardingCompleted) {
router.push('/dashboard/default');
} else {
router.push('/welcome');
}
router.push('/welcome');
} catch (error) {
return Promise.reject(error);
}
},
async checkOnboardingCompleted(): Promise<boolean> {
try {
// 1. 检查平台配置
const platformRes = await axios.get('/api/config/get');
const hasPlatform = (platformRes.data.data.config.platform || []).length > 0;
if (!hasPlatform) return false;
// 2. 检查提供者配置
const providerRes = await axios.get('/api/config/provider/template');
const providers = providerRes.data.data?.providers || [];
const sources = providerRes.data.data?.provider_sources || [];
const sourceMap = new Map();
sources.forEach((s: any) => sourceMap.set(s.id, s.provider_type));
const hasProvider = providers.some((provider: any) => {
if (provider.provider_type) return provider.provider_type === 'chat_completion';
if (provider.provider_source_id) {
const type = sourceMap.get(provider.provider_source_id);
if (type === 'chat_completion') return true;
}
return String(provider.type || '').includes('chat_completion');
});
return hasProvider;
} catch (e) {
console.error('Failed to check onboarding status:', e);
return false;
}
},
logout() {
this.username = '';
localStorage.removeItem('user');

View File

@@ -94,21 +94,7 @@
<template v-slot:item.title="{ item }">
<div class="conversation-title-cell">
<div class="conversation-title-row">
<span class="conversation-title-text">{{ item.title || tm('status.noTitle') }}</span>
<v-btn
icon
variant="plain"
size="x-small"
density="compact"
:ripple="false"
class="conversation-inline-edit"
@click.stop="editConversation(item)"
:disabled="loading"
>
<v-icon size="14">mdi-pencil</v-icon>
</v-btn>
</div>
<span class="conversation-title-text">{{ item.title || tm('status.noTitle') }}</span>
<span class="conversation-title-meta">{{ item.cid || tm('status.unknown') }}</span>
</div>
</template>
@@ -155,6 +141,10 @@
@click="viewConversation(item)" :disabled="loading">
<v-icon>mdi-eye</v-icon>
</v-btn>
<v-btn icon variant="plain" size="x-small" class="action-button"
@click="editConversation(item)" :disabled="loading">
<v-icon>mdi-pencil</v-icon>
</v-btn>
<v-btn icon color="error" variant="plain" size="x-small" class="action-button"
@click="confirmDeleteConversation(item)" :disabled="loading">
<v-icon>mdi-delete</v-icon>
@@ -1198,29 +1188,14 @@ export default {
max-width: 145px;
}
.conversation-title-row {
display: flex;
align-items: center;
gap: 2px;
min-width: 0;
}
.conversation-title-text {
display: inline-block;
flex: 1;
min-width: 0;
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conversation-inline-edit {
width: 18px;
height: 18px;
min-width: 18px;
flex-shrink: 0;
}
.conversation-title-meta {
display: block;
color: rgba(var(--v-theme-on-surface), 0.58);

View File

@@ -29,9 +29,9 @@
</v-tabs>
<!-- Chat Completion: 左侧列表 + 右侧上下卡片布局 -->
<div v-if="selectedProviderType === 'chat_completion'" class="provider-workbench">
<v-row class="provider-workbench__shell">
<v-col cols="12" md="4" lg="3" class="provider-workbench__sources">
<div v-if="selectedProviderType === 'chat_completion'" class="d-flex align-center justify-center">
<v-row style="max-width: 1500px; ">
<v-col cols="12" md="4" lg="3" class="pr-md-4">
<ProviderSourcesPanel
:displayed-provider-sources="displayedProviderSources"
:selected-provider-source="selectedProviderSource"
@@ -45,77 +45,66 @@
/>
</v-col>
<v-col cols="12" md="8" lg="9" class="provider-workbench__settings">
<v-card class="provider-config-card provider-settings-panel h-100" elevation="0">
<div v-if="selectedProviderSource" class="provider-config-header">
<div class="provider-config-headline">
<div class="provider-config-kicker">{{ tm('providers.settings') }}</div>
<div class="provider-config-title">{{ selectedProviderSource.id }}</div>
<div class="provider-config-subtitle">
{{ selectedProviderSource.api_base || 'N/A' }}
<v-col cols="12" md="8" lg="9">
<v-card class="provider-config-card h-100" elevation="0" style="overflow-y: auto;">
<v-card-title class="d-flex align-center justify-space-between flex-wrap ga-3 pt-4 pl-5">
<div class="d-flex align-center ga-3" v-if="selectedProviderSource">
<div>
<div class="text-h4 font-weight-bold">{{ selectedProviderSource.id }}</div>
<div class="text-caption text-medium-emphasis">{{ selectedProviderSource.api_base || 'N/A' }}
</div>
</div>
</div>
<div class="provider-config-actions">
<v-btn
color="primary"
prepend-icon="mdi-content-save-outline"
:loading="savingSource"
:disabled="!isSourceModified"
@click="saveProviderSource"
variant="tonal"
>
<div class="d-flex align-center ga-2" v-if="selectedProviderSource">
<v-btn color="success" prepend-icon="mdi-check" :loading="savingSource"
:disabled="!isSourceModified" @click="saveProviderSource" variant="flat">
{{ tm('providerSources.save') }}
</v-btn>
</div>
</div>
</v-card-title>
<v-card-text class="provider-config-body">
<v-card-text>
<template v-if="selectedProviderSource">
<section class="provider-section">
<div class="provider-section-head">
<div class="provider-section-title">{{ tm('providers.settings') }}</div>
</div>
<div>
<AstrBotConfig v-if="basicSourceConfig" :iterable="basicSourceConfig" :metadata="providerSourceSchema"
metadataKey="provider" :is-editing="true" />
</section>
</div>
<section v-if="advancedSourceConfig" class="provider-section">
<div class="provider-section-head">
<div class="provider-section-title">{{ tm('providerSources.advancedConfig') }}</div>
</div>
<AstrBotConfig
:iterable="advancedSourceConfig"
:metadata="providerSourceSchema"
metadataKey="provider"
:is-editing="true"
/>
</section>
<v-expansion-panels variant="accordion" class="mb-2">
<v-expansion-panel elevation="0" class="border rounded-lg">
<v-expansion-panel-title>
<span class="font-weight-medium">{{ tm('providerSources.advancedConfig') }}</span>
</v-expansion-panel-title>
<v-expansion-panel-text>
<AstrBotConfig v-if="advancedSourceConfig" :iterable="advancedSourceConfig"
:metadata="providerSourceSchema" metadataKey="provider" :is-editing="true" />
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
<section class="provider-section provider-section--models">
<ProviderModelsPanel
:entries="filteredMergedModelEntries"
:available-count="availableModels.length"
v-model:model-search="modelSearch"
:loading-models="loadingModels"
:is-source-modified="isSourceModified"
:supports-image-input="supportsImageInput"
:supports-tool-call="supportsToolCall"
:supports-reasoning="supportsReasoning"
:format-context-limit="formatContextLimit"
:testing-providers="testingProviders"
:tm="tm"
@fetch-models="fetchAvailableModels"
@open-manual-model="openManualModelDialog"
@open-provider-edit="openProviderEdit"
@toggle-provider-enable="toggleProviderEnable"
@test-provider="testProvider"
@delete-provider="deleteProvider"
@add-model-provider="addModelProvider"
/>
</section>
<ProviderModelsPanel
:entries="filteredMergedModelEntries"
:available-count="availableModels.length"
v-model:model-search="modelSearch"
:loading-models="loadingModels"
:is-source-modified="isSourceModified"
:supports-image-input="supportsImageInput"
:supports-tool-call="supportsToolCall"
:supports-reasoning="supportsReasoning"
:format-context-limit="formatContextLimit"
:testing-providers="testingProviders"
:tm="tm"
@fetch-models="fetchAvailableModels"
@open-manual-model="openManualModelDialog"
@open-provider-edit="openProviderEdit"
@toggle-provider-enable="toggleProviderEnable"
@test-provider="testProvider"
@delete-provider="deleteProvider"
@add-model-provider="addModelProvider"
/>
</template>
<div v-else class="provider-empty-state">
<div v-else class="text-center py-8 text-medium-emphasis">
<v-icon size="48" color="grey-lighten-1">mdi-cursor-default-click</v-icon>
<p class="mt-2">{{ tm('providerSources.selectHint') }}</p>
</div>
@@ -676,141 +665,18 @@ function goToConfigPage() {
<style scoped>
.provider-page {
--provider-surface: rgb(var(--v-theme-surface));
--provider-text: rgb(var(--v-theme-on-surface));
--provider-muted: rgba(var(--v-theme-on-surface), 0.68);
--provider-subtle: rgba(var(--v-theme-on-surface), 0.56);
--provider-border: rgba(var(--v-theme-on-surface), 0.1);
--provider-border-strong: rgba(var(--v-theme-on-surface), 0.14);
--provider-soft: rgba(var(--v-theme-primary), 0.08);
padding: 20px;
padding-top: 8px;
padding-bottom: 40px;
}
.provider-workbench {
display: flex;
justify-content: center;
}
.provider-workbench__shell {
width: 100%;
max-width: 1500px;
}
.provider-workbench__sources,
.provider-workbench__settings {
min-width: 0;
}
.provider-config-card {
min-height: 280px;
border: 1px solid var(--provider-border);
border-radius: 16px;
background: var(--provider-surface);
overflow: hidden;
}
.provider-settings-panel {
display: flex;
flex-direction: column;
}
.provider-config-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
padding: 20px 20px 16px;
border-bottom: 1px solid var(--provider-border);
}
.provider-config-headline {
min-width: 0;
}
.provider-config-kicker {
color: var(--provider-subtle);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.provider-config-title {
margin-top: 8px;
font-size: 22px;
line-height: 1.1;
font-weight: 650;
letter-spacing: -0.03em;
overflow-wrap: anywhere;
color: var(--provider-text);
}
.provider-config-subtitle {
margin-top: 8px;
color: var(--provider-muted);
font-size: 13px;
line-height: 1.6;
overflow-wrap: anywhere;
}
.provider-config-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.provider-config-body {
display: grid;
gap: 14px;
padding: 18px 20px 20px;
}
.provider-section {
border: 1px solid var(--provider-border);
border-radius: 14px;
background: rgba(var(--v-theme-primary), 0.02);
padding: 16px;
}
.provider-section--models {
padding: 18px;
}
.provider-section-head {
margin-bottom: 10px;
}
.provider-section-title {
font-size: 16px;
font-weight: 650;
line-height: 1.4;
color: var(--provider-text);
}
.provider-empty-state {
min-height: 420px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--provider-muted);
}
@media (max-width: 960px) {
.provider-config-card {
min-height: auto;
}
.provider-config-header {
flex-direction: column;
align-items: flex-start;
}
.provider-config-body {
padding: 18px;
}
}
</style>

View File

@@ -22,15 +22,10 @@ function toggleTheme() {
theme.global.name.value = newTheme;
}
onMounted(async () => {
onMounted(() => {
// 检查用户是否已登录,如果已登录则重定向
if (authStore.has_token()) {
const onboardingCompleted = await authStore.checkOnboardingCompleted();
if (onboardingCompleted) {
router.push('/dashboard/default');
} else {
router.push('/welcome');
}
router.push('/welcome');
return;
}

View File

@@ -27,27 +27,14 @@
</div>
<div v-else-if="kbList.length > 0" class="kb-grid">
<v-card v-for="kb in kbList" :key="kb.kb_id" class="kb-card" elevation="2" :hover="!kb.init_error"
:class="{ 'kb-card-error': kb.init_error }"
@click="!kb.init_error && navigateToDetail(kb.kb_id)">
<!-- Error badge -->
<v-badge v-if="kb.init_error" color="error" icon="mdi-alert-circle"
class="kb-error-badge position-absolute" style="top: 0; right: 0; transform: translate(34%, -34%);" />
<div class="kb-card-content" :class="{ 'kb-card-content-error': kb.init_error }">
<v-card v-for="kb in kbList" :key="kb.kb_id" class="kb-card" elevation="2" hover
@click="navigateToDetail(kb.kb_id)">
<div class="kb-card-content">
<div class="kb-emoji">{{ kb.emoji || '📚' }}</div>
<h3 class="kb-name">{{ kb.kb_name }}</h3>
<p v-if="!kb.init_error" class="kb-description text-medium-emphasis">{{ kb.description || '暂无描述' }}</p>
<p class="kb-description text-medium-emphasis">{{ kb.description || '暂无描述' }}</p>
<!-- Error message display -->
<div v-if="kb.init_error" class="kb-error-panel mt-3 mb-2">
<div class="kb-error-title">
<v-icon size="16" color="error">mdi-close-circle</v-icon>
<span>{{ t('list.initError') }}</span>
</div>
<div class="kb-error-detail" :title="kb.init_error">{{ kb.init_error }}</div>
</div>
<div class="kb-stats mt-4" v-if="!kb.init_error">
<div class="kb-stats mt-4">
<div class="stat-item">
<v-icon size="small" color="primary">mdi-file-document</v-icon>
<span>{{ kb.doc_count || 0 }} {{ t('list.documents') }}</span>
@@ -58,8 +45,8 @@
</div>
</div>
<div class="kb-actions" :class="{ 'error-actions': kb.init_error }">
<v-btn v-if="!kb.init_error" icon="mdi-pencil" size="small" variant="text" color="info" @click.stop="editKB(kb)" />
<div class="kb-actions">
<v-btn icon="mdi-pencil" size="small" variant="text" color="info" @click.stop="editKB(kb)" />
<v-btn icon="mdi-delete" size="small" variant="text" color="error" @click.stop="confirmDelete(kb)" />
</div>
</div>
@@ -491,34 +478,6 @@ onMounted(() => {
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15) !important;
}
/* Error state card styles */
.kb-card-error {
cursor: not-allowed;
border: 1px solid rgba(var(--v-theme-error), 0.3);
background-color: rgba(var(--v-theme-error), 0.02) !important;
overflow: visible; /* Allow badge to overflow */
}
.kb-card-error:hover {
transform: none;
box-shadow: 0 4px 12px rgba(var(--v-theme-error), 0.1) !important;
border-color: rgba(var(--v-theme-error), 0.5);
}
.kb-card-error .kb-emoji {
opacity: 0.7;
filter: grayscale(0.5);
}
.kb-card-error .kb-name {
color: rgba(var(--v-theme-on-surface), 0.7);
}
.kb-error-badge {
z-index: 10;
opacity: 0.9;
}
.kb-card-content {
padding: 24px;
display: flex;
@@ -529,11 +488,6 @@ onMounted(() => {
position: relative;
}
.kb-card-content-error {
justify-content: center;
gap: 8px;
}
.kb-emoji {
font-size: 56px;
margin-bottom: 8px;
@@ -564,36 +518,6 @@ onMounted(() => {
justify-content: center;
}
.kb-error-panel {
width: 100%;
text-align: left;
background: rgba(var(--v-theme-error), 0.08);
border: 1px solid rgba(var(--v-theme-error), 0.18);
border-radius: 10px;
padding: 10px 12px;
}
.kb-error-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8rem;
font-weight: 600;
color: rgb(var(--v-theme-error));
margin-bottom: 4px;
}
.kb-error-detail {
font-size: 0.78rem;
line-height: 1.35;
color: rgba(var(--v-theme-on-surface), 0.82);
word-break: break-word;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.stat-item {
display: flex;
align-items: center;

View File

@@ -9,11 +9,6 @@ If `uv` is not installed, install it first by following the official guide:
`uv` supports Linux, Windows, and macOS.
## Important Notes
> [!WARNING]
> AstrBot deployed via `uv` **does not support upgrading through the WebUI**. To update, run `uv tool upgrade astrbot` from the command line.
## Install and Start
```bash

View File

@@ -8,11 +8,6 @@
`uv` 支持 Linux、Windows、macOS。
## 注意事项
> [!WARNING]
> 通过 `uv` 部署的 AstrBot **不支持在 WebUI 中进行版本升级**。如需更新,请在命令行中执行 `uv tool upgrade astrbot`。
## 安装并启动
```bash

View File

@@ -87,4 +87,4 @@
## 🎉 大功告成
此时,你的 AstrBot 应该已经成功连接 QQ 官方接口。使用 `私聊` 的方式在 QQ 对机器人发送 `/help` 以检查是否连接成功。
此时,你的 AstrBot 和 NapCatQQ 应该已经连接成功。使用 `私聊` 的方式在 QQ 对机器人发送 `/help` 以检查是否连接成功。

View File

@@ -66,7 +66,6 @@ dependencies = [
"shipyard-python-sdk>=0.2.4",
"shipyard-neo-sdk>=0.2.0",
"python-socks>=2.8.0",
"pysocks>=1.7.1",
"packaging>=24.2",
]

View File

@@ -32,7 +32,6 @@ pydub>=0.25.1
pyjwt>=2.10.1
python-telegram-bot>=22.6
qq-botpy>=1.2.1
python-socks>=2.8.0
quart>=0.20.0
readability-lxml>=0.8.4.1
silk-python>=0.2.6
@@ -46,7 +45,6 @@ wechatpy>=1.8.18
audioop-lts ; python_full_version >= '3.13'
click>=8.2.1
pypdf>=6.1.1
pysocks>=1.7.1
aiofiles>=25.1.0
rank-bm25>=0.2.2
jieba>=0.42.1
@@ -56,4 +54,3 @@ tenacity>=9.1.2
shipyard-python-sdk>=0.2.4
shipyard-neo-sdk>=0.2.0
packaging>=24.2
qrcode>=8.2

View File

@@ -1173,360 +1173,3 @@ async def test_parse_openai_completion_raises_empty_model_output_error():
await provider._parse_openai_completion(completion, tools=None)
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_query_filters_empty_assistant_message_without_tool_calls(monkeypatch):
"""Test that empty assistant messages without tool_calls are filtered out."""
provider = _make_provider()
try:
captured_kwargs = {}
async def fake_create(**kwargs):
captured_kwargs.update(kwargs)
return ChatCompletion.model_validate(
{
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 0,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "ok",
},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 1,
"completion_tokens": 1,
"total_tokens": 2,
},
}
)
monkeypatch.setattr(provider.client.chat.completions, "create", fake_create)
payloads = {
"model": "gpt-4o-mini",
"messages": [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": ""}, # Should be filtered
{"role": "user", "content": "world"},
],
}
await provider._query(payloads=payloads, tools=None)
# The empty assistant message should be filtered out
messages = captured_kwargs["messages"]
assert len(messages) == 2
assert messages[0] == {"role": "user", "content": "hello"}
assert messages[1] == {"role": "user", "content": "world"}
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_query_filters_null_content_assistant_message_without_tool_calls(
monkeypatch,
):
"""Test that assistant messages with null content and no tool_calls are filtered."""
provider = _make_provider()
try:
captured_kwargs = {}
async def fake_create(**kwargs):
captured_kwargs.update(kwargs)
return ChatCompletion.model_validate(
{
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 0,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "ok",
},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 1,
"completion_tokens": 1,
"total_tokens": 2,
},
}
)
monkeypatch.setattr(provider.client.chat.completions, "create", fake_create)
payloads = {
"model": "gpt-4o-mini",
"messages": [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": None}, # Should be filtered
{"role": "user", "content": "world"},
],
}
await provider._query(payloads=payloads, tools=None)
# The null content assistant message should be filtered out
messages = captured_kwargs["messages"]
assert len(messages) == 2
assert messages[0] == {"role": "user", "content": "hello"}
assert messages[1] == {"role": "user", "content": "world"}
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_query_converts_empty_content_to_none_with_tool_calls(monkeypatch):
"""Test that empty content with tool_calls is converted to None (OpenAI spec)."""
provider = _make_provider()
try:
captured_kwargs = {}
async def fake_create(**kwargs):
captured_kwargs.update(kwargs)
return ChatCompletion.model_validate(
{
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 0,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "ok",
},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 1,
"completion_tokens": 1,
"total_tokens": 2,
},
}
)
monkeypatch.setattr(provider.client.chat.completions, "create", fake_create)
payloads = {
"model": "gpt-4o-mini",
"messages": [
{"role": "user", "content": "hello"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call-123",
"type": "function",
"function": {"name": "test", "arguments": "{}"},
}
],
},
{"role": "user", "content": "world"},
],
}
await provider._query(payloads=payloads, tools=None)
# The assistant message with tool_calls should be kept but content set to None
messages = captured_kwargs["messages"]
assert len(messages) == 3
assert messages[1]["role"] == "assistant"
assert messages[1]["content"] is None
assert messages[1]["tool_calls"] is not None
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_query_keeps_valid_assistant_message_with_content(monkeypatch):
"""Test that valid assistant messages with content are kept."""
provider = _make_provider()
try:
captured_kwargs = {}
async def fake_create(**kwargs):
captured_kwargs.update(kwargs)
return ChatCompletion.model_validate(
{
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 0,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "ok",
},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 1,
"completion_tokens": 1,
"total_tokens": 2,
},
}
)
monkeypatch.setattr(provider.client.chat.completions, "create", fake_create)
payloads = {
"model": "gpt-4o-mini",
"messages": [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "response"},
{"role": "user", "content": "world"},
],
}
await provider._query(payloads=payloads, tools=None)
# All messages should be kept
messages = captured_kwargs["messages"]
assert len(messages) == 3
assert messages[1] == {"role": "assistant", "content": "response"}
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_query_keeps_assistant_message_with_tool_calls_and_none_content(
monkeypatch,
):
"""Test that assistant messages with tool_calls and None content are kept."""
provider = _make_provider()
try:
captured_kwargs = {}
async def fake_create(**kwargs):
captured_kwargs.update(kwargs)
return ChatCompletion.model_validate(
{
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 0,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "ok",
},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 1,
"completion_tokens": 1,
"total_tokens": 2,
},
}
)
monkeypatch.setattr(provider.client.chat.completions, "create", fake_create)
payloads = {
"model": "gpt-4o-mini",
"messages": [
{"role": "user", "content": "hello"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call-123",
"type": "function",
"function": {"name": "test", "arguments": "{}"},
}
],
},
{"role": "user", "content": "world"},
],
}
await provider._query(payloads=payloads, tools=None)
# The assistant message with tool_calls should be kept
messages = captured_kwargs["messages"]
assert len(messages) == 3
assert messages[1]["role"] == "assistant"
assert messages[1]["content"] is None
assert messages[1]["tool_calls"] is not None
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_query_does_not_filter_user_or_system_messages(monkeypatch):
"""Test that user and system messages are not affected by the filter."""
provider = _make_provider()
try:
captured_kwargs = {}
async def fake_create(**kwargs):
captured_kwargs.update(kwargs)
return ChatCompletion.model_validate(
{
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 0,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "ok",
},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 1,
"completion_tokens": 1,
"total_tokens": 2,
},
}
)
monkeypatch.setattr(provider.client.chat.completions, "create", fake_create)
payloads = {
"model": "gpt-4o-mini",
"messages": [
{"role": "system", "content": ""}, # Empty system message
{"role": "user", "content": ""}, # Empty user message
{"role": "assistant", "content": ""}, # Should be filtered
{"role": "user", "content": "hello"},
],
}
await provider._query(payloads=payloads, tools=None)
# Only assistant message should be filtered
messages = captured_kwargs["messages"]
assert len(messages) == 3
assert messages[0] == {"role": "system", "content": ""}
assert messages[1] == {"role": "user", "content": ""}
assert messages[2] == {"role": "user", "content": "hello"}
finally:
await provider.terminate()

View File

@@ -1,305 +0,0 @@
"""
Unit tests for knowledge base manager resilience behavior.
Tests the following scenarios:
1. update_kb preserves old instance when re-initialization fails
2. update_kb switches instance only after new instance initializes successfully
3. _ensure_vec_db clears stale init_error after successful initialization
These tests use lazy imports and mocks to avoid circular import issues
in the astrbot core module chain.
"""
import sys
import types
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@pytest.fixture
def stub_provider_manager_module():
"""Stub provider manager module to avoid circular imports in unit tests."""
original_module = sys.modules.get("astrbot.core.provider.manager")
stub_module = types.ModuleType("astrbot.core.provider.manager")
class ProviderManager: ...
setattr(stub_module, "ProviderManager", ProviderManager)
sys.modules["astrbot.core.provider.manager"] = stub_module
try:
yield
finally:
if original_module is not None:
sys.modules["astrbot.core.provider.manager"] = original_module
else:
sys.modules.pop("astrbot.core.provider.manager", None)
@pytest.fixture
def mock_provider_manager():
"""Create a mock ProviderManager."""
manager = MagicMock()
manager.get_provider_by_id = AsyncMock()
manager.acm = MagicMock()
manager.acm.default_conf = {}
return manager
@pytest.fixture
def mock_kb_db():
"""Create a mock KBSQLiteDatabase."""
db = MagicMock()
db.get_db = MagicMock()
db.list_kbs = AsyncMock(return_value=[])
db.get_kb_by_id = AsyncMock()
return db
@pytest.fixture
def mock_knowledge_base():
"""Create a mock KnowledgeBase instance."""
# Use lazy import to avoid circular import
from astrbot.core.knowledge_base.models import KnowledgeBase
kb = KnowledgeBase(
kb_name="test_kb",
description="Test knowledge base",
emoji="📚",
embedding_provider_id="test-embedding-provider",
rerank_provider_id=None,
chunk_size=512,
chunk_overlap=50,
top_k_dense=50,
top_k_sparse=50,
top_m_final=5,
)
return kb
@pytest.fixture
def mock_embedding_provider():
"""Create a mock EmbeddingProvider."""
provider = MagicMock()
provider.get_embeddings_batch = AsyncMock(return_value=[[0.1, 0.2, 0.3]])
return provider
@pytest.mark.asyncio
async def test_update_kb_preserves_old_instance_when_reinit_fails(
stub_provider_manager_module,
mock_provider_manager,
mock_kb_db,
mock_knowledge_base,
mock_embedding_provider,
):
"""
Test that update_kb preserves the old KBHelper instance when
re-initialization fails, ensuring the knowledge base remains available.
"""
# Lazy import to avoid circular import
from astrbot.core.knowledge_base.kb_helper import KBHelper
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
# Setup: create an existing KBHelper with working vec_db
mock_provider_manager.get_provider_by_id.return_value = mock_embedding_provider
# Create KBHelper using __new__ to avoid __init__ side effects
old_helper = KBHelper.__new__(KBHelper)
old_helper.kb = mock_knowledge_base
old_helper.prov_mgr = mock_provider_manager
old_helper.kb_db = mock_kb_db
old_helper.kb_root_dir = "/tmp/test_kb"
old_helper.chunker = MagicMock()
old_helper.init_error = None
old_helper.vec_db = MagicMock() # Simulate existing working vec_db
old_helper.terminate = AsyncMock()
# Create KBManager and inject the existing helper
kb_mgr = KnowledgeBaseManager.__new__(KnowledgeBaseManager)
kb_mgr.provider_manager = mock_provider_manager
kb_mgr.kb_db = mock_kb_db
kb_mgr.kb_insts = {mock_knowledge_base.kb_id: old_helper}
kb_mgr.retrieval_manager = MagicMock()
# Mock KBHelper creation to simulate initialization failure
with patch.object(KBHelper, "initialize", new_callable=AsyncMock) as mock_init:
# First call (for new_helper) should fail
mock_init.side_effect = Exception("Embedding provider unavailable")
# Execute update_kb with a different embedding provider
result = await kb_mgr.update_kb(
kb_id=mock_knowledge_base.kb_id,
kb_name="updated_kb",
embedding_provider_id="new-embedding-provider",
)
# Verify: the old helper should be returned, not a new one
assert result is not None
assert result is old_helper
assert kb_mgr.kb_insts[mock_knowledge_base.kb_id] is old_helper
# Verify: old helper's vec_db should still be available
assert hasattr(result, "vec_db")
assert result.vec_db is not None
# Verify: failure does not replace the existing helper state
assert result.init_error is None
assert result.kb.kb_name == "test_kb"
assert result.kb.embedding_provider_id == "test-embedding-provider"
@pytest.mark.asyncio
async def test_update_kb_switches_instance_only_after_new_reinit_success(
stub_provider_manager_module,
mock_provider_manager,
mock_kb_db,
mock_knowledge_base,
mock_embedding_provider,
):
"""
Test that update_kb only switches to the new KBHelper instance
after the new instance successfully initializes.
"""
# Lazy import to avoid circular import
from astrbot.core.knowledge_base.kb_helper import KBHelper
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
# Setup: create an existing KBHelper
mock_provider_manager.get_provider_by_id.return_value = mock_embedding_provider
old_helper = KBHelper.__new__(KBHelper)
old_helper.kb = mock_knowledge_base
old_helper.prov_mgr = mock_provider_manager
old_helper.kb_db = mock_kb_db
old_helper.kb_root_dir = "/tmp/test_kb"
old_helper.chunker = MagicMock()
old_helper.init_error = None
old_helper.vec_db = MagicMock()
old_helper.terminate = AsyncMock()
kb_mgr = KnowledgeBaseManager.__new__(KnowledgeBaseManager)
kb_mgr.provider_manager = mock_provider_manager
kb_mgr.kb_db = mock_kb_db
kb_mgr.kb_insts = {mock_knowledge_base.kb_id: old_helper}
kb_mgr.retrieval_manager = MagicMock()
# Mock session context for database operations
mock_session = MagicMock()
mock_session.add = MagicMock()
mock_session.commit = AsyncMock()
mock_session.refresh = AsyncMock()
mock_db_context = MagicMock()
mock_db_context.__aenter__ = AsyncMock(return_value=mock_session)
mock_db_context.__aexit__ = AsyncMock()
mock_kb_db.get_db.return_value = mock_db_context
# Mock KBHelper.initialize to succeed
with patch.object(KBHelper, "initialize", new_callable=AsyncMock) as mock_init:
mock_init.return_value = None
# Execute update_kb
result = await kb_mgr.update_kb(
kb_id=mock_knowledge_base.kb_id,
kb_name="updated_kb",
embedding_provider_id="new-embedding-provider",
)
# Verify: a new helper should be returned
assert result is not None
assert result is not old_helper
assert result.init_error is None
assert kb_mgr.kb_insts[mock_knowledge_base.kb_id] is result
# Verify: old helper should be terminated
old_helper.terminate.assert_called_once()
@pytest.mark.asyncio
async def test_ensure_vec_db_clears_stale_init_error(
stub_provider_manager_module,
mock_provider_manager,
mock_kb_db,
mock_knowledge_base,
mock_embedding_provider,
):
"""
Test that _ensure_vec_db clears the init_error attribute
after successful initialization, removing stale error state.
"""
# Lazy import to avoid circular import
from astrbot.core.knowledge_base.kb_helper import KBHelper
# Setup: create KBHelper with stale init_error
mock_provider_manager.get_provider_by_id.return_value = mock_embedding_provider
helper = KBHelper.__new__(KBHelper)
helper.kb = mock_knowledge_base
helper.prov_mgr = mock_provider_manager
helper.kb_db = mock_kb_db
helper.kb_root_dir = "/tmp/test_kb"
helper.chunker = MagicMock()
helper.init_error = "Previous initialization failed"
helper.kb_dir = Path("/tmp/test_kb") / mock_knowledge_base.kb_id
helper.kb_medias_dir = helper.kb_dir / "medias" / mock_knowledge_base.kb_id
helper.kb_files_dir = helper.kb_dir / "files" / mock_knowledge_base.kb_id
# Mock FaissVecDB initialization
mock_vec_db = MagicMock()
mock_vec_db.initialize = AsyncMock()
mock_vec_db.close = AsyncMock()
with patch(
"astrbot.core.knowledge_base.kb_helper.FaissVecDB",
return_value=mock_vec_db,
):
# Execute _ensure_vec_db
await helper._ensure_vec_db()
# Verify: init_error should be cleared
assert helper.init_error is None
assert helper.vec_db is mock_vec_db
@pytest.mark.asyncio
async def test_ensure_vec_db_sets_init_error_on_failure(
stub_provider_manager_module,
mock_provider_manager,
mock_kb_db,
mock_knowledge_base,
):
"""
Test that _ensure_vec_db does NOT clear init_error when
initialization fails, preserving the error state.
"""
# Lazy import to avoid circular import
from astrbot.core.knowledge_base.kb_helper import KBHelper
# Setup: provider unavailable
mock_provider_manager.get_provider_by_id.return_value = None
helper = KBHelper.__new__(KBHelper)
helper.kb = mock_knowledge_base
helper.prov_mgr = mock_provider_manager
helper.kb_db = mock_kb_db
helper.kb_root_dir = "/tmp/test_kb"
helper.chunker = MagicMock()
helper.init_error = "Previous initialization failed"
helper.kb_dir = Path("/tmp/test_kb") / mock_knowledge_base.kb_id
helper.kb_medias_dir = helper.kb_dir / "medias" / mock_knowledge_base.kb_id
helper.kb_files_dir = helper.kb_dir / "files" / mock_knowledge_base.kb_id
# Execute _ensure_vec_db - should raise exception
try:
await helper._ensure_vec_db()
pytest.fail("Expected exception but none was raised")
except ValueError as e:
# Verify: exception should be raised
assert "无法找到" in str(e) or "未配置" in str(e)
# Verify: init_error should NOT be cleared (still has previous error)
# Note: _ensure_vec_db doesn't set init_error; that's done by the caller
assert helper.init_error is not None