mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-04 19:50:16 +08:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d612c9eed4 | ||
|
|
12039fe7d0 | ||
|
|
5886c43752 | ||
|
|
88d70a8013 | ||
|
|
9d4472cb2d | ||
|
|
e8d6938d31 | ||
|
|
206973e8ad | ||
|
|
0ecddb4c06 | ||
|
|
2de23184d0 | ||
|
|
4d2791aa9a | ||
|
|
3dd7799f27 | ||
|
|
deedf85360 | ||
|
|
4d9dce184f | ||
|
|
788d103a36 | ||
|
|
328748bd63 |
@@ -12,6 +12,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
bash \
|
||||
ffmpeg \
|
||||
libavcodec-extra \
|
||||
curl \
|
||||
gnupg \
|
||||
git \
|
||||
|
||||
@@ -92,6 +92,9 @@ 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.
|
||||
@@ -184,6 +187,7 @@ 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 |
|
||||
@@ -193,6 +197,7 @@ 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
|
||||
|
||||
@@ -92,6 +92,9 @@ 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.
|
||||
@@ -184,6 +187,7 @@ 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 |
|
||||
@@ -193,6 +197,7 @@ 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
|
||||
|
||||
@@ -92,6 +92,9 @@ astrbot run
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> `uv` 経由でデプロイした AstrBot は、**WebUI からのバージョンアップグレードに対応していません**。更新するには、上記のコマンドをコマンドラインで実行してください。
|
||||
|
||||
### Docker デプロイ
|
||||
|
||||
コンテナ運用に慣れており、より安定した本番向けのデプロイ方法を求めるユーザーには、Docker / Docker Compose での AstrBot デプロイをおすすめします。
|
||||
@@ -185,6 +188,7 @@ AstrBot をよく使うチャットプラットフォームに接続できます
|
||||
| Coze | LLMOps プラットフォーム |
|
||||
| OpenAI Whisper | 音声認識サービス |
|
||||
| SenseVoice | 音声認識サービス |
|
||||
| Xiaomi MiMo Omni | 音声認識サービス |
|
||||
| OpenAI TTS | 音声合成サービス |
|
||||
| Gemini TTS | 音声合成サービス |
|
||||
| GPT-Sovits-Inference | 音声合成サービス |
|
||||
@@ -194,6 +198,7 @@ AstrBot をよく使うチャットプラットフォームに接続できます
|
||||
| Alibaba Cloud 百炼 TTS | 音声合成サービス |
|
||||
| Azure TTS | 音声合成サービス |
|
||||
| Minimax TTS | 音声合成サービス |
|
||||
| Xiaomi MiMo TTS | 音声合成サービス |
|
||||
| Volcano Engine TTS | 音声合成サービス |
|
||||
|
||||
## ❤️ コントリビューション
|
||||
|
||||
@@ -92,6 +92,9 @@ astrbot run
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> AstrBot, развёрнутый через `uv`, **не поддерживает обновление через WebUI**. Для обновления выполните указанную выше команду из командной строки.
|
||||
|
||||
### Развёртывание Docker
|
||||
|
||||
Для пользователей, знакомых с контейнерами и которым нужен более стабильный и подходящий для production способ, мы рекомендуем разворачивать AstrBot через Docker / Docker Compose.
|
||||
@@ -184,6 +187,7 @@ yay -S astrbot-git
|
||||
| Coze | Платформы LLMOps |
|
||||
| OpenAI Whisper | Сервисы распознавания речи |
|
||||
| SenseVoice | Сервисы распознавания речи |
|
||||
| Xiaomi MiMo Omni | Сервисы распознавания речи |
|
||||
| OpenAI TTS | Сервисы синтеза речи |
|
||||
| Gemini TTS | Сервисы синтеза речи |
|
||||
| GPT-Sovits-Inference | Сервисы синтеза речи |
|
||||
@@ -193,6 +197,7 @@ yay -S astrbot-git
|
||||
| Alibaba Cloud Bailian TTS | Сервисы синтеза речи |
|
||||
| Azure TTS | Сервисы синтеза речи |
|
||||
| Minimax TTS | Сервисы синтеза речи |
|
||||
| Xiaomi MiMo TTS | Сервисы синтеза речи |
|
||||
| Volcano Engine TTS | Сервисы синтеза речи |
|
||||
|
||||
## ❤️ Вклад в проект
|
||||
|
||||
@@ -92,6 +92,9 @@ astrbot run
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> 透過 `uv` 部署的 AstrBot **不支援在 WebUI 中進行版本升級**。如需更新,請透過命令列執行上述命令。
|
||||
|
||||
### Docker 部署
|
||||
|
||||
對於熟悉容器、希望獲得更穩定且更適合正式環境部署方式的使用者,我們推薦使用 Docker / Docker Compose 部署 AstrBot。
|
||||
@@ -184,6 +187,7 @@ yay -S astrbot-git
|
||||
| Coze | LLMOps 平台 |
|
||||
| OpenAI Whisper | 語音轉文字服務 |
|
||||
| SenseVoice | 語音轉文字服務 |
|
||||
| Xiaomi MiMo Omni | 語音轉文字服務 |
|
||||
| OpenAI TTS | 文字轉語音服務 |
|
||||
| Gemini TTS | 文字轉語音服務 |
|
||||
| GPT-Sovits-Inference | 文字轉語音服務 |
|
||||
@@ -193,6 +197,7 @@ yay -S astrbot-git
|
||||
| 阿里雲百煉 TTS | 文字轉語音服務 |
|
||||
| Azure TTS | 文字轉語音服務 |
|
||||
| Minimax TTS | 文字轉語音服務 |
|
||||
| Xiaomi MiMo TTS | 文字轉語音服務 |
|
||||
| 火山引擎 TTS | 文字轉語音服務 |
|
||||
|
||||
## ❤️ 貢獻
|
||||
|
||||
@@ -92,6 +92,9 @@ astrbot run
|
||||
uv tool upgrade astrbot
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> 通过 `uv` 部署的 AstrBot **不支持在 WebUI 中进行版本升级**。如需更新,请通过命令行执行上述命令。
|
||||
|
||||
### Docker 部署
|
||||
|
||||
对于熟悉容器、希望获得更稳定且更适合生产环境部署方式的用户,我们推荐使用 Docker / Docker Compose 部署 AstrBot。
|
||||
@@ -185,6 +188,7 @@ yay -S astrbot-git
|
||||
| Coze | LLMOps 平台 |
|
||||
| OpenAI Whisper | 语音转文本 |
|
||||
| SenseVoice | 语音转文本 |
|
||||
| Xiaomi MiMo Omni | 语音转文本 |
|
||||
| OpenAI TTS | 文本转语音 |
|
||||
| Gemini TTS | 文本转语音 |
|
||||
| GPT-Sovits-Inference | 文本转语音 |
|
||||
@@ -194,6 +198,7 @@ yay -S astrbot-git
|
||||
| 阿里云百炼 TTS | 文本转语音 |
|
||||
| Azure TTS | 文本转语音 |
|
||||
| Minimax TTS | 文本转语音 |
|
||||
| Xiaomi MiMo TTS | 文本转语音 |
|
||||
| 火山引擎 TTS | 文本转语音 |
|
||||
|
||||
## ❤️ 贡献
|
||||
|
||||
@@ -15,7 +15,6 @@ 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
|
||||
|
||||
@@ -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": "HTTP 代理",
|
||||
"description": "代理",
|
||||
"type": "string",
|
||||
"hint": "启用后,会以添加环境变量的方式设置代理。格式为 `http://ip:port`",
|
||||
"hint": "启用后,会以添加环境变量的方式设置代理。支持 http://、https://、socks5:// 格式,例如:http://127.0.0.1:7890 或 socks5://127.0.0.1:7891",
|
||||
},
|
||||
"no_proxy": {
|
||||
"description": "直连地址列表",
|
||||
|
||||
@@ -108,6 +108,7 @@ Text chunk to process:
|
||||
class KBHelper:
|
||||
vec_db: BaseVecDB
|
||||
kb: KnowledgeBase
|
||||
init_error: str | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -122,6 +123,7 @@ 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
|
||||
@@ -148,13 +150,14 @@ class KBHelper:
|
||||
async def get_rp(self) -> RerankProvider | None:
|
||||
if not self.kb.rerank_provider_id:
|
||||
return None
|
||||
rp: RerankProvider = await self.prov_mgr.get_provider_by_id(
|
||||
rp: RerankProvider | None = await self.prov_mgr.get_provider_by_id(
|
||||
self.kb.rerank_provider_id,
|
||||
) # type: ignore
|
||||
if not rp:
|
||||
raise ValueError(
|
||||
f"无法找到 ID 为 {self.kb.rerank_provider_id} 的 Rerank Provider",
|
||||
logger.warning(
|
||||
f"知识库 {self.kb.kb_name}({self.kb.kb_id}) 的 Rerank Provider({self.kb.rerank_provider_id}) 不可用,将跳过重排序。",
|
||||
)
|
||||
return None
|
||||
return rp
|
||||
|
||||
async def _ensure_vec_db(self) -> FaissVecDB:
|
||||
@@ -162,7 +165,13 @@ class KBHelper:
|
||||
raise ValueError(f"知识库 {self.kb.kb_name} 未配置 Embedding Provider")
|
||||
|
||||
ep = await self.get_ep()
|
||||
rp = await self.get_rp()
|
||||
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}",
|
||||
)
|
||||
|
||||
vec_db = FaissVecDB(
|
||||
doc_store_path=str(self.kb_dir / "doc.db"),
|
||||
@@ -172,6 +181,8 @@ 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:
|
||||
@@ -183,7 +194,7 @@ class KBHelper:
|
||||
shutil.rmtree(self.kb_dir)
|
||||
|
||||
async def terminate(self) -> None:
|
||||
if self.vec_db:
|
||||
if hasattr(self, "vec_db") and self.vec_db:
|
||||
await self.vec_db.close()
|
||||
|
||||
async def upload_document(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
from astrbot.core import logger
|
||||
@@ -56,8 +55,7 @@ class KnowledgeBaseManager:
|
||||
logger.error(f"知识库模块导入失败: {e}")
|
||||
logger.warning("请确保已安装所需依赖: pypdf, aiofiles, Pillow, rank-bm25")
|
||||
except Exception as e:
|
||||
logger.error(f"知识库模块初始化失败: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
logger.error(f"知识库模块初始化失败: {e}", exc_info=True)
|
||||
|
||||
async def _init_kb_database(self) -> None:
|
||||
self.kb_db = KBSQLiteDatabase(DB_PATH.as_posix())
|
||||
@@ -76,7 +74,14 @@ class KnowledgeBaseManager:
|
||||
kb_root_dir=FILES_PATH,
|
||||
chunker=CHUNKER,
|
||||
)
|
||||
await kb_helper.initialize()
|
||||
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,
|
||||
)
|
||||
self.kb_insts[record.kb_id] = kb_helper
|
||||
|
||||
async def create_kb(
|
||||
@@ -179,6 +184,20 @@ 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:
|
||||
@@ -198,12 +217,47 @@ 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)
|
||||
|
||||
return kb_helper
|
||||
old_helper = kb_helper
|
||||
self.kb_insts[kb_id] = new_helper
|
||||
await old_helper.terminate()
|
||||
new_helper.init_error = None
|
||||
return new_helper
|
||||
|
||||
async def retrieve(
|
||||
self,
|
||||
@@ -215,11 +269,21 @@ 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 {}
|
||||
|
||||
|
||||
@@ -184,12 +184,15 @@ class RetrievalManager:
|
||||
first_rerank = vec_db.rerank_provider
|
||||
break
|
||||
if first_rerank and retrieval_results:
|
||||
retrieval_results = await self._rerank(
|
||||
query=query,
|
||||
results=retrieval_results,
|
||||
top_k=top_m_final,
|
||||
rerank_provider=first_rerank,
|
||||
)
|
||||
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}")
|
||||
|
||||
return retrieval_results[:top_m_final]
|
||||
|
||||
@@ -229,10 +232,10 @@ class RetrievalManager:
|
||||
|
||||
all_results.extend(vec_results)
|
||||
except Exception as e:
|
||||
from astrbot.core import logger
|
||||
|
||||
logger.warning(f"知识库 {kb_id} 稠密检索失败: {e}")
|
||||
continue
|
||||
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
|
||||
|
||||
# 按相似度排序并返回 top_k
|
||||
all_results.sort(key=lambda x: x.similarity, reverse=True)
|
||||
|
||||
@@ -35,11 +35,11 @@ class DiscordBotClient(discord.Bot):
|
||||
async def on_ready(self) -> None:
|
||||
"""当机器人成功连接并准备就绪时触发"""
|
||||
if self.user is None:
|
||||
logger.error("[Discord] 客户端未正确加载用户信息 (self.user is None)")
|
||||
logger.error("[Discord] Bot user not loaded correctly (self.user is None)")
|
||||
return
|
||||
|
||||
logger.info(f"[Discord] 已作为 {self.user} (ID: {self.user.id}) 登录")
|
||||
logger.info("[Discord] 客户端已准备就绪。")
|
||||
logger.info(f"[Discord] Logged in as {self.user} (ID: {self.user.id})")
|
||||
logger.info("[Discord] Client is ready.")
|
||||
|
||||
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] on_ready_once_callback 执行失败: {e}",
|
||||
f"[Discord] Failed to execute on_ready_once_callback: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
@@ -99,7 +99,7 @@ class DiscordBotClient(discord.Bot):
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
f"[Discord] 收到原始消息 from {message.author.name}: {message.content}",
|
||||
f"[Discord] Received raw message from {message.author.name}: {message.content}",
|
||||
)
|
||||
|
||||
if self.on_message_received:
|
||||
|
||||
@@ -46,7 +46,7 @@ class DiscordPlatformAdapter(Platform):
|
||||
) -> None:
|
||||
super().__init__(platform_config, event_queue)
|
||||
self.settings = platform_settings
|
||||
self.client_self_id: str | None = None
|
||||
self.bot_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] 客户端未就绪 (self.client.user is None),无法发送消息"
|
||||
"[Discord] Client is not ready (self.client.user is None); message send skipped"
|
||||
)
|
||||
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.client_self_id),
|
||||
user_id=str(self.bot_self_id),
|
||||
nickname=self.client.user.display_name,
|
||||
)
|
||||
message_obj.self_id = cast(str, self.client_self_id)
|
||||
message_obj.self_id = cast(str, self.bot_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 适配器",
|
||||
"Discord Adapter",
|
||||
id=cast(str, self.config.get("id")),
|
||||
default_config_tmpl=self.config,
|
||||
support_streaming_message=False,
|
||||
@@ -127,16 +127,18 @@ class DiscordPlatformAdapter(Platform):
|
||||
|
||||
# 初始化回调函数
|
||||
async def on_received(message_data) -> None:
|
||||
logger.debug(f"[Discord] 收到消息: {message_data}")
|
||||
if self.client_self_id is None:
|
||||
self.client_self_id = message_data.get("bot_id")
|
||||
logger.debug(f"[Discord] Message received: {message_data}")
|
||||
if self.bot_self_id is None:
|
||||
self.bot_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 未配置。请在配置文件中正确设置 token。")
|
||||
logger.error(
|
||||
"[Discord] Bot token is not configured. Please set a valid token in the config file."
|
||||
)
|
||||
return
|
||||
|
||||
proxy = self.config.get("discord_proxy") or None
|
||||
@@ -144,12 +146,17 @@ class DiscordPlatformAdapter(Platform):
|
||||
self.client.on_message_received = on_received
|
||||
|
||||
async def callback() -> None:
|
||||
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),
|
||||
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
|
||||
)
|
||||
|
||||
self.client.on_ready_once_callback = callback
|
||||
@@ -158,11 +165,16 @@ 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] 登录失败。请检查你的 Bot Token 是否正确。")
|
||||
logger.error(
|
||||
"[Discord] Login failed. Please check whether the bot token is correct."
|
||||
)
|
||||
except discord.errors.ConnectionClosed:
|
||||
logger.warning("[Discord] 与 Discord 的连接已关闭。")
|
||||
logger.warning("[Discord] Connection with Discord has been closed.")
|
||||
except Exception as e:
|
||||
logger.error(f"[Discord] 适配器运行时发生意外错误: {e}", exc_info=True)
|
||||
logger.error(
|
||||
f"[Discord] Unexpected error while adapter is running: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def _get_message_type(
|
||||
self,
|
||||
@@ -241,7 +253,7 @@ class DiscordPlatformAdapter(Platform):
|
||||
)
|
||||
abm.message = message_chain
|
||||
abm.raw_message = message
|
||||
abm.self_id = cast(str, self.client_self_id)
|
||||
abm.self_id = cast(str, self.bot_self_id)
|
||||
abm.session_id = str(message.channel.id)
|
||||
abm.message_id = str(message.id)
|
||||
return abm
|
||||
@@ -264,7 +276,7 @@ class DiscordPlatformAdapter(Platform):
|
||||
|
||||
if self.client.user is None:
|
||||
logger.error(
|
||||
"[Discord] 客户端未就绪 (self.client.user is None),无法处理消息"
|
||||
"[Discord] Client is not ready (self.client.user is None); message handling skipped"
|
||||
)
|
||||
return
|
||||
|
||||
@@ -283,7 +295,7 @@ class DiscordPlatformAdapter(Platform):
|
||||
raw_message = message.raw_message
|
||||
if not isinstance(raw_message, discord.Message):
|
||||
logger.warning(
|
||||
f"[Discord] 收到非 Message 类型的消息: {type(raw_message)},已忽略。"
|
||||
f"[Discord] Non-Message type received and ignored: {type(raw_message)}"
|
||||
)
|
||||
return
|
||||
|
||||
@@ -324,20 +336,9 @@ class DiscordPlatformAdapter(Platform):
|
||||
|
||||
@override
|
||||
async def terminate(self) -> None:
|
||||
"""终止适配器"""
|
||||
logger.info("[Discord] 正在终止适配器... (step 1: cancel polling task)")
|
||||
logger.info("[Discord] Shutting down adapter...")
|
||||
self.shutdown_event.set()
|
||||
# 优先 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)")
|
||||
# 清理指令
|
||||
logger.info("[Discord] Cleaning up commands...")
|
||||
if self.enable_command_register and self.client:
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
@@ -347,16 +348,29 @@ class DiscordPlatformAdapter(Platform):
|
||||
),
|
||||
timeout=10,
|
||||
)
|
||||
logger.info("[Discord] 指令清理完成。")
|
||||
logger.info("[Discord] Commands cleaned up successfully.")
|
||||
except Exception as e:
|
||||
logger.error(f"[Discord] 清理指令时发生错误: {e}", exc_info=True)
|
||||
logger.info("[Discord] 正在关闭 Discord 客户端... (step 3)")
|
||||
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...")
|
||||
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] 客户端关闭异常: {e}")
|
||||
logger.info("[Discord] 适配器已终止。")
|
||||
logger.warning(f"[Discord] Error occurred while closing client: {e}")
|
||||
logger.info("[Discord] Adapter shutdown complete.")
|
||||
|
||||
def register_handler(self, handler_info) -> None:
|
||||
"""注册处理器信息"""
|
||||
@@ -364,7 +378,7 @@ class DiscordPlatformAdapter(Platform):
|
||||
|
||||
async def _collect_and_register_commands(self) -> None:
|
||||
"""收集所有指令并注册到Discord"""
|
||||
logger.info("[Discord] 开始收集并注册斜杠指令...")
|
||||
logger.info("[Discord] Collecting and registering slash commands...")
|
||||
registered_commands = []
|
||||
|
||||
for handler_md in star_handlers_registry:
|
||||
@@ -405,15 +419,15 @@ class DiscordPlatformAdapter(Platform):
|
||||
|
||||
if registered_commands:
|
||||
logger.info(
|
||||
f"[Discord] 准备同步 {len(registered_commands)} 个指令: {', '.join(registered_commands)}",
|
||||
f"[Discord] Ready to sync {len(registered_commands)} commands: {', '.join(registered_commands)}",
|
||||
)
|
||||
else:
|
||||
logger.info("[Discord] 没有发现可注册的指令。")
|
||||
logger.info("[Discord] No commands found for registration.")
|
||||
|
||||
# 使用 Pycord 的方法同步指令
|
||||
# 注意:这可能需要一些时间,并且有频率限制
|
||||
await self.client.sync_commands()
|
||||
logger.info("[Discord] 指令同步完成。")
|
||||
logger.info("[Discord] Command synchronization completed.")
|
||||
|
||||
def _create_dynamic_callback(self, cmd_name: str):
|
||||
"""为每个指令动态创建一个异步回调函数"""
|
||||
@@ -422,17 +436,17 @@ class DiscordPlatformAdapter(Platform):
|
||||
ctx: discord.ApplicationContext, params: str | None = None
|
||||
) -> None:
|
||||
# 将平台特定的前缀'/'剥离,以适配通用的CommandFilter
|
||||
logger.debug(f"[Discord] 回调函数触发: {cmd_name}")
|
||||
logger.debug(f"[Discord] 回调函数参数: {ctx}")
|
||||
logger.debug(f"[Discord] 回调函数参数: {params}")
|
||||
logger.debug(f"[Discord] Callback triggered: {cmd_name}")
|
||||
logger.debug(f"[Discord] Callback context: {ctx}")
|
||||
logger.debug(f"[Discord] Callback params: {params}")
|
||||
message_str_for_filter = cmd_name
|
||||
if params:
|
||||
message_str_for_filter += f" {params}"
|
||||
|
||||
logger.debug(
|
||||
f"[Discord] 斜杠指令 '{cmd_name}' 被触发。 "
|
||||
f"原始参数: '{params}'. "
|
||||
f"构建的指令字符串: '{message_str_for_filter}'",
|
||||
f"[Discord] Slash command '{cmd_name}' triggered. "
|
||||
f"Raw params: '{params}'. "
|
||||
f"Built command string: '{message_str_for_filter}'",
|
||||
)
|
||||
|
||||
# 尝试立即响应,防止超时
|
||||
@@ -441,7 +455,7 @@ class DiscordPlatformAdapter(Platform):
|
||||
await ctx.defer()
|
||||
followup_webhook = ctx.followup
|
||||
except Exception as e:
|
||||
logger.warning(f"[Discord] 指令 '{cmd_name}' defer 失败: {e}")
|
||||
logger.warning(f"[Discord] Failed to defer command '{cmd_name}': {e}")
|
||||
|
||||
# 2. 构建 AstrBotMessage
|
||||
channel = ctx.channel
|
||||
@@ -465,7 +479,7 @@ class DiscordPlatformAdapter(Platform):
|
||||
)
|
||||
abm.message = [Plain(text=message_str_for_filter)]
|
||||
abm.raw_message = ctx.interaction
|
||||
abm.self_id = cast(str, self.client_self_id)
|
||||
abm.self_id = cast(str, self.bot_self_id)
|
||||
abm.session_id = str(ctx.channel_id)
|
||||
abm.message_id = str(ctx.interaction.id)
|
||||
|
||||
@@ -503,10 +517,10 @@ class DiscordPlatformAdapter(Platform):
|
||||
|
||||
# Discord 斜杠指令名称规范
|
||||
if not re.match(r"^[a-z0-9_-]{1,32}$", cmd_name):
|
||||
logger.debug(f"[Discord] 跳过不符合规范的指令: {cmd_name}")
|
||||
logger.debug(f"[Discord] Skipping invalid slash command format: {cmd_name}")
|
||||
return None
|
||||
|
||||
description = handler_metadata.desc or f"指令: {cmd_name}"
|
||||
description = handler_metadata.desc or f"Command: {cmd_name}"
|
||||
if len(description) > 100:
|
||||
description = f"{description[:97]}..."
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ class MisskeyPlatformAdapter(Platform):
|
||||
|
||||
self.api: MisskeyAPI | None = None
|
||||
self._running = False
|
||||
self.client_self_id = ""
|
||||
self.bot_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.client_self_id = str(user_info.get("id", ""))
|
||||
self.bot_self_id = str(user_info.get("id", ""))
|
||||
self._bot_username = user_info.get("username", "")
|
||||
logger.info(
|
||||
f"[Misskey] 已连接用户: {self._bot_username} (ID: {self.client_self_id})",
|
||||
f"[Misskey] 已连接用户: {self._bot_username} (ID: {self.bot_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.client_self_id}",
|
||||
f"[Misskey] 收到聊天事件: sender_id={sender_id}, room_id={room_id}, is_self={sender_id == self.bot_self_id}",
|
||||
)
|
||||
if sender_id == self.client_self_id:
|
||||
if sender_id == self.bot_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.client_self_id in [str(uid) for uid in mentions]:
|
||||
if self.bot_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.client_self_id:
|
||||
if reply_user_id == self.bot_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.client_self_id,
|
||||
self_id=self.bot_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.client_self_id,
|
||||
self.bot_self_id,
|
||||
is_chat=False,
|
||||
)
|
||||
cache_user_info(
|
||||
self._user_cache,
|
||||
sender_info,
|
||||
raw_data,
|
||||
self.client_self_id,
|
||||
self.bot_self_id,
|
||||
is_chat=False,
|
||||
)
|
||||
|
||||
@@ -656,7 +656,7 @@ class MisskeyPlatformAdapter(Platform):
|
||||
message,
|
||||
raw_text,
|
||||
self._bot_username,
|
||||
self.client_self_id,
|
||||
self.bot_self_id,
|
||||
)
|
||||
message_parts.extend(text_parts)
|
||||
|
||||
@@ -685,14 +685,14 @@ class MisskeyPlatformAdapter(Platform):
|
||||
message = create_base_message(
|
||||
raw_data,
|
||||
sender_info,
|
||||
self.client_self_id,
|
||||
self.bot_self_id,
|
||||
is_chat=True,
|
||||
)
|
||||
cache_user_info(
|
||||
self._user_cache,
|
||||
sender_info,
|
||||
raw_data,
|
||||
self.client_self_id,
|
||||
self.bot_self_id,
|
||||
is_chat=True,
|
||||
)
|
||||
|
||||
@@ -713,7 +713,7 @@ class MisskeyPlatformAdapter(Platform):
|
||||
message = create_base_message(
|
||||
raw_data,
|
||||
sender_info,
|
||||
self.client_self_id,
|
||||
self.bot_self_id,
|
||||
is_chat=False,
|
||||
room_id=room_id,
|
||||
)
|
||||
@@ -722,10 +722,10 @@ class MisskeyPlatformAdapter(Platform):
|
||||
self._user_cache,
|
||||
sender_info,
|
||||
raw_data,
|
||||
self.client_self_id,
|
||||
self.bot_self_id,
|
||||
is_chat=False,
|
||||
)
|
||||
cache_room_info(self._user_cache, raw_data, self.client_self_id)
|
||||
cache_room_info(self._user_cache, raw_data, self.bot_self_id)
|
||||
|
||||
raw_text = raw_data.get("text", "")
|
||||
message_parts = []
|
||||
@@ -736,7 +736,7 @@ class MisskeyPlatformAdapter(Platform):
|
||||
message,
|
||||
raw_text,
|
||||
self._bot_username,
|
||||
self.client_self_id,
|
||||
self.bot_self_id,
|
||||
)
|
||||
message_parts.extend(text_parts)
|
||||
else:
|
||||
|
||||
@@ -335,7 +335,7 @@ def extract_sender_info(
|
||||
def create_base_message(
|
||||
raw_data: dict[str, Any],
|
||||
sender_info: dict[str, Any],
|
||||
client_self_id: str,
|
||||
bot_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 = client_self_id
|
||||
message.self_id = bot_self_id
|
||||
|
||||
return message
|
||||
|
||||
@@ -376,7 +376,7 @@ def process_at_mention(
|
||||
message: AstrBotMessage,
|
||||
raw_text: str,
|
||||
bot_username: str,
|
||||
client_self_id: str,
|
||||
bot_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=client_self_id))
|
||||
message.message.append(Comp.At(qq=bot_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],
|
||||
client_self_id: str,
|
||||
bot_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": [client_self_id, sender_info["sender_id"]],
|
||||
"visible_user_ids": [bot_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],
|
||||
client_self_id: str,
|
||||
bot_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": [client_self_id],
|
||||
"visible_user_ids": [bot_self_id],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -50,7 +50,6 @@ 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",
|
||||
@@ -336,6 +335,8 @@ 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))
|
||||
|
||||
@@ -148,7 +148,6 @@ 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/",
|
||||
|
||||
@@ -2,7 +2,6 @@ import asyncio
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any, cast
|
||||
|
||||
@@ -324,7 +323,6 @@ 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/",
|
||||
|
||||
@@ -142,7 +142,8 @@ class BailianRerankProvider(RerankProvider):
|
||||
f"百炼 API 错误: {data.get('code')} – {data.get('message', '')}"
|
||||
)
|
||||
|
||||
results = data.get("output", {}).get("results", [])
|
||||
# 兼容旧版 API (output.results) 和新版 compatible API (results)
|
||||
results = (data.get("output") or {}).get("results") or data.get("results") or []
|
||||
if not results:
|
||||
logger.warning(f"百炼 Rerank 返回空结果: {data}")
|
||||
return []
|
||||
|
||||
@@ -457,6 +457,27 @@ 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,
|
||||
@@ -589,7 +610,10 @@ 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
|
||||
prompt_tokens = getattr(usage, "prompt_tokens", 0) or 0
|
||||
cached = (
|
||||
cached if isinstance(cached, int) else 0
|
||||
) # ptd.cached_tokens 可能为None
|
||||
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
|
||||
|
||||
@@ -394,7 +394,6 @@ 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()
|
||||
|
||||
@@ -314,7 +314,12 @@ class KnowledgeBaseRoute(Route):
|
||||
# 转换为字典列表
|
||||
kb_list = []
|
||||
for kb in kbs:
|
||||
kb_list.append(kb.model_dump())
|
||||
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)
|
||||
|
||||
return (
|
||||
Response()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Auto-generated MDI subset – 256 icons */
|
||||
/* Auto-generated MDI subset – 255 icons */
|
||||
/* Do not edit manually. Run: pnpm run subset-icons */
|
||||
|
||||
@font-face {
|
||||
@@ -132,10 +132,6 @@
|
||||
content: "\F0B66";
|
||||
}
|
||||
|
||||
.mdi-calendar-clock-outline::before {
|
||||
content: "\F16E1";
|
||||
}
|
||||
|
||||
.mdi-calendar-edit::before {
|
||||
content: "\F08A7";
|
||||
}
|
||||
@@ -296,6 +292,10 @@
|
||||
content: "\F0193";
|
||||
}
|
||||
|
||||
.mdi-content-save-outline::before {
|
||||
content: "\F0818";
|
||||
}
|
||||
|
||||
.mdi-creation::before {
|
||||
content: "\F0674";
|
||||
}
|
||||
@@ -844,10 +844,6 @@
|
||||
content: "\F048A";
|
||||
}
|
||||
|
||||
.mdi-send-clock-outline::before {
|
||||
content: "\F1164";
|
||||
}
|
||||
|
||||
.mdi-server::before {
|
||||
content: "\F048B";
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -65,18 +65,26 @@ const authorDisplay = computed(() => {
|
||||
>
|
||||
<v-menu offset-y>
|
||||
<template #activator="{ props: menuProps }">
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<v-card>
|
||||
@@ -182,8 +190,7 @@ const authorDisplay = computed(() => {
|
||||
.pinned-card-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.pinned-item {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<template>
|
||||
<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>
|
||||
<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>
|
||||
<v-text-field
|
||||
v-model="modelSearchProxy"
|
||||
density="compact"
|
||||
@@ -11,37 +13,35 @@
|
||||
hide-details
|
||||
variant="solo-filled"
|
||||
flat
|
||||
class="ml-1"
|
||||
style="max-width: 240px;"
|
||||
class="provider-models-search"
|
||||
:placeholder="tm('models.searchPlaceholder')"
|
||||
/>
|
||||
<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 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>
|
||||
</div>
|
||||
|
||||
<v-list
|
||||
density="compact"
|
||||
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;"
|
||||
class="provider-models-list"
|
||||
>
|
||||
<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="text-caption text-grey d-flex align-center ga-1" style="font-family: monospace;">
|
||||
<v-list-item-subtitle class="provider-model-subtitle d-flex align-center ga-1">
|
||||
<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="text-caption text-grey d-flex align-center ga-1">
|
||||
<v-list-item-subtitle class="provider-model-subtitle 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,11 +231,87 @@ const isProviderTesting = (providerId) => props.testingProviders.includes(provid
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.border {
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
.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;
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
<template>
|
||||
<v-card class="provider-sources-panel h-100" elevation="0">
|
||||
<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 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>
|
||||
<StyledMenu>
|
||||
<template #activator="{ props }">
|
||||
@@ -11,7 +16,6 @@
|
||||
prepend-icon="mdi-plus"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
rounded="xl"
|
||||
size="small"
|
||||
>
|
||||
{{ tm('providerSources.add') }}
|
||||
@@ -34,7 +38,7 @@
|
||||
</StyledMenu>
|
||||
</div>
|
||||
|
||||
<div v-if="isMobile && displayedProviderSources.length > 0" class="px-4 pb-3">
|
||||
<div v-if="isMobile && displayedProviderSources.length > 0" class="provider-sources-mobile">
|
||||
<div class="d-flex align-center ga-2">
|
||||
<v-select
|
||||
:model-value="selectedId"
|
||||
@@ -71,7 +75,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="displayedProviderSources.length > 0">
|
||||
<div v-else-if="displayedProviderSources.length > 0" class="provider-sources-list-wrap">
|
||||
<v-list class="provider-source-list" nav density="compact" lines="two">
|
||||
<v-list-item
|
||||
v-for="source in displayedProviderSources"
|
||||
@@ -83,13 +87,13 @@
|
||||
@click="emitSelectSource(source)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-avatar size="32" class="bg-grey-lighten-4" rounded="0">
|
||||
<v-avatar size="28" class="provider-source-avatar" rounded="0">
|
||||
<v-img v-if="source?.provider" :src="resolveSourceIcon(source)" alt="logo" cover></v-img>
|
||||
<v-icon v-else size="32">mdi-creation</v-icon>
|
||||
<v-icon v-else size="20">mdi-creation</v-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
<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>
|
||||
<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>
|
||||
<template #append>
|
||||
<div class="d-flex align-center ga-1">
|
||||
<v-btn
|
||||
@@ -98,6 +102,7 @@
|
||||
variant="text"
|
||||
size="x-small"
|
||||
color="error"
|
||||
:ripple="false"
|
||||
@click.stop="emitDeleteSource(source)"
|
||||
></v-btn>
|
||||
</div>
|
||||
@@ -181,22 +186,98 @@ 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: 6px 8px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.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: #E8F0FE;
|
||||
border: 1px solid rgba(var(--v-theme-primary), 0.25);
|
||||
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;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
@@ -212,7 +293,7 @@ const emitDeleteSource = (source) => emit('delete-provider-source', source)
|
||||
|
||||
<style>
|
||||
.v-theme--PurpleThemeDark .provider-source-list-item--active {
|
||||
background-color: #2d2d2d;
|
||||
border: none;
|
||||
background-color: rgba(var(--v-theme-primary), 0.1);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { EventSourcePolyfill } from 'event-source-polyfill';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="console-displayer-wrapper" id="console-wrapper">
|
||||
<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,6 +13,14 @@ 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%">
|
||||
@@ -26,6 +34,7 @@ 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;',
|
||||
@@ -80,8 +89,10 @@ 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;
|
||||
@@ -253,6 +264,21 @@ 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) {
|
||||
@@ -282,14 +308,30 @@ 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;
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"loading": "Loading...",
|
||||
"documents": "Documents",
|
||||
"chunks": "Chunks",
|
||||
"sessionConfig": "Session Config"
|
||||
"sessionConfig": "Session Config",
|
||||
"initError": "Initialization Failed"
|
||||
},
|
||||
"card": {
|
||||
"edit": "Edit",
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"loading": "Загрузка...",
|
||||
"documents": "док.",
|
||||
"chunks": "фрагм.",
|
||||
"sessionConfig": "Профиль"
|
||||
"sessionConfig": "Профиль",
|
||||
"initError": "Ошибка инициализации"
|
||||
},
|
||||
"card": {
|
||||
"edit": "Изменить",
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"loading": "正在加载...",
|
||||
"documents": "文档",
|
||||
"chunks": "分块",
|
||||
"sessionConfig": "会话配置"
|
||||
"sessionConfig": "会话配置",
|
||||
"initError": "初始化失败"
|
||||
},
|
||||
"card": {
|
||||
"edit": "编辑",
|
||||
|
||||
@@ -10,9 +10,33 @@ import VueApexCharts from 'vue3-apexcharts';
|
||||
|
||||
import print from 'vue3-print-nb';
|
||||
import { loader } from '@guolao/vue-monaco-editor'
|
||||
import * as monaco from 'monaco-editor';
|
||||
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
|
||||
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
|
||||
import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker';
|
||||
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker';
|
||||
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
|
||||
import axios from 'axios';
|
||||
import { waitForRouterReadyInBackground } from './utils/routerReadiness.mjs';
|
||||
|
||||
(self as any).MonacoEnvironment = {
|
||||
getWorker(_: string, label: string) {
|
||||
if (label === 'json') {
|
||||
return new jsonWorker();
|
||||
}
|
||||
if (label === 'css' || label === 'scss' || label === 'less') {
|
||||
return new cssWorker();
|
||||
}
|
||||
if (label === 'html' || label === 'handlebars' || label === 'razor') {
|
||||
return new htmlWorker();
|
||||
}
|
||||
if (label === 'typescript' || label === 'javascript') {
|
||||
return new tsWorker();
|
||||
}
|
||||
return new editorWorker();
|
||||
},
|
||||
};
|
||||
|
||||
// 初始化新的i18n系统,等待完成后再挂载应用
|
||||
setupI18n().then(async () => {
|
||||
console.log('🌍 新i18n系统初始化完成');
|
||||
@@ -112,8 +136,4 @@ window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
return _origFetch(input, { ...init, headers });
|
||||
};
|
||||
|
||||
loader.config({
|
||||
paths: {
|
||||
vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.54.0/min/vs',
|
||||
},
|
||||
})
|
||||
loader.config({ monaco })
|
||||
|
||||
@@ -25,12 +25,47 @@ 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;
|
||||
router.push('/welcome');
|
||||
if (onboardingCompleted) {
|
||||
router.push('/dashboard/default');
|
||||
} else {
|
||||
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');
|
||||
|
||||
@@ -94,7 +94,21 @@
|
||||
|
||||
<template v-slot:item.title="{ item }">
|
||||
<div class="conversation-title-cell">
|
||||
<span class="conversation-title-text">{{ item.title || tm('status.noTitle') }}</span>
|
||||
<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-meta">{{ item.cid || tm('status.unknown') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -141,10 +155,6 @@
|
||||
@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>
|
||||
@@ -1188,14 +1198,29 @@ export default {
|
||||
max-width: 145px;
|
||||
}
|
||||
|
||||
.conversation-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.conversation-title-text {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
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);
|
||||
|
||||
@@ -29,9 +29,9 @@
|
||||
</v-tabs>
|
||||
|
||||
<!-- Chat Completion: 左侧列表 + 右侧上下卡片布局 -->
|
||||
<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">
|
||||
<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">
|
||||
<ProviderSourcesPanel
|
||||
:displayed-provider-sources="displayedProviderSources"
|
||||
:selected-provider-source="selectedProviderSource"
|
||||
@@ -45,66 +45,77 @@
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<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>
|
||||
<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' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div class="provider-config-actions">
|
||||
<v-btn
|
||||
color="primary"
|
||||
prepend-icon="mdi-content-save-outline"
|
||||
:loading="savingSource"
|
||||
:disabled="!isSourceModified"
|
||||
@click="saveProviderSource"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ tm('providerSources.save') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-title>
|
||||
</div>
|
||||
|
||||
<v-card-text>
|
||||
<v-card-text class="provider-config-body">
|
||||
<template v-if="selectedProviderSource">
|
||||
<div>
|
||||
<section class="provider-section">
|
||||
<div class="provider-section-head">
|
||||
<div class="provider-section-title">{{ tm('providers.settings') }}</div>
|
||||
</div>
|
||||
<AstrBotConfig v-if="basicSourceConfig" :iterable="basicSourceConfig" :metadata="providerSourceSchema"
|
||||
metadataKey="provider" :is-editing="true" />
|
||||
</div>
|
||||
</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 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>
|
||||
|
||||
<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 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>
|
||||
</template>
|
||||
<div v-else class="text-center py-8 text-medium-emphasis">
|
||||
<div v-else class="provider-empty-state">
|
||||
<v-icon size="48" color="grey-lighten-1">mdi-cursor-default-click</v-icon>
|
||||
<p class="mt-2">{{ tm('providerSources.selectHint') }}</p>
|
||||
</div>
|
||||
@@ -665,18 +676,141 @@ 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>
|
||||
|
||||
@@ -22,10 +22,15 @@ function toggleTheme() {
|
||||
theme.global.name.value = newTheme;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
// 检查用户是否已登录,如果已登录则重定向
|
||||
if (authStore.has_token()) {
|
||||
router.push('/welcome');
|
||||
const onboardingCompleted = await authStore.checkOnboardingCompleted();
|
||||
if (onboardingCompleted) {
|
||||
router.push('/dashboard/default');
|
||||
} else {
|
||||
router.push('/welcome');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -27,14 +27,27 @@
|
||||
</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
|
||||
@click="navigateToDetail(kb.kb_id)">
|
||||
<div class="kb-card-content">
|
||||
<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 }">
|
||||
<div class="kb-emoji">{{ kb.emoji || '📚' }}</div>
|
||||
<h3 class="kb-name">{{ kb.kb_name }}</h3>
|
||||
<p class="kb-description text-medium-emphasis">{{ kb.description || '暂无描述' }}</p>
|
||||
<p v-if="!kb.init_error" class="kb-description text-medium-emphasis">{{ kb.description || '暂无描述' }}</p>
|
||||
|
||||
<div class="kb-stats mt-4">
|
||||
<!-- 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="stat-item">
|
||||
<v-icon size="small" color="primary">mdi-file-document</v-icon>
|
||||
<span>{{ kb.doc_count || 0 }} {{ t('list.documents') }}</span>
|
||||
@@ -45,8 +58,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kb-actions">
|
||||
<v-btn icon="mdi-pencil" size="small" variant="text" color="info" @click.stop="editKB(kb)" />
|
||||
<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)" />
|
||||
<v-btn icon="mdi-delete" size="small" variant="text" color="error" @click.stop="confirmDelete(kb)" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -478,6 +491,34 @@ 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;
|
||||
@@ -488,6 +529,11 @@ onMounted(() => {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.kb-card-content-error {
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.kb-emoji {
|
||||
font-size: 56px;
|
||||
margin-bottom: 8px;
|
||||
@@ -518,6 +564,36 @@ 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;
|
||||
|
||||
@@ -9,6 +9,11 @@ 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
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
|
||||
`uv` 支持 Linux、Windows、macOS。
|
||||
|
||||
## 注意事项
|
||||
|
||||
> [!WARNING]
|
||||
> 通过 `uv` 部署的 AstrBot **不支持在 WebUI 中进行版本升级**。如需更新,请在命令行中执行 `uv tool upgrade astrbot`。
|
||||
|
||||
## 安装并启动
|
||||
|
||||
```bash
|
||||
|
||||
@@ -87,4 +87,4 @@
|
||||
|
||||
## 🎉 大功告成
|
||||
|
||||
此时,你的 AstrBot 和 NapCatQQ 应该已经连接成功。使用 `私聊` 的方式在 QQ 对机器人发送 `/help` 以检查是否连接成功。
|
||||
此时,你的 AstrBot 应该已经成功连接 QQ 官方接口。使用 `私聊` 的方式在 QQ 对机器人发送 `/help` 以检查是否连接成功。
|
||||
|
||||
@@ -66,6 +66,7 @@ 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",
|
||||
]
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ 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
|
||||
@@ -45,6 +46,7 @@ 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
|
||||
@@ -54,3 +56,4 @@ tenacity>=9.1.2
|
||||
shipyard-python-sdk>=0.2.4
|
||||
shipyard-neo-sdk>=0.2.0
|
||||
packaging>=24.2
|
||||
qrcode>=8.2
|
||||
@@ -1173,3 +1173,360 @@ 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()
|
||||
|
||||
305
tests/unit/test_kb_manager_resilience.py
Normal file
305
tests/unit/test_kb_manager_resilience.py
Normal file
@@ -0,0 +1,305 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user