Merge remote-tracking branch 'origin/master' into feature/command-panel
This commit is contained in:
@@ -1 +1 @@
|
||||
__version__ = "4.8.0"
|
||||
__version__ = "4.9.0"
|
||||
|
||||
@@ -4,7 +4,7 @@ import os
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
VERSION = "4.8.0"
|
||||
VERSION = "4.9.0"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||
|
||||
WEBHOOK_SUPPORTED_PLATFORMS = [
|
||||
@@ -13,6 +13,7 @@ WEBHOOK_SUPPORTED_PLATFORMS = [
|
||||
"wecom",
|
||||
"wecom_ai_bot",
|
||||
"slack",
|
||||
"lark",
|
||||
]
|
||||
|
||||
# 默认配置
|
||||
@@ -277,6 +278,10 @@ CONFIG_METADATA_2 = {
|
||||
"app_id": "",
|
||||
"app_secret": "",
|
||||
"domain": "https://open.feishu.cn",
|
||||
"lark_connection_mode": "socket", # webhook, socket
|
||||
"webhook_uuid": "",
|
||||
"lark_encrypt_key": "",
|
||||
"lark_verification_token": "",
|
||||
},
|
||||
"钉钉(DingTalk)": {
|
||||
"id": "dingtalk",
|
||||
@@ -370,6 +375,28 @@ CONFIG_METADATA_2 = {
|
||||
# "type": "string",
|
||||
# "options": ["fullscreen", "embedded"],
|
||||
# },
|
||||
"lark_connection_mode": {
|
||||
"description": "订阅方式",
|
||||
"type": "string",
|
||||
"options": ["socket", "webhook"],
|
||||
"labels": ["长连接模式", "推送至服务器模式"],
|
||||
},
|
||||
"lark_encrypt_key": {
|
||||
"description": "Encrypt Key",
|
||||
"type": "string",
|
||||
"hint": "用于解密飞书回调数据的加密密钥",
|
||||
"condition": {
|
||||
"lark_connection_mode": "webhook",
|
||||
},
|
||||
},
|
||||
"lark_verification_token": {
|
||||
"description": "Verification Token",
|
||||
"type": "string",
|
||||
"hint": "用于验证飞书回调请求的令牌",
|
||||
"condition": {
|
||||
"lark_connection_mode": "webhook",
|
||||
},
|
||||
},
|
||||
"is_sandbox": {
|
||||
"description": "沙箱模式",
|
||||
"type": "bool",
|
||||
|
||||
+2
-1
@@ -24,6 +24,7 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from asyncio import Queue
|
||||
from collections import deque
|
||||
|
||||
@@ -148,7 +149,7 @@ class LogQueueHandler(logging.Handler):
|
||||
self.log_broker.publish(
|
||||
{
|
||||
"level": record.levelname,
|
||||
"time": record.asctime,
|
||||
"time": time.time(),
|
||||
"data": log_entry,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ from asyncio import Queue
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.config.astrbot_config import AstrBotConfig
|
||||
from astrbot.core.star.star_handler import EventType, star_handlers_registry, star_map
|
||||
from astrbot.core.utils.webhook_utils import ensure_platform_webhook_config
|
||||
|
||||
from .platform import Platform, PlatformStatus
|
||||
from .register import platform_cls_map
|
||||
@@ -18,6 +19,7 @@ class PlatformManager:
|
||||
|
||||
self._inst_map: dict[str, dict] = {}
|
||||
|
||||
self.astrbot_config = config
|
||||
self.platforms_config = config["platform"]
|
||||
self.settings = config["platform_settings"]
|
||||
"""NOTE: 这里是 default 的配置文件,以保证最大的兼容性;
|
||||
@@ -29,6 +31,8 @@ class PlatformManager:
|
||||
"""初始化所有平台适配器"""
|
||||
for platform in self.platforms_config:
|
||||
try:
|
||||
if ensure_platform_webhook_config(platform):
|
||||
self.astrbot_config.save_config()
|
||||
await self.load_platform(platform)
|
||||
except Exception as e:
|
||||
logger.error(f"初始化 {platform} 平台适配器失败: {e}")
|
||||
|
||||
@@ -80,6 +80,13 @@ class Platform(abc.ABC):
|
||||
if self._status == PlatformStatus.ERROR:
|
||||
self._status = PlatformStatus.RUNNING
|
||||
|
||||
def unified_webhook(self) -> bool:
|
||||
"""是否正在使用统一 Webhook 模式"""
|
||||
return bool(
|
||||
self.config.get("unified_webhook_mode", False)
|
||||
and self.config.get("webhook_uuid")
|
||||
)
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
"""获取平台统计信息"""
|
||||
meta = self.meta()
|
||||
@@ -97,6 +104,7 @@ class Platform(abc.ABC):
|
||||
}
|
||||
if self.last_error
|
||||
else None,
|
||||
"unified_webhook": self.unified_webhook(),
|
||||
}
|
||||
|
||||
@abc.abstractmethod
|
||||
|
||||
@@ -421,7 +421,7 @@ class AiocqhttpAdapter(Platform):
|
||||
|
||||
async def shutdown_trigger_placeholder(self):
|
||||
await self.shutdown_event.wait()
|
||||
logger.info("aiocqhttp 适配器已被优雅地关闭")
|
||||
logger.info("aiocqhttp 适配器已被关闭")
|
||||
|
||||
def meta(self) -> PlatformMetadata:
|
||||
return self.metadata
|
||||
|
||||
@@ -245,7 +245,7 @@ class DingtalkPlatformAdapter(Platform):
|
||||
task.result()
|
||||
except Exception as e:
|
||||
if "Graceful shutdown" in str(e):
|
||||
logger.info("钉钉适配器已被优雅地关闭")
|
||||
logger.info("钉钉适配器已被关闭")
|
||||
return
|
||||
logger.error(f"钉钉机器人启动失败: {e}")
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@ import asyncio
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from typing import cast
|
||||
from typing import Any, cast
|
||||
|
||||
import lark_oapi as lark
|
||||
from lark_oapi.api.im.v1 import (
|
||||
@@ -11,6 +12,7 @@ from lark_oapi.api.im.v1 import (
|
||||
CreateMessageRequestBody,
|
||||
GetMessageResourceRequest,
|
||||
)
|
||||
from lark_oapi.api.im.v1.processor import P2ImMessageReceiveV1Processor
|
||||
|
||||
import astrbot.api.message_components as Comp
|
||||
from astrbot import logger
|
||||
@@ -23,9 +25,11 @@ from astrbot.api.platform import (
|
||||
PlatformMetadata,
|
||||
)
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
from astrbot.core.utils.webhook_utils import log_webhook_info
|
||||
|
||||
from ...register import register_platform_adapter
|
||||
from .lark_event import LarkMessageEvent
|
||||
from .server import LarkWebhookServer
|
||||
|
||||
|
||||
@register_platform_adapter(
|
||||
@@ -47,9 +51,13 @@ class LarkPlatformAdapter(Platform):
|
||||
self.domain = platform_config.get("domain", lark.FEISHU_DOMAIN)
|
||||
self.bot_name = platform_config.get("lark_bot_name", "astrbot")
|
||||
|
||||
# socket or webhook
|
||||
self.connection_mode = platform_config.get("lark_connection_mode", "socket")
|
||||
|
||||
if not self.bot_name:
|
||||
logger.warning("未设置飞书机器人名称,@ 机器人可能得不到回复。")
|
||||
|
||||
# 初始化 WebSocket 长连接相关配置
|
||||
async def on_msg_event_recv(event: lark.im.v1.P2ImMessageReceiveV1):
|
||||
await self.convert_msg(event)
|
||||
|
||||
@@ -62,6 +70,8 @@ class LarkPlatformAdapter(Platform):
|
||||
.build()
|
||||
)
|
||||
|
||||
self.do_v2_msg_event = do_v2_msg_event
|
||||
|
||||
self.client = lark.ws.Client(
|
||||
app_id=self.appid,
|
||||
app_secret=self.appsecret,
|
||||
@@ -71,9 +81,47 @@ class LarkPlatformAdapter(Platform):
|
||||
)
|
||||
|
||||
self.lark_api = (
|
||||
lark.Client.builder().app_id(self.appid).app_secret(self.appsecret).build()
|
||||
lark.Client.builder()
|
||||
.app_id(self.appid)
|
||||
.app_secret(self.appsecret)
|
||||
.log_level(lark.LogLevel.ERROR)
|
||||
.domain(self.domain)
|
||||
.build()
|
||||
)
|
||||
|
||||
self.webhook_server = None
|
||||
if self.connection_mode == "webhook":
|
||||
self.webhook_server = LarkWebhookServer(platform_config, event_queue)
|
||||
self.webhook_server.set_callback(self.handle_webhook_event)
|
||||
|
||||
self.event_id_timestamps: dict[str, float] = {}
|
||||
|
||||
def _clean_expired_events(self):
|
||||
"""清理超过 30 分钟的事件记录"""
|
||||
current_time = time.time()
|
||||
expired_keys = [
|
||||
event_id
|
||||
for event_id, timestamp in self.event_id_timestamps.items()
|
||||
if current_time - timestamp > 1800
|
||||
]
|
||||
for event_id in expired_keys:
|
||||
del self.event_id_timestamps[event_id]
|
||||
|
||||
def _is_duplicate_event(self, event_id: str) -> bool:
|
||||
"""检查事件是否重复
|
||||
|
||||
Args:
|
||||
event_id: 事件ID
|
||||
|
||||
Returns:
|
||||
True 表示重复事件,False 表示新事件
|
||||
"""
|
||||
self._clean_expired_events()
|
||||
if event_id in self.event_id_timestamps:
|
||||
return True
|
||||
self.event_id_timestamps[event_id] = time.time()
|
||||
return False
|
||||
|
||||
async def send_by_session(
|
||||
self,
|
||||
session: MessageSesion,
|
||||
@@ -137,7 +185,11 @@ class LarkPlatformAdapter(Platform):
|
||||
return
|
||||
|
||||
abm = AstrBotMessage()
|
||||
abm.timestamp = cast(int, message.create_time) // 1000
|
||||
|
||||
if message.create_time:
|
||||
abm.timestamp = int(message.create_time) // 1000
|
||||
else:
|
||||
abm.timestamp = int(time.time())
|
||||
abm.message = []
|
||||
abm.type = (
|
||||
MessageType.GROUP_MESSAGE
|
||||
@@ -290,13 +342,61 @@ class LarkPlatformAdapter(Platform):
|
||||
|
||||
self._event_queue.put_nowait(event)
|
||||
|
||||
async def handle_webhook_event(self, event_data: dict):
|
||||
"""处理 Webhook 事件
|
||||
|
||||
Args:
|
||||
event_data: Webhook 事件数据
|
||||
"""
|
||||
try:
|
||||
header = event_data.get("header", {})
|
||||
event_id = header.get("event_id", "")
|
||||
if event_id and self._is_duplicate_event(event_id):
|
||||
logger.debug(f"[Lark Webhook] 跳过重复事件: {event_id}")
|
||||
return
|
||||
event_type = header.get("event_type", "")
|
||||
if event_type == "im.message.receive_v1":
|
||||
processor = P2ImMessageReceiveV1Processor(self.do_v2_msg_event)
|
||||
data = (processor.type())(event_data)
|
||||
processor.do(data)
|
||||
else:
|
||||
logger.debug(f"[Lark Webhook] 未处理的事件类型: {event_type}")
|
||||
except Exception as e:
|
||||
logger.error(f"[Lark Webhook] 处理事件失败: {e}", exc_info=True)
|
||||
|
||||
async def run(self):
|
||||
# self.client.start()
|
||||
await self.client._connect()
|
||||
if self.connection_mode == "webhook":
|
||||
# Webhook 模式
|
||||
if self.webhook_server is None:
|
||||
logger.error("[Lark] Webhook 模式已启用,但 webhook_server 未初始化")
|
||||
return
|
||||
|
||||
webhook_uuid = self.config.get("webhook_uuid")
|
||||
if webhook_uuid:
|
||||
log_webhook_info(f"{self.meta().id}(飞书 Webhook)", webhook_uuid)
|
||||
else:
|
||||
logger.warning("[Lark] Webhook 模式已启用,但未配置 webhook_uuid")
|
||||
else:
|
||||
# 长连接模式
|
||||
await self.client._connect()
|
||||
|
||||
async def webhook_callback(self, request: Any) -> Any:
|
||||
"""统一 Webhook 回调入口"""
|
||||
if not self.webhook_server:
|
||||
return {"error": "Webhook server not initialized"}, 500
|
||||
|
||||
return await self.webhook_server.handle_callback(request)
|
||||
|
||||
async def terminate(self):
|
||||
await self.client._disconnect()
|
||||
logger.info("飞书(Lark) 适配器已被优雅地关闭")
|
||||
if self.connection_mode == "socket":
|
||||
await self.client._disconnect()
|
||||
logger.info("飞书(Lark) 适配器已关闭")
|
||||
|
||||
def get_client(self) -> lark.ws.Client:
|
||||
return self.client
|
||||
|
||||
def unified_webhook(self) -> bool:
|
||||
return bool(
|
||||
self.config.get("lark_connection_mode", "") == "webhook"
|
||||
and self.config.get("webhook_uuid")
|
||||
)
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""飞书(Lark) Webhook 服务器实现
|
||||
|
||||
实现飞书事件订阅的 Webhook 模式,支持:
|
||||
1. 请求 URL 验证 (challenge 验证)
|
||||
2. 事件加密/解密 (AES-256-CBC)
|
||||
3. 签名校验 (SHA256)
|
||||
4. 事件接收和处理
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
from astrbot.api import logger
|
||||
|
||||
|
||||
class AESCipher:
|
||||
"""AES 加密/解密工具类"""
|
||||
|
||||
def __init__(self, key: str):
|
||||
self.bs = AES.block_size
|
||||
self.key = hashlib.sha256(self.str_to_bytes(key)).digest()
|
||||
|
||||
@staticmethod
|
||||
def str_to_bytes(data):
|
||||
u_type = type(b"".decode("utf8"))
|
||||
if isinstance(data, u_type):
|
||||
return data.encode("utf8")
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _unpad(s):
|
||||
return s[: -ord(s[len(s) - 1 :])]
|
||||
|
||||
def decrypt(self, enc):
|
||||
iv = enc[: AES.block_size]
|
||||
cipher = AES.new(self.key, AES.MODE_CBC, iv)
|
||||
return self._unpad(cipher.decrypt(enc[AES.block_size :]))
|
||||
|
||||
def decrypt_string(self, enc):
|
||||
enc = base64.b64decode(enc)
|
||||
return self.decrypt(enc).decode("utf8")
|
||||
|
||||
|
||||
class LarkWebhookServer:
|
||||
"""飞书 Webhook 服务器
|
||||
|
||||
仅支持统一 Webhook 模式
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict, event_queue: asyncio.Queue):
|
||||
"""初始化 Webhook 服务器
|
||||
|
||||
Args:
|
||||
config: 飞书配置
|
||||
event_queue: 事件队列
|
||||
"""
|
||||
self.app_id = config["app_id"]
|
||||
self.app_secret = config["app_secret"]
|
||||
self.encrypt_key = config.get("lark_encrypt_key", "")
|
||||
self.verification_token = config.get("lark_verification_token", "")
|
||||
|
||||
self.event_queue = event_queue
|
||||
self.callback: Callable[[dict], Awaitable[None]] | None = None
|
||||
|
||||
# 初始化加密工具
|
||||
self.cipher = None
|
||||
if self.encrypt_key:
|
||||
self.cipher = AESCipher(self.encrypt_key)
|
||||
|
||||
def verify_signature(
|
||||
self,
|
||||
timestamp: str,
|
||||
nonce: str,
|
||||
encrypt_key: str,
|
||||
body: bytes,
|
||||
signature: str,
|
||||
) -> bool:
|
||||
"""验证签名
|
||||
|
||||
Args:
|
||||
timestamp: 请求时间戳
|
||||
nonce: 随机数
|
||||
encrypt_key: 加密密钥
|
||||
body: 请求体
|
||||
signature: 签名
|
||||
|
||||
Returns:
|
||||
签名是否有效
|
||||
"""
|
||||
# 拼接字符串: timestamp + nonce + encrypt_key + body
|
||||
bytes_b1 = (timestamp + nonce + encrypt_key).encode("utf-8")
|
||||
bytes_b = bytes_b1 + body
|
||||
h = hashlib.sha256(bytes_b)
|
||||
calculated_signature = h.hexdigest()
|
||||
return calculated_signature == signature
|
||||
|
||||
def decrypt_event(self, encrypted_data: str) -> dict:
|
||||
"""解密事件数据
|
||||
|
||||
Args:
|
||||
encrypted_data: 加密的事件数据
|
||||
|
||||
Returns:
|
||||
解密后的事件字典
|
||||
"""
|
||||
if not self.cipher:
|
||||
raise ValueError("未配置 encrypt_key,无法解密事件")
|
||||
|
||||
decrypted_str = self.cipher.decrypt_string(encrypted_data)
|
||||
return json.loads(decrypted_str)
|
||||
|
||||
async def handle_challenge(self, event_data: dict) -> dict:
|
||||
"""处理 challenge 验证请求
|
||||
|
||||
Args:
|
||||
event_data: 事件数据
|
||||
|
||||
Returns:
|
||||
包含 challenge 的响应
|
||||
"""
|
||||
challenge = event_data.get("challenge", "")
|
||||
logger.info(f"[Lark Webhook] 收到 challenge 验证请求: {challenge}")
|
||||
|
||||
return {"challenge": challenge}
|
||||
|
||||
async def handle_callback(self, request) -> tuple[dict, int] | dict:
|
||||
"""处理 webhook 回调,可被统一 webhook 入口复用
|
||||
|
||||
Args:
|
||||
request: Quart 请求对象
|
||||
|
||||
Returns:
|
||||
响应数据
|
||||
"""
|
||||
# 获取原始请求体
|
||||
body = await request.get_data()
|
||||
|
||||
try:
|
||||
event_data = await request.json
|
||||
except Exception as e:
|
||||
logger.error(f"[Lark Webhook] 解析请求体失败: {e}")
|
||||
return {"error": "Invalid JSON"}, 400
|
||||
|
||||
if not event_data:
|
||||
logger.error("[Lark Webhook] 请求体为空")
|
||||
return {"error": "Empty request body"}, 400
|
||||
|
||||
# 如果配置了 encrypt_key,进行签名验证
|
||||
if self.encrypt_key:
|
||||
timestamp = request.headers.get("X-Lark-Request-Timestamp", "")
|
||||
nonce = request.headers.get("X-Lark-Request-Nonce", "")
|
||||
signature = request.headers.get("X-Lark-Signature", "")
|
||||
|
||||
if timestamp and nonce and signature:
|
||||
if not self.verify_signature(
|
||||
timestamp, nonce, self.encrypt_key, body, signature
|
||||
):
|
||||
logger.error("[Lark Webhook] 签名验证失败")
|
||||
return {"error": "Invalid signature"}, 401
|
||||
|
||||
# 检查是否是加密事件
|
||||
if "encrypt" in event_data:
|
||||
try:
|
||||
event_data = self.decrypt_event(event_data["encrypt"])
|
||||
logger.debug(f"[Lark Webhook] 解密后的事件: {event_data}")
|
||||
except Exception as e:
|
||||
logger.error(f"[Lark Webhook] 解密事件失败: {e}")
|
||||
return {"error": "Decryption failed"}, 400
|
||||
|
||||
# 验证 token
|
||||
if self.verification_token:
|
||||
header = event_data.get("header", {})
|
||||
if header:
|
||||
token = header.get("token", "")
|
||||
else:
|
||||
token = event_data.get("token", "")
|
||||
if token != self.verification_token:
|
||||
logger.error("[Lark Webhook] Verification Token 不匹配。")
|
||||
return {"error": "Invalid verification token"}, 401
|
||||
|
||||
# 处理 URL 验证 (challenge)
|
||||
if event_data.get("type") == "url_verification":
|
||||
return await self.handle_challenge(event_data)
|
||||
|
||||
# 调用回调函数处理事件
|
||||
if self.callback:
|
||||
try:
|
||||
await self.callback(event_data)
|
||||
except Exception as e:
|
||||
logger.error(f"[Lark Webhook] 处理事件回调失败: {e}", exc_info=True)
|
||||
return {"error": "Event processing failed"}, 500
|
||||
|
||||
return {}
|
||||
|
||||
def set_callback(self, callback: Callable[[dict], Awaitable[None]]):
|
||||
"""设置事件回调函数
|
||||
|
||||
Args:
|
||||
callback: 处理事件的异步函数
|
||||
"""
|
||||
self.callback = callback
|
||||
@@ -409,7 +409,7 @@ class SlackAdapter(Platform):
|
||||
await self.socket_client.stop()
|
||||
if self.webhook_client:
|
||||
await self.webhook_client.stop()
|
||||
logger.info("Slack 适配器已被优雅地关闭")
|
||||
logger.info("Slack 适配器已被关闭")
|
||||
|
||||
def meta(self) -> PlatformMetadata:
|
||||
return self.metadata
|
||||
@@ -427,3 +427,10 @@ class SlackAdapter(Platform):
|
||||
|
||||
def get_client(self):
|
||||
return self.web_client
|
||||
|
||||
def unified_webhook(self) -> bool:
|
||||
return bool(
|
||||
self.config.get("unified_webhook_mode", False)
|
||||
and self.config.get("slack_connection_mode", "") == "webhook"
|
||||
and self.config.get("webhook_uuid")
|
||||
)
|
||||
|
||||
@@ -424,6 +424,6 @@ class TelegramPlatformAdapter(Platform):
|
||||
if self.application.updater is not None:
|
||||
await self.application.updater.stop()
|
||||
|
||||
logger.info("Telegram 适配器已被优雅地关闭")
|
||||
logger.info("Telegram 适配器已被关闭")
|
||||
except Exception as e:
|
||||
logger.error(f"Telegram 适配器关闭时出错: {e}")
|
||||
|
||||
@@ -422,4 +422,4 @@ class WecomPlatformAdapter(Platform):
|
||||
await self.server.server.shutdown()
|
||||
except Exception as _:
|
||||
pass
|
||||
logger.info("企业微信 适配器已被优雅地关闭")
|
||||
logger.info("企业微信 适配器已被关闭")
|
||||
|
||||
@@ -349,4 +349,4 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
|
||||
await self.server.server.shutdown()
|
||||
except Exception as _:
|
||||
pass
|
||||
logger.info("微信公众平台 适配器已被优雅地关闭")
|
||||
logger.info("微信公众平台 适配器已被关闭")
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import uuid
|
||||
|
||||
from astrbot.core import astrbot_config, logger
|
||||
from astrbot.core.config.default import WEBHOOK_SUPPORTED_PLATFORMS
|
||||
|
||||
|
||||
def _get_callback_api_base() -> str:
|
||||
@@ -45,3 +48,19 @@ def log_webhook_info(platform_name: str, webhook_uuid: str):
|
||||
"====================\n"
|
||||
)
|
||||
logger.info(display_log)
|
||||
|
||||
|
||||
def ensure_platform_webhook_config(platform_cfg: dict) -> bool:
|
||||
"""为支持统一 webhook 的平台自动生成 webhook_uuid
|
||||
|
||||
Args:
|
||||
platform_cfg (dict): 平台配置字典
|
||||
|
||||
Returns:
|
||||
bool: 如果生成了 webhook_uuid 则返回 True,否则返回 False
|
||||
"""
|
||||
pt = platform_cfg.get("type", "")
|
||||
if pt in WEBHOOK_SUPPORTED_PLATFORMS and not platform_cfg.get("webhook_uuid"):
|
||||
platform_cfg["webhook_uuid"] = uuid.uuid4().hex[:16]
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -2,7 +2,6 @@ import asyncio
|
||||
import inspect
|
||||
import os
|
||||
import traceback
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from quart import request
|
||||
@@ -15,7 +14,6 @@ from astrbot.core.config.default import (
|
||||
CONFIG_METADATA_3_SYSTEM,
|
||||
DEFAULT_CONFIG,
|
||||
DEFAULT_VALUE_MAP,
|
||||
WEBHOOK_SUPPORTED_PLATFORMS,
|
||||
)
|
||||
from astrbot.core.config.i18n_utils import ConfigMetadataI18n
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
@@ -23,6 +21,7 @@ from astrbot.core.platform.register import platform_cls_map, platform_registry
|
||||
from astrbot.core.provider import Provider
|
||||
from astrbot.core.provider.register import provider_registry
|
||||
from astrbot.core.star.star import star_registry
|
||||
from astrbot.core.utils.webhook_utils import ensure_platform_webhook_config
|
||||
|
||||
from .route import Response, Route, RouteContext
|
||||
|
||||
@@ -559,13 +558,8 @@ class ConfigRoute(Route):
|
||||
async def post_new_platform(self):
|
||||
new_platform_config = await request.json
|
||||
|
||||
# 如果是支持统一 webhook 模式的平台,且启用了统一 webhook 模式,自动生成 webhook_uuid
|
||||
platform_type = new_platform_config.get("type", "")
|
||||
if platform_type in WEBHOOK_SUPPORTED_PLATFORMS:
|
||||
if new_platform_config.get("unified_webhook_mode", False):
|
||||
# 如果没有 webhook_uuid 或为空,自动生成
|
||||
if not new_platform_config.get("webhook_uuid"):
|
||||
new_platform_config["webhook_uuid"] = uuid.uuid4().hex[:16]
|
||||
# 如果是支持统一 webhook 模式的平台,生成 webhook_uuid
|
||||
ensure_platform_webhook_config(new_platform_config)
|
||||
|
||||
self.config["platform"].append(new_platform_config)
|
||||
try:
|
||||
@@ -597,12 +591,7 @@ class ConfigRoute(Route):
|
||||
return Response().error("参数错误").__dict__
|
||||
|
||||
# 如果是支持统一 webhook 模式的平台,且启用了统一 webhook 模式,确保有 webhook_uuid
|
||||
platform_type = new_config.get("type", "")
|
||||
if platform_type in WEBHOOK_SUPPORTED_PLATFORMS:
|
||||
if new_config.get("unified_webhook_mode", False):
|
||||
# 如果没有 webhook_uuid 或为空,自动生成
|
||||
if not new_config.get("webhook_uuid"):
|
||||
new_config["webhook_uuid"] = uuid.uuid4().hex
|
||||
ensure_platform_webhook_config(new_config)
|
||||
|
||||
for i, platform in enumerate(self.config["platform"]):
|
||||
if platform["id"] == platform_id:
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import json
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
|
||||
from quart import request
|
||||
from quart import request, send_file
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
@@ -30,6 +32,7 @@ class ConversationRoute(Route):
|
||||
"POST",
|
||||
self.update_history,
|
||||
),
|
||||
"/conversation/export": ("POST", self.export_conversations),
|
||||
}
|
||||
self.db_helper = db_helper
|
||||
self.conv_mgr = core_lifecycle.conversation_manager
|
||||
@@ -283,3 +286,90 @@ class ConversationRoute(Route):
|
||||
except Exception as e:
|
||||
logger.error(f"更新对话历史失败: {e!s}\n{traceback.format_exc()}")
|
||||
return Response().error(f"更新对话历史失败: {e!s}").__dict__
|
||||
|
||||
async def export_conversations(self):
|
||||
"""批量导出对话为 JSONL 格式"""
|
||||
try:
|
||||
data = await request.get_json()
|
||||
conversations_to_export = data.get("conversations", [])
|
||||
|
||||
if not conversations_to_export:
|
||||
return Response().error("导出列表不能为空").__dict__
|
||||
|
||||
# 收集所有对话的内容
|
||||
jsonl_lines = []
|
||||
exported_count = 0
|
||||
failed_items = []
|
||||
|
||||
for conv_info in conversations_to_export:
|
||||
user_id = conv_info.get("user_id")
|
||||
cid = conv_info.get("cid")
|
||||
|
||||
if not user_id or not cid:
|
||||
failed_items.append(
|
||||
f"user_id:{user_id}, cid:{cid} - 缺少必要参数",
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
conversation = await self.conv_mgr.get_conversation(
|
||||
unified_msg_origin=user_id,
|
||||
conversation_id=cid,
|
||||
)
|
||||
|
||||
if not conversation:
|
||||
failed_items.append(
|
||||
f"user_id:{user_id}, cid:{cid} - 对话不存在"
|
||||
)
|
||||
continue
|
||||
|
||||
# 解析对话内容 (history is always a JSON string from _convert_conv_from_v2_to_v1)
|
||||
content = json.loads(conversation.history)
|
||||
|
||||
# 创建导出记录
|
||||
export_record = {
|
||||
"cid": cid,
|
||||
"user_id": user_id,
|
||||
"platform_id": conversation.platform_id,
|
||||
"title": conversation.title,
|
||||
"persona_id": conversation.persona_id,
|
||||
"created_at": conversation.created_at,
|
||||
"updated_at": conversation.updated_at,
|
||||
"content": content,
|
||||
}
|
||||
|
||||
# 将记录转换为 JSON 字符串并添加到 JSONL
|
||||
jsonl_lines.append(json.dumps(export_record, ensure_ascii=False))
|
||||
exported_count += 1
|
||||
|
||||
except Exception as e:
|
||||
failed_items.append(f"user_id:{user_id}, cid:{cid} - {e!s}")
|
||||
logger.error(
|
||||
f"导出对话失败: user_id={user_id}, cid={cid}, error={e!s}"
|
||||
)
|
||||
|
||||
if exported_count == 0:
|
||||
return Response().error("没有成功导出任何对话").__dict__
|
||||
|
||||
# 创建 JSONL 内容
|
||||
jsonl_content = "\n".join(jsonl_lines)
|
||||
|
||||
# 创建一个内存文件对象
|
||||
file_obj = BytesIO(jsonl_content.encode("utf-8"))
|
||||
file_obj.seek(0)
|
||||
|
||||
# 生成文件名
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"astrbot_conversations_export_{timestamp}.jsonl"
|
||||
|
||||
# 返回文件流
|
||||
return await send_file(
|
||||
file_obj,
|
||||
mimetype="application/jsonl",
|
||||
as_attachment=True,
|
||||
attachment_filename=filename,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"批量导出对话失败: {e!s}\n{traceback.format_exc()}")
|
||||
return Response().error(f"批量导出对话失败: {e!s}").__dict__
|
||||
|
||||
@@ -48,6 +48,7 @@ class KnowledgeBaseRoute(Route):
|
||||
# 文档管理
|
||||
"/kb/document/list": ("GET", self.list_documents),
|
||||
"/kb/document/upload": ("POST", self.upload_document),
|
||||
"/kb/document/import": ("POST", self.import_documents),
|
||||
"/kb/document/upload/url": ("POST", self.upload_document_from_url),
|
||||
"/kb/document/upload/progress": ("GET", self.get_upload_progress),
|
||||
"/kb/document/get": ("GET", self.get_document),
|
||||
@@ -66,6 +67,65 @@ class KnowledgeBaseRoute(Route):
|
||||
def _get_kb_manager(self):
|
||||
return self.core_lifecycle.kb_manager
|
||||
|
||||
def _init_task(self, task_id: str, status: str = "pending") -> None:
|
||||
self.upload_tasks[task_id] = {
|
||||
"status": status,
|
||||
"result": None,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
def _set_task_result(
|
||||
self, task_id: str, status: str, result: any = None, error: str | None = None
|
||||
) -> None:
|
||||
self.upload_tasks[task_id] = {
|
||||
"status": status,
|
||||
"result": result,
|
||||
"error": error,
|
||||
}
|
||||
if task_id in self.upload_progress:
|
||||
self.upload_progress[task_id]["status"] = status
|
||||
|
||||
def _update_progress(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
status: str | None = None,
|
||||
file_index: int | None = None,
|
||||
file_name: str | None = None,
|
||||
stage: str | None = None,
|
||||
current: int | None = None,
|
||||
total: int | None = None,
|
||||
) -> None:
|
||||
if task_id not in self.upload_progress:
|
||||
return
|
||||
p = self.upload_progress[task_id]
|
||||
if status is not None:
|
||||
p["status"] = status
|
||||
if file_index is not None:
|
||||
p["file_index"] = file_index
|
||||
if file_name is not None:
|
||||
p["file_name"] = file_name
|
||||
if stage is not None:
|
||||
p["stage"] = stage
|
||||
if current is not None:
|
||||
p["current"] = current
|
||||
if total is not None:
|
||||
p["total"] = total
|
||||
|
||||
def _make_progress_callback(self, task_id: str, file_idx: int, file_name: str):
|
||||
async def _callback(stage: str, current: int, total: int):
|
||||
self._update_progress(
|
||||
task_id,
|
||||
status="processing",
|
||||
file_index=file_idx,
|
||||
file_name=file_name,
|
||||
stage=stage,
|
||||
current=current,
|
||||
total=total,
|
||||
)
|
||||
|
||||
return _callback
|
||||
|
||||
async def _background_upload_task(
|
||||
self,
|
||||
task_id: str,
|
||||
@@ -80,11 +140,7 @@ class KnowledgeBaseRoute(Route):
|
||||
"""后台上传任务"""
|
||||
try:
|
||||
# 初始化任务状态
|
||||
self.upload_tasks[task_id] = {
|
||||
"status": "processing",
|
||||
"result": None,
|
||||
"error": None,
|
||||
}
|
||||
self._init_task(task_id, status="processing")
|
||||
self.upload_progress[task_id] = {
|
||||
"status": "processing",
|
||||
"file_index": 0,
|
||||
@@ -100,30 +156,20 @@ class KnowledgeBaseRoute(Route):
|
||||
for file_idx, file_info in enumerate(files_to_upload):
|
||||
try:
|
||||
# 更新整体进度
|
||||
self.upload_progress[task_id].update(
|
||||
{
|
||||
"status": "processing",
|
||||
"file_index": file_idx,
|
||||
"file_name": file_info["file_name"],
|
||||
"stage": "parsing",
|
||||
"current": 0,
|
||||
"total": 100,
|
||||
},
|
||||
self._update_progress(
|
||||
task_id,
|
||||
status="processing",
|
||||
file_index=file_idx,
|
||||
file_name=file_info["file_name"],
|
||||
stage="parsing",
|
||||
current=0,
|
||||
total=100,
|
||||
)
|
||||
|
||||
# 创建进度回调函数
|
||||
async def progress_callback(stage, current, total):
|
||||
if task_id in self.upload_progress:
|
||||
self.upload_progress[task_id].update(
|
||||
{
|
||||
"status": "processing",
|
||||
"file_index": file_idx,
|
||||
"file_name": file_info["file_name"],
|
||||
"stage": stage,
|
||||
"current": current,
|
||||
"total": total,
|
||||
},
|
||||
)
|
||||
progress_callback = self._make_progress_callback(
|
||||
task_id, file_idx, file_info["file_name"]
|
||||
)
|
||||
|
||||
doc = await kb_helper.upload_document(
|
||||
file_name=file_info["file_name"],
|
||||
@@ -154,23 +200,99 @@ class KnowledgeBaseRoute(Route):
|
||||
"failed_count": len(failed_docs),
|
||||
}
|
||||
|
||||
self.upload_tasks[task_id] = {
|
||||
"status": "completed",
|
||||
"result": result,
|
||||
"error": None,
|
||||
}
|
||||
self.upload_progress[task_id]["status"] = "completed"
|
||||
self._set_task_result(task_id, "completed", result=result)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"后台上传任务 {task_id} 失败: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
self.upload_tasks[task_id] = {
|
||||
"status": "failed",
|
||||
"result": None,
|
||||
"error": str(e),
|
||||
self._set_task_result(task_id, "failed", error=str(e))
|
||||
|
||||
async def _background_import_task(
|
||||
self,
|
||||
task_id: str,
|
||||
kb_helper,
|
||||
documents: list,
|
||||
batch_size: int,
|
||||
tasks_limit: int,
|
||||
max_retries: int,
|
||||
):
|
||||
"""后台导入预切片文档任务"""
|
||||
try:
|
||||
# 初始化任务状态
|
||||
self._init_task(task_id, status="processing")
|
||||
self.upload_progress[task_id] = {
|
||||
"status": "processing",
|
||||
"file_index": 0,
|
||||
"file_total": len(documents),
|
||||
"stage": "waiting",
|
||||
"current": 0,
|
||||
"total": 100,
|
||||
}
|
||||
if task_id in self.upload_progress:
|
||||
self.upload_progress[task_id]["status"] = "failed"
|
||||
|
||||
uploaded_docs = []
|
||||
failed_docs = []
|
||||
|
||||
for file_idx, doc_info in enumerate(documents):
|
||||
file_name = doc_info.get("file_name", f"imported_doc_{file_idx}")
|
||||
chunks = doc_info.get("chunks", [])
|
||||
|
||||
try:
|
||||
# 更新整体进度
|
||||
self._update_progress(
|
||||
task_id,
|
||||
status="processing",
|
||||
file_index=file_idx,
|
||||
file_name=file_name,
|
||||
stage="importing",
|
||||
current=0,
|
||||
total=100,
|
||||
)
|
||||
|
||||
# 创建进度回调函数
|
||||
progress_callback = self._make_progress_callback(
|
||||
task_id, file_idx, file_name
|
||||
)
|
||||
|
||||
# 调用 upload_document,传入 pre_chunked_text
|
||||
doc = await kb_helper.upload_document(
|
||||
file_name=file_name,
|
||||
file_content=None, # 预切片模式下不需要原始内容
|
||||
file_type=doc_info.get("file_type")
|
||||
or (
|
||||
file_name.rsplit(".", 1)[-1].lower()
|
||||
if "." in file_name
|
||||
else "txt"
|
||||
),
|
||||
batch_size=batch_size,
|
||||
tasks_limit=tasks_limit,
|
||||
max_retries=max_retries,
|
||||
progress_callback=progress_callback,
|
||||
pre_chunked_text=chunks,
|
||||
)
|
||||
|
||||
uploaded_docs.append(doc.model_dump())
|
||||
except Exception as e:
|
||||
logger.error(f"导入文档 {file_name} 失败: {e}")
|
||||
failed_docs.append(
|
||||
{"file_name": file_name, "error": str(e)},
|
||||
)
|
||||
|
||||
# 更新任务完成状态
|
||||
result = {
|
||||
"task_id": task_id,
|
||||
"uploaded": uploaded_docs,
|
||||
"failed": failed_docs,
|
||||
"total": len(documents),
|
||||
"success_count": len(uploaded_docs),
|
||||
"failed_count": len(failed_docs),
|
||||
}
|
||||
|
||||
self._set_task_result(task_id, "completed", result=result)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"后台导入任务 {task_id} 失败: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
self._set_task_result(task_id, "failed", error=str(e))
|
||||
|
||||
async def list_kbs(self):
|
||||
"""获取知识库列表
|
||||
@@ -614,11 +736,7 @@ class KnowledgeBaseRoute(Route):
|
||||
task_id = str(uuid.uuid4())
|
||||
|
||||
# 初始化任务状态
|
||||
self.upload_tasks[task_id] = {
|
||||
"status": "pending",
|
||||
"result": None,
|
||||
"error": None,
|
||||
}
|
||||
self._init_task(task_id, status="pending")
|
||||
|
||||
# 启动后台任务
|
||||
asyncio.create_task(
|
||||
@@ -653,6 +771,93 @@ class KnowledgeBaseRoute(Route):
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"上传文档失败: {e!s}").__dict__
|
||||
|
||||
def _validate_import_request(self, data: dict):
|
||||
kb_id = data.get("kb_id")
|
||||
if not kb_id:
|
||||
raise ValueError("缺少参数 kb_id")
|
||||
|
||||
documents = data.get("documents")
|
||||
if not documents or not isinstance(documents, list):
|
||||
raise ValueError("缺少参数 documents 或格式错误")
|
||||
|
||||
for doc in documents:
|
||||
if "file_name" not in doc or "chunks" not in doc:
|
||||
raise ValueError("文档格式错误,必须包含 file_name 和 chunks")
|
||||
if not isinstance(doc["chunks"], list):
|
||||
raise ValueError("chunks 必须是列表")
|
||||
if not all(
|
||||
isinstance(chunk, str) and chunk.strip() for chunk in doc["chunks"]
|
||||
):
|
||||
raise ValueError("chunks 必须是非空字符串列表")
|
||||
|
||||
batch_size = data.get("batch_size", 32)
|
||||
tasks_limit = data.get("tasks_limit", 3)
|
||||
max_retries = data.get("max_retries", 3)
|
||||
return kb_id, documents, batch_size, tasks_limit, max_retries
|
||||
|
||||
async def import_documents(self):
|
||||
"""导入预切片文档
|
||||
|
||||
Body:
|
||||
- kb_id: 知识库 ID (必填)
|
||||
- documents: 文档列表 (必填)
|
||||
- file_name: 文件名 (必填)
|
||||
- chunks: 切片列表 (必填, list[str])
|
||||
- file_type: 文件类型 (可选, 默认从文件名推断或为 txt)
|
||||
- batch_size: 批处理大小 (可选, 默认32)
|
||||
- tasks_limit: 并发任务限制 (可选, 默认3)
|
||||
- max_retries: 最大重试次数 (可选, 默认3)
|
||||
"""
|
||||
try:
|
||||
kb_manager = self._get_kb_manager()
|
||||
data = await request.json
|
||||
|
||||
kb_id, documents, batch_size, tasks_limit, max_retries = (
|
||||
self._validate_import_request(data)
|
||||
)
|
||||
|
||||
# 获取知识库
|
||||
kb_helper = await kb_manager.get_kb(kb_id)
|
||||
if not kb_helper:
|
||||
return Response().error("知识库不存在").__dict__
|
||||
|
||||
# 生成任务ID
|
||||
task_id = str(uuid.uuid4())
|
||||
|
||||
# 初始化任务状态
|
||||
self._init_task(task_id, status="pending")
|
||||
|
||||
# 启动后台任务
|
||||
asyncio.create_task(
|
||||
self._background_import_task(
|
||||
task_id=task_id,
|
||||
kb_helper=kb_helper,
|
||||
documents=documents,
|
||||
batch_size=batch_size,
|
||||
tasks_limit=tasks_limit,
|
||||
max_retries=max_retries,
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
{
|
||||
"task_id": task_id,
|
||||
"doc_count": len(documents),
|
||||
"message": "import task created, processing in background",
|
||||
},
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
return Response().error(str(e)).__dict__
|
||||
except Exception as e:
|
||||
logger.error(f"导入文档失败: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"导入文档失败: {e!s}").__dict__
|
||||
|
||||
async def get_upload_progress(self):
|
||||
"""获取上传进度和结果
|
||||
|
||||
@@ -960,11 +1165,7 @@ class KnowledgeBaseRoute(Route):
|
||||
task_id = str(uuid.uuid4())
|
||||
|
||||
# 初始化任务状态
|
||||
self.upload_tasks[task_id] = {
|
||||
"status": "pending",
|
||||
"result": None,
|
||||
"error": None,
|
||||
}
|
||||
self._init_task(task_id, status="pending")
|
||||
|
||||
# 启动后台任务
|
||||
asyncio.create_task(
|
||||
@@ -1017,11 +1218,7 @@ class KnowledgeBaseRoute(Route):
|
||||
"""后台上传URL任务"""
|
||||
try:
|
||||
# 初始化任务状态
|
||||
self.upload_tasks[task_id] = {
|
||||
"status": "processing",
|
||||
"result": None,
|
||||
"error": None,
|
||||
}
|
||||
self._init_task(task_id, status="processing")
|
||||
self.upload_progress[task_id] = {
|
||||
"status": "processing",
|
||||
"file_index": 0,
|
||||
@@ -1033,18 +1230,7 @@ class KnowledgeBaseRoute(Route):
|
||||
}
|
||||
|
||||
# 创建进度回调函数
|
||||
async def progress_callback(stage, current, total):
|
||||
if task_id in self.upload_progress:
|
||||
self.upload_progress[task_id].update(
|
||||
{
|
||||
"status": "processing",
|
||||
"file_index": 0,
|
||||
"file_name": f"URL: {url}",
|
||||
"stage": stage,
|
||||
"current": current,
|
||||
"total": total,
|
||||
},
|
||||
)
|
||||
progress_callback = self._make_progress_callback(task_id, 0, f"URL: {url}")
|
||||
|
||||
# 上传文档
|
||||
doc = await kb_helper.upload_from_url(
|
||||
@@ -1069,20 +1255,9 @@ class KnowledgeBaseRoute(Route):
|
||||
"failed_count": 0,
|
||||
}
|
||||
|
||||
self.upload_tasks[task_id] = {
|
||||
"status": "completed",
|
||||
"result": result,
|
||||
"error": None,
|
||||
}
|
||||
self.upload_progress[task_id]["status"] = "completed"
|
||||
self._set_task_result(task_id, "completed", result=result)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"后台上传URL任务 {task_id} 失败: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
self.upload_tasks[task_id] = {
|
||||
"status": "failed",
|
||||
"result": None,
|
||||
"error": str(e),
|
||||
}
|
||||
if task_id in self.upload_progress:
|
||||
self.upload_progress[task_id]["status"] = "failed"
|
||||
self._set_task_result(task_id, "failed", error=str(e))
|
||||
|
||||
@@ -82,7 +82,7 @@ class PlatformRoute(Route):
|
||||
"""
|
||||
for platform in self.platform_manager.platform_insts:
|
||||
if platform.config.get("webhook_uuid") == webhook_uuid:
|
||||
if platform.config.get("unified_webhook_mode", False):
|
||||
if platform.unified_webhook():
|
||||
return platform
|
||||
return None
|
||||
|
||||
|
||||
@@ -124,7 +124,11 @@ class PluginRoute(Route):
|
||||
session.get(url) as response,
|
||||
):
|
||||
if response.status == 200:
|
||||
remote_data = await response.json()
|
||||
try:
|
||||
remote_data = await response.json()
|
||||
except aiohttp.ContentTypeError:
|
||||
remote_text = await response.text()
|
||||
remote_data = json.loads(remote_text)
|
||||
|
||||
# 检查远程数据是否为空
|
||||
if not remote_data or (
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
## What's Changed
|
||||
|
||||
### 新增
|
||||
|
||||
- 支持自定义插件源。
|
||||
- 支持飞书(Lark)的 Webhook 模式(将事件推送至开发者服务器)。
|
||||
- 支持 “禁用自带指令” 快捷配置项,启用后将禁用所有 AstrBot 自带指令。入口: WebUI -> 配置文件 -> 平台配置。
|
||||
|
||||
### 优化
|
||||
|
||||
- 从 WebUI 移除了开发版本渠道。
|
||||
- 当试图测试"Agent Runner"时,提示前往配置文件页测试。
|
||||
- WebUI 列表项支持批量粘贴、回车创建项目。
|
||||
|
||||
### 修复
|
||||
|
||||
- Gemini API 部分调用失败的问题。
|
||||
- WebUI 插件安装加载 Dialog 关闭按钮在手机端下显示异常的问题。
|
||||
- 部分情况下,WebUI 日志显示不全的问题。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { useCommonStore } from '@/stores/common';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import axios from 'axios';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -24,8 +25,6 @@ import { storeToRefs } from 'pinia';
|
||||
export default {
|
||||
name: 'ConsoleDisplayer',
|
||||
data() {
|
||||
const commonStore = useCommonStore();
|
||||
const { log_cache } = storeToRefs(commonStore);
|
||||
return {
|
||||
autoScroll: true, // 默认开启自动滚动
|
||||
logColorAnsiMap: {
|
||||
@@ -38,7 +37,6 @@ export default {
|
||||
'\u001b[32m': 'color: #00FF00;', // green
|
||||
'default': 'color: #FFFFFF;'
|
||||
},
|
||||
logCache: log_cache,
|
||||
historyNum_: -1,
|
||||
logLevels: ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
|
||||
selectedLevels: [0, 1, 2, 3, 4], // 默认选中所有级别
|
||||
@@ -48,7 +46,17 @@ export default {
|
||||
'WARNING': 'amber',
|
||||
'ERROR': 'red',
|
||||
'CRITICAL': 'purple'
|
||||
}
|
||||
},
|
||||
lastProcessedTime: 0, // 记录最后处理的日志时间戳
|
||||
localLogCache: [], // 本地日志缓存
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
commonStore() {
|
||||
return useCommonStore();
|
||||
},
|
||||
logCache() {
|
||||
return this.commonStore.log_cache;
|
||||
}
|
||||
},
|
||||
props: {
|
||||
@@ -63,13 +71,39 @@ export default {
|
||||
},
|
||||
watch: {
|
||||
logCache: {
|
||||
handler(val) {
|
||||
const lastLog = val[this.logCache.length - 1];
|
||||
if (lastLog && this.isLevelSelected(lastLog.level)) {
|
||||
this.printLog(lastLog.data);
|
||||
handler(newVal) {
|
||||
// 基于 timestamp 处理新增的日志
|
||||
if (newVal && newVal.length > 0) {
|
||||
// 确保 DOM 已经准备好
|
||||
this.$nextTick(() => {
|
||||
// 合并到本地缓存并按时间排序
|
||||
const newLogs = newVal.filter(log => log.time > this.lastProcessedTime);
|
||||
|
||||
if (newLogs.length > 0) {
|
||||
this.localLogCache.push(...newLogs);
|
||||
// 按时间戳排序
|
||||
this.localLogCache.sort((a, b) => a.time - b.time);
|
||||
|
||||
// 只保留最新的 log_cache_max_len 条
|
||||
if (this.localLogCache.length > this.commonStore.log_cache_max_len) {
|
||||
this.localLogCache.splice(0, this.localLogCache.length - this.commonStore.log_cache_max_len);
|
||||
}
|
||||
|
||||
// 显示新日志
|
||||
newLogs.forEach(logItem => {
|
||||
if (this.isLevelSelected(logItem.level)) {
|
||||
this.printLog(logItem.data);
|
||||
}
|
||||
});
|
||||
|
||||
// 更新最后处理时间
|
||||
this.lastProcessedTime = Math.max(...newLogs.map(log => log.time));
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
deep: true
|
||||
deep: true,
|
||||
immediate: false
|
||||
},
|
||||
selectedLevels: {
|
||||
handler() {
|
||||
@@ -78,14 +112,37 @@ export default {
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.logCache.length === 0) {
|
||||
this.delayInit()
|
||||
} else {
|
||||
this.init()
|
||||
}
|
||||
async mounted() {
|
||||
// 请求历史日志
|
||||
await this.fetchLogHistory();
|
||||
|
||||
// 等待 DOM 准备好后,显示历史日志
|
||||
this.$nextTick(() => {
|
||||
if (this.localLogCache.length > 0) {
|
||||
this.localLogCache.forEach(logItem => {
|
||||
if (this.isLevelSelected(logItem.level)) {
|
||||
this.printLog(logItem.data);
|
||||
}
|
||||
});
|
||||
// 更新最后处理时间
|
||||
this.lastProcessedTime = Math.max(...this.localLogCache.map(log => log.time));
|
||||
}
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
async fetchLogHistory() {
|
||||
try {
|
||||
const res = await axios.get('/api/log-history');
|
||||
if (res.data.data.logs && res.data.data.logs.length > 0) {
|
||||
this.localLogCache = [...res.data.data.logs];
|
||||
// 按时间戳排序
|
||||
this.localLogCache.sort((a, b) => a.time - b.time);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch log history:', err);
|
||||
}
|
||||
},
|
||||
|
||||
getLevelColor(level) {
|
||||
return this.levelColors[level] || 'grey';
|
||||
},
|
||||
@@ -101,41 +158,22 @@ export default {
|
||||
},
|
||||
|
||||
refreshDisplay() {
|
||||
// 清空现有的显示
|
||||
const termElement = document.getElementById('term');
|
||||
if (termElement) {
|
||||
termElement.innerHTML = '';
|
||||
}
|
||||
|
||||
// 重新显示符合筛选条件的日志
|
||||
this.init();
|
||||
},
|
||||
|
||||
delayInit() {
|
||||
if (this.logCache.length === 0) {
|
||||
setTimeout(() => {
|
||||
this.delayInit()
|
||||
}, 500)
|
||||
} else {
|
||||
this.init()
|
||||
}
|
||||
},
|
||||
|
||||
init() {
|
||||
this.historyNum_ = parseInt(this.historyNum)
|
||||
let i = 0
|
||||
for (let log of this.logCache) {
|
||||
if (this.isLevelSelected(log.level)) { // 只显示选中级别的日志
|
||||
if (this.historyNum_ != -1 && i >= this.logCache.length - this.historyNum_) {
|
||||
this.printLog(log.data)
|
||||
++i
|
||||
} else if (this.historyNum_ == -1) {
|
||||
this.printLog(log.data)
|
||||
}
|
||||
|
||||
// 重新显示所有符合筛选条件的日志
|
||||
if (this.localLogCache && this.localLogCache.length > 0) {
|
||||
this.localLogCache.forEach(logItem => {
|
||||
if (this.isLevelSelected(logItem.level)) {
|
||||
this.printLog(logItem.data);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
toggleAutoScroll() {
|
||||
this.autoScroll = !this.autoScroll;
|
||||
},
|
||||
@@ -143,6 +181,11 @@ export default {
|
||||
printLog(log) {
|
||||
// append 一个 span 标签到 term,block 的方式
|
||||
let ele = document.getElementById('term')
|
||||
if (!ele) {
|
||||
console.warn('term element not found, skipping log print');
|
||||
return;
|
||||
}
|
||||
|
||||
let span = document.createElement('pre')
|
||||
let style = this.logColorAnsiMap['default']
|
||||
for (let key in this.logColorAnsiMap) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<div>
|
||||
<span v-if="!modelValue || modelValue.length === 0" style="color: rgb(var(--v-theme-primaryText));">
|
||||
暂无项目
|
||||
{{ t('core.common.list.noItems') }}
|
||||
</span>
|
||||
<div v-else class="d-flex flex-wrap ga-2">
|
||||
<v-chip v-for="item in displayItems" :key="item" size="x-small" label color="primary">
|
||||
@@ -14,7 +14,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<v-btn size="small" color="primary" variant="tonal" @click="openDialog">
|
||||
{{ buttonText }}
|
||||
{{ buttonText || t('core.common.list.modifyButton') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
@@ -22,17 +22,43 @@
|
||||
<v-dialog v-model="dialog" max-width="600px">
|
||||
<v-card>
|
||||
<v-card-title class="text-h3 py-4" style="font-weight: normal;">
|
||||
{{ dialogTitle }}
|
||||
{{ dialogTitle || t('core.common.list.editTitle') }}
|
||||
</v-card-title>
|
||||
|
||||
<!-- Add new item section - moved to top -->
|
||||
<v-card-text class="pa-4 pb-2">
|
||||
<div class="d-flex align-center ga-2">
|
||||
<v-text-field
|
||||
v-model="newItem"
|
||||
:label="t('core.common.list.addItemPlaceholder')"
|
||||
@keyup.enter="addItem"
|
||||
clearable
|
||||
hide-details
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
:placeholder="t('core.common.list.inputPlaceholder')"
|
||||
class="flex-grow-1">
|
||||
</v-text-field>
|
||||
<v-btn
|
||||
@click="showBatchImport = true"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
size="small">
|
||||
<v-icon size="small">mdi-import</v-icon>
|
||||
{{ t('core.common.list.batchImport') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-text class="pa-0" style="max-height: 400px; overflow-y: auto;">
|
||||
<v-list v-if="localItems.length > 0" density="compact">
|
||||
<v-list-item
|
||||
v-for="(item, index) in localItems"
|
||||
:key="index"
|
||||
rounded="md"
|
||||
class="ma-1">
|
||||
<v-list-item-title v-if="editIndex !== index">
|
||||
class="ma-1 list-item-clickable"
|
||||
@click="startEdit(index, item)">
|
||||
<v-list-item-title v-if="editIndex !== index" class="item-text">
|
||||
{{ item }}
|
||||
</v-list-item-title>
|
||||
<v-text-field
|
||||
@@ -43,23 +69,27 @@
|
||||
density="compact"
|
||||
@keyup.enter="saveEdit"
|
||||
@keyup.esc="cancelEdit"
|
||||
@click.stop
|
||||
autofocus
|
||||
></v-text-field>
|
||||
|
||||
<template v-slot:append>
|
||||
<div v-if="editIndex !== index" class="d-flex">
|
||||
<v-btn @click="startEdit(index, item)" variant="plain" icon size="small">
|
||||
<v-icon>mdi-pencil</v-icon>
|
||||
</v-btn>
|
||||
<v-btn @click="removeItem(index)" variant="plain" icon size="small">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
<div v-else class="d-flex">
|
||||
<v-btn @click="saveEdit" variant="plain" color="success" icon size="small">
|
||||
<div class="d-flex">
|
||||
<v-btn
|
||||
v-if="editIndex === index"
|
||||
@click.stop="saveEdit"
|
||||
variant="plain"
|
||||
color="success"
|
||||
icon
|
||||
size="small">
|
||||
<v-icon>mdi-check</v-icon>
|
||||
</v-btn>
|
||||
<v-btn @click="cancelEdit" variant="plain" color="error" icon size="small">
|
||||
<v-btn
|
||||
@click.stop="editIndex === index ? cancelEdit() : removeItem(index)"
|
||||
variant="plain"
|
||||
:color="editIndex === index ? 'error' : 'default'"
|
||||
icon
|
||||
size="small">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
@@ -69,34 +99,43 @@
|
||||
|
||||
<div v-else class="text-center py-8">
|
||||
<v-icon size="64" color="grey-lighten-1">mdi-format-list-bulleted</v-icon>
|
||||
<p class="text-grey mt-4">暂无项目</p>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<!-- Add new item section -->
|
||||
<v-card-text class="pa-4">
|
||||
<div class="d-flex align-center ga-2">
|
||||
<v-text-field
|
||||
v-model="newItem"
|
||||
:label="t('core.common.list.addItemPlaceholder')"
|
||||
@keyup.enter="addItem"
|
||||
clearable
|
||||
hide-details
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="flex-grow-1">
|
||||
</v-text-field>
|
||||
<v-btn @click="addItem" variant="tonal" color="primary">
|
||||
<v-icon>mdi-plus</v-icon>
|
||||
{{ t('core.common.list.addButton') }}
|
||||
</v-btn>
|
||||
<p class="text-grey mt-4">{{ t('core.common.list.noItemsHint') }}</p>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="text" @click="cancelDialog">取消</v-btn>
|
||||
<v-btn color="primary" @click="confirmDialog">确认</v-btn>
|
||||
<v-btn variant="text" @click="cancelDialog">{{ t('core.common.cancel') }}</v-btn>
|
||||
<v-btn color="primary" @click="confirmDialog">{{ t('core.common.confirm') }}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Batch Import Dialog -->
|
||||
<v-dialog v-model="showBatchImport" max-width="600px">
|
||||
<v-card>
|
||||
<v-card-title class="text-h3 py-4" style="font-weight: normal;">
|
||||
{{ t('core.common.list.batchImportTitle') }}
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<v-textarea
|
||||
v-model="batchImportText"
|
||||
:label="t('core.common.list.batchImportLabel')"
|
||||
:placeholder="t('core.common.list.batchImportPlaceholder')"
|
||||
rows="10"
|
||||
variant="outlined"
|
||||
:hint="t('core.common.list.batchImportHint')"
|
||||
persistent-hint
|
||||
></v-textarea>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="text" @click="cancelBatchImport">{{ t('core.common.cancel') }}</v-btn>
|
||||
<v-btn color="primary" @click="confirmBatchImport">
|
||||
{{ t('core.common.list.batchImportButton', { count: batchImportPreviewCount }) }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
@@ -139,12 +178,24 @@ const originalItems = ref([])
|
||||
const newItem = ref('')
|
||||
const editIndex = ref(-1)
|
||||
const editItem = ref('')
|
||||
const showBatchImport = ref(false)
|
||||
const batchImportText = ref('')
|
||||
|
||||
// 计算要显示的项目
|
||||
const displayItems = computed(() => {
|
||||
return props.modelValue.slice(0, props.maxDisplayItems)
|
||||
})
|
||||
|
||||
// 计算批量导入的项目数量
|
||||
const batchImportPreviewCount = computed(() => {
|
||||
if (!batchImportText.value) return 0
|
||||
return batchImportText.value
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0)
|
||||
.length
|
||||
})
|
||||
|
||||
// 监听 modelValue 变化,同步到 localItems
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
localItems.value = [...(newValue || [])]
|
||||
@@ -199,6 +250,24 @@ function cancelDialog() {
|
||||
newItem.value = ''
|
||||
dialog.value = false
|
||||
}
|
||||
|
||||
function confirmBatchImport() {
|
||||
if (batchImportText.value.trim()) {
|
||||
const newItems = batchImportText.value
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0)
|
||||
|
||||
localItems.value.push(...newItems)
|
||||
batchImportText.value = ''
|
||||
showBatchImport.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancelBatchImport() {
|
||||
batchImportText.value = ''
|
||||
showBatchImport.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -206,8 +275,16 @@ function cancelDialog() {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.v-list-item:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.04);
|
||||
.list-item-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.list-item-clickable:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
|
||||
.item-text {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.v-chip {
|
||||
|
||||
@@ -66,7 +66,18 @@
|
||||
},
|
||||
"list": {
|
||||
"addItemPlaceholder": "Add new item, press Enter to confirm",
|
||||
"addButton": "Add"
|
||||
"addButton": "Add",
|
||||
"batchImport": "Batch Import",
|
||||
"batchImportTitle": "Batch Import",
|
||||
"batchImportLabel": "One item per line",
|
||||
"batchImportPlaceholder": "Example:\nItem 1\nItem 2\nItem 3\nItem 4",
|
||||
"batchImportHint": "Each line will be treated as a separate item, empty lines will be ignored",
|
||||
"batchImportButton": "Import {count} items",
|
||||
"noItems": "No items",
|
||||
"noItemsHint": "No items yet, type in the input above and press Enter to add",
|
||||
"inputPlaceholder": "Type and press Enter to add",
|
||||
"editTitle": "Edit List Items",
|
||||
"modifyButton": "Modify"
|
||||
},
|
||||
"itemCard": {
|
||||
"enabled": "Enabled",
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"batch": {
|
||||
"deleteSelected": "Delete Selected ({count})"
|
||||
"deleteSelected": "Delete Selected ({count})",
|
||||
"exportSelected": "Export Selected ({count})"
|
||||
},
|
||||
"pagination": {
|
||||
"itemsPerPage": "Items per page",
|
||||
@@ -76,7 +77,8 @@
|
||||
"message": "Are you sure you want to delete the selected {count} conversations? This action cannot be undone, please proceed with caution!",
|
||||
"andMore": "and {count} more",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Batch Delete"
|
||||
"confirm": "Batch Delete",
|
||||
"warning": "Warning: This action cannot be undone!"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
@@ -92,6 +94,9 @@
|
||||
"noItemSelected": "Please select conversations to delete first",
|
||||
"batchDeleteSuccess": "Successfully deleted {count} conversations",
|
||||
"batchDeleteError": "Batch delete failed",
|
||||
"batchDeletePartial": "Delete completed: {deleted} successful, {failed} failed"
|
||||
"batchDeletePartial": "Delete completed: {deleted} successful, {failed} failed",
|
||||
"exportSuccess": "Export successful",
|
||||
"exportError": "Export failed",
|
||||
"noItemSelectedForExport": "Please select conversations to export first"
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,18 @@
|
||||
},
|
||||
"list": {
|
||||
"addItemPlaceholder": "添加新项,按回车确认添加",
|
||||
"addButton": "添加"
|
||||
"addButton": "添加",
|
||||
"batchImport": "批量导入",
|
||||
"batchImportTitle": "批量导入",
|
||||
"batchImportLabel": "每行一个项目",
|
||||
"batchImportPlaceholder": "例如:\n项目1\n项目2\n项目3\n项目4",
|
||||
"batchImportHint": "每行将作为一个单独的项目,空行会被自动忽略",
|
||||
"batchImportButton": "导入 {count} 项",
|
||||
"noItems": "暂无项目",
|
||||
"noItemsHint": "暂无项目,在上方输入框输入后按回车添加",
|
||||
"inputPlaceholder": "输入后按回车添加",
|
||||
"editTitle": "修改列表项",
|
||||
"modifyButton": "修改"
|
||||
},
|
||||
"itemCard": {
|
||||
"enabled": "已启用",
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"refresh": "刷新"
|
||||
},
|
||||
"batch": {
|
||||
"deleteSelected": "删除选中 ({count})"
|
||||
"deleteSelected": "删除选中 ({count})",
|
||||
"exportSelected": "导出选中 ({count})"
|
||||
},
|
||||
"pagination": {
|
||||
"itemsPerPage": "每页",
|
||||
@@ -76,7 +77,8 @@
|
||||
"message": "确定要删除选中的 {count} 个对话吗?此操作不可恢复,请谨慎操作!",
|
||||
"andMore": "等 {count} 个",
|
||||
"cancel": "取消",
|
||||
"confirm": "批量删除"
|
||||
"confirm": "批量删除",
|
||||
"warning": "警告:此操作不可撤销!"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
@@ -92,6 +94,9 @@
|
||||
"noItemSelected": "请先选择要删除的对话",
|
||||
"batchDeleteSuccess": "成功删除 {count} 个对话",
|
||||
"batchDeleteError": "批量删除失败",
|
||||
"batchDeletePartial": "删除完成:成功 {deleted} 个,失败 {failed} 个"
|
||||
"batchDeletePartial": "删除完成:成功 {deleted} 个,失败 {failed} 个",
|
||||
"exportSuccess": "导出成功",
|
||||
"exportError": "导出失败",
|
||||
"noItemSelectedForExport": "请先选择要导出的对话"
|
||||
}
|
||||
}
|
||||
@@ -16,21 +16,6 @@ export const useCommonStore = defineStore({
|
||||
}),
|
||||
actions: {
|
||||
async createEventSource() {
|
||||
|
||||
const fetchLogHistory = async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/log-history');
|
||||
if (res.data.data.logs) {
|
||||
this.log_cache.push(...res.data.data.logs);
|
||||
} else {
|
||||
this.log_cache = [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch log history:', err);
|
||||
}
|
||||
};
|
||||
await fetchLogHistory();
|
||||
|
||||
if (this.eventSource) {
|
||||
return
|
||||
}
|
||||
@@ -54,25 +39,9 @@ export const useCommonStore = defineStore({
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
let incompleteLine = ""; // 用于存储不完整的行
|
||||
|
||||
const handleIncompleteLine = (line) => {
|
||||
incompleteLine += line;
|
||||
// if can parse as JSON, return it
|
||||
try {
|
||||
const data_json = JSON.parse(incompleteLine);
|
||||
incompleteLine = ""; // 清空不完整行
|
||||
return data_json;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
let bufferedText = '';
|
||||
|
||||
const processStream = ({ done, value }) => {
|
||||
// get bytes length
|
||||
const bytesLength = value ? value.byteLength : 0;
|
||||
console.log(`Received ${bytesLength} bytes from live log`);
|
||||
if (done) {
|
||||
console.log('SSE stream closed');
|
||||
setTimeout(() => {
|
||||
@@ -82,44 +51,41 @@ export const useCommonStore = defineStore({
|
||||
return;
|
||||
}
|
||||
|
||||
const text = decoder.decode(value);
|
||||
const lines = text.split('\n\n');
|
||||
lines.forEach(line => {
|
||||
if (!line.trim()) {
|
||||
// Accumulate partial chunks; SSE data may split JSON across reads.
|
||||
const text = decoder.decode(value, { stream: true });
|
||||
bufferedText += text;
|
||||
|
||||
// Split completed events; keep the trailing partial in buffer.
|
||||
const segments = bufferedText.split('\n\n');
|
||||
bufferedText = segments.pop() || '';
|
||||
|
||||
segments.forEach(segment => {
|
||||
const line = segment.trim();
|
||||
if (!line.startsWith('data: ')) {
|
||||
return;
|
||||
}
|
||||
if (line.startsWith('data:')) {
|
||||
const data = line.substring(5).trim();
|
||||
// {"type":"log","data":"[2021-08-01 00:00:00] INFO: Hello, world!"}
|
||||
let data_json = {}
|
||||
try {
|
||||
data_json = JSON.parse(data);
|
||||
} catch (e) {
|
||||
console.warn('Invalid JSON:', data);
|
||||
// 尝试处理不完整的行
|
||||
const parsedData = handleIncompleteLine(data);
|
||||
if (parsedData) {
|
||||
data_json = parsedData;
|
||||
} else {
|
||||
return; // 如果无法解析,跳过当前行
|
||||
}
|
||||
|
||||
const logLine = line.replace('data: ', '').trim();
|
||||
if (!logLine) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const logObject = JSON.parse(logLine);
|
||||
// give a uuid if not exists
|
||||
if (!logObject.uuid) {
|
||||
logObject.uuid = crypto.randomUUID();
|
||||
}
|
||||
if (data_json.type === 'log') {
|
||||
this.log_cache.push(data_json);
|
||||
if (this.log_cache.length > this.log_cache_max_len) {
|
||||
this.log_cache.shift();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const parsedData = handleIncompleteLine(line);
|
||||
if (parsedData && parsedData.type === 'log') {
|
||||
this.log_cache.push(parsedData);
|
||||
if (this.log_cache.length > this.log_cache_max_len) {
|
||||
this.log_cache.shift();
|
||||
}
|
||||
this.log_cache.push(logObject);
|
||||
// Limit log cache size
|
||||
if (this.log_cache.length > this.log_cache_max_len) {
|
||||
this.log_cache.splice(0, this.log_cache.length - this.log_cache_max_len);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to parse SSE log line, skipping:', err, logLine);
|
||||
}
|
||||
});
|
||||
|
||||
return reader.read().then(processStream);
|
||||
};
|
||||
|
||||
|
||||
@@ -40,6 +40,17 @@
|
||||
:loading="loading" size="small" class="mr-2">
|
||||
{{ tm('history.refresh') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="selectedItems.length > 0"
|
||||
color="success"
|
||||
prepend-icon="mdi-download"
|
||||
variant="tonal"
|
||||
@click="exportConversations"
|
||||
:disabled="loading"
|
||||
size="small"
|
||||
class="mr-2">
|
||||
{{ tm('batch.exportSelected', { count: selectedItems.length }) }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="selectedItems.length > 0"
|
||||
color="error"
|
||||
@@ -910,6 +921,53 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
// 导出选中的对话
|
||||
async exportConversations() {
|
||||
if (this.selectedItems.length === 0) {
|
||||
this.showErrorMessage(this.tm('messages.noItemSelectedForExport'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
// 准备导出的数据
|
||||
const conversations = this.selectedItems.map(item => ({
|
||||
user_id: item.user_id,
|
||||
cid: item.cid
|
||||
}));
|
||||
|
||||
const response = await axios.post('/api/conversation/export', {
|
||||
conversations: conversations
|
||||
}, {
|
||||
responseType: 'blob' // 重要:告诉 axios 响应是一个 blob
|
||||
});
|
||||
|
||||
// 创建一个下载链接
|
||||
const url = window.URL.createObjectURL(response.data);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
|
||||
// 生成文件名(使用时间戳)
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
|
||||
const filename = `conversations_export_${timestamp}.jsonl`;
|
||||
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
// 清理
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
this.showSuccessMessage(this.tm('messages.exportSuccess'));
|
||||
} catch (error) {
|
||||
console.error(this.tm('messages.exportError'), error);
|
||||
this.showErrorMessage(error.response?.data?.message || error.message || this.tm('messages.exportError'));
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 格式化时间戳
|
||||
formatTimestamp(timestamp) {
|
||||
if (!timestamp) return this.tm('status.unknown');
|
||||
|
||||
@@ -1504,7 +1504,7 @@ watch(marketSearch, (newVal) => {
|
||||
<v-dialog v-model="loadingDialog.show" width="700" persistent>
|
||||
<v-card>
|
||||
<v-card-title class="text-h5">{{ loadingDialog.title }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-card-text style="max-height: calc(100vh - 200px); overflow-y: auto;">
|
||||
<v-progress-linear v-if="loadingDialog.statusCode === 0" indeterminate color="primary"
|
||||
class="mb-4"></v-progress-linear>
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
{{ getPlatformStat(item.id)?.error_count }} {{ tm('runtimeStatus.errors') }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<div v-if="item.unified_webhook_mode && item.webhook_uuid" class="webhook-info">
|
||||
<div v-if="getPlatformStat(item.id)?.unified_webhook && item.webhook_uuid" class="webhook-info">
|
||||
<v-chip
|
||||
size="small"
|
||||
color="primary"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "AstrBot"
|
||||
version = "4.8.0"
|
||||
version = "4.9.0"
|
||||
description = "Easy-to-use multi-platform LLM chatbot and development framework"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from quart import Quart
|
||||
|
||||
from astrbot.core import LogBroker
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from astrbot.core.db.sqlite import SQLiteDatabase
|
||||
from astrbot.core.knowledge_base.kb_helper import KBHelper
|
||||
from astrbot.core.knowledge_base.models import KBDocument
|
||||
from astrbot.dashboard.server import AstrBotDashboard
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="module")
|
||||
async def core_lifecycle_td(tmp_path_factory):
|
||||
"""Creates and initializes a core lifecycle instance with a temporary database."""
|
||||
tmp_db_path = tmp_path_factory.mktemp("data") / "test_data_kb.db"
|
||||
db = SQLiteDatabase(str(tmp_db_path))
|
||||
log_broker = LogBroker()
|
||||
core_lifecycle = AstrBotCoreLifecycle(log_broker, db)
|
||||
await core_lifecycle.initialize()
|
||||
|
||||
# Mock kb_manager and kb_helper
|
||||
kb_manager = MagicMock()
|
||||
kb_helper = AsyncMock(spec=KBHelper)
|
||||
|
||||
# Configure get_kb to be an async mock that returns kb_helper
|
||||
kb_manager.get_kb = AsyncMock(return_value=kb_helper)
|
||||
|
||||
# Mock upload_document return value
|
||||
mock_doc = KBDocument(
|
||||
doc_id="test_doc_id",
|
||||
kb_id="test_kb_id",
|
||||
doc_name="test_file.txt",
|
||||
file_type="txt",
|
||||
file_size=100,
|
||||
file_path="",
|
||||
chunk_count=2,
|
||||
media_count=0,
|
||||
)
|
||||
kb_helper.upload_document.return_value = mock_doc
|
||||
|
||||
# kb_manager.get_kb.return_value = kb_helper # Removed this line as it's handled above
|
||||
core_lifecycle.kb_manager = kb_manager
|
||||
|
||||
try:
|
||||
yield core_lifecycle
|
||||
finally:
|
||||
try:
|
||||
_stop_res = core_lifecycle.stop()
|
||||
if asyncio.iscoroutine(_stop_res):
|
||||
await _stop_res
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app(core_lifecycle_td: AstrBotCoreLifecycle):
|
||||
"""Creates a Quart app instance for testing."""
|
||||
shutdown_event = asyncio.Event()
|
||||
server = AstrBotDashboard(core_lifecycle_td, core_lifecycle_td.db, shutdown_event)
|
||||
return server.app
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="module")
|
||||
async def authenticated_header(app: Quart, core_lifecycle_td: AstrBotCoreLifecycle):
|
||||
"""Handles login and returns an authenticated header."""
|
||||
test_client = app.test_client()
|
||||
response = await test_client.post(
|
||||
"/api/auth/login",
|
||||
json={
|
||||
"username": core_lifecycle_td.astrbot_config["dashboard"]["username"],
|
||||
"password": core_lifecycle_td.astrbot_config["dashboard"]["password"],
|
||||
},
|
||||
)
|
||||
data = await response.get_json()
|
||||
assert data["status"] == "ok"
|
||||
token = data["data"]["token"]
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_documents(
|
||||
app: Quart, authenticated_header: dict, core_lifecycle_td: AstrBotCoreLifecycle
|
||||
):
|
||||
"""Tests the import documents functionality."""
|
||||
test_client = app.test_client()
|
||||
|
||||
# Test data
|
||||
import_data = {
|
||||
"kb_id": "test_kb_id",
|
||||
"documents": [
|
||||
{"file_name": "test_file_1.txt", "chunks": ["chunk1", "chunk2"]},
|
||||
{"file_name": "test_file_2.md", "chunks": ["chunk3", "chunk4", "chunk5"]},
|
||||
],
|
||||
}
|
||||
|
||||
# Send request
|
||||
response = await test_client.post(
|
||||
"/api/kb/document/import", json=import_data, headers=authenticated_header
|
||||
)
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data["status"] == "ok"
|
||||
assert "task_id" in data["data"]
|
||||
assert data["data"]["doc_count"] == 2
|
||||
|
||||
task_id = data["data"]["task_id"]
|
||||
|
||||
# Wait for background task to complete (mocked)
|
||||
# Since we mocked upload_document, it should be fast, but we might need to poll progress
|
||||
for _ in range(10):
|
||||
progress_response = await test_client.get(
|
||||
f"/api/kb/document/upload/progress?task_id={task_id}",
|
||||
headers=authenticated_header,
|
||||
)
|
||||
progress_data = await progress_response.get_json()
|
||||
if progress_data["data"]["status"] == "completed":
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
assert progress_data["data"]["status"] == "completed"
|
||||
result = progress_data["data"]["result"]
|
||||
assert result["success_count"] == 2
|
||||
assert result["failed_count"] == 0
|
||||
|
||||
# Verify kb_helper.upload_document was called correctly
|
||||
kb_helper = await core_lifecycle_td.kb_manager.get_kb("test_kb_id")
|
||||
assert kb_helper.upload_document.call_count == 2
|
||||
|
||||
# Check first call arguments
|
||||
call_args_list = kb_helper.upload_document.call_args_list
|
||||
|
||||
# First document
|
||||
args1, kwargs1 = call_args_list[0]
|
||||
assert kwargs1["file_name"] == "test_file_1.txt"
|
||||
assert kwargs1["pre_chunked_text"] == ["chunk1", "chunk2"]
|
||||
|
||||
# Second document
|
||||
args2, kwargs2 = call_args_list[1]
|
||||
assert kwargs2["file_name"] == "test_file_2.md"
|
||||
assert kwargs2["pre_chunked_text"] == ["chunk3", "chunk4", "chunk5"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_documents_invalid_input(app: Quart, authenticated_header: dict):
|
||||
"""Tests import documents with invalid input."""
|
||||
test_client = app.test_client()
|
||||
|
||||
# Missing kb_id
|
||||
response = await test_client.post(
|
||||
"/api/kb/document/import", json={"documents": []}, headers=authenticated_header
|
||||
)
|
||||
data = await response.get_json()
|
||||
assert data["status"] == "error"
|
||||
assert "缺少参数 kb_id" in data["message"]
|
||||
|
||||
# Missing documents
|
||||
response = await test_client.post(
|
||||
"/api/kb/document/import",
|
||||
json={"kb_id": "test_kb"},
|
||||
headers=authenticated_header,
|
||||
)
|
||||
data = await response.get_json()
|
||||
assert data["status"] == "error"
|
||||
assert "缺少参数 documents" in data["message"]
|
||||
|
||||
# Invalid document format
|
||||
response = await test_client.post(
|
||||
"/api/kb/document/import",
|
||||
json={
|
||||
"kb_id": "test_kb",
|
||||
"documents": [{"file_name": "test"}], # Missing chunks
|
||||
},
|
||||
headers=authenticated_header,
|
||||
)
|
||||
data = await response.get_json()
|
||||
assert data["status"] == "error"
|
||||
assert "文档格式错误" in data["message"]
|
||||
|
||||
# Invalid chunks type
|
||||
response = await test_client.post(
|
||||
"/api/kb/document/import",
|
||||
json={
|
||||
"kb_id": "test_kb",
|
||||
"documents": [{"file_name": "test", "chunks": "not-a-list"}],
|
||||
},
|
||||
headers=authenticated_header,
|
||||
)
|
||||
data = await response.get_json()
|
||||
assert data["status"] == "error"
|
||||
assert "chunks 必须是列表" in data["message"]
|
||||
|
||||
# Invalid chunks content
|
||||
response = await test_client.post(
|
||||
"/api/kb/document/import",
|
||||
json={
|
||||
"kb_id": "test_kb",
|
||||
"documents": [{"file_name": "test", "chunks": ["valid", ""]}],
|
||||
},
|
||||
headers=authenticated_header,
|
||||
)
|
||||
data = await response.get_json()
|
||||
assert data["status"] == "error"
|
||||
assert "chunks 必须是非空字符串列表" in data["message"]
|
||||
Reference in New Issue
Block a user