Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 565c371e5c | |||
| a1c9dc5d01 |
@@ -3,8 +3,8 @@
|
||||
|
||||
### Modifications / 改动点
|
||||
|
||||
<!--Please summarize your changes: What core files were modified? What functionality was implemented?-->
|
||||
<!--请总结你的改动:哪些核心文件被修改了?实现了什么功能?-->
|
||||
<!--Please summarize your changes: What core files were modified? What functionality was implemented?-->
|
||||
|
||||
- [x] This is NOT a breaking change. / 这不是一个破坏性变更。
|
||||
<!-- If your changes is a breaking change, please uncheck the checkbox above -->
|
||||
@@ -21,14 +21,23 @@
|
||||
<!--If merged, your code will serve tens of thousands of users! Please double-check the following items before submitting.-->
|
||||
<!--如果分支被合并,您的代码将服务于数万名用户!在提交前,请核查一下几点内容。-->
|
||||
|
||||
- [ ] 😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
|
||||
/ 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。
|
||||
- [ ] 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。
|
||||
/ If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
|
||||
|
||||
- [ ] 👀 My changes have been well-tested, **and "Verification Steps" and "Screenshots" have been provided above**.
|
||||
/ 我的更改经过了良好的测试,**并已在上方提供了“验证步骤”和“运行截图”**。
|
||||
- [ ] 👀 我的更改经过了良好的测试,**并已在上方提供了“验证步骤”和“运行截图”**。
|
||||
/ My changes have been well-tested, **and "Verification Steps" and "Screenshots" have been provided above**.
|
||||
|
||||
- [ ] 🤓 I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in `requirements.txt` and `pyproject.toml`.
|
||||
/ 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到 `requirements.txt` 和 `pyproject.toml` 文件相应位置。
|
||||
- [ ] 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到 `requirements.txt` 和 `pyproject.toml` 文件相应位置。
|
||||
/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in `requirements.txt` and `pyproject.toml`.
|
||||
|
||||
- [ ] 😮 My changes do not introduce malicious code.
|
||||
/ 我的更改没有引入恶意代码。
|
||||
- [ ] 😮 我的更改没有引入恶意代码。
|
||||
/ My changes do not introduce malicious code.
|
||||
|
||||
- [ ] ⚠️ 我已认真阅读并理解以上所有内容,确保本次提交符合规范。
|
||||
/ I have read and understood all the above and confirm this PR follows the rules.
|
||||
|
||||
- [ ] 🚀 我确保本次开发**基于 dev 分支**,并将代码合并至**开发分支**(除非极其紧急,才允许合并到主分支)。
|
||||
/ I confirm that this development is **based on the dev branch** and will be merged into the **development branch**, unless it is extremely urgent to merge into the main branch.
|
||||
|
||||
- [ ] ⚠️ 我**没有**认真阅读以上内容,直接提交。
|
||||
/ I **did not** read the above carefully before submitting.
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: github.event_name == 'push'
|
||||
uses: ncipollo/release-action@v1.21.0
|
||||
uses: ncipollo/release-action@v1.20.0
|
||||
with:
|
||||
tag: release-${{ github.sha }}
|
||||
owner: AstrBotDevs
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
name: PR Checklist Check
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, reopened, synchronize]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Check checklist
|
||||
id: check
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const body = context.payload.pull_request.body || "";
|
||||
const regex = /-\s*\[\s*x\s*\].*没有.*认真阅读/i;
|
||||
const bad = regex.test(body);
|
||||
core.setOutput("bad", bad);
|
||||
|
||||
- name: Close PR
|
||||
if: steps.check.outputs.bad == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
body: `检测到你勾选了“我没有认真阅读”,PR 已关闭。`
|
||||
});
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: pr.number,
|
||||
state: "closed"
|
||||
});
|
||||
@@ -1,53 +0,0 @@
|
||||
name: PR Title Check
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, reopened, synchronize]
|
||||
|
||||
jobs:
|
||||
title-format:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Validate PR title
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const title = (context.payload.pull_request.title || "").trim();
|
||||
// allow only:
|
||||
// feat: xxx
|
||||
// feat(scope): xxx
|
||||
const pattern = /^(feat)(\([a-z0-9-]+\))?:\s.+$/i;
|
||||
const isValid = pattern.test(title);
|
||||
const isSameRepo =
|
||||
context.payload.pull_request.head.repo.full_name === context.payload.repository.full_name;
|
||||
|
||||
if (!isValid) {
|
||||
if (isSameRepo) {
|
||||
try {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
body: [
|
||||
"⚠️ PR title format check failed.",
|
||||
"Required formats:",
|
||||
"- `feat: xxx`",
|
||||
"- `feat(scope): xxx`",
|
||||
"Please update your PR title and push again."
|
||||
].join("\n")
|
||||
});
|
||||
} catch (e) {
|
||||
core.warning(`Failed to post PR title comment: ${e.message}`);
|
||||
}
|
||||
} else {
|
||||
core.warning("Fork PR: comment permission is restricted; skip posting review comment.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
core.setFailed("Invalid PR title. Expected format: feat: xxx or feat(scope): xxx.");
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.20.1"
|
||||
__version__ = "4.20.0"
|
||||
|
||||
@@ -62,4 +62,4 @@ class HandoffTool(FunctionTool, Generic[TContext]):
|
||||
|
||||
def default_description(self, agent_name: str | None) -> str:
|
||||
agent_name = agent_name or "another"
|
||||
return f"Delegate tasks to {agent_name} agent to handle the request."
|
||||
return f"Delegate tasks to {self.name} agent to handle the request."
|
||||
|
||||
@@ -326,6 +326,7 @@ async def run_live_agent(
|
||||
|
||||
# 创建队列
|
||||
text_queue: asyncio.Queue[str | None] = asyncio.Queue()
|
||||
delta_queue: asyncio.Queue[str | None] = asyncio.Queue()
|
||||
# audio_queue stored bytes or (text, bytes)
|
||||
audio_queue: asyncio.Queue[bytes | tuple[str, bytes] | None] = asyncio.Queue()
|
||||
|
||||
@@ -334,6 +335,7 @@ async def run_live_agent(
|
||||
_run_agent_feeder(
|
||||
agent_runner,
|
||||
text_queue,
|
||||
delta_queue,
|
||||
max_step,
|
||||
show_tool_use,
|
||||
show_tool_call_result,
|
||||
@@ -353,32 +355,63 @@ async def run_live_agent(
|
||||
|
||||
# 3. 主循环:从 audio_queue 读取音频并 yield
|
||||
try:
|
||||
while True:
|
||||
queue_item = await audio_queue.get()
|
||||
delta_done = False
|
||||
audio_done = False
|
||||
while not (delta_done and audio_done):
|
||||
task_sources: dict[asyncio.Task, str] = {}
|
||||
if not delta_done:
|
||||
task = asyncio.create_task(delta_queue.get())
|
||||
task_sources[task] = "delta"
|
||||
if not audio_done:
|
||||
task = asyncio.create_task(audio_queue.get())
|
||||
task_sources[task] = "audio"
|
||||
|
||||
if queue_item is None:
|
||||
break
|
||||
done, pending = await asyncio.wait(
|
||||
list(task_sources),
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
text = None
|
||||
if isinstance(queue_item, tuple):
|
||||
text, audio_data = queue_item
|
||||
else:
|
||||
audio_data = queue_item
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
if pending:
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
|
||||
if not first_chunk_received:
|
||||
# 记录首帧延迟(从开始处理到收到第一个音频块)
|
||||
tts_first_frame_time = time.time() - tts_start_time
|
||||
first_chunk_received = True
|
||||
for task in done:
|
||||
source = task_sources[task]
|
||||
queue_item = task.result()
|
||||
if source == "delta":
|
||||
if queue_item is None:
|
||||
delta_done = True
|
||||
continue
|
||||
yield MessageChain(
|
||||
chain=[Plain(queue_item)], type="live_text_delta"
|
||||
)
|
||||
continue
|
||||
|
||||
# 将音频数据封装为 MessageChain
|
||||
import base64
|
||||
if queue_item is None:
|
||||
audio_done = True
|
||||
continue
|
||||
|
||||
audio_b64 = base64.b64encode(audio_data).decode("utf-8")
|
||||
comps: list[BaseMessageComponent] = [Plain(audio_b64)]
|
||||
if text:
|
||||
comps.append(Json(data={"text": text}))
|
||||
chain = MessageChain(chain=comps, type="audio_chunk")
|
||||
yield chain
|
||||
text = None
|
||||
if isinstance(queue_item, tuple):
|
||||
text, audio_data = queue_item
|
||||
else:
|
||||
audio_data = queue_item
|
||||
|
||||
if not first_chunk_received:
|
||||
# 记录首帧延迟(从开始处理到收到第一个音频块)
|
||||
tts_first_frame_time = time.time() - tts_start_time
|
||||
first_chunk_received = True
|
||||
|
||||
# 将音频数据封装为 MessageChain
|
||||
import base64
|
||||
|
||||
audio_b64 = base64.b64encode(audio_data).decode("utf-8")
|
||||
comps: list[BaseMessageComponent] = [Plain(audio_b64)]
|
||||
if text:
|
||||
comps.append(Json(data={"text": text}))
|
||||
chain = MessageChain(chain=comps, type="audio_chunk")
|
||||
yield chain
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Live Agent] 运行时发生错误: {e}", exc_info=True)
|
||||
@@ -421,6 +454,7 @@ async def run_live_agent(
|
||||
async def _run_agent_feeder(
|
||||
agent_runner: AgentRunner,
|
||||
text_queue: asyncio.Queue,
|
||||
delta_queue: asyncio.Queue,
|
||||
max_step: int,
|
||||
show_tool_use: bool,
|
||||
show_tool_call_result: bool,
|
||||
@@ -440,9 +474,13 @@ async def _run_agent_feeder(
|
||||
if chain is None:
|
||||
continue
|
||||
|
||||
if chain.type == "reasoning":
|
||||
continue
|
||||
|
||||
# 提取文本
|
||||
text = chain.get_plain_text()
|
||||
if text:
|
||||
await delta_queue.put(text)
|
||||
buffer += text
|
||||
|
||||
# 分句逻辑:匹配标点符号
|
||||
@@ -477,6 +515,7 @@ async def _run_agent_feeder(
|
||||
finally:
|
||||
# 发送结束信号
|
||||
await text_queue.put(None)
|
||||
await delta_queue.put(None)
|
||||
|
||||
|
||||
async def _safe_tts_stream_wrapper(
|
||||
|
||||
@@ -390,9 +390,14 @@ async def _ensure_persona_and_skills(
|
||||
persona_tools = None
|
||||
pid = a.get("persona_id")
|
||||
if pid:
|
||||
persona = plugin_context.persona_manager.get_persona_v3_by_id(pid)
|
||||
if persona is not None:
|
||||
persona_tools = persona.get("tools")
|
||||
persona_tools = next(
|
||||
(
|
||||
p.get("tools")
|
||||
for p in plugin_context.persona_manager.personas_v3
|
||||
if p["name"] == pid
|
||||
),
|
||||
None,
|
||||
)
|
||||
tools = a.get("tools", [])
|
||||
if persona_tools is not None:
|
||||
tools = persona_tools
|
||||
|
||||
@@ -213,24 +213,13 @@ def parse_description(text: str) -> str:
|
||||
break
|
||||
if end_idx is None:
|
||||
return ""
|
||||
|
||||
frontmatter = "\n".join(lines[1:end_idx])
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
return ""
|
||||
|
||||
try:
|
||||
payload = yaml.safe_load(frontmatter) or dict()
|
||||
except yaml.YAMLError:
|
||||
return ""
|
||||
if not isinstance(payload, dict):
|
||||
return ""
|
||||
|
||||
description = payload.get("description", "")
|
||||
if not isinstance(description, str):
|
||||
return ""
|
||||
return description.strip()
|
||||
for line in lines[1:end_idx]:
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
if key.strip().lower() == "description":
|
||||
return value.strip().strip('"').strip("'")
|
||||
return ""
|
||||
|
||||
|
||||
def load_managed_skills() -> list[str]:
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
VERSION = "4.20.1"
|
||||
VERSION = "4.20.0"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||
|
||||
WEBHOOK_SUPPORTED_PLATFORMS = [
|
||||
@@ -463,6 +463,7 @@ CONFIG_METADATA_2 = {
|
||||
"type": "kook",
|
||||
"enable": False,
|
||||
"kook_bot_token": "",
|
||||
"kook_bot_nickname": "",
|
||||
"kook_reconnect_delay": 1,
|
||||
"kook_max_reconnect_delay": 60,
|
||||
"kook_max_retry_delay": 60,
|
||||
@@ -874,6 +875,11 @@ CONFIG_METADATA_2 = {
|
||||
"type": "string",
|
||||
"hint": "必填项。从 KOOK 开发者平台获取的机器人 Token。",
|
||||
},
|
||||
"kook_bot_nickname": {
|
||||
"description": "Bot Nickname",
|
||||
"type": "string",
|
||||
"hint": "可选项。若发送者昵称与此值一致,将忽略该消息以避免广播风暴。",
|
||||
},
|
||||
"kook_reconnect_delay": {
|
||||
"description": "重连延迟",
|
||||
"type": "int",
|
||||
|
||||
@@ -33,18 +33,10 @@ class BaseDatabase(abc.ABC):
|
||||
DATABASE_URL = ""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# SQLite only supports a single writer at a time. Without a busy
|
||||
# timeout the driver raises "database is locked" instantly when a
|
||||
# second write is attempted. Setting timeout=30 tells SQLite to
|
||||
# wait up to 30 s for the lock, which is enough to ride out brief
|
||||
# write bursts from concurrent agent/metrics/session operations.
|
||||
is_sqlite = "sqlite" in self.DATABASE_URL
|
||||
connect_args = {"timeout": 30} if is_sqlite else {}
|
||||
self.engine = create_async_engine(
|
||||
self.DATABASE_URL,
|
||||
echo=False,
|
||||
future=True,
|
||||
connect_args=connect_args,
|
||||
)
|
||||
self.AsyncSessionLocal = async_sessionmaker(
|
||||
self.engine,
|
||||
|
||||
@@ -44,22 +44,6 @@ class PersonaManager:
|
||||
raise ValueError(f"Persona with ID {persona_id} does not exist.")
|
||||
return persona
|
||||
|
||||
def get_persona_v3_by_id(self, persona_id: str | None) -> Personality | None:
|
||||
"""Resolve a v3 persona object by id.
|
||||
|
||||
- None/empty id returns None.
|
||||
- "default" maps to in-memory DEFAULT_PERSONALITY.
|
||||
- Otherwise search in personas_v3 by persona name.
|
||||
"""
|
||||
if not persona_id:
|
||||
return None
|
||||
if persona_id == "default":
|
||||
return DEFAULT_PERSONALITY
|
||||
return next(
|
||||
(persona for persona in self.personas_v3 if persona["name"] == persona_id),
|
||||
None,
|
||||
)
|
||||
|
||||
async def get_default_persona_v3(
|
||||
self,
|
||||
umo: str | MessageSession | None = None,
|
||||
@@ -70,7 +54,12 @@ class PersonaManager:
|
||||
"default_personality",
|
||||
"default",
|
||||
)
|
||||
return self.get_persona_v3_by_id(default_persona_id) or DEFAULT_PERSONALITY
|
||||
if not default_persona_id or default_persona_id == "default":
|
||||
return DEFAULT_PERSONALITY
|
||||
try:
|
||||
return next(p for p in self.personas_v3 if p["name"] == default_persona_id)
|
||||
except Exception:
|
||||
return DEFAULT_PERSONALITY
|
||||
|
||||
async def resolve_selected_persona(
|
||||
self,
|
||||
|
||||
@@ -13,28 +13,11 @@ from astrbot.api.platform import (
|
||||
PlatformMetadata,
|
||||
register_platform_adapter,
|
||||
)
|
||||
from astrbot.core.message.components import File, Record, Video
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
|
||||
from .kook_client import KookClient
|
||||
from .kook_config import KookConfig
|
||||
from .kook_event import KookEvent
|
||||
from .kook_types import (
|
||||
ContainerModule,
|
||||
FileModule,
|
||||
HeaderModule,
|
||||
ImageGroupModule,
|
||||
KmarkdownElement,
|
||||
KookCardMessageContainer,
|
||||
KookChannelType,
|
||||
KookMessageEventData,
|
||||
KookMessageType,
|
||||
KookModuleType,
|
||||
PlainTextElement,
|
||||
SectionModule,
|
||||
)
|
||||
|
||||
KOOK_AT_SELECTOR_REGEX = re.compile(r"\(met\)([^()]+)\(met\)")
|
||||
|
||||
|
||||
@register_platform_adapter(
|
||||
@@ -74,26 +57,35 @@ class KookPlatformAdapter(Platform):
|
||||
name="kook", description="KOOK 适配器", id=self.kook_config.id
|
||||
)
|
||||
|
||||
def _should_ignore_event_by_bot_nickname(self, author_id: str) -> bool:
|
||||
return self.client.bot_id == author_id
|
||||
def _should_ignore_event_by_bot_nickname(self, payload: dict) -> bool:
|
||||
bot_nickname = self.kook_config.bot_nickname.strip()
|
||||
if not bot_nickname:
|
||||
return False
|
||||
|
||||
async def _on_received(self, event: KookMessageEventData):
|
||||
logger.debug(
|
||||
f'[KOOK] 收到来自"{event.channel_type.name}"渠道的消息, 消息类型为: {event.type.name}({event.type.value})'
|
||||
)
|
||||
event_type = event.type
|
||||
if event_type in (KookMessageType.KMARKDOWN, KookMessageType.CARD):
|
||||
if self._should_ignore_event_by_bot_nickname(event.author_id):
|
||||
logger.debug("[KOOK] 收到来自机器人自身的消息, 忽略此消息")
|
||||
return
|
||||
try:
|
||||
abm = await self.convert_message(event)
|
||||
await self.handle_msg(abm)
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 消息处理异常: {e}")
|
||||
elif event_type == KookMessageType.SYSTEM:
|
||||
logger.debug(f'[KOOK] 消息为系统通知, 通知类型为: "{event.extra.type}"')
|
||||
logger.debug(f"[KOOK] 原始消息数据: {event.to_json()}")
|
||||
author = payload.get("extra", {}).get("author", {})
|
||||
if not isinstance(author, dict):
|
||||
return False
|
||||
|
||||
author_nickname = author.get("nickname") or author.get("username") or ""
|
||||
if not isinstance(author_nickname, str):
|
||||
author_nickname = str(author_nickname)
|
||||
|
||||
return author_nickname.strip().casefold() == bot_nickname.casefold()
|
||||
|
||||
async def _on_received(self, data: dict):
|
||||
logger.debug(f"KOOK 收到数据: {data}")
|
||||
if "d" in data and data["s"] == 0:
|
||||
payload = data["d"]
|
||||
event_type = payload.get("type")
|
||||
# 支持type=9(文本)和type=10(卡片)
|
||||
if event_type in (9, 10):
|
||||
if self._should_ignore_event_by_bot_nickname(payload):
|
||||
return
|
||||
try:
|
||||
abm = await self.convert_message(payload)
|
||||
await self.handle_msg(abm)
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 消息处理异常: {e}")
|
||||
|
||||
async def run(self):
|
||||
"""主运行循环"""
|
||||
@@ -192,26 +184,18 @@ class KookPlatformAdapter(Platform):
|
||||
logger.info("[KOOK] 资源清理完成")
|
||||
|
||||
def _parse_kmarkdown_text_message(
|
||||
self, data: KookMessageEventData, self_id: str
|
||||
self, data: dict, self_id: str
|
||||
) -> tuple[list, str]:
|
||||
kmarkdown = data.extra.kmarkdown
|
||||
content = data.content or ""
|
||||
if kmarkdown is None:
|
||||
logger.error(
|
||||
f'[KOOK] 无法转换"{KookMessageType.KMARKDOWN.name}"消息, 消息中找不到kmarkdown字段'
|
||||
)
|
||||
logger.error(f"[KOOK] 原始消息内容: {data.to_json()}")
|
||||
return [], ""
|
||||
|
||||
raw_content = kmarkdown.raw_content or content
|
||||
kmarkdown = data.get("extra", {}).get("kmarkdown", {})
|
||||
content = data.get("content") or ""
|
||||
raw_content = kmarkdown.get("raw_content") or content
|
||||
if not isinstance(content, str):
|
||||
content = str(content)
|
||||
if not isinstance(raw_content, str):
|
||||
raw_content = str(raw_content)
|
||||
|
||||
# TODO 后面的pydantic类型替换,以后再来探索吧 :(
|
||||
mention_name_map: dict[str, str] = {}
|
||||
mention_part = kmarkdown.mention_part
|
||||
mention_part = kmarkdown.get("mention_part", [])
|
||||
if isinstance(mention_part, list):
|
||||
for item in mention_part:
|
||||
if not isinstance(item, dict):
|
||||
@@ -223,7 +207,7 @@ class KookPlatformAdapter(Platform):
|
||||
|
||||
components = []
|
||||
cursor = 0
|
||||
for match in KOOK_AT_SELECTOR_REGEX.finditer(content):
|
||||
for match in re.finditer(r"\(met\)([^()]+)\(met\)", content):
|
||||
if match.start() > cursor:
|
||||
plain_text = content[cursor : match.start()]
|
||||
if plain_text:
|
||||
@@ -270,109 +254,77 @@ class KookPlatformAdapter(Platform):
|
||||
|
||||
return components, message_str
|
||||
|
||||
def _parse_card_message(self, data: KookMessageEventData) -> tuple[list, str]:
|
||||
content = data.content
|
||||
def _parse_card_message(self, data: dict) -> tuple[list, str]:
|
||||
content = data.get("content", "[]")
|
||||
if not isinstance(content, str):
|
||||
content = str(content)
|
||||
|
||||
card_list = KookCardMessageContainer.from_dict(json.loads(content))
|
||||
card_list = json.loads(content)
|
||||
|
||||
text_parts: list[str] = []
|
||||
images: list[str] = []
|
||||
files: list[tuple[KookModuleType, str, str]] = []
|
||||
|
||||
for card in card_list:
|
||||
for module in card.modules:
|
||||
match module:
|
||||
case SectionModule():
|
||||
if content := self._handle_section_text(module):
|
||||
text_parts.append(content)
|
||||
if not isinstance(card, dict):
|
||||
continue
|
||||
for module in card.get("modules", []):
|
||||
if not isinstance(module, dict):
|
||||
continue
|
||||
|
||||
case ContainerModule() | ImageGroupModule():
|
||||
urls = self._handle_image_group(module)
|
||||
images.extend(urls)
|
||||
text_parts.append(" [image]" * len(urls))
|
||||
module_type = module.get("type")
|
||||
if module_type == "section":
|
||||
section_text = module.get("text", {}).get("content", "")
|
||||
if section_text:
|
||||
text_parts.append(str(section_text))
|
||||
continue
|
||||
|
||||
case HeaderModule():
|
||||
text_parts.append(module.text.content)
|
||||
if module_type != "container":
|
||||
continue
|
||||
|
||||
case FileModule():
|
||||
files.append((module.type, module.title, module.src))
|
||||
text_parts.append(f" [{module.type.value}]")
|
||||
for element in module.get("elements", []):
|
||||
if not isinstance(element, dict):
|
||||
continue
|
||||
if element.get("type") != "image":
|
||||
continue
|
||||
|
||||
case _:
|
||||
logger.debug(f"[KOOK] 跳过或未处理模块: {module.type}")
|
||||
image_src = element.get("src")
|
||||
if not isinstance(image_src, str):
|
||||
logger.warning(
|
||||
f'[KOOK] 处理卡片中的图片时发生错误,图片url "{image_src}" 应该为str类型, 而不是 "{type(image_src)}" '
|
||||
)
|
||||
continue
|
||||
if not image_src.startswith(("http://", "https://")):
|
||||
logger.warning(f"[KOOK] 屏蔽非http图片url: {image_src}")
|
||||
continue
|
||||
images.append(image_src)
|
||||
|
||||
text = "".join(text_parts)
|
||||
message = []
|
||||
|
||||
if text:
|
||||
for search in KOOK_AT_SELECTOR_REGEX.finditer(text):
|
||||
search_text = search.group(1).strip()
|
||||
if search_text == "all":
|
||||
message.append(AtAll())
|
||||
continue
|
||||
message.append(At(qq=search_text))
|
||||
text = text.replace(f"(met){search_text}(met)", "")
|
||||
|
||||
message.append(Plain(text=text))
|
||||
|
||||
for img_url in images:
|
||||
message.append(Image(file=img_url))
|
||||
for file in files:
|
||||
file_type = file[0]
|
||||
file_name = file[1]
|
||||
file_url = file[2]
|
||||
if file_type == KookModuleType.FILE:
|
||||
message.append(File(name=file_name, file=file_url))
|
||||
elif file_type == KookModuleType.VIDEO:
|
||||
message.append(Video(file=file_url))
|
||||
elif file_type == KookModuleType.AUDIO:
|
||||
message.append(Record(file=file_url))
|
||||
else:
|
||||
logger.warning(f"[KOOK] 跳过未知文件类型: {file_type.name}")
|
||||
|
||||
return message, text
|
||||
|
||||
def _handle_section_text(self, module: SectionModule) -> str:
|
||||
"""专门处理 Section 里的文本提取"""
|
||||
if isinstance(module.text, (KmarkdownElement, PlainTextElement)):
|
||||
return module.text.content or ""
|
||||
return ""
|
||||
|
||||
def _handle_image_group(
|
||||
self, module: ContainerModule | ImageGroupModule
|
||||
) -> list[str]:
|
||||
"""专门处理图片组/容器里的合法 URL 提取"""
|
||||
valid_urls = []
|
||||
for el in module.elements:
|
||||
image_src = el.src
|
||||
if not el.src.startswith(("http://", "https://")):
|
||||
logger.warning(f"[KOOK] 屏蔽非http图片url: {image_src}")
|
||||
continue
|
||||
valid_urls.append(el.src)
|
||||
return valid_urls
|
||||
|
||||
async def convert_message(self, data: KookMessageEventData) -> AstrBotMessage:
|
||||
async def convert_message(self, data: dict) -> AstrBotMessage:
|
||||
abm = AstrBotMessage()
|
||||
abm.raw_message = data.to_dict()
|
||||
abm.raw_message = data
|
||||
abm.self_id = self.client.bot_id
|
||||
|
||||
channel_type = data.channel_type
|
||||
author_id = data.author_id
|
||||
channel_type = data.get("channel_type")
|
||||
author_id = data.get("author_id", "unknown")
|
||||
# channel_type定义: https://developer.kookapp.cn/doc/event/event-introduction
|
||||
match channel_type:
|
||||
case KookChannelType.GROUP:
|
||||
session_id = data.target_id or "unknown"
|
||||
case "GROUP":
|
||||
session_id = data.get("target_id") or "unknown"
|
||||
abm.type = MessageType.GROUP_MESSAGE
|
||||
abm.group_id = session_id
|
||||
abm.session_id = session_id
|
||||
case KookChannelType.PERSON:
|
||||
case "PERSON":
|
||||
abm.type = MessageType.FRIEND_MESSAGE
|
||||
abm.group_id = ""
|
||||
abm.session_id = data.author_id or "unknown"
|
||||
case KookChannelType.BROADCAST:
|
||||
session_id = data.target_id or "unknown"
|
||||
abm.session_id = data.get("author_id", "unknown")
|
||||
case "BROADCAST":
|
||||
session_id = data.get("target_id") or "unknown"
|
||||
abm.type = MessageType.OTHER_MESSAGE
|
||||
abm.group_id = session_id
|
||||
abm.session_id = session_id
|
||||
@@ -381,25 +333,28 @@ class KookPlatformAdapter(Platform):
|
||||
|
||||
abm.sender = MessageMember(
|
||||
user_id=author_id,
|
||||
nickname=data.extra.author.username if data.extra.author else "unknown",
|
||||
nickname=data.get("extra", {}).get("author", {}).get("username", ""),
|
||||
)
|
||||
|
||||
abm.message_id = data.msg_id or "unknown"
|
||||
abm.message_id = data.get("msg_id", "unknown")
|
||||
|
||||
if data.type == KookMessageType.KMARKDOWN:
|
||||
message, message_str = self._parse_kmarkdown_text_message(data, abm.self_id)
|
||||
# 普通文本消息
|
||||
if data.get("type") == 9:
|
||||
message, message_str = self._parse_kmarkdown_text_message(
|
||||
data, str(abm.self_id)
|
||||
)
|
||||
abm.message = message
|
||||
abm.message_str = message_str
|
||||
elif data.type == KookMessageType.CARD:
|
||||
# 卡片消息
|
||||
elif data.get("type") == 10:
|
||||
try:
|
||||
abm.message, abm.message_str = self._parse_card_message(data)
|
||||
except Exception as exp:
|
||||
logger.error(f"[KOOK] 卡片消息解析失败: {exp}")
|
||||
logger.error(f"[KOOK] 原始消息内容: {data.to_json()}")
|
||||
abm.message_str = "[卡片消息解析失败]"
|
||||
abm.message = [Plain(text="[卡片消息解析失败]")]
|
||||
else:
|
||||
logger.warning(f'[KOOK] 不支持的kook消息类型: "{data.type.name}"')
|
||||
logger.warning(f'[KOOK] 不支持的kook消息类型: "{data.get("type")}"')
|
||||
abm.message_str = "[不支持的消息类型]"
|
||||
abm.message = [Plain(text="[不支持的消息类型]")]
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
@@ -8,23 +9,13 @@ from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
import aiohttp
|
||||
import pydantic
|
||||
import websockets
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.platform.message_type import MessageType
|
||||
|
||||
from .kook_config import KookConfig
|
||||
from .kook_types import (
|
||||
KookApiPaths,
|
||||
KookGatewayIndexResponse,
|
||||
KookHelloEventData,
|
||||
KookMessageSignal,
|
||||
KookMessageType,
|
||||
KookResumeAckEventData,
|
||||
KookUserMeResponse,
|
||||
KookWebsocketEvent,
|
||||
)
|
||||
from .kook_types import KookApiPaths, KookMessageType
|
||||
|
||||
|
||||
class KookClient:
|
||||
@@ -32,8 +23,7 @@ class KookClient:
|
||||
# 数据字段
|
||||
self.config = config
|
||||
self._bot_id = ""
|
||||
self._bot_username = ""
|
||||
self._bot_nickname = ""
|
||||
self._bot_name = ""
|
||||
|
||||
# 资源字段
|
||||
self._http_client = aiohttp.ClientSession(
|
||||
@@ -58,50 +48,37 @@ class KookClient:
|
||||
return self._bot_id
|
||||
|
||||
@property
|
||||
def bot_nickname(self):
|
||||
return self._bot_nickname
|
||||
def bot_name(self):
|
||||
return self._bot_name
|
||||
|
||||
@property
|
||||
def bot_username(self):
|
||||
return self._bot_username
|
||||
|
||||
async def get_bot_info(self) -> None:
|
||||
"""获取机器人账号信息"""
|
||||
async def get_bot_info(self) -> str:
|
||||
"""获取机器人账号ID"""
|
||||
url = KookApiPaths.USER_ME
|
||||
|
||||
try:
|
||||
async with self._http_client.get(url) as resp:
|
||||
if resp.status != 200:
|
||||
logger.error(
|
||||
f"[KOOK] 获取机器人账号信息失败,状态码: {resp.status} , {await resp.text()}"
|
||||
)
|
||||
return
|
||||
try:
|
||||
resp_content = KookUserMeResponse.from_dict(await resp.json())
|
||||
except pydantic.ValidationError as e:
|
||||
logger.error(
|
||||
f"[KOOK] 获取机器人账号信息失败, 响应数据格式错误: \n{e}"
|
||||
)
|
||||
logger.error(f"[KOOK] 响应内容: {await resp.text()}")
|
||||
return
|
||||
logger.error(f"[KOOK] 获取机器人账号ID失败,状态码: {resp.status}")
|
||||
return ""
|
||||
|
||||
if not resp_content.success():
|
||||
logger.error(
|
||||
f"[KOOK] 获取机器人账号信息失败: {resp_content.model_dump_json()}"
|
||||
)
|
||||
return
|
||||
data = await resp.json()
|
||||
if data.get("code") != 0:
|
||||
logger.error(f"[KOOK] 获取机器人账号ID失败: {data}")
|
||||
return ""
|
||||
|
||||
bot_id: str = resp_content.data.id
|
||||
bot_id: str = data["data"]["id"]
|
||||
self._bot_id = bot_id
|
||||
logger.info(f"[KOOK] 获取机器人账号ID成功: {bot_id}")
|
||||
self._bot_nickname = resp_content.data.nickname
|
||||
self._bot_username = resp_content.data.username
|
||||
logger.info(f"[KOOK] 获取机器人名称成功: {self._bot_nickname}")
|
||||
bot_name: str = data["data"]["nickname"] or data["data"]["username"]
|
||||
self._bot_name = bot_name
|
||||
logger.info(f"[KOOK] 获取机器人名称成功: {self._bot_name}")
|
||||
|
||||
return bot_id
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 获取机器人账号信息异常: {e}")
|
||||
logger.error(f"[KOOK] 获取机器人账号ID异常: {e}")
|
||||
return ""
|
||||
|
||||
async def get_gateway_url(self, resume=False, sn=0, session_id=None) -> str | None:
|
||||
async def get_gateway_url(self, resume=False, sn=0, session_id=None):
|
||||
"""获取网关连接地址"""
|
||||
url = KookApiPaths.GATEWAY_INDEX
|
||||
|
||||
@@ -119,20 +96,14 @@ class KookClient:
|
||||
logger.error(f"[KOOK] 获取gateway失败,状态码: {resp.status}")
|
||||
return None
|
||||
|
||||
resp_content = KookGatewayIndexResponse.from_dict(await resp.json())
|
||||
if not resp_content.success():
|
||||
logger.error(f"[KOOK] 获取gateway失败: {resp_content}")
|
||||
data = await resp.json()
|
||||
if data.get("code") != 0:
|
||||
logger.error(f"[KOOK] 获取gateway失败: {data}")
|
||||
return None
|
||||
|
||||
gateway_url: str = resp_content.data.url
|
||||
gateway_url: str = data["data"]["url"]
|
||||
logger.info(f"[KOOK] 获取gateway成功: {gateway_url.split('?')[0]}")
|
||||
return gateway_url
|
||||
|
||||
except pydantic.ValidationError as e:
|
||||
logger.error(f"[KOOK] 获取gateway失败, 响应数据格式错误: \n{e}")
|
||||
logger.error(f"[KOOK] 原始响应内容: {await resp.text()}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 获取gateway异常: {e}")
|
||||
return None
|
||||
@@ -185,11 +156,7 @@ class KookClient:
|
||||
try:
|
||||
while self.running:
|
||||
try:
|
||||
if self.ws is None:
|
||||
logger.error("[KOOK] WebSocket 对象丢失,结束监听流程。")
|
||||
break
|
||||
|
||||
msg = await asyncio.wait_for(self.ws.recv(), timeout=10)
|
||||
msg = await asyncio.wait_for(self.ws.recv(), timeout=10) # type: ignore
|
||||
|
||||
if isinstance(msg, bytes):
|
||||
try:
|
||||
@@ -199,15 +166,10 @@ class KookClient:
|
||||
continue
|
||||
msg = msg.decode("utf-8")
|
||||
|
||||
event = KookWebsocketEvent.from_json(msg)
|
||||
data = json.loads(msg)
|
||||
|
||||
# 处理不同类型的信令
|
||||
await self._handle_signal(event)
|
||||
|
||||
except pydantic.ValidationError as e:
|
||||
logger.error(f"[KOOK] 解析WebSocket事件数据格式失败: \n{e}")
|
||||
logger.error(f"[KOOK] 原始响应内容: {msg}")
|
||||
continue
|
||||
await self._handle_signal(data)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
# 超时检查,继续循环
|
||||
@@ -225,41 +187,38 @@ class KookClient:
|
||||
self.running = False
|
||||
self._stop_event.set()
|
||||
|
||||
async def _handle_signal(self, event: KookWebsocketEvent):
|
||||
async def _handle_signal(self, data):
|
||||
"""处理不同类型的信令"""
|
||||
data = event.data
|
||||
signal_type = data.get("s")
|
||||
|
||||
match event.signal:
|
||||
case KookMessageSignal.MESSAGE:
|
||||
if event.sn is not None:
|
||||
self.last_sn = event.sn
|
||||
await self.event_callback(data)
|
||||
if signal_type == 0: # 事件消息
|
||||
# 更新消息序号
|
||||
if "sn" in data:
|
||||
self.last_sn = data["sn"]
|
||||
await self.event_callback(data)
|
||||
|
||||
case KookMessageSignal.HELLO:
|
||||
assert isinstance(data, KookHelloEventData)
|
||||
await self._handle_hello(data)
|
||||
elif signal_type == 1: # HELLO握手
|
||||
await self._handle_hello(data)
|
||||
|
||||
case KookMessageSignal.RESUME_ACK:
|
||||
assert isinstance(data, KookResumeAckEventData)
|
||||
await self._handle_resume_ack(data)
|
||||
elif signal_type == 3: # PONG心跳响应
|
||||
await self._handle_pong(data)
|
||||
|
||||
case KookMessageSignal.PONG:
|
||||
await self._handle_pong()
|
||||
elif signal_type == 5: # RECONNECT重连指令
|
||||
await self._handle_reconnect(data)
|
||||
|
||||
case KookMessageSignal.RECONNECT:
|
||||
await self._handle_reconnect()
|
||||
elif signal_type == 6: # RESUME ACK
|
||||
await self._handle_resume_ack(data)
|
||||
|
||||
case _:
|
||||
logger.debug(
|
||||
f"[KOOK] 未处理的信令类型: {event.signal.name}({event.signal.value})"
|
||||
)
|
||||
else:
|
||||
logger.debug(f"[KOOK] 未处理的信令类型: {signal_type}")
|
||||
|
||||
async def _handle_hello(self, data: KookHelloEventData):
|
||||
async def _handle_hello(self, data):
|
||||
"""处理HELLO握手"""
|
||||
code = data.code
|
||||
hello_data = data.get("d", {})
|
||||
code = hello_data.get("code", 0)
|
||||
|
||||
if code == 0:
|
||||
self.session_id = data.session_id
|
||||
self.session_id = hello_data.get("session_id")
|
||||
logger.info(f"[KOOK] 握手成功,session_id: {self.session_id}")
|
||||
# TODO 重置重连延迟
|
||||
# self.reconnect_delay = 1
|
||||
@@ -269,12 +228,12 @@ class KookClient:
|
||||
logger.error("[KOOK] Token已过期,需要重新获取")
|
||||
self.running = False
|
||||
|
||||
async def _handle_pong(self):
|
||||
async def _handle_pong(self, data):
|
||||
"""处理PONG心跳响应"""
|
||||
self.last_heartbeat_time = time.time()
|
||||
self.heartbeat_failed_count = 0
|
||||
|
||||
async def _handle_reconnect(self):
|
||||
async def _handle_reconnect(self, data):
|
||||
"""处理重连指令"""
|
||||
logger.warning("[KOOK] 收到重连指令")
|
||||
# 清空本地状态
|
||||
@@ -282,9 +241,10 @@ class KookClient:
|
||||
self.session_id = None
|
||||
self.running = False
|
||||
|
||||
async def _handle_resume_ack(self, data: KookResumeAckEventData):
|
||||
async def _handle_resume_ack(self, data):
|
||||
"""处理RESUME确认"""
|
||||
self.session_id = data.session_id
|
||||
resume_data = data.get("d", {})
|
||||
self.session_id = resume_data.get("session_id")
|
||||
logger.info(f"[KOOK] Resume成功,session_id: {self.session_id}")
|
||||
|
||||
async def _heartbeat_loop(self):
|
||||
@@ -332,16 +292,9 @@ class KookClient:
|
||||
|
||||
async def _send_ping(self):
|
||||
"""发送心跳PING"""
|
||||
if self.ws is None:
|
||||
logger.warning("[KOOK] 尚未连接kook WebSocket服务器, 跳过发送心跳包流程")
|
||||
return
|
||||
try:
|
||||
ping_data = KookWebsocketEvent(
|
||||
signal=KookMessageSignal.PING,
|
||||
data=None,
|
||||
sn=self.last_sn,
|
||||
)
|
||||
await self.ws.send(ping_data.to_json())
|
||||
ping_data = {"s": 2, "sn": self.last_sn}
|
||||
await self.ws.send(json.dumps(ping_data)) # type: ignore
|
||||
except Exception as e:
|
||||
logger.error(f"[KOOK] 发送心跳失败: {e}")
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ class KookConfig:
|
||||
|
||||
# 基础配置
|
||||
token: str
|
||||
bot_nickname: str = ""
|
||||
enable: bool = False
|
||||
id: str = "kook"
|
||||
|
||||
@@ -40,6 +41,7 @@ class KookConfig:
|
||||
# id=config_dict.get("id", "kook"),
|
||||
enable=config_dict.get("enable", False),
|
||||
token=config_dict.get("kook_bot_token", ""),
|
||||
bot_nickname=config_dict.get("kook_bot_nickname", ""),
|
||||
reconnect_delay=config_dict.get(
|
||||
"kook_reconnect_delay",
|
||||
KookConfig.reconnect_delay,
|
||||
|
||||
@@ -27,7 +27,6 @@ from .kook_types import (
|
||||
KookCardMessage,
|
||||
KookCardMessageContainer,
|
||||
KookMessageType,
|
||||
KookModuleType,
|
||||
OrderMessage,
|
||||
)
|
||||
|
||||
@@ -112,7 +111,7 @@ class KookEvent(AstrMessageEvent):
|
||||
KookCardMessage(
|
||||
modules=[
|
||||
FileModule(
|
||||
type=KookModuleType.AUDIO,
|
||||
type="audio",
|
||||
title=title,
|
||||
src=url,
|
||||
)
|
||||
@@ -183,7 +182,7 @@ class KookEvent(AstrMessageEvent):
|
||||
if item.reply_id:
|
||||
reply_id = item.reply_id
|
||||
if not item.text:
|
||||
logger.debug(f'[Kook] 跳过空消息,类型为"{item.type.name}"')
|
||||
logger.debug(f'[Kook] 跳过空消息,类型为"{item.type}"')
|
||||
continue
|
||||
try:
|
||||
await self.client.send_text(
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import json
|
||||
from enum import IntEnum, StrEnum
|
||||
from typing import Annotated, Any, Literal
|
||||
from dataclasses import field
|
||||
from enum import IntEnum
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic.dataclasses import dataclass
|
||||
|
||||
|
||||
class KookApiPaths:
|
||||
@@ -23,9 +25,8 @@ class KookApiPaths:
|
||||
DIRECT_MESSAGE_CREATE = f"{BASE_URL}{API_VERSION_PATH}/direct-message/create"
|
||||
|
||||
|
||||
# 定义参见kook事件结构文档: https://developer.kookapp.cn/doc/event/event-introduction
|
||||
class KookMessageType(IntEnum):
|
||||
"""定义参见kook事件结构文档: https://developer.kookapp.cn/doc/event/event-introduction"""
|
||||
|
||||
TEXT = 1
|
||||
IMAGE = 2
|
||||
VIDEO = 3
|
||||
@@ -36,26 +37,6 @@ class KookMessageType(IntEnum):
|
||||
SYSTEM = 255
|
||||
|
||||
|
||||
class KookModuleType(StrEnum):
|
||||
PLAIN_TEXT = "plain-text"
|
||||
KMARKDOWN = "kmarkdown"
|
||||
IMAGE = "image"
|
||||
BUTTON = "button"
|
||||
HEADER = "header"
|
||||
SECTION = "section"
|
||||
IMAGE_GROUP = "image-group"
|
||||
CONTAINER = "container"
|
||||
ACTION_GROUP = "action-group"
|
||||
CONTEXT = "context"
|
||||
DIVIDER = "divider"
|
||||
FILE = "file"
|
||||
AUDIO = "audio"
|
||||
VIDEO = "video"
|
||||
COUNTDOWN = "countdown"
|
||||
INVITE = "invite"
|
||||
CARD = "card"
|
||||
|
||||
|
||||
ThemeType = Literal[
|
||||
"primary", "success", "danger", "warning", "info", "secondary", "none", "invisible"
|
||||
]
|
||||
@@ -67,81 +48,43 @@ SectionMode = Literal["left", "right"]
|
||||
CountdownMode = Literal["day", "hour", "second"]
|
||||
|
||||
|
||||
class KookBaseDataClass(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
extra="allow",
|
||||
arbitrary_types_allowed=True,
|
||||
populate_by_name=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, raw_data: dict):
|
||||
return cls.model_validate(raw_data)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, raw_data: str | bytes | bytearray):
|
||||
return cls.model_validate_json(raw_data)
|
||||
|
||||
def to_dict(
|
||||
self,
|
||||
mode: Literal["json", "python"] | str = "python",
|
||||
by_alias=True,
|
||||
exclude_none=True,
|
||||
exclude_unset=False,
|
||||
) -> dict:
|
||||
return self.model_dump(
|
||||
by_alias=by_alias,
|
||||
exclude_none=exclude_none,
|
||||
mode=mode,
|
||||
exclude_unset=exclude_unset,
|
||||
)
|
||||
|
||||
def to_json(
|
||||
self,
|
||||
indent: int | None = None,
|
||||
ensure_ascii=False,
|
||||
by_alias=True,
|
||||
exclude_none=True,
|
||||
exclude_unset=False,
|
||||
) -> str:
|
||||
return self.model_dump_json(
|
||||
indent=indent,
|
||||
ensure_ascii=ensure_ascii,
|
||||
by_alias=by_alias,
|
||||
exclude_none=exclude_none,
|
||||
exclude_unset=exclude_unset,
|
||||
)
|
||||
class KookCardColor(str):
|
||||
"""16 进制色值"""
|
||||
|
||||
|
||||
class KookCardModelBase(KookBaseDataClass):
|
||||
class KookCardModelBase:
|
||||
"""卡片模块基类"""
|
||||
|
||||
type: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlainTextElement(KookCardModelBase):
|
||||
content: str
|
||||
type: Literal[KookModuleType.PLAIN_TEXT] = KookModuleType.PLAIN_TEXT
|
||||
type: str = "plain-text"
|
||||
emoji: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class KmarkdownElement(KookCardModelBase):
|
||||
content: str
|
||||
type: Literal[KookModuleType.KMARKDOWN] = KookModuleType.KMARKDOWN
|
||||
type: str = "kmarkdown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageElement(KookCardModelBase):
|
||||
src: str
|
||||
type: Literal[KookModuleType.IMAGE] = KookModuleType.IMAGE
|
||||
type: str = "image"
|
||||
alt: str = ""
|
||||
size: SizeType = "lg"
|
||||
circle: bool = False
|
||||
fallbackUrl: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ButtonElement(KookCardModelBase):
|
||||
text: str
|
||||
type: Literal[KookModuleType.BUTTON] = KookModuleType.BUTTON
|
||||
type: str = "button"
|
||||
theme: ThemeType = "primary"
|
||||
value: str = ""
|
||||
"""当为 link 时,会跳转到 value 代表的链接;
|
||||
@@ -153,88 +96,93 @@ class ButtonElement(KookCardModelBase):
|
||||
AnyElement = PlainTextElement | KmarkdownElement | ImageElement | ButtonElement | str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParagraphStructure(KookCardModelBase):
|
||||
fields: list[PlainTextElement | KmarkdownElement]
|
||||
type: Literal["paragraph"] = "paragraph"
|
||||
type: str = "paragraph"
|
||||
cols: int = 1
|
||||
"""范围是 1-3 , 移动端忽略此参数"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class HeaderModule(KookCardModelBase):
|
||||
text: PlainTextElement
|
||||
type: Literal[KookModuleType.HEADER] = KookModuleType.HEADER
|
||||
type: str = "header"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SectionModule(KookCardModelBase):
|
||||
text: PlainTextElement | KmarkdownElement | ParagraphStructure
|
||||
type: Literal[KookModuleType.SECTION] = KookModuleType.SECTION
|
||||
type: str = "section"
|
||||
mode: SectionMode = "left"
|
||||
accessory: ImageElement | ButtonElement | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageGroupModule(KookCardModelBase):
|
||||
"""1 到多张图片的组合"""
|
||||
|
||||
elements: list[ImageElement]
|
||||
type: Literal[KookModuleType.IMAGE_GROUP] = KookModuleType.IMAGE_GROUP
|
||||
type: str = "image-group"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContainerModule(KookCardModelBase):
|
||||
"""1 到多张图片的组合,与图片组模块(ImageGroupModule)不同,图片并不会裁切为正方形。多张图片会纵向排列。"""
|
||||
|
||||
elements: list[ImageElement]
|
||||
type: Literal[KookModuleType.CONTAINER] = KookModuleType.CONTAINER
|
||||
type: str = "container"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionGroupModule(KookCardModelBase):
|
||||
"""用来放按钮的模块"""
|
||||
|
||||
elements: list[ButtonElement]
|
||||
type: Literal[KookModuleType.ACTION_GROUP] = KookModuleType.ACTION_GROUP
|
||||
type: str = "action-group"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContextModule(KookCardModelBase):
|
||||
elements: list[PlainTextElement | KmarkdownElement | ImageElement]
|
||||
"""最多包含10个元素"""
|
||||
type: Literal[KookModuleType.CONTEXT] = KookModuleType.CONTEXT
|
||||
type: str = "context"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DividerModule(KookCardModelBase):
|
||||
"""展示分割线用的"""
|
||||
|
||||
type: Literal[KookModuleType.DIVIDER] = KookModuleType.DIVIDER
|
||||
type: str = "divider"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileModule(KookCardModelBase):
|
||||
src: str
|
||||
title: str = ""
|
||||
type: Literal[KookModuleType.FILE, KookModuleType.AUDIO, KookModuleType.VIDEO] = (
|
||||
KookModuleType.FILE
|
||||
)
|
||||
type: Literal["file", "audio", "video"] = "file"
|
||||
cover: str | None = None
|
||||
"""cover 仅音频有效, 是音频的封面图"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class CountdownModule(KookCardModelBase):
|
||||
"""startTime 和 endTime 为毫秒时间戳,startTime 和 endTime 不能小于服务器当前时间戳。"""
|
||||
|
||||
endTime: int
|
||||
"""毫秒时间戳"""
|
||||
type: Literal[KookModuleType.COUNTDOWN] = KookModuleType.COUNTDOWN
|
||||
type: str = "countdown"
|
||||
startTime: int | None = None
|
||||
"""毫秒时间戳, 仅当mode为second才有这个字段"""
|
||||
mode: CountdownMode = "day"
|
||||
"""mode 主要是倒计时的样式"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class InviteModule(KookCardModelBase):
|
||||
code: str
|
||||
"""邀请链接或者邀请码"""
|
||||
type: Literal[KookModuleType.INVITE] = KookModuleType.INVITE
|
||||
type: str = "invite"
|
||||
|
||||
|
||||
# 所有模块的联合类型
|
||||
AnyModule = Annotated[
|
||||
AnyModule = (
|
||||
HeaderModule
|
||||
| SectionModule
|
||||
| ImageGroupModule
|
||||
@@ -244,29 +192,34 @@ AnyModule = Annotated[
|
||||
| DividerModule
|
||||
| FileModule
|
||||
| CountdownModule
|
||||
| InviteModule,
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
| InviteModule
|
||||
)
|
||||
|
||||
|
||||
class KookCardMessage(KookBaseDataClass):
|
||||
class KookCardMessage(BaseModel):
|
||||
"""卡片定义文档详见 : https://developer.kookapp.cn/doc/cardmessage
|
||||
此类型不能直接to_json后发送,因为kook要求卡片容器json顶层必须是**列表**
|
||||
若要发送卡片消息,请使用KookCardMessageContainer
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
type: Literal[KookModuleType.CARD] = KookModuleType.CARD
|
||||
type: str = "card"
|
||||
theme: ThemeType | None = None
|
||||
size: SizeType | None = None
|
||||
color: str | None = None
|
||||
"""16 进制色值"""
|
||||
modules: list[AnyModule] = Field(default_factory=list)
|
||||
color: KookCardColor | None = None
|
||||
modules: list[AnyModule] = field(default_factory=list)
|
||||
"""单个 card 模块数量不限制,但是一条消息中所有卡片的模块数量之和最多是 50"""
|
||||
|
||||
def add_module(self, module: AnyModule):
|
||||
self.modules.append(module)
|
||||
|
||||
def to_dict(self, exclude_none: bool = True):
|
||||
"""exclude_none:去掉值为 None 字段,保留结构"""
|
||||
return self.model_dump(exclude_none=exclude_none)
|
||||
|
||||
def to_json(self, indent: int | None = None, ensure_ascii: bool = True):
|
||||
return json.dumps(self.to_dict(), indent=indent, ensure_ascii=ensure_ascii)
|
||||
|
||||
|
||||
class KookCardMessageContainer(list[KookCardMessage]):
|
||||
"""卡片消息容器(列表),此类型可以直接to_json后发送出去"""
|
||||
@@ -279,227 +232,10 @@ class KookCardMessageContainer(list[KookCardMessage]):
|
||||
[i.to_dict() for i in self], indent=indent, ensure_ascii=ensure_ascii
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, raw_data: list[dict[str, Any]]):
|
||||
return cls(KookCardMessage.from_dict(item) for item in raw_data)
|
||||
|
||||
|
||||
class OrderMessage(BaseModel):
|
||||
@dataclass
|
||||
class OrderMessage:
|
||||
index: int
|
||||
text: str
|
||||
type: KookMessageType
|
||||
reply_id: str | int = ""
|
||||
|
||||
|
||||
class KookMessageSignal(IntEnum):
|
||||
"""KOOK WebSocket 信令类型
|
||||
ws文档: https://developer.kookapp.cn/doc/websocket""" # noqa: W291
|
||||
|
||||
MESSAGE = 0
|
||||
"""server->client 消息(s包含聊天和通知消息)"""
|
||||
HELLO = 1
|
||||
"""server->client 客户端连接 ws 时, 服务端返回握手结果"""
|
||||
PING = 2
|
||||
"""client->server 心跳,ping"""
|
||||
PONG = 3
|
||||
"""server->client 心跳,pong"""
|
||||
RESUME = 4
|
||||
"""client->server resume, 恢复会话"""
|
||||
RECONNECT = 5
|
||||
"""server->client reconnect, 要求客户端断开当前连接重新连接"""
|
||||
RESUME_ACK = 6
|
||||
"""server->client resume ack"""
|
||||
|
||||
|
||||
class KookChannelType(StrEnum):
|
||||
GROUP = "GROUP"
|
||||
PERSON = "PERSON"
|
||||
BROADCAST = "BROADCAST"
|
||||
|
||||
|
||||
class KookAuthor(KookBaseDataClass):
|
||||
id: str
|
||||
username: str
|
||||
identify_num: str
|
||||
nickname: str
|
||||
bot: bool
|
||||
online: bool
|
||||
avatar: str | None = None
|
||||
vip_avatar: str | None = None
|
||||
status: int
|
||||
roles: list[int] = Field(default_factory=list)
|
||||
|
||||
|
||||
class KookKMarkdown(KookBaseDataClass):
|
||||
raw_content: str
|
||||
mention_part: list[Any] = Field(default_factory=list)
|
||||
mention_role_part: list[Any] = Field(default_factory=list)
|
||||
|
||||
|
||||
class KookExtra(KookBaseDataClass):
|
||||
type: int | str
|
||||
code: str | None = None
|
||||
body: dict[str, Any] | None = None
|
||||
author: KookAuthor | None = None
|
||||
kmarkdown: KookKMarkdown | None = None
|
||||
last_msg_content: str | None = None
|
||||
mention: list[str] = Field(default_factory=list)
|
||||
mention_all: bool = False
|
||||
mention_here: bool = False
|
||||
|
||||
|
||||
class KookMessageEventData(KookBaseDataClass):
|
||||
signal: Literal[KookMessageSignal.MESSAGE] = Field(
|
||||
KookMessageSignal.MESSAGE, exclude=True
|
||||
)
|
||||
"""only for type hint"""
|
||||
|
||||
channel_type: KookChannelType
|
||||
type: KookMessageType
|
||||
target_id: str
|
||||
author_id: str
|
||||
content: str | dict[str, Any]
|
||||
msg_id: str
|
||||
msg_timestamp: int
|
||||
nonce: str
|
||||
from_type: int
|
||||
extra: KookExtra
|
||||
|
||||
|
||||
class KookHelloEventData(KookBaseDataClass):
|
||||
signal: Literal[KookMessageSignal.HELLO] = Field(
|
||||
KookMessageSignal.HELLO, exclude=True
|
||||
)
|
||||
"""only for type hint"""
|
||||
|
||||
code: int
|
||||
session_id: str
|
||||
|
||||
|
||||
class KookPingEventData(KookBaseDataClass):
|
||||
signal: Literal[KookMessageSignal.PING] = Field(
|
||||
KookMessageSignal.PING, exclude=True
|
||||
)
|
||||
"""only for type hint"""
|
||||
|
||||
|
||||
class KookPongEventData(KookBaseDataClass):
|
||||
signal: Literal[KookMessageSignal.PONG] = Field(
|
||||
KookMessageSignal.PONG, exclude=True
|
||||
)
|
||||
"""only for type hint"""
|
||||
|
||||
|
||||
class KookResumeEventData(KookBaseDataClass):
|
||||
signal: Literal[KookMessageSignal.RESUME] = Field(
|
||||
KookMessageSignal.RESUME, exclude=True
|
||||
)
|
||||
"""only for type hint"""
|
||||
|
||||
|
||||
class KookReconnectEventData(KookBaseDataClass):
|
||||
signal: Literal[KookMessageSignal.RECONNECT] = Field(
|
||||
KookMessageSignal.RECONNECT, exclude=True
|
||||
)
|
||||
"""only for type hint"""
|
||||
|
||||
code: int
|
||||
err: str
|
||||
|
||||
|
||||
class KookResumeAckEventData(KookBaseDataClass):
|
||||
signal: Literal[KookMessageSignal.RESUME_ACK] = Field(
|
||||
KookMessageSignal.RESUME_ACK, exclude=True
|
||||
)
|
||||
"""only for type hint"""
|
||||
|
||||
session_id: str
|
||||
|
||||
|
||||
class KookWebsocketEvent(KookBaseDataClass):
|
||||
"""KOOK WebSocket 原始推送结构"""
|
||||
|
||||
signal: KookMessageSignal = Field(
|
||||
..., validation_alias="s", serialization_alias="s"
|
||||
)
|
||||
"""信令类型"""
|
||||
data: Annotated[
|
||||
KookMessageEventData
|
||||
| KookHelloEventData
|
||||
| KookPingEventData
|
||||
| KookPongEventData
|
||||
| KookResumeEventData
|
||||
| KookReconnectEventData
|
||||
| KookResumeAckEventData
|
||||
| None,
|
||||
Field(discriminator="signal"),
|
||||
] = Field(None, validation_alias="d", serialization_alias="d")
|
||||
"""数据事件主体,对应原字段是'd'"""
|
||||
sn: int | None = None
|
||||
"""消息序号 , 用来确定消息顺序和ws重连时使用
|
||||
详见ws连接流程文档: https://developer.kookapp.cn/doc/websocket#%E8%BF%9E%E6%8E%A5%E6%B5%81%E7%A8%8B""" # noqa: W291
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _inject_signal_into_data(cls, data: Any) -> Any:
|
||||
"""在解析前,把外层的 s 同步到内层的 d 中,供 discriminator 使用"""
|
||||
if isinstance(data, dict):
|
||||
s_value = data.get("s")
|
||||
d_value = data.get("d")
|
||||
if s_value is not None and isinstance(d_value, dict):
|
||||
d_value["signal"] = s_value
|
||||
return data
|
||||
|
||||
|
||||
class KookUserTag(KookBaseDataClass):
|
||||
color: str
|
||||
bg_color: str
|
||||
text: str
|
||||
|
||||
|
||||
class KookApiResponseBase(KookBaseDataClass):
|
||||
code: int
|
||||
message: str
|
||||
data: Any
|
||||
|
||||
def success(self) -> bool:
|
||||
return self.code == 0
|
||||
|
||||
|
||||
class KookUserMeData(KookBaseDataClass):
|
||||
"""USER_ME 接口返回的 'data' 字段主体"""
|
||||
|
||||
id: str
|
||||
username: str
|
||||
identify_num: str
|
||||
nickname: str
|
||||
bot: bool
|
||||
online: bool
|
||||
status: int
|
||||
bot_status: int
|
||||
avatar: str
|
||||
vip_avatar: str | None = None
|
||||
banner: str | None = None
|
||||
roles: list[Any] = Field(default_factory=list)
|
||||
is_vip: bool
|
||||
vip_amp: bool
|
||||
wealth_level: int
|
||||
mobile_verified: bool
|
||||
client_id: str
|
||||
tag_info: KookUserTag | None = None
|
||||
|
||||
|
||||
class KookUserMeResponse(KookApiResponseBase):
|
||||
"""USER_ME 完整响应结构"""
|
||||
|
||||
data: KookUserMeData
|
||||
|
||||
|
||||
class KookGatewayIndexData(KookBaseDataClass):
|
||||
url: str
|
||||
|
||||
|
||||
class KookGatewayIndexResponse(KookApiResponseBase):
|
||||
"""USER_ME 完整响应结构"""
|
||||
|
||||
data: KookGatewayIndexData
|
||||
|
||||
@@ -40,46 +40,25 @@ class OpenAIEmbeddingProvider(EmbeddingProvider):
|
||||
|
||||
async def get_embedding(self, text: str) -> list[float]:
|
||||
"""获取文本的嵌入"""
|
||||
kwargs = self._embedding_kwargs()
|
||||
embedding = await self.client.embeddings.create(
|
||||
input=text,
|
||||
model=self.model,
|
||||
**kwargs,
|
||||
dimensions=self.get_dim(),
|
||||
)
|
||||
return embedding.data[0].embedding
|
||||
|
||||
async def get_embeddings(self, text: list[str]) -> list[list[float]]:
|
||||
"""批量获取文本的嵌入"""
|
||||
kwargs = self._embedding_kwargs()
|
||||
embeddings = await self.client.embeddings.create(
|
||||
input=text,
|
||||
model=self.model,
|
||||
**kwargs,
|
||||
dimensions=self.get_dim(),
|
||||
)
|
||||
return [item.embedding for item in embeddings.data]
|
||||
|
||||
def _embedding_kwargs(self) -> dict:
|
||||
"""构建嵌入请求的可选参数"""
|
||||
kwargs = {}
|
||||
if "embedding_dimensions" in self.provider_config:
|
||||
try:
|
||||
kwargs["dimensions"] = int(self.provider_config["embedding_dimensions"])
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(
|
||||
f"embedding_dimensions in embedding configs is not a valid integer: '{self.provider_config['embedding_dimensions']}', ignored."
|
||||
)
|
||||
return kwargs
|
||||
|
||||
def get_dim(self) -> int:
|
||||
"""获取向量的维度"""
|
||||
if "embedding_dimensions" in self.provider_config:
|
||||
try:
|
||||
return int(self.provider_config["embedding_dimensions"])
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(
|
||||
f"embedding_dimensions in embedding configs is not a valid integer: '{self.provider_config['embedding_dimensions']}', ignored."
|
||||
)
|
||||
return 0
|
||||
return int(self.provider_config.get("embedding_dimensions", 1024))
|
||||
|
||||
async def terminate(self):
|
||||
if self.client:
|
||||
|
||||
@@ -11,8 +11,6 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
import yaml
|
||||
|
||||
from astrbot.core.utils.astrbot_path import (
|
||||
get_astrbot_data_path,
|
||||
get_astrbot_skills_path,
|
||||
@@ -71,19 +69,13 @@ def _parse_frontmatter_description(text: str) -> str:
|
||||
break
|
||||
if end_idx is None:
|
||||
return ""
|
||||
|
||||
frontmatter = "\n".join(lines[1:end_idx])
|
||||
try:
|
||||
payload = yaml.safe_load(frontmatter) or {}
|
||||
except yaml.YAMLError:
|
||||
return ""
|
||||
if not isinstance(payload, dict):
|
||||
return ""
|
||||
|
||||
description = payload.get("description", "")
|
||||
if not isinstance(description, str):
|
||||
return ""
|
||||
return description.strip()
|
||||
for line in lines[1:end_idx]:
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
if key.strip().lower() == "description":
|
||||
return value.strip().strip('"').strip("'")
|
||||
return ""
|
||||
|
||||
|
||||
# Regex for sanitizing paths used in prompt examples — only allow
|
||||
@@ -136,7 +128,7 @@ def _build_skill_read_command_example(path: str) -> str:
|
||||
return f"cat {path}"
|
||||
if _is_windows_prompt_path(path):
|
||||
command = "type"
|
||||
path_arg = f'"{os.path.normpath(path)}"'
|
||||
path_arg = f'"{path}"'
|
||||
else:
|
||||
command = "cat"
|
||||
path_arg = shlex.quote(path)
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import docstring_parser
|
||||
|
||||
@@ -15,6 +15,9 @@ from astrbot.core.message.message_event_result import MessageEventResult
|
||||
from astrbot.core.provider.func_tool_manager import PY_TO_JSON_TYPE, SUPPORTED_TYPES
|
||||
from astrbot.core.provider.register import llm_tools
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
|
||||
from ..filter.command import CommandFilter
|
||||
from ..filter.command_group import CommandGroupFilter
|
||||
from ..filter.custom_filter import CustomFilterAnd, CustomFilterOr
|
||||
@@ -616,7 +619,7 @@ class RegisteringAgent:
|
||||
kwargs["registering_agent"] = self
|
||||
return register_llm_tool(*args, **kwargs)
|
||||
|
||||
def __init__(self, agent: Agent[Any]) -> None:
|
||||
def __init__(self, agent: Agent[AstrAgentContext]) -> None:
|
||||
self._agent = agent
|
||||
|
||||
|
||||
@@ -624,7 +627,7 @@ def register_agent(
|
||||
name: str,
|
||||
instruction: str,
|
||||
tools: list[str | FunctionTool] | None = None,
|
||||
run_hooks: BaseAgentRunHooks[Any] | None = None,
|
||||
run_hooks: BaseAgentRunHooks[AstrAgentContext] | None = None,
|
||||
):
|
||||
"""注册一个 Agent
|
||||
|
||||
@@ -638,12 +641,12 @@ def register_agent(
|
||||
tools_ = tools or []
|
||||
|
||||
def decorator(awaitable: Callable[..., Awaitable[Any]]):
|
||||
AstrAgent = Agent[Any]
|
||||
AstrAgent = Agent[AstrAgentContext]
|
||||
agent = AstrAgent(
|
||||
name=name,
|
||||
instructions=instruction,
|
||||
tools=tools_,
|
||||
run_hooks=run_hooks or BaseAgentRunHooks[Any](),
|
||||
run_hooks=run_hooks or BaseAgentRunHooks[AstrAgentContext](),
|
||||
)
|
||||
handoff_tool = HandoffTool(agent=agent)
|
||||
handoff_tool.handler = awaitable
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import Any
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.agent.agent import Agent
|
||||
from astrbot.core.agent.handoff import HandoffTool
|
||||
from astrbot.core.persona_mgr import PersonaManager
|
||||
from astrbot.core.provider.func_tool_manager import FunctionToolManager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from astrbot.core.persona_mgr import PersonaManager
|
||||
|
||||
|
||||
class SubAgentOrchestrator:
|
||||
"""Loads subagent definitions from config and registers handoff tools.
|
||||
@@ -46,14 +43,15 @@ class SubAgentOrchestrator:
|
||||
continue
|
||||
|
||||
persona_id = item.get("persona_id")
|
||||
if persona_id is not None:
|
||||
persona_id = str(persona_id).strip() or None
|
||||
persona_data = self._persona_mgr.get_persona_v3_by_id(persona_id)
|
||||
if persona_id and persona_data is None:
|
||||
logger.warning(
|
||||
"SubAgent persona %s not found, fallback to inline prompt.",
|
||||
persona_id,
|
||||
)
|
||||
persona_data = None
|
||||
if persona_id:
|
||||
try:
|
||||
persona_data = await self._persona_mgr.get_persona(persona_id)
|
||||
except StopIteration:
|
||||
logger.warning(
|
||||
"SubAgent persona %s not found, fallback to inline prompt.",
|
||||
persona_id,
|
||||
)
|
||||
|
||||
instructions = str(item.get("system_prompt", "")).strip()
|
||||
public_description = str(item.get("public_description", "")).strip()
|
||||
@@ -64,15 +62,11 @@ class SubAgentOrchestrator:
|
||||
begin_dialogs = None
|
||||
|
||||
if persona_data:
|
||||
prompt = str(persona_data.get("prompt", "")).strip()
|
||||
if prompt:
|
||||
instructions = prompt
|
||||
begin_dialogs = copy.deepcopy(
|
||||
persona_data.get("_begin_dialogs_processed")
|
||||
)
|
||||
tools = persona_data.get("tools")
|
||||
if public_description == "" and prompt:
|
||||
public_description = prompt[:120]
|
||||
instructions = persona_data.system_prompt or instructions
|
||||
begin_dialogs = persona_data.begin_dialogs
|
||||
tools = persona_data.tools
|
||||
if public_description == "" and persona_data.system_prompt:
|
||||
public_description = persona_data.system_prompt[:120]
|
||||
if tools is None:
|
||||
tools = None
|
||||
elif not isinstance(tools, list):
|
||||
|
||||
@@ -130,16 +130,6 @@ class LiveChatRoute(Route):
|
||||
|
||||
async def live_chat_ws(self) -> None:
|
||||
"""Legacy Live Chat WebSocket 处理器(默认 ct=live)"""
|
||||
await self._unified_ws_loop(force_ct="live")
|
||||
|
||||
async def unified_chat_ws(self) -> None:
|
||||
"""Unified Chat WebSocket 处理器(支持 ct=live/chat)"""
|
||||
await self._unified_ws_loop(force_ct=None)
|
||||
|
||||
async def _unified_ws_loop(self, force_ct: str | None = None) -> None:
|
||||
"""统一 WebSocket 循环"""
|
||||
# WebSocket 不能通过 header 传递 token,需要从 query 参数获取
|
||||
# 注意:WebSocket 上下文使用 websocket.args 而不是 request.args
|
||||
token = websocket.args.get("token")
|
||||
if not token:
|
||||
await websocket.close(1008, "Missing authentication token")
|
||||
@@ -156,6 +146,49 @@ class LiveChatRoute(Route):
|
||||
await websocket.close(1008, "Invalid token")
|
||||
return
|
||||
|
||||
await self.run_ws_session(username=username, force_ct="live")
|
||||
|
||||
async def unified_chat_ws(self) -> None:
|
||||
"""Unified Chat WebSocket 处理器(支持 ct=live/chat)"""
|
||||
token = websocket.args.get("token")
|
||||
if not token:
|
||||
await websocket.close(1008, "Missing authentication token")
|
||||
return
|
||||
|
||||
try:
|
||||
jwt_secret = self.config["dashboard"].get("jwt_secret")
|
||||
payload = jwt.decode(token, jwt_secret, algorithms=["HS256"])
|
||||
username = payload["username"]
|
||||
except jwt.ExpiredSignatureError:
|
||||
await websocket.close(1008, "Token expired")
|
||||
return
|
||||
except jwt.InvalidTokenError:
|
||||
await websocket.close(1008, "Invalid token")
|
||||
return
|
||||
|
||||
await self.run_ws_session(username=username, force_ct=None)
|
||||
|
||||
async def _unified_ws_loop(self, force_ct: str | None = None) -> None:
|
||||
"""统一 WebSocket 循环"""
|
||||
# Keep the legacy entry point for internal call sites.
|
||||
token = websocket.args.get("token")
|
||||
if not token:
|
||||
await websocket.close(1008, "Missing authentication token")
|
||||
return
|
||||
try:
|
||||
jwt_secret = self.config["dashboard"].get("jwt_secret")
|
||||
payload = jwt.decode(token, jwt_secret, algorithms=["HS256"])
|
||||
username = payload["username"]
|
||||
except jwt.ExpiredSignatureError:
|
||||
await websocket.close(1008, "Token expired")
|
||||
return
|
||||
except jwt.InvalidTokenError:
|
||||
await websocket.close(1008, "Invalid token")
|
||||
return
|
||||
await self.run_ws_session(username=username, force_ct=force_ct)
|
||||
|
||||
async def run_ws_session(self, username: str, force_ct: str | None = None) -> None:
|
||||
"""Run a live/unified websocket session for an authenticated username."""
|
||||
session_id = f"webchat_live!{username}!{uuid.uuid4()}"
|
||||
live_session = LiveChatSession(session_id, username)
|
||||
self.sessions[session_id] = live_session
|
||||
@@ -690,6 +723,16 @@ class LiveChatRoute(Route):
|
||||
|
||||
elif msg_type == "end_speaking":
|
||||
# 结束说话
|
||||
if session.is_processing:
|
||||
await websocket.send_json(
|
||||
{
|
||||
"t": "error",
|
||||
"data": "Session is busy",
|
||||
"code": "PROCESSING_ERROR",
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
stamp = message.get("stamp")
|
||||
if not stamp:
|
||||
logger.warning("[Live Chat] end_speaking 缺少 stamp")
|
||||
@@ -703,45 +746,59 @@ class LiveChatRoute(Route):
|
||||
# 处理音频:STT -> LLM -> TTS
|
||||
await self._process_audio(session, audio_path, assemble_duration)
|
||||
|
||||
elif msg_type == "text_input":
|
||||
if session.is_processing:
|
||||
await websocket.send_json(
|
||||
{
|
||||
"t": "error",
|
||||
"data": "Session is busy",
|
||||
"code": "PROCESSING_ERROR",
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
user_text = message.get("text")
|
||||
if not isinstance(user_text, str):
|
||||
user_text = message.get("message")
|
||||
|
||||
if not isinstance(user_text, str) or not user_text.strip():
|
||||
await websocket.send_json(
|
||||
{
|
||||
"t": "error",
|
||||
"data": "message must be non-empty text",
|
||||
"code": "INVALID_MESSAGE_FORMAT",
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
await self._process_live_user_text(
|
||||
session,
|
||||
user_text=user_text.strip(),
|
||||
initial_metrics={"input_type": "text"},
|
||||
processing_start_time=time.time(),
|
||||
)
|
||||
|
||||
elif msg_type == "interrupt":
|
||||
# 用户打断
|
||||
session.should_interrupt = True
|
||||
logger.info(f"[Live Chat] 用户打断: {session.username}")
|
||||
|
||||
async def _process_audio(
|
||||
self, session: LiveChatSession, audio_path: str, assemble_duration: float
|
||||
async def _process_live_user_text(
|
||||
self,
|
||||
session: LiveChatSession,
|
||||
user_text: str,
|
||||
initial_metrics: dict[str, Any] | None = None,
|
||||
processing_start_time: float | None = None,
|
||||
) -> None:
|
||||
"""处理音频:STT -> LLM -> 流式 TTS"""
|
||||
"""处理 Live 用户文本:走 run_live_agent pipeline 并回传流式 TTS."""
|
||||
try:
|
||||
# 发送 WAV 组装耗时
|
||||
await websocket.send_json(
|
||||
{"t": "metrics", "data": {"wav_assemble_time": assemble_duration}}
|
||||
)
|
||||
wav_assembly_finish_time = time.time()
|
||||
if initial_metrics:
|
||||
await websocket.send_json({"t": "metrics", "data": initial_metrics})
|
||||
|
||||
processing_start = processing_start_time or time.time()
|
||||
session.is_processing = True
|
||||
session.should_interrupt = False
|
||||
|
||||
# 1. STT - 语音转文字
|
||||
ctx = self.plugin_manager.context
|
||||
stt_provider = ctx.provider_manager.stt_provider_insts[0]
|
||||
|
||||
if not stt_provider:
|
||||
logger.error("[Live Chat] STT Provider 未配置")
|
||||
await websocket.send_json({"t": "error", "data": "语音识别服务未配置"})
|
||||
return
|
||||
|
||||
await websocket.send_json(
|
||||
{"t": "metrics", "data": {"stt": stt_provider.meta().type}}
|
||||
)
|
||||
|
||||
user_text = await stt_provider.get_text(audio_path)
|
||||
if not user_text:
|
||||
logger.warning("[Live Chat] STT 识别结果为空")
|
||||
return
|
||||
|
||||
logger.info(f"[Live Chat] STT 结果: {user_text}")
|
||||
|
||||
await websocket.send_json(
|
||||
{
|
||||
"t": "user_msg",
|
||||
@@ -761,7 +818,6 @@ class LiveChatRoute(Route):
|
||||
"action_type": "live", # 标记为 live mode
|
||||
}
|
||||
|
||||
# 将消息放入队列
|
||||
await queue.put((session.username, cid, payload))
|
||||
|
||||
# 3. 等待响应并流式发送 TTS 音频
|
||||
@@ -776,11 +832,9 @@ class LiveChatRoute(Route):
|
||||
# 用户打断,停止处理
|
||||
logger.info("[Live Chat] 检测到用户打断")
|
||||
await websocket.send_json({"t": "stop_play"})
|
||||
# 保存消息并标记为被打断
|
||||
await self._save_interrupted_message(
|
||||
session, user_text, bot_text
|
||||
)
|
||||
# 清空队列中未处理的消息
|
||||
while not back_queue.empty():
|
||||
try:
|
||||
back_queue.get_nowait()
|
||||
@@ -805,6 +859,7 @@ class LiveChatRoute(Route):
|
||||
|
||||
result_type = result.get("type")
|
||||
result_chain_type = result.get("chain_type")
|
||||
result_streaming = bool(result.get("streaming", False))
|
||||
data = result.get("data", "")
|
||||
|
||||
if result_chain_type == "agent_stats":
|
||||
@@ -827,29 +882,41 @@ class LiveChatRoute(Route):
|
||||
if result_chain_type == "tts_stats":
|
||||
try:
|
||||
stats = json.loads(data)
|
||||
await websocket.send_json(
|
||||
{
|
||||
"t": "metrics",
|
||||
"data": stats,
|
||||
}
|
||||
)
|
||||
await websocket.send_json({"t": "metrics", "data": stats})
|
||||
except Exception as e:
|
||||
logger.error(f"[Live Chat] 解析 TTSStats 失败: {e}")
|
||||
continue
|
||||
|
||||
if result_chain_type == "live_text_delta":
|
||||
if data:
|
||||
await websocket.send_json(
|
||||
{
|
||||
"t": "bot_delta_chunk",
|
||||
"data": {"text": data},
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
if result_type == "plain":
|
||||
# 普通文本消息
|
||||
if (
|
||||
result_streaming
|
||||
and data
|
||||
and result_chain_type != "reasoning"
|
||||
):
|
||||
await websocket.send_json(
|
||||
{
|
||||
"t": "bot_delta_chunk",
|
||||
"data": {"text": data},
|
||||
}
|
||||
)
|
||||
bot_text += data
|
||||
|
||||
elif result_type == "audio_chunk":
|
||||
# 流式音频数据
|
||||
if not audio_playing:
|
||||
audio_playing = True
|
||||
logger.debug("[Live Chat] 开始播放音频流")
|
||||
|
||||
# Calculate latency from wav assembly finish to first audio chunk
|
||||
speak_to_first_frame_latency = (
|
||||
time.time() - wav_assembly_finish_time
|
||||
time.time() - processing_start
|
||||
)
|
||||
await websocket.send_json(
|
||||
{
|
||||
@@ -869,19 +936,15 @@ class LiveChatRoute(Route):
|
||||
}
|
||||
)
|
||||
|
||||
# 发送音频数据给前端
|
||||
await websocket.send_json(
|
||||
{
|
||||
"t": "response",
|
||||
"data": data, # base64 编码的音频数据
|
||||
"data": data,
|
||||
}
|
||||
)
|
||||
|
||||
elif result_type in ["complete", "end"]:
|
||||
# 处理完成
|
||||
logger.info(f"[Live Chat] Bot 回复完成: {bot_text}")
|
||||
|
||||
# 如果没有音频流,发送 bot 消息文本
|
||||
if not audio_playing:
|
||||
await websocket.send_json(
|
||||
{
|
||||
@@ -893,11 +956,8 @@ class LiveChatRoute(Route):
|
||||
}
|
||||
)
|
||||
|
||||
# 发送结束标记
|
||||
await websocket.send_json({"t": "end"})
|
||||
|
||||
# 发送总耗时
|
||||
wav_to_tts_duration = time.time() - wav_assembly_finish_time
|
||||
wav_to_tts_duration = time.time() - processing_start
|
||||
await websocket.send_json(
|
||||
{
|
||||
"t": "metrics",
|
||||
@@ -909,13 +969,65 @@ class LiveChatRoute(Route):
|
||||
webchat_queue_mgr.remove_back_queue(message_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Live Chat] 处理音频失败: {e}", exc_info=True)
|
||||
logger.error(f"[Live Chat] 处理文本失败: {e}", exc_info=True)
|
||||
await websocket.send_json({"t": "error", "data": f"处理失败: {str(e)}"})
|
||||
|
||||
finally:
|
||||
session.is_processing = False
|
||||
session.should_interrupt = False
|
||||
|
||||
async def _process_audio(
|
||||
self, session: LiveChatSession, audio_path: str, assemble_duration: float
|
||||
) -> None:
|
||||
"""处理音频:STT -> LLM -> 流式 TTS"""
|
||||
try:
|
||||
await websocket.send_json(
|
||||
{
|
||||
"t": "metrics",
|
||||
"data": {
|
||||
"wav_assemble_time": assemble_duration,
|
||||
"input_type": "audio",
|
||||
},
|
||||
}
|
||||
)
|
||||
wav_assembly_finish_time = time.time()
|
||||
|
||||
# 1. STT - 语音转文字
|
||||
ctx = self.plugin_manager.context
|
||||
stt_provider = ctx.provider_manager.stt_provider_insts[0]
|
||||
|
||||
if not stt_provider:
|
||||
logger.error("[Live Chat] STT Provider 未配置")
|
||||
await websocket.send_json({"t": "error", "data": "语音识别服务未配置"})
|
||||
return
|
||||
|
||||
await websocket.send_json(
|
||||
{
|
||||
"t": "metrics",
|
||||
"data": {
|
||||
"stt": stt_provider.meta().type,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
user_text = await stt_provider.get_text(audio_path)
|
||||
if not user_text:
|
||||
logger.warning("[Live Chat] STT 识别结果为空")
|
||||
return
|
||||
|
||||
logger.info(f"[Live Chat] STT 结果: {user_text}")
|
||||
|
||||
await self._process_live_user_text(
|
||||
session,
|
||||
user_text=user_text,
|
||||
initial_metrics=None,
|
||||
processing_start_time=wav_assembly_finish_time,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Live Chat] 处理音频失败: {e}", exc_info=True)
|
||||
await websocket.send_json({"t": "error", "data": f"处理失败: {str(e)}"})
|
||||
|
||||
async def _save_interrupted_message(
|
||||
self, session: LiveChatSession, user_text: str, bot_text: str
|
||||
) -> None:
|
||||
|
||||
@@ -19,6 +19,7 @@ from astrbot.core.utils.datetime_utils import to_utc_isoformat
|
||||
|
||||
from .api_key import ALL_OPEN_API_SCOPES
|
||||
from .chat import ChatRoute
|
||||
from .live_chat import LiveChatRoute
|
||||
from .route import Response, Route, RouteContext
|
||||
|
||||
|
||||
@@ -29,12 +30,14 @@ class OpenApiRoute(Route):
|
||||
db: BaseDatabase,
|
||||
core_lifecycle: AstrBotCoreLifecycle,
|
||||
chat_route: ChatRoute,
|
||||
live_chat_route: LiveChatRoute,
|
||||
) -> None:
|
||||
super().__init__(context)
|
||||
self.db = db
|
||||
self.core_lifecycle = core_lifecycle
|
||||
self.platform_manager = core_lifecycle.platform_manager
|
||||
self.chat_route = chat_route
|
||||
self.live_chat_route = live_chat_route
|
||||
|
||||
self.routes = {
|
||||
"/v1/chat": ("POST", self.chat_send),
|
||||
@@ -46,6 +49,7 @@ class OpenApiRoute(Route):
|
||||
}
|
||||
self.register_routes()
|
||||
self.app.websocket("/api/v1/chat/ws")(self.chat_ws)
|
||||
self.app.websocket("/api/v1/live/ws")(self.live_ws)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_open_username(
|
||||
@@ -534,6 +538,39 @@ class OpenApiRoute(Route):
|
||||
except Exception as e:
|
||||
logger.debug("Open API WS connection closed: %s", e)
|
||||
|
||||
async def live_ws(self) -> None:
|
||||
authed, auth_err = await self._authenticate_chat_ws_api_key()
|
||||
if not authed:
|
||||
await self._send_chat_ws_error(auth_err or "Unauthorized", "UNAUTHORIZED")
|
||||
await websocket.close(1008, auth_err or "Unauthorized")
|
||||
return
|
||||
|
||||
username, username_err = self._resolve_open_username(
|
||||
websocket.args.get("username")
|
||||
)
|
||||
if username_err or not username:
|
||||
await self._send_chat_ws_error(
|
||||
username_err or "Invalid username",
|
||||
"BAD_USER",
|
||||
)
|
||||
await websocket.close(1008, username_err or "Invalid username")
|
||||
return
|
||||
|
||||
ct = websocket.args.get("ct")
|
||||
force_ct = ct.strip() if isinstance(ct, str) and ct.strip() else "live"
|
||||
if force_ct not in {"live", "chat"}:
|
||||
await self._send_chat_ws_error(
|
||||
"ct must be 'live' or 'chat'",
|
||||
"INVALID_MESSAGE",
|
||||
)
|
||||
await websocket.close(1008, "Invalid ct")
|
||||
return
|
||||
|
||||
await self.live_chat_route.run_ws_session(
|
||||
username=username,
|
||||
force_ct=force_ct,
|
||||
)
|
||||
|
||||
async def upload_file(self):
|
||||
return await self.chat_route.post_file()
|
||||
|
||||
|
||||
@@ -115,11 +115,13 @@ class AstrBotDashboard:
|
||||
self.ar = AuthRoute(self.context)
|
||||
self.api_key_route = ApiKeyRoute(self.context, db)
|
||||
self.chat_route = ChatRoute(self.context, db, core_lifecycle)
|
||||
self.live_chat_route = LiveChatRoute(self.context, db, core_lifecycle)
|
||||
self.open_api_route = OpenApiRoute(
|
||||
self.context,
|
||||
db,
|
||||
core_lifecycle,
|
||||
self.chat_route,
|
||||
self.live_chat_route,
|
||||
)
|
||||
self.chatui_project_route = ChatUIProjectRoute(self.context, db)
|
||||
self.tools_root = ToolsRoute(self.context, core_lifecycle)
|
||||
@@ -138,7 +140,6 @@ class AstrBotDashboard:
|
||||
self.kb_route = KnowledgeBaseRoute(self.context, core_lifecycle)
|
||||
self.platform_route = PlatformRoute(self.context, core_lifecycle)
|
||||
self.backup_route = BackupRoute(self.context, db, core_lifecycle)
|
||||
self.live_chat_route = LiveChatRoute(self.context, db, core_lifecycle)
|
||||
|
||||
self.app.add_url_rule(
|
||||
"/api/plug/<path:subpath>",
|
||||
@@ -244,6 +245,7 @@ class AstrBotDashboard:
|
||||
scope_map = {
|
||||
"/api/v1/chat": "chat",
|
||||
"/api/v1/chat/ws": "chat",
|
||||
"/api/v1/live/ws": "chat",
|
||||
"/api/v1/chat/sessions": "chat",
|
||||
"/api/v1/configs": "config",
|
||||
"/api/v1/file": "file",
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
## What's Changed
|
||||
|
||||
### 新增
|
||||
|
||||
- 补充 MiniMax Provider。([#6318](https://github.com/AstrBotDevs/AstrBot/pull/6318))
|
||||
- 新增 WebUI ChatUI 页面的会话批量删除功能。([#6160](https://github.com/AstrBotDevs/AstrBot/pull/6160))
|
||||
- 新增 WebUI ChatUI 配置发送快捷键。([#6272](https://github.com/AstrBotDevs/AstrBot/pull/6272))
|
||||
|
||||
### 优化
|
||||
|
||||
- 优化 UMO 处理兼容性。([#5996](https://github.com/AstrBotDevs/AstrBot/pull/5996))
|
||||
- 重构 `_extract_session_id`,改进聊天类型分支处理。(#5775)
|
||||
- 优化聊天组件行为,使用 `shiki` 进行代码块渲染。([#6286](https://github.com/AstrBotDevs/AstrBot/pull/6286))
|
||||
- 优化 WebUI 主题配色与视觉体验。([#6263](https://github.com/AstrBotDevs/AstrBot/pull/6263))
|
||||
- 优化 OneBot @ 组件后处理,避免消息文本解析空格问题。([#6238](https://github.com/AstrBotDevs/AstrBot/pull/6238))
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复创建新 Provider 后未同步 `providers_config` 的问题。([#6388](https://github.com/AstrBotDevs/AstrBot/pull/6388))
|
||||
- 修复 API 返回 `null choices` 时的 `TypeError`。([#6313](https://github.com/AstrBotDevs/AstrBot/pull/6313))
|
||||
- 修复 QQ Webhook 重试回调重复触发的问题。([#6320](https://github.com/AstrBotDevs/AstrBot/pull/6320))
|
||||
- 修复流式模式下 `delta` 为 `None` 导致工具调用时报错的问题。([#6365](https://github.com/AstrBotDevs/AstrBot/pull/6365))
|
||||
- 修复模型服务链接说明文字错误。([#6296](https://github.com/AstrBotDevs/AstrBot/pull/6296))
|
||||
- 修复 AI 在 tool-calling 模式设为 `skills-like` 时发送媒体失败的问题。([#6317](https://github.com/AstrBotDevs/AstrBot/pull/6317))
|
||||
- 修复 Telegram 适配器中 GIF 被错误转成静态图的问题。([#6329](https://github.com/AstrBotDevs/AstrBot/pull/6329))
|
||||
- 将 Provider 图标来源替换为 jsDelivr CDN 地址,修复部分环境下图标加载问题。([#6340](https://github.com/AstrBotDevs/AstrBot/pull/6340))
|
||||
- 修复 QQ 官方表情消息未解析为可读文本的问题。([#6355](https://github.com/AstrBotDevs/AstrBot/pull/6355))
|
||||
- 修复 WebChat 队列异常时流式结果页面崩溃的问题。([#6123](https://github.com/AstrBotDevs/AstrBot/pull/6123))
|
||||
- 修复子代理 handoff 工具在插件过滤时丢失的问题。([#6155](https://github.com/AstrBotDevs/AstrBot/pull/6155))
|
||||
- 修复 Cron 提示文案缺少空格及 `utcnow()` 的弃用警告问题。([#6192](https://github.com/AstrBotDevs/AstrBot/pull/6192))
|
||||
- 修复 WebUI 启动时 Sidebar hash 导航抖动/定位问题。([#6159](https://github.com/AstrBotDevs/AstrBot/pull/6159))
|
||||
- 修复启动重试过程中移除已移除 API Key 的 `ValueError` 报错。([#6193](https://github.com/AstrBotDevs/AstrBot/pull/6193))
|
||||
- 修复 README 启动命令引用更新为 `astrbot run`。([#6189](https://github.com/AstrBotDevs/AstrBot/pull/6189))
|
||||
- 修复 `Plain.toDict()` 在 `@` 提及场景下空白字符丢失的问题。([#6244](https://github.com/AstrBotDevs/AstrBot/pull/6244))
|
||||
- 修复 provider 依赖重复定义问题。([#6247](https://github.com/AstrBotDevs/AstrBot/pull/6247))
|
||||
- 修复 Telegram 中普通回复被误判为线程的处理问题。([#6174](https://github.com/AstrBotDevs/AstrBot/pull/6174))
|
||||
|
||||
### 其他
|
||||
|
||||
- 调整 `astrbot.service` 及 CI 配置,升级 GitHub Actions 版本。
|
||||
|
||||
---
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### New Features
|
||||
|
||||
- Added OpenRouter chat completion provider adapter with support for custom headers ([#6436](https://github.com/AstrBotDevs/AstrBot/pull/6436)).
|
||||
- Added MiniMax provider ([#6318](https://github.com/AstrBotDevs/AstrBot/pull/6318)).
|
||||
- Added batch conversation deletion in WebChat ([#6160](https://github.com/AstrBotDevs/AstrBot/pull/6160)).
|
||||
- Added send shortcut settings and localization support for WebChat input ([#6272](https://github.com/AstrBotDevs/AstrBot/pull/6272)).
|
||||
- Added local temporary directory binding in YAML config ([#6191](https://github.com/AstrBotDevs/AstrBot/pull/6191)).
|
||||
|
||||
### Improvements
|
||||
|
||||
- Improved UMO processing compatibility ([#5996](https://github.com/AstrBotDevs/AstrBot/pull/5996)).
|
||||
- Refactored `_extract_session_id` for chat type handling (#5775).
|
||||
- Improved chat component behavior and uses `shiki` for code-block rendering ([#6286](https://github.com/AstrBotDevs/AstrBot/pull/6286)).
|
||||
- Improved WebUI theme color and visual behavior ([#6263](https://github.com/AstrBotDevs/AstrBot/pull/6263)).
|
||||
- Improved OneBot `@` component spacing handling ([#6238](https://github.com/AstrBotDevs/AstrBot/pull/6238)).
|
||||
- Improved PR checklist validation and closure messaging.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fixed missing `providers_config` sync after creating new providers ([#6388](https://github.com/AstrBotDevs/AstrBot/pull/6388)).
|
||||
- Fixed `TypeError` when API returns null choices ([#6313](https://github.com/AstrBotDevs/AstrBot/pull/6313)).
|
||||
- Fixed repeated QQ webhook retry callbacks ([#6320](https://github.com/AstrBotDevs/AstrBot/pull/6320)).
|
||||
- Fixed tool-calling streaming null `delta` handling to prevent `AttributeError` ([#6365](https://github.com/AstrBotDevs/AstrBot/pull/6365)).
|
||||
- Fixed model service link wording in docs/config ([#6296](https://github.com/AstrBotDevs/AstrBot/pull/6296)).
|
||||
- Fixed AI media sending failure when tool-calling mode is set to `skills-like` ([#6317](https://github.com/AstrBotDevs/AstrBot/pull/6317)).
|
||||
- Fixed GIF being sent as static image in Telegram adapter ([#6329](https://github.com/AstrBotDevs/AstrBot/pull/6329)).
|
||||
- Replaced npm registry URLs with jsDelivr CDN for provider icons ([#6340](https://github.com/AstrBotDevs/AstrBot/pull/6340)).
|
||||
- Fixed QQ official face message parsing to readable text ([#6355](https://github.com/AstrBotDevs/AstrBot/pull/6355)).
|
||||
- Fixed WebChat stream-result crash on queue errors ([#6123](https://github.com/AstrBotDevs/AstrBot/pull/6123)).
|
||||
- Preserved subagent handoff tools during plugin filtering ([#6155](https://github.com/AstrBotDevs/AstrBot/pull/6155)).
|
||||
- Fixed cron prompt spacing and deprecated `utcnow()` usage ([#6192](https://github.com/AstrBotDevs/AstrBot/pull/6192)).
|
||||
- Fixed unstable sidebar hash navigation on startup ([#6159](https://github.com/AstrBotDevs/AstrBot/pull/6159)).
|
||||
- Fixed `ValueError` in retry loop when removing an already removed API key ([#6193](https://github.com/AstrBotDevs/AstrBot/pull/6193)).
|
||||
- Updated startup command to `astrbot run` across READMEs ([#6189](https://github.com/AstrBotDevs/AstrBot/pull/6189)).
|
||||
- Preserved whitespace in `Plain.toDict()` for @ mentions ([#6244](https://github.com/AstrBotDevs/AstrBot/pull/6244)).
|
||||
- Removed duplicate dependencies entries ([#6247](https://github.com/AstrBotDevs/AstrBot/pull/6247)).
|
||||
- Fixed Telegram normal reply being treated as topic thread ([#6174](https://github.com/AstrBotDevs/AstrBot/pull/6174)).
|
||||
|
||||
### Documentation
|
||||
|
||||
- Updated `rainyun` backup/access documentation ([#6427](https://github.com/AstrBotDevs/AstrBot/pull/6427)).
|
||||
- Updated `package.md` and platform docs, including Matrix and Wecom AI bot documentation.
|
||||
- Fixed Discord invite link in community docs.
|
||||
|
||||
### Chores
|
||||
|
||||
- Updated PR templates/checklist workflow, repository service config, and automated checks.
|
||||
- Refreshed repository automation and formatting maintenance, and removed obsolete changelog scripts.
|
||||
@@ -1,3 +1,5 @@
|
||||
version: '3.8'
|
||||
|
||||
# 当接入 QQ NapCat 时,请使用这个 compose 文件一键部署: https://github.com/NapNeko/NapCat-Docker/blob/main/compose/astrbot.yml
|
||||
|
||||
services:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -619,6 +619,11 @@
|
||||
"type": "string",
|
||||
"hint": "Required. The Bot Token obtained from the KOOK Developer Platform."
|
||||
},
|
||||
"kook_bot_nickname": {
|
||||
"description": "Bot Nickname",
|
||||
"type": "string",
|
||||
"hint": "Optional. If the sender nickname matches this value, the message will be ignored to prevent broadcast storms."
|
||||
},
|
||||
"kook_reconnect_delay": {
|
||||
"description": "Reconnect Delay",
|
||||
"type": "int",
|
||||
@@ -846,7 +851,7 @@
|
||||
},
|
||||
"interval_method": {
|
||||
"description": "Interval Method",
|
||||
"hint": "random uses a random delay. log calculates delay by message length: $y=log_{log\\_base}(x)$, where x is word count and y is in seconds."
|
||||
"hint": "random 为随机时间,log 为根据消息长度计算,$y=log_<log_base>(x)$,x为字数,y的单位为秒。"
|
||||
},
|
||||
"interval": {
|
||||
"description": "Random Interval Time",
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
"title": "Confirm Batch Delete",
|
||||
"message": "Are you sure you want to delete {count} selected rules? Global settings will be used after deletion."
|
||||
},
|
||||
"batchOperations": {
|
||||
"batchOperations": {
|
||||
"title": "Batch Operations",
|
||||
"hint": "Quick batch modify session settings",
|
||||
"scope": "Apply to",
|
||||
@@ -108,24 +108,23 @@
|
||||
"ttsProvider": "TTS Model",
|
||||
"apply": "Apply Changes"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Group Management",
|
||||
"count": "{count} groups",
|
||||
"addToGroup": "Add to Group",
|
||||
"create": "Create Group",
|
||||
"edit": "Edit Group",
|
||||
"name": "Group Name",
|
||||
"sessionsCount": "{count} sessions",
|
||||
"empty": "No groups yet. Click 'Create Group' to create one.",
|
||||
"availableSessions": "Available Sessions ({count})",
|
||||
"selectedSessions": "Selected Sessions ({count})",
|
||||
"searchPlaceholder": "Search...",
|
||||
"noMatch": "No matches",
|
||||
"noMembers": "No members",
|
||||
"customGroupDivider": "── Custom Groups ──",
|
||||
"customGroupOption": "📁 {name} ({count})",
|
||||
"groupOption": "{name} ({count} sessions)",
|
||||
"deleteConfirm": "Are you sure you want to delete group \"{name}\"?"
|
||||
"status": {
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled"
|
||||
},
|
||||
"batchOperations": {
|
||||
"title": "Batch Operations",
|
||||
"hint": "Quick batch modify session settings",
|
||||
"scope": "Apply to",
|
||||
"scopeSelected": "Selected sessions",
|
||||
"scopeAll": "All sessions",
|
||||
"scopeGroup": "All groups",
|
||||
"scopePrivate": "All private chats",
|
||||
"llmStatus": "LLM Status",
|
||||
"ttsStatus": "TTS Status",
|
||||
"chatProvider": "Chat Model",
|
||||
"ttsProvider": "TTS Model",
|
||||
"apply": "Apply Changes"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Enabled",
|
||||
@@ -143,16 +142,7 @@
|
||||
"noChanges": "No changes to save",
|
||||
"batchDeleteSuccess": "Batch delete successful",
|
||||
"batchDeleteError": "Batch delete failed",
|
||||
"selectSessionsFirst": "Please select sessions first",
|
||||
"selectAtLeastOneConfig": "Please select at least one setting to modify",
|
||||
"batchUpdateSuccess": "Batch update successful",
|
||||
"partialUpdateFailed": "Some updates failed",
|
||||
"batchUpdateError": "Batch update failed",
|
||||
"groupNameRequired": "Group name cannot be empty",
|
||||
"saveGroupError": "Failed to save group",
|
||||
"deleteGroupError": "Failed to delete group",
|
||||
"selectSessionsToAddFirst": "Please select sessions to add first",
|
||||
"addToGroupSuccess": "Added {count} sessions to the group",
|
||||
"addToGroupError": "Failed to add to group"
|
||||
"batchUpdateSuccess": "Batch update success"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,25 +108,6 @@
|
||||
"ttsProvider": "TTS-модель",
|
||||
"apply": "Применить"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Управление группами",
|
||||
"count": "групп: {count}",
|
||||
"addToGroup": "Добавить в группу",
|
||||
"create": "Создать группу",
|
||||
"edit": "Изменить группу",
|
||||
"name": "Имя группы",
|
||||
"sessionsCount": "сессий: {count}",
|
||||
"empty": "Пока нет групп. Нажмите «Создать группу», чтобы добавить.",
|
||||
"availableSessions": "Доступные сессии ({count})",
|
||||
"selectedSessions": "Выбранные сессии ({count})",
|
||||
"searchPlaceholder": "Поиск...",
|
||||
"noMatch": "Нет совпадений",
|
||||
"noMembers": "Нет участников",
|
||||
"customGroupDivider": "── Пользовательские группы ──",
|
||||
"customGroupOption": "📁 {name} ({count})",
|
||||
"groupOption": "{name} (сессий: {count})",
|
||||
"deleteConfirm": "Вы уверены, что хотите удалить группу \"{name}\"?"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Включено",
|
||||
"disabled": "Выключено"
|
||||
@@ -143,16 +124,7 @@
|
||||
"noChanges": "Изменений не обнаружено",
|
||||
"batchDeleteSuccess": "Массовое удаление выполнено",
|
||||
"batchDeleteError": "Ошибка массового удаления",
|
||||
"selectSessionsFirst": "Пожалуйста, сначала выберите сессии",
|
||||
"selectAtLeastOneConfig": "Пожалуйста, выберите хотя бы одну настройку для изменения",
|
||||
"batchUpdateSuccess": "Пакетное обновление успешно выполнено",
|
||||
"partialUpdateFailed": "Некоторые обновления не выполнены",
|
||||
"batchUpdateError": "Ошибка пакетного обновления",
|
||||
"groupNameRequired": "Имя группы не может быть пустым",
|
||||
"saveGroupError": "Ошибка сохранения группы",
|
||||
"deleteGroupError": "Ошибка удаления группы",
|
||||
"selectSessionsToAddFirst": "Пожалуйста, сначала выберите сессии для добавления",
|
||||
"addToGroupSuccess": "Добавлено сессий в группу: {count}",
|
||||
"addToGroupError": "Ошибка добавления в группу"
|
||||
"batchUpdateSuccess": "Пакетное обновление успешно выполнено"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -621,6 +621,11 @@
|
||||
"type": "string",
|
||||
"hint": "必填项。从 KOOK 开发者平台获取的机器人 Token"
|
||||
},
|
||||
"kook_bot_nickname": {
|
||||
"description": "Bot Nickname",
|
||||
"type": "string",
|
||||
"hint": "可选项。若发送者昵称与此值一致,将忽略该消息。"
|
||||
},
|
||||
"kook_reconnect_delay": {
|
||||
"description": "重连延迟",
|
||||
"type": "int",
|
||||
|
||||
@@ -108,25 +108,6 @@
|
||||
"ttsProvider": "TTS 模型",
|
||||
"apply": "应用更改"
|
||||
},
|
||||
"groups": {
|
||||
"title": "分组管理",
|
||||
"count": "{count} 个分组",
|
||||
"addToGroup": "添加到分组",
|
||||
"create": "新建分组",
|
||||
"edit": "编辑分组",
|
||||
"name": "分组名称",
|
||||
"sessionsCount": "{count} 个会话",
|
||||
"empty": "暂无分组,点击「新建分组」创建",
|
||||
"availableSessions": "可选会话 ({count})",
|
||||
"selectedSessions": "已选会话 ({count})",
|
||||
"searchPlaceholder": "搜索...",
|
||||
"noMatch": "无匹配项",
|
||||
"noMembers": "暂无成员",
|
||||
"customGroupDivider": "── 自定义分组 ──",
|
||||
"customGroupOption": "📁 {name} ({count})",
|
||||
"groupOption": "{name} ({count} 个会话)",
|
||||
"deleteConfirm": "确定要删除分组 \"{name}\" 吗?"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "启用",
|
||||
"disabled": "禁用"
|
||||
@@ -142,17 +123,6 @@
|
||||
"deleteError": "删除失败",
|
||||
"noChanges": "没有需要保存的更改",
|
||||
"batchDeleteSuccess": "批量删除成功",
|
||||
"batchDeleteError": "批量删除失败",
|
||||
"selectSessionsFirst": "请先选择要操作的会话",
|
||||
"selectAtLeastOneConfig": "请至少选择一项要修改的配置",
|
||||
"batchUpdateSuccess": "批量更新成功",
|
||||
"partialUpdateFailed": "部分更新失败",
|
||||
"batchUpdateError": "批量更新失败",
|
||||
"groupNameRequired": "分组名称不能为空",
|
||||
"saveGroupError": "保存分组失败",
|
||||
"deleteGroupError": "删除分组失败",
|
||||
"selectSessionsToAddFirst": "请先选择要添加的会话",
|
||||
"addToGroupSuccess": "已添加 {count} 个会话到分组",
|
||||
"addToGroupError": "添加失败"
|
||||
"batchDeleteError": "批量删除失败"
|
||||
}
|
||||
}
|
||||
|
||||
+17
-3
@@ -98,14 +98,28 @@ axios.interceptors.request.use((config) => {
|
||||
// Some parts of the UI use fetch directly; without this, those requests will 401.
|
||||
const _origFetch = window.fetch.bind(window);
|
||||
window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const requestUrl = (() => {
|
||||
if (typeof input === 'string') return input;
|
||||
if (input instanceof URL) return input.toString();
|
||||
return input.url;
|
||||
})();
|
||||
|
||||
let shouldAttachAuth = false;
|
||||
try {
|
||||
const resolvedUrl = new URL(requestUrl, window.location.origin);
|
||||
shouldAttachAuth = resolvedUrl.origin === window.location.origin;
|
||||
} catch (_) {
|
||||
shouldAttachAuth = requestUrl.startsWith('/');
|
||||
}
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return _origFetch(input, init);
|
||||
const locale = localStorage.getItem('astrbot-locale');
|
||||
if (!token && !locale) return _origFetch(input, init);
|
||||
|
||||
const headers = new Headers(init?.headers || (typeof input !== 'string' && 'headers' in input ? (input as Request).headers : undefined));
|
||||
if (!headers.has('Authorization')) {
|
||||
if (shouldAttachAuth && token && !headers.has('Authorization')) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
const locale = localStorage.getItem('astrbot-locale');
|
||||
if (locale && !headers.has('Accept-Language')) {
|
||||
headers.set('Accept-Language', locale);
|
||||
}
|
||||
|
||||
@@ -156,24 +156,24 @@
|
||||
<!-- 分组管理面板 -->
|
||||
<v-card flat class="mt-4">
|
||||
<v-card-title class="d-flex align-center py-3 px-4">
|
||||
<span class="text-h6">{{ tm('groups.title') }}</span>
|
||||
<span class="text-h6">分组管理</span>
|
||||
<v-chip size="small" class="ml-2" color="secondary" variant="outlined">
|
||||
{{ tm('groups.count', { count: groups.length }) }}
|
||||
{{ groups.length }} 个分组
|
||||
</v-chip>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn v-if="selectedItems.length > 0 && groups.length > 0" color="info" variant="tonal" size="small" class="mr-2">
|
||||
<v-icon start>mdi-folder-plus</v-icon>
|
||||
{{ tm('groups.addToGroup') }}
|
||||
添加到分组
|
||||
<v-menu activator="parent">
|
||||
<v-list density="compact">
|
||||
<v-list-item v-for="g in groups" :key="g.id" @click="addSelectedToGroup(g.id)">
|
||||
<v-list-item-title>{{ tm('groups.customGroupOption', { name: g.name, count: g.umo_count }) }}</v-list-item-title>
|
||||
<v-list-item-title>{{ g.name }} ({{ g.umo_count }})</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-btn>
|
||||
<v-btn color="success" variant="tonal" size="small" @click="openCreateGroupDialog" prepend-icon="mdi-folder-plus">
|
||||
{{ tm('groups.create') }}
|
||||
新建分组
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<v-card-text v-if="groups.length > 0">
|
||||
@@ -183,7 +183,7 @@
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<div>
|
||||
<div class="font-weight-bold">{{ group.name }}</div>
|
||||
<div class="text-caption text-grey">{{ tm('groups.sessionsCount', { count: group.umo_count }) }}</div>
|
||||
<div class="text-caption text-grey">{{ group.umo_count }} 个会话</div>
|
||||
</div>
|
||||
<div>
|
||||
<v-btn icon size="small" variant="text" @click="openEditGroupDialog(group)">
|
||||
@@ -199,7 +199,7 @@
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
<v-card-text v-else class="text-center text-grey py-6">
|
||||
{{ tm('groups.empty') }}
|
||||
暂无分组,点击「新建分组」创建
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
@@ -207,15 +207,15 @@
|
||||
<v-dialog v-model="groupDialog" max-width="800" @after-enter="loadAvailableUmos">
|
||||
<v-card>
|
||||
<v-card-title class="py-3 px-4">
|
||||
{{ groupDialogMode === 'create' ? tm('groups.create') : tm('groups.edit') }}
|
||||
{{ groupDialogMode === 'create' ? '新建分组' : '编辑分组' }}
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field v-model="editingGroup.name" :label="tm('groups.name')" variant="outlined" hide-details class="mb-4"></v-text-field>
|
||||
<v-text-field v-model="editingGroup.name" label="分组名称" variant="outlined" hide-details class="mb-4"></v-text-field>
|
||||
<v-row dense>
|
||||
<!-- 左侧:可选会话 -->
|
||||
<v-col cols="5">
|
||||
<div class="text-subtitle-2 mb-2">{{ tm('groups.availableSessions', { count: unselectedUmos.length }) }}</div>
|
||||
<v-text-field v-model="groupMemberSearch" :placeholder="tm('groups.searchPlaceholder')" variant="outlined" density="compact" hide-details class="mb-2" clearable prepend-inner-icon="mdi-magnify"></v-text-field>
|
||||
<div class="text-subtitle-2 mb-2">可选会话 ({{ unselectedUmos.length }})</div>
|
||||
<v-text-field v-model="groupMemberSearch" placeholder="搜索..." variant="outlined" density="compact" hide-details class="mb-2" clearable prepend-inner-icon="mdi-magnify"></v-text-field>
|
||||
<v-list density="compact" class="transfer-list" lines="one">
|
||||
<v-list-item v-for="umo in filteredUnselectedUmos" :key="umo" @click="addToGroup(umo)" class="transfer-item">
|
||||
<template v-slot:prepend>
|
||||
@@ -224,7 +224,7 @@
|
||||
<v-list-item-title class="text-caption">{{ formatUmoShort(umo) }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="filteredUnselectedUmos.length === 0 && !loadingUmos">
|
||||
<v-list-item-title class="text-caption text-grey text-center">{{ tm('groups.noMatch') }}</v-list-item-title>
|
||||
<v-list-item-title class="text-caption text-grey text-center">无匹配项</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="loadingUmos">
|
||||
<v-list-item-title class="text-center"><v-progress-circular indeterminate size="20"></v-progress-circular></v-list-item-title>
|
||||
@@ -242,8 +242,8 @@
|
||||
</v-col>
|
||||
<!-- 右侧:已选会话 -->
|
||||
<v-col cols="5">
|
||||
<div class="text-subtitle-2 mb-2">{{ tm('groups.selectedSessions', { count: editingGroup.umos.length }) }}</div>
|
||||
<v-text-field v-model="groupSelectedSearch" :placeholder="tm('groups.searchPlaceholder')" variant="outlined" density="compact" hide-details class="mb-2" clearable prepend-inner-icon="mdi-magnify"></v-text-field>
|
||||
<div class="text-subtitle-2 mb-2">已选会话 ({{ editingGroup.umos.length }})</div>
|
||||
<v-text-field v-model="groupSelectedSearch" placeholder="搜索..." variant="outlined" density="compact" hide-details class="mb-2" clearable prepend-inner-icon="mdi-magnify"></v-text-field>
|
||||
<v-list density="compact" class="transfer-list" lines="one">
|
||||
<v-list-item v-for="umo in filteredSelectedUmos" :key="umo" @click="removeFromGroup(umo)" class="transfer-item">
|
||||
<template v-slot:prepend>
|
||||
@@ -252,7 +252,7 @@
|
||||
<v-list-item-title class="text-caption">{{ formatUmoShort(umo) }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="editingGroup.umos.length === 0">
|
||||
<v-list-item-title class="text-caption text-grey text-center">{{ tm('groups.noMembers') }}</v-list-item-title>
|
||||
<v-list-item-title class="text-caption text-grey text-center">暂无成员</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-col>
|
||||
@@ -260,8 +260,8 @@
|
||||
</v-card-text>
|
||||
<v-card-actions class="px-4 pb-4">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="text" @click="groupDialog = false">{{ tm('buttons.cancel') }}</v-btn>
|
||||
<v-btn color="primary" variant="tonal" @click="saveGroup">{{ tm('buttons.save') }}</v-btn>
|
||||
<v-btn variant="text" @click="groupDialog = false">取消</v-btn>
|
||||
<v-btn color="primary" variant="tonal" @click="saveGroup">保存</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
@@ -721,12 +721,9 @@ export default {
|
||||
]
|
||||
// 添加自定义分组选项
|
||||
if (this.groups.length > 0) {
|
||||
options.push({ label: this.tm('groups.customGroupDivider'), value: '_divider', disabled: true })
|
||||
options.push({ label: '── 自定义分组 ──', value: '_divider', disabled: true })
|
||||
this.groups.forEach(g => {
|
||||
options.push({
|
||||
label: this.tm('groups.customGroupOption', { name: g.name, count: g.umo_count }),
|
||||
value: `custom_group:${g.id}`
|
||||
})
|
||||
options.push({ label: `📁 ${g.name} (${g.umo_count})`, value: `custom_group:${g.id}` })
|
||||
})
|
||||
}
|
||||
return options
|
||||
@@ -734,7 +731,7 @@ export default {
|
||||
|
||||
groupOptions() {
|
||||
return this.groups.map(g => ({
|
||||
label: this.tm('groups.groupOption', { name: g.name, count: g.umo_count }),
|
||||
label: `${g.name} (${g.umo_count} 个会话)`,
|
||||
value: g.id
|
||||
}))
|
||||
},
|
||||
@@ -1334,7 +1331,7 @@ export default {
|
||||
if (scope === 'selected') {
|
||||
umos = this.selectedItems.map(item => item.umo)
|
||||
if (umos.length === 0) {
|
||||
this.showError(this.tm('messages.selectSessionsFirst'))
|
||||
this.showError('请先选择要操作的会话')
|
||||
this.batchUpdating = false
|
||||
return
|
||||
}
|
||||
@@ -1374,7 +1371,7 @@ export default {
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
this.showError(this.tm('messages.selectAtLeastOneConfig'))
|
||||
this.showError('请至少选择一项要修改的配置')
|
||||
this.batchUpdating = false
|
||||
return
|
||||
}
|
||||
@@ -1383,17 +1380,17 @@ export default {
|
||||
const allOk = results.every(r => r.data.status === 'ok')
|
||||
|
||||
if (allOk) {
|
||||
this.showSuccess(this.tm('messages.batchUpdateSuccess'))
|
||||
this.showSuccess('批量更新成功')
|
||||
this.batchLlmStatus = null
|
||||
this.batchTtsStatus = null
|
||||
this.batchChatProvider = null
|
||||
this.batchTtsProvider = null
|
||||
await this.loadData()
|
||||
} else {
|
||||
this.showError(this.tm('messages.partialUpdateFailed'))
|
||||
this.showError('部分更新失败')
|
||||
}
|
||||
} catch (error) {
|
||||
this.showError(error.response?.data?.message || this.tm('messages.batchUpdateError'))
|
||||
this.showError(error.response?.data?.message || '批量更新失败')
|
||||
}
|
||||
this.batchUpdating = false
|
||||
},
|
||||
@@ -1480,7 +1477,7 @@ export default {
|
||||
|
||||
async saveGroup() {
|
||||
if (!this.editingGroup.name.trim()) {
|
||||
this.showError(this.tm('messages.groupNameRequired'))
|
||||
this.showError('分组名称不能为空')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1507,12 +1504,12 @@ export default {
|
||||
this.showError(response.data.message)
|
||||
}
|
||||
} catch (error) {
|
||||
this.showError(error.response?.data?.message || this.tm('messages.saveGroupError'))
|
||||
this.showError(error.response?.data?.message || '保存分组失败')
|
||||
}
|
||||
},
|
||||
|
||||
async deleteGroup(group) {
|
||||
const message = this.tm('groups.deleteConfirm', { name: group.name })
|
||||
const message = `确定要删除分组 "${group.name}" 吗?`
|
||||
if (!(await askForConfirmationDialog(message, this.confirmDialog))) return
|
||||
|
||||
try {
|
||||
@@ -1524,7 +1521,7 @@ export default {
|
||||
this.showError(response.data.message)
|
||||
}
|
||||
} catch (error) {
|
||||
this.showError(error.response?.data?.message || this.tm('messages.deleteGroupError'))
|
||||
this.showError(error.response?.data?.message || '删除分组失败')
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1535,7 +1532,7 @@ export default {
|
||||
|
||||
async addSelectedToGroup(groupId) {
|
||||
if (this.selectedItems.length === 0) {
|
||||
this.showError(this.tm('messages.selectSessionsToAddFirst'))
|
||||
this.showError('请先选择要添加的会话')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1545,13 +1542,13 @@ export default {
|
||||
add_umos: this.selectedItems.map(item => item.umo)
|
||||
})
|
||||
if (response.data.status === 'ok') {
|
||||
this.showSuccess(this.tm('messages.addToGroupSuccess', { count: this.selectedItems.length }))
|
||||
this.showSuccess(`已添加 ${this.selectedItems.length} 个会话到分组`)
|
||||
await this.loadGroups()
|
||||
} else {
|
||||
this.showError(response.data.message)
|
||||
}
|
||||
} catch (error) {
|
||||
this.showError(error.response?.data?.message || this.tm('messages.addToGroupError'))
|
||||
this.showError(error.response?.data?.message || '添加失败')
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -29,6 +29,7 @@ X-API-Key: abk_xxx
|
||||
## Common Endpoints
|
||||
|
||||
- `POST /api/v1/chat`: send chat message (SSE stream, server generates UUID when `session_id` is omitted)
|
||||
- `GET /api/v1/live/ws`: Live API WebSocket (API Key auth, requires `username` query parameter, optional `ct=live|chat`)
|
||||
- `GET /api/v1/chat/sessions`: list sessions for a specific `username` with pagination
|
||||
- `GET /api/v1/configs`: list available config files
|
||||
- `POST /api/v1/file`: upload attachment
|
||||
@@ -49,3 +50,7 @@ curl -N 'http://localhost:6185/api/v1/chat' \
|
||||
Use the interactive docs:
|
||||
|
||||
- https://docs.astrbot.app/scalar.html
|
||||
|
||||
For the full Live API wire protocol, see:
|
||||
|
||||
- `docs/live-api/README.md`
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
# AstrBot Live API Protocol
|
||||
|
||||
This document describes the current WebSocket protocol for AstrBot Live API.
|
||||
|
||||
## Endpoint
|
||||
|
||||
- Legacy JWT endpoint: `/api/live_chat/ws`
|
||||
- Legacy unified JWT endpoint: `/api/unified_chat/ws`
|
||||
- Open API endpoint: `/api/v1/live/ws`
|
||||
|
||||
## Authentication
|
||||
|
||||
### Legacy dashboard endpoints
|
||||
|
||||
Pass a dashboard JWT in the `token` query parameter.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
ws://localhost:6185/api/live_chat/ws?token=<dashboard_jwt>
|
||||
```
|
||||
|
||||
### Open API endpoint
|
||||
|
||||
Use an API key and provide `username` in the query string.
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
ws://localhost:6185/api/v1/live/ws?api_key=<api_key>&username=alice
|
||||
ws://localhost:6185/api/v1/live/ws?api_key=<api_key>&username=alice&ct=chat
|
||||
```
|
||||
|
||||
`ct` values:
|
||||
|
||||
- `live`: voice conversation mode
|
||||
- `chat`: unified chat mode over the same WebSocket transport
|
||||
|
||||
The Open API endpoint reuses the `chat` API key scope.
|
||||
|
||||
## Transport
|
||||
|
||||
- Protocol: WebSocket
|
||||
- Payload format: UTF-8 JSON text frames
|
||||
- Audio upload format in `live` mode:
|
||||
- client sends raw PCM frames encoded as Base64
|
||||
- sample rate: `16000`
|
||||
- channels: `1`
|
||||
- sample width: `16-bit`
|
||||
|
||||
## Top-Level Envelope
|
||||
|
||||
### Client to server
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "message_type",
|
||||
"...": "message specific fields"
|
||||
}
|
||||
```
|
||||
|
||||
When using the unified socket, the client can also include:
|
||||
|
||||
```json
|
||||
{
|
||||
"ct": "live|chat",
|
||||
"t": "message_type"
|
||||
}
|
||||
```
|
||||
|
||||
### Server to client
|
||||
|
||||
Legacy `live` mode uses:
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "message_type",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
Unified `chat` mode uses:
|
||||
|
||||
```json
|
||||
{
|
||||
"ct": "chat",
|
||||
"type": "message_type",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
Some forwarded `chat` frames may also contain `t`, `streaming`, `chain_type`, `message_id`, or `session_id`.
|
||||
|
||||
## Live Mode
|
||||
|
||||
### Client messages
|
||||
|
||||
#### `start_speaking`
|
||||
|
||||
Start a voice capture segment.
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "start_speaking",
|
||||
"stamp": "seg_001"
|
||||
}
|
||||
```
|
||||
|
||||
#### `speaking_part`
|
||||
|
||||
Send one audio frame.
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "speaking_part",
|
||||
"data": "<base64_pcm_bytes>"
|
||||
}
|
||||
```
|
||||
|
||||
#### `end_speaking`
|
||||
|
||||
Finish the current voice capture segment.
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "end_speaking",
|
||||
"stamp": "seg_001"
|
||||
}
|
||||
```
|
||||
|
||||
#### `text_input`
|
||||
|
||||
Send a plain text input directly while using `ct=live`. The server will still route through Live mode with TTS and interrupt handling.
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "text_input",
|
||||
"text": "Hello, what is the weather today?"
|
||||
}
|
||||
```
|
||||
|
||||
#### `interrupt`
|
||||
|
||||
Interrupt the current model or TTS response.
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "interrupt"
|
||||
}
|
||||
```
|
||||
|
||||
### Server messages
|
||||
|
||||
#### `metrics`
|
||||
|
||||
Performance and provider metadata.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "metrics",
|
||||
"data": {
|
||||
"wav_assemble_time": 0.12,
|
||||
"stt": "whisper_api",
|
||||
"llm_ttft": 0.84,
|
||||
"tts_total_time": 1.72
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `user_msg`
|
||||
|
||||
STT result from the uploaded audio.
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "user_msg",
|
||||
"data": {
|
||||
"text": "Hello there",
|
||||
"ts": 1710000000000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `bot_delta_chunk`
|
||||
|
||||
Raw model text delta. This is the token or chunk level stream and is not sentence segmented.
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "bot_delta_chunk",
|
||||
"data": {
|
||||
"text": "Hel"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- This event is generated directly from the model streaming path.
|
||||
- It is independent from TTS chunking.
|
||||
- Consumers should append `data.text` to a local buffer.
|
||||
|
||||
#### `bot_text_chunk`
|
||||
|
||||
Text associated with the current TTS chunk. This is usually sentence or phrase segmented.
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "bot_text_chunk",
|
||||
"data": {
|
||||
"text": "Hello there."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- This event is aligned to TTS output, not raw token streaming.
|
||||
- It may be coarser than `bot_delta_chunk`.
|
||||
|
||||
#### `response`
|
||||
|
||||
One TTS audio chunk, Base64 encoded.
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "response",
|
||||
"data": "<base64_audio_bytes>"
|
||||
}
|
||||
```
|
||||
|
||||
#### `bot_msg`
|
||||
|
||||
Final bot text when the response completed without audio streaming.
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "bot_msg",
|
||||
"data": {
|
||||
"text": "Final reply text",
|
||||
"ts": 1710000001234
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `stop_play`
|
||||
|
||||
Stop client-side audio playback because the response was interrupted.
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "stop_play"
|
||||
}
|
||||
```
|
||||
|
||||
#### `end`
|
||||
|
||||
Marks the end of the current response turn.
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "end"
|
||||
}
|
||||
```
|
||||
|
||||
#### `error`
|
||||
|
||||
Recoverable or terminal processing error.
|
||||
|
||||
```json
|
||||
{
|
||||
"t": "error",
|
||||
"data": "error message"
|
||||
}
|
||||
```
|
||||
|
||||
## Unified Chat Mode
|
||||
|
||||
Set `ct=chat` on the Open API endpoint or include `"ct": "chat"` in each client frame when using `/api/unified_chat/ws`.
|
||||
|
||||
### Client messages
|
||||
|
||||
#### `bind`
|
||||
|
||||
Subscribe to an existing webchat session.
|
||||
|
||||
```json
|
||||
{
|
||||
"ct": "chat",
|
||||
"t": "bind",
|
||||
"session_id": "session_001"
|
||||
}
|
||||
```
|
||||
|
||||
#### `send`
|
||||
|
||||
Send a chat request.
|
||||
|
||||
```json
|
||||
{
|
||||
"ct": "chat",
|
||||
"t": "send",
|
||||
"username": "alice",
|
||||
"session_id": "session_001",
|
||||
"message_id": "msg_001",
|
||||
"message": [
|
||||
{
|
||||
"type": "plain",
|
||||
"text": "Please summarize this"
|
||||
}
|
||||
],
|
||||
"selected_provider": "openai_chat_completion",
|
||||
"selected_model": "gpt-4.1-mini",
|
||||
"enable_streaming": true
|
||||
}
|
||||
```
|
||||
|
||||
`message` uses the same message-part schema as `POST /api/v1/chat`.
|
||||
|
||||
#### `interrupt`
|
||||
|
||||
Interrupt the current chat response.
|
||||
|
||||
```json
|
||||
{
|
||||
"ct": "chat",
|
||||
"t": "interrupt"
|
||||
}
|
||||
```
|
||||
|
||||
### Server messages
|
||||
|
||||
#### `session_bound`
|
||||
|
||||
Acknowledges a successful `bind`.
|
||||
|
||||
```json
|
||||
{
|
||||
"ct": "chat",
|
||||
"type": "session_bound",
|
||||
"session_id": "session_001",
|
||||
"message_id": "ws_sub_xxx"
|
||||
}
|
||||
```
|
||||
|
||||
#### Forwarded streaming events
|
||||
|
||||
The server forwards the normal webchat queue payloads. Common examples:
|
||||
|
||||
```json
|
||||
{
|
||||
"ct": "chat",
|
||||
"type": "plain",
|
||||
"data": "Hello",
|
||||
"streaming": true,
|
||||
"chain_type": null,
|
||||
"message_id": "msg_001"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"ct": "chat",
|
||||
"type": "image",
|
||||
"data": "[IMAGE]file.jpg",
|
||||
"streaming": false,
|
||||
"message_id": "msg_001"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"ct": "chat",
|
||||
"type": "agent_stats",
|
||||
"data": {
|
||||
"time_to_first_token": 0.8
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"ct": "chat",
|
||||
"type": "message_saved",
|
||||
"data": {
|
||||
"id": 123,
|
||||
"created_at": "2026-03-16T10:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"ct": "chat",
|
||||
"type": "end",
|
||||
"data": "",
|
||||
"streaming": false,
|
||||
"message_id": "msg_001"
|
||||
}
|
||||
```
|
||||
|
||||
#### Chat errors
|
||||
|
||||
```json
|
||||
{
|
||||
"ct": "chat",
|
||||
"t": "error",
|
||||
"code": "INVALID_MESSAGE_FORMAT",
|
||||
"data": "message must be list"
|
||||
}
|
||||
```
|
||||
|
||||
## Recommended Client Strategy
|
||||
|
||||
For `live` mode:
|
||||
|
||||
1. Append every `bot_delta_chunk.data.text` into a raw transcript buffer.
|
||||
2. Use `bot_text_chunk` only when you need text aligned with audio playback.
|
||||
3. Decode and play each `response` audio chunk in arrival order.
|
||||
4. Reset per-turn buffers after `end`.
|
||||
|
||||
For `chat` mode:
|
||||
|
||||
1. Treat `plain + streaming=true` as incremental text.
|
||||
2. Treat `complete` or `end` as the end of a response turn.
|
||||
3. Persist `message_saved` metadata if you need server-side history IDs.
|
||||
|
||||
## Compatibility Notes
|
||||
|
||||
- `bot_text_chunk` remains sentence or phrase segmented for TTS compatibility.
|
||||
- `bot_delta_chunk` is the new delta-level text event for real-time rendering.
|
||||
- The legacy JWT endpoints and the new Open API endpoint share the same runtime behavior after authentication.
|
||||
@@ -257,6 +257,56 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/live/ws": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Open API"
|
||||
],
|
||||
"summary": "Live API WebSocket",
|
||||
"description": "WebSocket endpoint for Live API. Authenticate with API Key using query parameter `api_key` or header `Authorization: Bearer <api_key>`, and pass `username` as a query parameter. Use `ct=live` for voice mode or `ct=chat` for unified chat mode. See docs/live-api/README.md for the full frame-level protocol.",
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyHeader": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "username",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Target username for the live session."
|
||||
},
|
||||
{
|
||||
"name": "ct",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"live",
|
||||
"chat"
|
||||
],
|
||||
"default": "live"
|
||||
},
|
||||
"description": "Session mode. `live` for voice conversation, `ct=chat` for the unified chat WebSocket."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"101": {
|
||||
"description": "WebSocket protocol switch"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/Forbidden"
|
||||
}
|
||||
},
|
||||
"x-websocket": true
|
||||
}
|
||||
},
|
||||
"/api/v1/im/message": {
|
||||
"post": {
|
||||
"tags": [
|
||||
|
||||
@@ -46,6 +46,7 @@ X-API-Key: abk_xxx
|
||||
调用 AstrBot 内建的 Agent 进行对话交互。支持插件调用、工具调用等能力,与 IM 端对话能力一致。
|
||||
|
||||
- `POST /api/v1/chat`:发送对话消息(SSE 流式返回,不传 `session_id` 会自动创建 UUID)
|
||||
- `GET /api/v1/live/ws`:Live API WebSocket(API Key 鉴权,查询参数必须包含 `username`,可选 `ct=live|chat`)
|
||||
- `GET /api/v1/chat/sessions`:分页获取指定 `username` 的会话
|
||||
- `GET /api/v1/configs`:获取可用配置文件列表
|
||||
|
||||
@@ -148,3 +149,7 @@ curl -N 'http://localhost:6185/api/v1/chat' \
|
||||
交互式 API 文档请查看:
|
||||
|
||||
- https://docs.astrbot.app/scalar.html
|
||||
|
||||
Live API 协议说明请查看:
|
||||
|
||||
- `docs/live-api/README.md`
|
||||
|
||||
@@ -257,6 +257,56 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/live/ws": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Open API"
|
||||
],
|
||||
"summary": "Live API WebSocket",
|
||||
"description": "WebSocket endpoint for Live API. Authenticate with API Key using query parameter `api_key` or header `Authorization: Bearer <api_key>`, and pass `username` as a query parameter. Use `ct=live` for voice mode or `ct=chat` for unified chat mode. See docs/live-api/README.md for the full frame-level protocol.",
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyHeader": []
|
||||
}
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "username",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Target username for the live session."
|
||||
},
|
||||
{
|
||||
"name": "ct",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"live",
|
||||
"chat"
|
||||
],
|
||||
"default": "live"
|
||||
},
|
||||
"description": "Session mode. `live` for voice conversation, `chat` for the unified chat WebSocket."
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"101": {
|
||||
"description": "WebSocket protocol switch"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/Forbidden"
|
||||
}
|
||||
},
|
||||
"x-websocket": true
|
||||
}
|
||||
},
|
||||
"/api/v1/im/message": {
|
||||
"post": {
|
||||
"tags": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "AstrBot"
|
||||
version = "4.20.1"
|
||||
version = "4.20.0"
|
||||
description = "Easy-to-use multi-platform LLM chatbot and development framework"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import asyncio
|
||||
import copy
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
@@ -108,53 +107,6 @@ async def test_get_stat(app: Quart, authenticated_header: dict):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subagent_config_accepts_default_persona(
|
||||
app: Quart,
|
||||
authenticated_header: dict,
|
||||
core_lifecycle_td: AstrBotCoreLifecycle,
|
||||
):
|
||||
test_client = app.test_client()
|
||||
old_cfg = copy.deepcopy(
|
||||
core_lifecycle_td.astrbot_config.get("subagent_orchestrator", {})
|
||||
)
|
||||
payload = {
|
||||
"main_enable": True,
|
||||
"remove_main_duplicate_tools": True,
|
||||
"agents": [
|
||||
{
|
||||
"name": "planner",
|
||||
"persona_id": "default",
|
||||
"public_description": "planner",
|
||||
"system_prompt": "",
|
||||
"enabled": True,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
try:
|
||||
response = await test_client.post(
|
||||
"/api/subagent/config",
|
||||
json=payload,
|
||||
headers=authenticated_header,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data["status"] == "ok"
|
||||
|
||||
get_response = await test_client.get(
|
||||
"/api/subagent/config", headers=authenticated_header
|
||||
)
|
||||
assert get_response.status_code == 200
|
||||
get_data = await get_response.get_json()
|
||||
assert get_data["status"] == "ok"
|
||||
assert get_data["data"]["agents"][0]["persona_id"] == "default"
|
||||
finally:
|
||||
await test_client.post(
|
||||
"/api/subagent/config",
|
||||
json=old_cfg,
|
||||
headers=authenticated_header,
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("payload", [[], "x"])
|
||||
async def test_batch_delete_sessions_rejects_non_object_payload(
|
||||
app: Quart, authenticated_header: dict, payload
|
||||
|
||||
@@ -4,97 +4,97 @@
|
||||
"size": "lg",
|
||||
"modules": [
|
||||
{
|
||||
"type": "header",
|
||||
"text": {
|
||||
"type": "plain-text",
|
||||
"content": "test1",
|
||||
"type": "plain-text",
|
||||
"emoji": true
|
||||
}
|
||||
},
|
||||
"type": "header"
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "kmarkdown",
|
||||
"content": "test2"
|
||||
"content": "test2",
|
||||
"type": "kmarkdown"
|
||||
},
|
||||
"type": "section",
|
||||
"mode": "left"
|
||||
},
|
||||
{
|
||||
"type": "divider"
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "paragraph",
|
||||
"fields": [
|
||||
{
|
||||
"type": "kmarkdown",
|
||||
"content": "test3"
|
||||
"content": "test3",
|
||||
"type": "kmarkdown"
|
||||
},
|
||||
{
|
||||
"type": "kmarkdown",
|
||||
"content": "**test4**"
|
||||
"content": "**test4**",
|
||||
"type": "kmarkdown"
|
||||
}
|
||||
],
|
||||
"type": "paragraph",
|
||||
"cols": 2
|
||||
},
|
||||
"type": "section",
|
||||
"mode": "left"
|
||||
},
|
||||
{
|
||||
"type": "image-group",
|
||||
"elements": [
|
||||
{
|
||||
"type": "image",
|
||||
"src": "https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg",
|
||||
"type": "image",
|
||||
"alt": "",
|
||||
"size": "lg",
|
||||
"circle": false
|
||||
}
|
||||
]
|
||||
],
|
||||
"type": "image-group"
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"src": "https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg",
|
||||
"title": "test5"
|
||||
"title": "test5",
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"type": "countdown",
|
||||
"endTime": 1772343427360,
|
||||
"type": "countdown",
|
||||
"startTime": 1772343378259,
|
||||
"mode": "second"
|
||||
},
|
||||
{
|
||||
"type": "action-group",
|
||||
"elements": [
|
||||
{
|
||||
"type": "button",
|
||||
"text": "点我测试回调",
|
||||
"type": "button",
|
||||
"theme": "primary",
|
||||
"value": "btn_clicked",
|
||||
"click": "return-val"
|
||||
},
|
||||
{
|
||||
"type": "button",
|
||||
"text": "访问官网",
|
||||
"type": "button",
|
||||
"theme": "danger",
|
||||
"value": "https://www.kookapp.cn",
|
||||
"click": "link"
|
||||
}
|
||||
]
|
||||
],
|
||||
"type": "action-group"
|
||||
},
|
||||
{
|
||||
"type": "context",
|
||||
"elements": [
|
||||
{
|
||||
"type": "plain-text",
|
||||
"content": "test6",
|
||||
"type": "plain-text",
|
||||
"emoji": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"type": "context"
|
||||
},
|
||||
{
|
||||
"type": "invite",
|
||||
"code": "test7"
|
||||
"code": "test7",
|
||||
"type": "invite"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
{
|
||||
"s": 0,
|
||||
"d": {
|
||||
"channel_type": "GROUP",
|
||||
"type": 9,
|
||||
"target_id": "2732467349811313213",
|
||||
"author_id": "7324688132731983",
|
||||
"content": "done!",
|
||||
"extra": {
|
||||
"quote": {
|
||||
"id": "69a788adb0cfb9ece50eae1c",
|
||||
"rong_id": "7baef72c-0cd7-49ad-9592-1615236136cb",
|
||||
"type": 9,
|
||||
"content": "/am 1",
|
||||
"interact_res": null,
|
||||
"create_at": 1772587180973,
|
||||
"author": {
|
||||
"id": "2701973210937821093781",
|
||||
"username": "some_username",
|
||||
"identify_num": "4198",
|
||||
"online": true,
|
||||
"os": "Websocket",
|
||||
"status": 1,
|
||||
"avatar": "https://example.com",
|
||||
"vip_avatar": "https://example.com",
|
||||
"banner": "",
|
||||
"nickname": "some_username",
|
||||
"roles": [
|
||||
63724577
|
||||
],
|
||||
"is_vip": false,
|
||||
"vip_amp": false,
|
||||
"bot": false,
|
||||
"nameplate": [],
|
||||
"kpm_vip": null,
|
||||
"wealth_level": 0,
|
||||
"decorations_id_map": null,
|
||||
"mobile_verified": true,
|
||||
"is_sys": false,
|
||||
"joined_at": 1772259607000,
|
||||
"active_time": 1772587181304
|
||||
},
|
||||
"can_jump": true,
|
||||
"preview_content": null,
|
||||
"kmarkdown": {
|
||||
"mention_part": [],
|
||||
"mention_role_part": [],
|
||||
"channel_part": [],
|
||||
"item_part": []
|
||||
}
|
||||
},
|
||||
"type": 9,
|
||||
"code": "",
|
||||
"guild_id": "273902183210983210983",
|
||||
"guild_type": 0,
|
||||
"channel_name": "聊天大厅",
|
||||
"author": {
|
||||
"id": "7324688132731983",
|
||||
"username": "Bot_Test",
|
||||
"identify_num": "9561",
|
||||
"online": true,
|
||||
"os": "Websocket",
|
||||
"status": 0,
|
||||
"avatar": "https://example.com",
|
||||
"vip_avatar": "https://example.com",
|
||||
"banner": "",
|
||||
"nickname": "Bot_Test",
|
||||
"roles": [
|
||||
63725384
|
||||
],
|
||||
"is_vip": false,
|
||||
"vip_amp": false,
|
||||
"bot": true,
|
||||
"nameplate": [],
|
||||
"kpm_vip": null,
|
||||
"wealth_level": 0,
|
||||
"bot_status": 0,
|
||||
"tag_info": {
|
||||
"color": "#0096FF",
|
||||
"bg_color": "#0096FF33",
|
||||
"text": "机器人"
|
||||
},
|
||||
"is_sys": false,
|
||||
"client_id": "sAdiIHoGhdSFUOA",
|
||||
"verified": false
|
||||
},
|
||||
"visible_only": "",
|
||||
"mention": [],
|
||||
"mention_no_at": [],
|
||||
"mention_all": false,
|
||||
"mention_roles": [],
|
||||
"mention_here": false,
|
||||
"nav_channels": [],
|
||||
"kmarkdown": {
|
||||
"raw_content": "done!",
|
||||
"mention_part": [],
|
||||
"mention_role_part": [],
|
||||
"channel_part": [],
|
||||
"spl": []
|
||||
},
|
||||
"emoji": [],
|
||||
"preview_content": "",
|
||||
"channel_type": 1,
|
||||
"last_msg_content": "Bot_Test:done!",
|
||||
"send_msg_device": 0
|
||||
},
|
||||
"msg_id": "c51a8761-63bv-5l2a-5681-0ac16e140a1b",
|
||||
"msg_timestamp": 1772587182234,
|
||||
"nonce": "",
|
||||
"from_type": 1
|
||||
},
|
||||
"extra": {
|
||||
"verifyToken": "kW4FH_ASHio1hosd",
|
||||
"encryptKey": "",
|
||||
"callbackUrl": "",
|
||||
"intent": 255
|
||||
},
|
||||
"sn": 3
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"s": 1,
|
||||
"d": {
|
||||
"sessionId": "67d7d497-2b10-4849-9c2c-dda2fe58ed60",
|
||||
"session_id": "67d7d497-2b10-4849-9c2c-dda2fe58ed60",
|
||||
"code": 0
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
{
|
||||
"s": 0,
|
||||
"d": {
|
||||
"channel_type": "PERSON",
|
||||
"type": 10,
|
||||
"target_id": "2732467349811313213",
|
||||
"author_id": "7324688132731983",
|
||||
"content": "[{\"theme\":\"primary\",\"color\":\"\",\"size\":\"lg\",\"expand\":false,\"modules\":[{\"type\":\"audio\",\"cover\":\"\",\"duration\":0,\"title\":\"dancing_shot5.wav\",\"src\":\"https:\\/\\/img.kookapp.cn\\/attachments\\/2026-03\\/03\\/69a6841c3125d.wav\",\"external\":false,\"size\":443414,\"canDownload\":true,\"elements\":[]}],\"type\":\"card\"}]",
|
||||
"extra": {
|
||||
"type": 10,
|
||||
"code": "1738914789hd8fd91098he809h19y491",
|
||||
"author": {
|
||||
"id": "7324688132731983",
|
||||
"username": "Bot_Test",
|
||||
"identify_num": "9561",
|
||||
"online": true,
|
||||
"os": "Websocket",
|
||||
"status": 0,
|
||||
"avatar": "https://example.com",
|
||||
"vip_avatar": "https://example.com",
|
||||
"banner": "",
|
||||
"nickname": "Bot_Test",
|
||||
"roles": [],
|
||||
"is_vip": false,
|
||||
"vip_amp": false,
|
||||
"bot": true,
|
||||
"nameplate": [],
|
||||
"kpm_vip": null,
|
||||
"wealth_level": 0,
|
||||
"bot_status": 0,
|
||||
"tag_info": {
|
||||
"color": "#0096FF",
|
||||
"bg_color": "#0096FF33",
|
||||
"text": "机器人"
|
||||
},
|
||||
"is_sys": false,
|
||||
"client_id": "u109u3108h8ds0qsdaHUIOS",
|
||||
"verified": false
|
||||
},
|
||||
"visible_only": "",
|
||||
"mention": [],
|
||||
"mention_no_at": [],
|
||||
"mention_all": false,
|
||||
"mention_roles": [],
|
||||
"mention_here": false,
|
||||
"nav_channels": [],
|
||||
"emoji": [],
|
||||
"kmarkdown": {
|
||||
"raw_content": "[音频]dancing_shot5.wav",
|
||||
"mention_part": [],
|
||||
"mention_role_part": [],
|
||||
"channel_part": []
|
||||
},
|
||||
"editable": false,
|
||||
"preview_content": "[音频]dancing_shot5.wav",
|
||||
"preview_content_search": "[音频]dancing_shot5.wav",
|
||||
"last_msg_content": "[音频]dancing_shot5.wav",
|
||||
"send_msg_device": 0
|
||||
},
|
||||
"msg_id": "82c0b042-79b4-4066-a0f4-6c7a95c74e67",
|
||||
"msg_timestamp": 1772587223043,
|
||||
"nonce": "",
|
||||
"from_type": 1
|
||||
},
|
||||
"extra": {
|
||||
"verifyToken": "kW4FH_ASHio1hosd",
|
||||
"encryptKey": "",
|
||||
"callbackUrl": "",
|
||||
"intent": 255
|
||||
},
|
||||
"sn": 5
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
{
|
||||
"s": 0,
|
||||
"d": {
|
||||
"channel_type": "GROUP",
|
||||
"type": 10,
|
||||
"target_id": "2723723449021809",
|
||||
"author_id": "1237198731983",
|
||||
"content": "[{\"theme\":\"invisible\",\"color\":\"\",\"size\":\"lg\",\"expand\":false,\"modules\":[{\"type\":\"section\",\"mode\":\"left\",\"accessory\":null,\"text\":{\"type\":\"kmarkdown\",\"content\":\"(met)(met) (met)all(met) #hello \\\\*\\\\*world\\\\*\\\\* \",\"elements\":[]},\"elements\":[]},{\"type\":\"audio\",\"cover\":\"\",\"duration\":0,\"title\":\"dancing_shot5.wav\",\"src\":\"https:\\/\\/img.kookapp.cn\\/attachments\\/2026-03\\/03\\/69a6841c3125d.wav\",\"external\":false,\"size\":443414,\"canDownload\":true,\"elements\":[]},{\"type\":\"section\",\"mode\":\"left\",\"accessory\":null,\"text\":{\"type\":\"kmarkdown\",\"content\":\"\\n😆 \",\"elements\":[]},\"elements\":[]}],\"type\":\"card\"}]",
|
||||
"msg_id": "ec4046e9-ea43-4907-9fc3-8c6d0bd4ec56",
|
||||
"msg_timestamp": 1772600762056,
|
||||
"nonce": "sy8f91y248yda",
|
||||
"from_type": 1,
|
||||
"extra": {
|
||||
"type": 10,
|
||||
"code": "",
|
||||
"author": {
|
||||
"id": "1237198731983",
|
||||
"username": "some_username",
|
||||
"identify_num": "4198",
|
||||
"nickname": "some_username",
|
||||
"bot": false,
|
||||
"online": true,
|
||||
"avatar": "https://example.com",
|
||||
"vip_avatar": "https://example.com",
|
||||
"status": 1,
|
||||
"roles": [
|
||||
12783219731984
|
||||
],
|
||||
"os": "Websocket",
|
||||
"banner": "",
|
||||
"is_vip": false,
|
||||
"vip_amp": false,
|
||||
"nameplate": [],
|
||||
"wealth_level": 0,
|
||||
"is_sys": false
|
||||
},
|
||||
"kmarkdown": {
|
||||
"raw_content": "@Bot_Test @全体成员 #hello **world**[音频]dancing_shot5.wav😆",
|
||||
"mention_part": [
|
||||
{
|
||||
"id": "",
|
||||
"username": "Bot_Test",
|
||||
"full_name": "Bot_Test#9561",
|
||||
"avatar": "https://example.com",
|
||||
"wealth_level": 0
|
||||
}
|
||||
],
|
||||
"mention_role_part": [],
|
||||
"channel_part": []
|
||||
},
|
||||
"last_msg_content": "some_username:@Bot_Test @ 全体成员 #hello **world**[音频]dancing_shot5.wav😆",
|
||||
"mention": [
|
||||
""
|
||||
],
|
||||
"mention_all": true,
|
||||
"mention_here": false,
|
||||
"guild_id": "28321098321093",
|
||||
"guild_type": 0,
|
||||
"channel_name": "聊天大厅",
|
||||
"visible_only": "",
|
||||
"mention_no_at": [],
|
||||
"mention_roles": [],
|
||||
"nav_channels": [],
|
||||
"emoji": [],
|
||||
"editable": true,
|
||||
"preview_content": "@Bot_Test @全体成员 #hello **world**[音频]dancing_shot5.wav😆",
|
||||
"preview_content_search": "@Bot_Test @全体成员 #hello **world**[音频]dancing_shot5.wav😆",
|
||||
"channel_type": 1,
|
||||
"send_msg_device": 0
|
||||
}
|
||||
},
|
||||
"extra": {
|
||||
"verifyToken": "kW4FH_ASHio1hosd",
|
||||
"encryptKey": "",
|
||||
"callbackUrl": "",
|
||||
"intent": 255
|
||||
},
|
||||
"sn": 5
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"s": 2,
|
||||
"sn": 0
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"s": 3
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
{
|
||||
"s": 0,
|
||||
"d": {
|
||||
"channel_type": "PERSON",
|
||||
"type": 9,
|
||||
"target_id": "7324688132731983",
|
||||
"author_id": "2732467349811313213",
|
||||
"content": "/help",
|
||||
"extra": {
|
||||
"type": 9,
|
||||
"code": "1738914789hd8fd91098he809h19y491",
|
||||
"author": {
|
||||
"id": "2732467349811313213",
|
||||
"username": "shuiping233",
|
||||
"identify_num": "4198",
|
||||
"online": true,
|
||||
"os": "Websocket",
|
||||
"status": 1,
|
||||
"avatar": "https://example.com",
|
||||
"vip_avatar": "https://example.com",
|
||||
"banner": "",
|
||||
"nickname": "shuiping233",
|
||||
"roles": [],
|
||||
"is_vip": false,
|
||||
"vip_amp": false,
|
||||
"bot": false,
|
||||
"nameplate": [],
|
||||
"kpm_vip": null,
|
||||
"wealth_level": 0,
|
||||
"decorations_id_map": null,
|
||||
"is_sys": false
|
||||
},
|
||||
"visible_only": "",
|
||||
"mention": [],
|
||||
"mention_no_at": [],
|
||||
"mention_all": false,
|
||||
"mention_roles": [],
|
||||
"mention_here": false,
|
||||
"nav_channels": [],
|
||||
"kmarkdown": {
|
||||
"raw_content": "/help",
|
||||
"mention_part": [],
|
||||
"mention_role_part": [],
|
||||
"channel_part": [],
|
||||
"spl": []
|
||||
},
|
||||
"emoji": [],
|
||||
"preview_content": "",
|
||||
"last_msg_content": "/help",
|
||||
"send_msg_device": 0
|
||||
},
|
||||
"msg_id": "b0f57b9e-2cd4-4e07-8f0e-9c1ecfeaa837",
|
||||
"msg_timestamp": 1772587358662,
|
||||
"nonce": "6AwzUe5YjgyC8pAfxcLGjewL",
|
||||
"from_type": 1
|
||||
},
|
||||
"extra": {
|
||||
"verifyToken": "kW4FH_ASHio1hosd",
|
||||
"encryptKey": "",
|
||||
"callbackUrl": "",
|
||||
"intent": 255
|
||||
},
|
||||
"sn": 19
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"s": 0,
|
||||
"d": {
|
||||
"channel_type": "PERSON",
|
||||
"type": 255,
|
||||
"target_id": "7324688132731983",
|
||||
"author_id": "1",
|
||||
"content": "[系统消息]",
|
||||
"extra": {
|
||||
"type": "guild_member_offline",
|
||||
"body": {
|
||||
"user_id": "2732467349811313213",
|
||||
"event_time": 1772589748914,
|
||||
"guilds": [
|
||||
"78941897317309873120973"
|
||||
]
|
||||
}
|
||||
},
|
||||
"msg_id": "e91b4451-75ce-47bd-bda6-e4498ed8d30d",
|
||||
"msg_timestamp": 1772589748933,
|
||||
"nonce": "",
|
||||
"from_type": 1
|
||||
},
|
||||
"extra": {
|
||||
"verifyToken": "kW4FH_ASHio1hosd",
|
||||
"encryptKey": "",
|
||||
"callbackUrl": "",
|
||||
"intent": 255
|
||||
},
|
||||
"sn": 1
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"s": 5,
|
||||
"d": {
|
||||
"code": 40108,
|
||||
"err": "Invalid SN"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"s": 4,
|
||||
"sn": 100
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"s": 6,
|
||||
"d": {
|
||||
"session_id": "xxxx-xxxxxx-xxx-xxx"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CURRENT_DIR = Path(__file__).parent
|
||||
TEST_DATA_DIR = CURRENT_DIR / "data"
|
||||
TEST_DATA_DIR = Path(__file__).parent / "data"
|
||||
|
||||
@@ -60,7 +60,7 @@ def mock_astrbot_message():
|
||||
Image("test image"),
|
||||
"test image",
|
||||
OrderMessage(
|
||||
index=1,
|
||||
1,
|
||||
text="test image",
|
||||
type=KookMessageType.IMAGE,
|
||||
),
|
||||
@@ -70,7 +70,7 @@ def mock_astrbot_message():
|
||||
Video("test video"),
|
||||
"test video",
|
||||
OrderMessage(
|
||||
index=1,
|
||||
1,
|
||||
text="test video",
|
||||
type=KookMessageType.VIDEO,
|
||||
),
|
||||
@@ -80,7 +80,7 @@ def mock_astrbot_message():
|
||||
mock_file_message("test file"),
|
||||
"test file",
|
||||
OrderMessage(
|
||||
index=1,
|
||||
1,
|
||||
text="test file",
|
||||
type=KookMessageType.FILE,
|
||||
),
|
||||
@@ -90,8 +90,8 @@ def mock_astrbot_message():
|
||||
mock_record_message("./tests/file.wav"),
|
||||
"./tests/file.wav",
|
||||
OrderMessage(
|
||||
index=1,
|
||||
text='[{"type": "card", "modules": [{"type": "audio", "src": "./tests/file.wav", "title": "./tests/file.wav"}]}]',
|
||||
1,
|
||||
text='[{"type": "card", "modules": [{"src": "./tests/file.wav", "title": "./tests/file.wav", "type": "audio"}]}]',
|
||||
type=KookMessageType.CARD,
|
||||
),
|
||||
None,
|
||||
@@ -100,7 +100,7 @@ def mock_astrbot_message():
|
||||
Plain("test plain"),
|
||||
"test plain",
|
||||
OrderMessage(
|
||||
index=1,
|
||||
1,
|
||||
text="test plain",
|
||||
type=KookMessageType.KMARKDOWN,
|
||||
),
|
||||
@@ -110,7 +110,7 @@ def mock_astrbot_message():
|
||||
At(qq="test at"),
|
||||
"test at",
|
||||
OrderMessage(
|
||||
index=1,
|
||||
1,
|
||||
text="(met)test at(met)",
|
||||
type=KookMessageType.KMARKDOWN,
|
||||
),
|
||||
@@ -120,7 +120,7 @@ def mock_astrbot_message():
|
||||
AtAll(qq="all"),
|
||||
"test atAll",
|
||||
OrderMessage(
|
||||
index=1,
|
||||
1,
|
||||
text="(met)all(met)",
|
||||
type=KookMessageType.KMARKDOWN,
|
||||
),
|
||||
@@ -130,7 +130,7 @@ def mock_astrbot_message():
|
||||
Reply(id="test reply"),
|
||||
"test reply",
|
||||
OrderMessage(
|
||||
index=1,
|
||||
1,
|
||||
text="",
|
||||
type=KookMessageType.KMARKDOWN,
|
||||
reply_id="test reply",
|
||||
@@ -141,7 +141,7 @@ def mock_astrbot_message():
|
||||
Json(data={"test": "json"}),
|
||||
"test json",
|
||||
OrderMessage(
|
||||
index=1,
|
||||
1,
|
||||
text='[{"test": "json"}]',
|
||||
type=KookMessageType.CARD,
|
||||
),
|
||||
@@ -159,7 +159,7 @@ async def test_kook_event_warp_message(
|
||||
input_message: BaseMessageComponent,
|
||||
upload_asset_return: str,
|
||||
expected_output: OrderMessage,
|
||||
expected_error: type[BaseException] | None,
|
||||
expected_error: type[Exception] | None,
|
||||
):
|
||||
client = await mock_kook_client(
|
||||
upload_asset_return,
|
||||
@@ -185,4 +185,39 @@ async def test_kook_event_warp_message(
|
||||
|
||||
result = await event._wrap_message(1, input_message)
|
||||
assert result == expected_output
|
||||
|
||||
|
||||
|
||||
# @pytest.mark.asyncio
|
||||
# @pytest.mark.parametrize(
|
||||
# "message_chain,send_text_expected_output,expected_error",
|
||||
# [
|
||||
# (
|
||||
# MessageChain(
|
||||
# chain=[
|
||||
# Image(file="test image"),
|
||||
# Plain(text="test plain"),
|
||||
# ],
|
||||
# ),
|
||||
# ""
|
||||
# ),
|
||||
# ],
|
||||
# )
|
||||
# async def test_kook_event_send():
|
||||
# client = await mock_kook_client(
|
||||
# "",
|
||||
# "",
|
||||
# )
|
||||
|
||||
# event = KookEvent(
|
||||
# "",
|
||||
# mock_astrbot_message(),
|
||||
# PlatformMetadata(
|
||||
# name="test",
|
||||
# id="test",
|
||||
# description="test",
|
||||
# ),
|
||||
# "",
|
||||
# client,
|
||||
# )
|
||||
|
||||
# await event.send(message=mock_astrbot_message())
|
||||
|
||||
@@ -16,9 +16,6 @@ from astrbot.core.platform.sources.kook.kook_types import (
|
||||
InviteModule,
|
||||
KmarkdownElement,
|
||||
KookCardMessage,
|
||||
KookMessageSignal,
|
||||
KookModuleType,
|
||||
KookWebsocketEvent,
|
||||
ParagraphStructure,
|
||||
PlainTextElement,
|
||||
SectionModule,
|
||||
@@ -80,7 +77,7 @@ def test_all_kook_card_type():
|
||||
FileModule(
|
||||
src="https://img.kookapp.cn/attachments/2023-01/05/63b645851ff19.svg",
|
||||
title="test5",
|
||||
type=KookModuleType.FILE,
|
||||
type="file",
|
||||
),
|
||||
CountdownModule(
|
||||
endTime=1772343427360,
|
||||
@@ -108,41 +105,3 @@ def test_all_kook_card_type():
|
||||
],
|
||||
).to_json(indent=4, ensure_ascii=False)
|
||||
assert json_output == expect_json_data
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"expected_json_data_filename",
|
||||
[
|
||||
("kook_ws_event_group_message.json"),
|
||||
("kook_ws_event_hello.json"),
|
||||
("kook_ws_event_message_with_card_1.json"),
|
||||
("kook_ws_event_message_with_card_2.json"),
|
||||
("kook_ws_event_ping.json"),
|
||||
("kook_ws_event_pong.json"),
|
||||
("kook_ws_event_private_message.json"),
|
||||
("kook_ws_event_private_system_message.json"),
|
||||
("kook_ws_event_reconnect_err.json"),
|
||||
("kook_ws_event_resume_ack.json"),
|
||||
("kook_ws_event_resume.json"),
|
||||
|
||||
],
|
||||
)
|
||||
def test_websocket_event_type_parse(expected_json_data_filename:str):
|
||||
expected_json_data_str =(TEST_DATA_DIR / expected_json_data_filename).read_text(encoding="utf-8")
|
||||
event = KookWebsocketEvent.from_json(
|
||||
expected_json_data_str,
|
||||
)
|
||||
event_dict = event.to_dict(mode="json",exclude_unset=True,exclude_none=False)
|
||||
assert event_dict == json.loads(expected_json_data_str)
|
||||
|
||||
|
||||
def test_websocket_event_create():
|
||||
ping_data = KookWebsocketEvent(
|
||||
signal=KookMessageSignal.PING,
|
||||
data=None,
|
||||
sn=0,
|
||||
)
|
||||
assert ping_data.to_dict(mode="json")== {
|
||||
"s": KookMessageSignal.PING.value,
|
||||
"sn": 0,
|
||||
}
|
||||
|
||||
@@ -49,39 +49,6 @@ def test_parse_frontmatter_quoted_description():
|
||||
assert _parse_frontmatter_description(text) == "quoted value"
|
||||
|
||||
|
||||
def test_parse_frontmatter_multiline_literal_description():
|
||||
text = (
|
||||
"---\n"
|
||||
"name: humanizer-zh\n"
|
||||
"description: |\n"
|
||||
" 去除文本中的 AI 生成痕迹。\n"
|
||||
" 适用于编辑或审阅文本,使其听起来更自然。\n"
|
||||
"---\n"
|
||||
)
|
||||
assert _parse_frontmatter_description(text) == (
|
||||
"去除文本中的 AI 生成痕迹。\n适用于编辑或审阅文本,使其听起来更自然。"
|
||||
)
|
||||
|
||||
|
||||
def test_parse_frontmatter_multiline_folded_description():
|
||||
text = (
|
||||
"---\n"
|
||||
"name: humanizer-zh\n"
|
||||
"description: >\n"
|
||||
" 去除文本中的 AI 生成痕迹。\n"
|
||||
" 适用于编辑或审阅文本,使其听起来更自然。\n"
|
||||
"---\n"
|
||||
)
|
||||
assert _parse_frontmatter_description(text) == (
|
||||
"去除文本中的 AI 生成痕迹。 适用于编辑或审阅文本,使其听起来更自然。"
|
||||
)
|
||||
|
||||
|
||||
def test_parse_frontmatter_invalid_yaml_returns_empty():
|
||||
text = "---\ndescription: [broken\n---\n"
|
||||
assert _parse_frontmatter_description(text) == ""
|
||||
|
||||
|
||||
# ---------- build_skills_prompt tests ----------
|
||||
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ def mock_context():
|
||||
ctx.persona_manager.resolve_selected_persona = AsyncMock(
|
||||
return_value=(None, None, None, False)
|
||||
)
|
||||
ctx.persona_manager.get_persona_v3_by_id = MagicMock(return_value=None)
|
||||
ctx.get_llm_tool_manager.return_value = MagicMock()
|
||||
ctx.subagent_orchestrator = None
|
||||
return ctx
|
||||
@@ -539,63 +538,6 @@ class TestEnsurePersonaAndSkills:
|
||||
|
||||
assert req.func_tool is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subagent_dedupe_uses_default_persona_tools(
|
||||
self, mock_event, mock_context
|
||||
):
|
||||
"""Test dedupe uses resolved default persona tools in subagent mode."""
|
||||
module = ama
|
||||
mock_context.persona_manager.resolve_selected_persona = AsyncMock(
|
||||
return_value=(None, None, None, False)
|
||||
)
|
||||
mock_context.persona_manager.get_persona_v3_by_id = MagicMock(
|
||||
return_value={"name": "default", "tools": ["tool_a"]}
|
||||
)
|
||||
|
||||
tool_a = FunctionTool(
|
||||
name="tool_a",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
description="tool a",
|
||||
)
|
||||
tool_b = FunctionTool(
|
||||
name="tool_b",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
description="tool b",
|
||||
)
|
||||
tmgr = mock_context.get_llm_tool_manager.return_value
|
||||
tmgr.func_list = [tool_a, tool_b]
|
||||
tmgr.get_full_tool_set.return_value = ToolSet([tool_a, tool_b])
|
||||
tmgr.get_func.side_effect = lambda name: {"tool_a": tool_a, "tool_b": tool_b}.get(
|
||||
name
|
||||
)
|
||||
|
||||
handoff = MagicMock()
|
||||
handoff.name = "transfer_to_planner"
|
||||
mock_context.subagent_orchestrator = MagicMock(handoffs=[handoff])
|
||||
mock_context.get_config.return_value = {
|
||||
"subagent_orchestrator": {
|
||||
"main_enable": True,
|
||||
"remove_main_duplicate_tools": True,
|
||||
"agents": [
|
||||
{
|
||||
"name": "planner",
|
||||
"enabled": True,
|
||||
"persona_id": "default",
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
req = ProviderRequest()
|
||||
req.conversation = MagicMock(persona_id=None)
|
||||
|
||||
await module._ensure_persona_and_skills(req, {}, mock_context, mock_event)
|
||||
|
||||
assert req.func_tool is not None
|
||||
assert "transfer_to_planner" in req.func_tool.names()
|
||||
assert "tool_a" not in req.func_tool.names()
|
||||
assert "tool_b" in req.func_tool.names()
|
||||
|
||||
|
||||
class TestDecorateLlmRequest:
|
||||
"""Tests for _decorate_llm_request function."""
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
from copy import deepcopy
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from astrbot.core.subagent_orchestrator import SubAgentOrchestrator
|
||||
|
||||
|
||||
def _build_cfg(agent_overrides: dict) -> dict:
|
||||
agent = {
|
||||
"name": "planner",
|
||||
"enabled": True,
|
||||
"persona_id": None,
|
||||
"system_prompt": "inline prompt",
|
||||
"public_description": "",
|
||||
"tools": ["tool_a", " ", "tool_b"],
|
||||
}
|
||||
agent.update(agent_overrides)
|
||||
return {"agents": [agent]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_from_config_default_persona_is_resolved():
|
||||
tool_mgr = MagicMock()
|
||||
persona_mgr = MagicMock()
|
||||
default_persona = {
|
||||
"name": "default",
|
||||
"prompt": "You are a helpful and friendly assistant.",
|
||||
"tools": None,
|
||||
"_begin_dialogs_processed": [],
|
||||
}
|
||||
persona_mgr.get_persona_v3_by_id.return_value = deepcopy(default_persona)
|
||||
orchestrator = SubAgentOrchestrator(tool_mgr=tool_mgr, persona_mgr=persona_mgr)
|
||||
|
||||
await orchestrator.reload_from_config(_build_cfg({"persona_id": "default"}))
|
||||
|
||||
assert len(orchestrator.handoffs) == 1
|
||||
handoff = orchestrator.handoffs[0]
|
||||
assert handoff.agent.instructions == default_persona["prompt"]
|
||||
assert handoff.agent.tools is None
|
||||
assert handoff.agent.begin_dialogs == default_persona["_begin_dialogs_processed"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_from_config_missing_persona_falls_back_to_inline_and_warns():
|
||||
tool_mgr = MagicMock()
|
||||
persona_mgr = MagicMock()
|
||||
persona_mgr.get_persona_v3_by_id.return_value = None
|
||||
orchestrator = SubAgentOrchestrator(tool_mgr=tool_mgr, persona_mgr=persona_mgr)
|
||||
|
||||
with patch("astrbot.core.subagent_orchestrator.logger") as mock_logger:
|
||||
await orchestrator.reload_from_config(_build_cfg({"persona_id": "not_exists"}))
|
||||
|
||||
assert len(orchestrator.handoffs) == 1
|
||||
handoff = orchestrator.handoffs[0]
|
||||
assert handoff.agent.instructions == "inline prompt"
|
||||
assert handoff.agent.tools == ["tool_a", "tool_b"]
|
||||
assert handoff.agent.begin_dialogs is None
|
||||
mock_logger.warning.assert_called_once_with(
|
||||
"SubAgent persona %s not found, fallback to inline prompt.",
|
||||
"not_exists",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_from_config_uses_processed_begin_dialogs_and_deepcopy():
|
||||
tool_mgr = MagicMock()
|
||||
persona_mgr = MagicMock()
|
||||
processed_dialogs = [{"role": "user", "content": "hello", "_no_save": True}]
|
||||
persona_mgr.get_persona_v3_by_id.return_value = {
|
||||
"name": "custom",
|
||||
"prompt": "persona prompt",
|
||||
"tools": ["tool_from_persona"],
|
||||
"_begin_dialogs_processed": processed_dialogs,
|
||||
}
|
||||
orchestrator = SubAgentOrchestrator(tool_mgr=tool_mgr, persona_mgr=persona_mgr)
|
||||
|
||||
await orchestrator.reload_from_config(_build_cfg({"persona_id": "custom"}))
|
||||
processed_dialogs[0]["content"] = "mutated"
|
||||
|
||||
handoff = orchestrator.handoffs[0]
|
||||
assert handoff.agent.instructions == "persona prompt"
|
||||
assert handoff.agent.tools == ["tool_from_persona"]
|
||||
assert handoff.agent.begin_dialogs[0]["content"] == "hello"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("raw_tools", "expected_tools"),
|
||||
[
|
||||
(None, None),
|
||||
([], []),
|
||||
("not-a-list", []),
|
||||
],
|
||||
)
|
||||
async def test_reload_from_config_tool_normalization(raw_tools, expected_tools):
|
||||
tool_mgr = MagicMock()
|
||||
persona_mgr = MagicMock()
|
||||
persona_mgr.get_persona_v3_by_id.return_value = {
|
||||
"name": "custom",
|
||||
"prompt": "persona prompt",
|
||||
"tools": raw_tools,
|
||||
"_begin_dialogs_processed": [],
|
||||
}
|
||||
orchestrator = SubAgentOrchestrator(tool_mgr=tool_mgr, persona_mgr=persona_mgr)
|
||||
|
||||
await orchestrator.reload_from_config(_build_cfg({"persona_id": "custom"}))
|
||||
|
||||
handoff = orchestrator.handoffs[0]
|
||||
assert handoff.agent.tools == expected_tools
|
||||
Reference in New Issue
Block a user