Compare commits

...

41 Commits

Author SHA1 Message Date
Soulter 6d687691a2 chore: bump version to 4.20.1 2026-03-17 00:35:57 +08:00
Soulter 0c71d351ee chore: revise PULL_REQUEST_TEMPLATE 2026-03-16 22:20:48 +08:00
LIghtJUNction f00ba5adc6 chore(github): 更新 PR 模板以区分 dev 和 master 提交规则 2026-03-16 21:43:14 +08:00
LIghtJUNction d3d4e1db7b Merge branch 'master' of https://github.com/AstrBotDevs/AstrBot 2026-03-16 19:17:42 +08:00
LIghtJUNction 78b3e12c66 chore: update astrbot.service configuration 2026-03-16 19:15:44 +08:00
Futureppo c42ac87ee1 feat: Add OpenRouter chat completion provider adapter with custom headers. (#6436) 2026-03-16 19:11:43 +08:00
QuietStars 3fbd16b211 docs: update rainyun.md with backup access instructions (#6427)
Added a note about using a backup address if the management panel cannot be accessed.
2026-03-16 15:38:01 +08:00
qingyun e77500ff69 fix(provider): sync providers_config after creating new provider (#6388)
Fixes #6283

When adding a new embedding provider, the knowledge base creation page
did not show the new provider until restart.

Root cause: create_provider() did not update self.providers_config,
which is used by get_provider_config_list() to return provider lists.

This fix syncs the in-memory config after loading the new provider,
consistent with how reload() handles config updates.

Co-authored-by: ccsang <ccsang@users.noreply.github.com>
2026-03-16 15:29:51 +08:00
lppsuixn 2c49ac0dcf Refactor _extract_session_id for chat type handling (#5775)
Update session ID extraction to handle group and single chat types.
2026-03-16 15:27:16 +08:00
Soulter 65decfbe87 chore: remove unused scripts for closing duplicate plugin publish issues and generating changelog 2026-03-16 12:39:39 +08:00
stevessr 92c31192de perf: enhance umo processing compatibility (#5996) 2026-03-16 12:34:21 +08:00
LIghtJUNction b795f804a7 更新 pr-checklist-check.yml 2026-03-16 02:51:39 +08:00
LIghtJUNction bc3b5e58a4 更新 pr-checklist-check.yml 2026-03-16 02:44:05 +08:00
LIghtJUNction 7e3c32b828 更新 pr-checklist-check.yml 2026-03-16 02:29:33 +08:00
LIghtJUNction ceb32dce9f 更新 pr-checklist-check.yml 2026-03-16 02:24:01 +08:00
LIghtJUNction 84e880af5f 更新 pr-checklist-check.yml 2026-03-16 02:21:05 +08:00
LIghtJUNction 9909d774ed Merge pull request #6400 from AstrBotDevs/copilot/implement-modifications-summary
feat: auto-close PRs when author checks "did not read" checklist item
2026-03-16 02:13:20 +08:00
LIghtJUNction 6b3868b4be Update pr-checklist-check.yml 2026-03-16 02:11:15 +08:00
LIghtJUNction 11c840953a 更新 pr-checklist-check.yml 2026-03-16 01:49:49 +08:00
LIghtJUNction 2bbca887ce Refine PR checklist validation and closure message
Updated the checklist validation script and modified the comment for PR closure.
2026-03-16 01:46:07 +08:00
copilot-swe-agent[bot] dd89a4b334 feat: add PR checklist enforcement workflow
Co-authored-by: LIghtJUNction <106986785+LIghtJUNction@users.noreply.github.com>
2026-03-15 17:30:29 +00:00
copilot-swe-agent[bot] a3fa8a5a7c Initial plan 2026-03-15 17:28:39 +00:00
LIghtJUNction aa60467782 Merge pull request #6399 from AstrBotDevs/LIghtJUNction-patch-1
Refactor checklist items in PR template
2026-03-16 01:24:30 +08:00
LIghtJUNction d936bb0a10 Refactor checklist items in PR template
Duplicated checklist items in the pull request template for clarity and emphasis.
2026-03-16 01:23:51 +08:00
Stable Genius 64e0183b55 fix: drop Groq reasoning_content from assistant history (#6065)
Co-authored-by: Stable Genius <259448942+stablegenius49@users.noreply.github.com>
2026-03-15 22:51:52 +08:00
Soulter 420d82df11 chore: ruff format 2026-03-15 22:43:29 +08:00
Yufeng He d87cf897da Fix TypeError when API returns null choices (#6313)
* Fix CreateSkillPayloadTool array schema missing items field

The payload parameter's anyOf array variant lacked an items field,
causing Gemini API to reject the tool declaration with 400 Bad Request:
'parameters.properties[payload].any_of[1].items: missing field.'

Add items: {type: object} to the array variant to satisfy the Gemini
API requirement for array type schemas.

Fixes #6279

* Fix TypeError when OpenAI-compatible API returns null choices

Some providers (e.g. OpenRouter) may return a completion where
choices is None rather than an empty list — for instance on rate
limiting, content filtering, or transient errors. The existing code
used len(completion.choices) which throws TypeError on None.

Replace all len(...choices) == 0 checks with 'not ... .choices' which
handles both None and empty list. Affects _query_stream, _parse_openai_completion,
and _extract_reasoning_content.

Fixes #6252
2026-03-15 22:28:26 +08:00
時壹 2f51916a73 fix: deduplicate repeated QQ webhook retry callbacks (#6320) 2026-03-15 22:18:37 +08:00
Rin b0e10cf479 fix: add null check for delta in streaming mode to prevent AttributeError when tool calls are returned (#6365) 2026-03-15 22:17:12 +08:00
Simon 20efaa5320 fix: revise link to model service configuration (#6296) 2026-03-15 22:03:52 +08:00
洛薇Lovie 3ccd70cd4e Fix: AI fails to send media files when tool-calling mode is set to "skills-like". (#6317)
* fix: improve send_message_to_user tool description for skills_like mode

* fix: enhance description for send_message_to_user tool to clarify usage

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-03-15 21:46:01 +08:00
xwsjjctz da520e573a feat(provider): add MiniMax (#6318)
* feat(provider): add MiniMax

* feat(provider): reintroduce MiniMax provider configuration and remove deprecated source

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-03-15 21:37:44 +08:00
Trainingcqy 6d055e81e9 fix: GIF sent as static image in Telegram adapter (#6329)
* fix(telegram): route GIF files to send_animation instead of send_photo

* fix: narrow exception in _is_gif to OSError

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* refactor: simplify image send dispatch in send_with_client

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* refactor: simplify image dispatch in _process_chain_items

* ruff format

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Soulter <905617992@qq.com>
2026-03-15 21:33:30 +08:00
Xial d41ccb70c5 fix: replace npm registry URLs with jsdelivr CDN for provider icons (#6340) 2026-03-15 21:15:04 +08:00
qingyun 18a99a25c2 fix(platform): parse QQ official face messages to readable text (#6355)
Fixes #6294

QQ official bot receives emoji/sticker messages as raw XML-like tags:
`<faceType=4,faceId="",ext="eyJ0ZXh0IjoiW+a7oeWktOmXruWPt10ifQ==">`

This made the LLM unable to understand the emoji content.

Changes:
- Added `_parse_face_message()` method to parse face message format
- Decode base64 `ext` field to get emoji description text
- Replace face tags with `[表情:描述]` format for readability

Example:
- Input: `<faceType=4,faceId="",ext="eyJ0ZXh0IjoiW+a7oeWktOmXruWPt10ifQ==">`
- Output: `[表情:[满头问号]]`

Co-authored-by: ccsang <ccsang@users.noreply.github.com>
2026-03-15 21:05:47 +08:00
LIghtJUNction 96cafe001d Merge pull request #6293 from AstrBotDevs/LIghtJUNction-patch-1
Update package.md
2026-03-15 03:15:10 +08:00
LIghtJUNction 29d100dd83 Update package.md 2026-03-15 02:55:34 +08:00
Soulter 14f3701c4a fix: update Discord invite link in community documentation
closes: #6188
2026-03-14 23:48:13 +08:00
Stable Genius 1044fc48ca fix: avoid webchat stream result crash on queue errors (#6123)
Co-authored-by: stablegenius49 <185121704+stablegenius49@users.noreply.github.com>
2026-03-14 23:41:28 +08:00
Soulter 693c2ca818 refactor: improve chat component behavior, use shiki to represent code block (#6286) 2026-03-14 23:37:17 +08:00
Soulter b1c486ba98 feat: add send shortcut configuration and localization support for chat input (#6272) 2026-03-14 21:25:12 +08:00
40 changed files with 665 additions and 365 deletions
+12 -5
View File
@@ -3,8 +3,8 @@
### Modifications / 改动点
<!--请总结你的改动:哪些核心文件被修改了?实现了什么功能?-->
<!--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,7 +21,14 @@
<!--If merged, your code will serve tens of thousands of users! Please double-check the following items before submitting.-->
<!--如果分支被合并,您的代码将服务于数万名用户!在提交前,请核查一下几点内容。-->
- [ ] 😊 如果 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**.
- [ ] 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 `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.
- [ ] 😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
/ 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。
- [ ] 👀 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` 文件相应位置。
- [ ] 😮 My changes do not introduce malicious code.
/ 我的更改没有引入恶意代码。
+45
View File
@@ -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 -1
View File
@@ -1 +1 @@
__version__ = "4.20.0"
__version__ = "4.20.1"
+6 -1
View File
@@ -188,7 +188,12 @@ class KnowledgeBaseQueryTool(FunctionTool[AstrAgentContext]):
@dataclass
class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
name: str = "send_message_to_user"
description: str = "Directly send message to the user. Only use this tool when you need to proactively message the user. Otherwise you can directly output the reply in the conversation."
description: str = (
"Send message to the user. "
"Supports various message types including `plain`, `image`, `record`, `video`, `file`, and `mention_user`. "
"Use this tool to send media files (`image`, `record`, `video`, `file`), "
"or when you need to proactively message the user(such as cron job). For normal text replies, you can output directly."
)
parameters: dict = Field(
default_factory=lambda: {
+4 -1
View File
@@ -164,7 +164,10 @@ class CreateSkillPayloadTool(NeoSkillToolBase):
"type": "object",
"properties": {
"payload": {
"anyOf": [{"type": "object"}, {"type": "array"}],
"anyOf": [
{"type": "object"},
{"type": "array", "items": {"type": "object"}},
],
"description": (
"Skill payload JSON. Typical schema: {skill_markdown, inputs, outputs, meta}. "
"This only stores content and returns payload_ref; it does not create a candidate or release."
+13 -1
View File
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.20.0"
VERSION = "4.20.1"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -1132,6 +1132,18 @@ CONFIG_METADATA_2 = {
"proxy": "",
"custom_headers": {},
},
"MiniMax": {
"id": "minimax",
"provider": "minimax",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.minimaxi.com/v1",
"timeout": 120,
"proxy": "",
"custom_headers": {},
},
"xAI": {
"id": "xai",
"provider": "xai",
@@ -391,6 +391,47 @@ class QQOfficialPlatformAdapter(Platform):
else:
msg.append(File(name=filename, file=url, url=url))
@staticmethod
def _parse_face_message(content: str) -> str:
"""Parse QQ official face message format and convert to readable text.
QQ official face message format:
<faceType=4,faceId="",ext="eyJ0ZXh0IjoiW+a7oeWktOmXruWPt10ifQ==">
The ext field contains base64-encoded JSON with a 'text' field
describing the emoji (e.g., '[满头问号]').
Args:
content: The message content that may contain face tags.
Returns:
Content with face tags replaced by readable emoji descriptions.
"""
import base64
import json
import re
def replace_face(match):
face_tag = match.group(0)
# Extract ext field from the face tag
ext_match = re.search(r'ext="([^"]*)"', face_tag)
if ext_match:
try:
ext_encoded = ext_match.group(1)
# Decode base64 and parse JSON
ext_decoded = base64.b64decode(ext_encoded).decode("utf-8")
ext_data = json.loads(ext_decoded)
emoji_text = ext_data.get("text", "")
if emoji_text:
return f"[表情:{emoji_text}]"
except Exception:
pass
# Fallback if parsing fails
return "[表情]"
# Match face tags: <faceType=...>
return re.sub(r"<faceType=\d+[^>]*>", replace_face, content)
@staticmethod
def _parse_from_qqofficial(
message: botpy.message.Message
@@ -416,7 +457,10 @@ class QQOfficialPlatformAdapter(Platform):
abm.group_id = message.group_openid
else:
abm.sender = MessageMember(message.author.user_openid, "")
abm.message_str = message.content.strip()
# Parse face messages to readable text
abm.message_str = QQOfficialPlatformAdapter._parse_face_message(
message.content.strip()
)
abm.self_id = "unknown_selfid"
msg.append(At(qq="qq_official"))
msg.append(Plain(abm.message_str))
@@ -432,10 +476,12 @@ class QQOfficialPlatformAdapter(Platform):
else:
abm.self_id = ""
plain_content = message.content.replace(
"<@!" + str(abm.self_id) + ">",
"",
).strip()
plain_content = QQOfficialPlatformAdapter._parse_face_message(
message.content.replace(
"<@!" + str(abm.self_id) + ">",
"",
).strip()
)
QQOfficialPlatformAdapter._append_attachments(msg, message.attachments)
abm.message = msg
@@ -1,5 +1,6 @@
import asyncio
import logging
import time
from typing import cast
import quart
@@ -39,6 +40,9 @@ class QQOfficialWebhook:
self.client = botpy_client
self.event_queue = event_queue
self.shutdown_event = asyncio.Event()
# Deduplication cache for webhook retry callbacks.
self._seen_event_ids: dict[str, float] = {}
self._dedup_ttl: int = 60 # seconds
async def initialize(self) -> None:
logger.info("正在登录到 QQ 官方机器人...")
@@ -106,6 +110,22 @@ class QQOfficialWebhook:
print(signed)
return signed
event_id = msg.get("id")
if event_id:
now = time.monotonic()
# Lazily evict expired entries to prevent unbounded growth.
expired = [
k
for k, ts in self._seen_event_ids.items()
if now - ts > self._dedup_ttl
]
for k in expired:
del self._seen_event_ids[k]
if event_id in self._seen_event_ids:
logger.debug(f"Duplicate webhook event {event_id!r}, skipping.")
return {"opcode": 12}
self._seen_event_ids[event_id] = now
if event and opcode == BotWebSocket.WS_DISPATCH_EVENT:
event = msg["t"].lower()
try:
@@ -25,6 +25,16 @@ from astrbot.api.platform import AstrBotMessage, MessageType, PlatformMetadata
from astrbot.core.utils.metrics import Metric
def _is_gif(path: str) -> bool:
if path.lower().endswith(".gif"):
return True
try:
with open(path, "rb") as f:
return f.read(6) in (b"GIF87a", b"GIF89a")
except OSError:
return False
class TelegramPlatformEvent(AstrMessageEvent):
# Telegram 的最大消息长度限制
MAX_MESSAGE_LENGTH = 4096
@@ -291,7 +301,13 @@ class TelegramPlatformEvent(AstrMessageEvent):
await client.send_message(text=chunk, **cast(Any, payload))
elif isinstance(i, Image):
image_path = await i.convert_to_file_path()
await client.send_photo(photo=image_path, **cast(Any, payload))
if _is_gif(image_path):
send_coro = client.send_animation
media_kwarg = {"animation": image_path}
else:
send_coro = client.send_photo
media_kwarg = {"photo": image_path}
await send_coro(**media_kwarg, **cast(Any, payload))
elif isinstance(i, File):
path = await i.get_file()
name = i.name or os.path.basename(path)
@@ -406,12 +422,20 @@ class TelegramPlatformEvent(AstrMessageEvent):
on_text(i.text)
elif isinstance(i, Image):
image_path = await i.convert_to_file_path()
if _is_gif(image_path):
action = ChatAction.UPLOAD_VIDEO
send_coro = self.client.send_animation
media_kwarg = {"animation": image_path}
else:
action = ChatAction.UPLOAD_PHOTO
send_coro = self.client.send_photo
media_kwarg = {"photo": image_path}
await self._send_media_with_action(
self.client,
ChatAction.UPLOAD_PHOTO,
self.client.send_photo,
action,
send_coro,
user_name=user_name,
photo=image_path,
**media_kwarg,
**cast(Any, payload),
)
elif isinstance(i, File):
@@ -440,9 +440,16 @@ class WecomAIBotAdapter(Platform):
)
def _extract_session_id(self, message_data: dict[str, Any]) -> str:
"""从消息数据中提取会话ID"""
user_id = message_data.get("from", {}).get("userid", "default_user")
return format_session_id("wecomai", user_id)
"""从消息数据中提取会话ID
群聊使用 chatid,单聊使用 userid
"""
chattype = message_data.get("chattype", "single")
if chattype == "group":
chat_id = message_data.get("chatid", "default_group")
return format_session_id("wecomai", chat_id)
else:
user_id = message_data.get("from", {}).get("userid", "default_user")
return format_session_id("wecomai", user_id)
async def _enqueue_message(
self,
+2
View File
@@ -808,6 +808,8 @@ class ProviderManager:
config.save_config()
# load instance
await self.load_provider(new_config)
# sync in-memory config for API queries (e.g., embedding provider list)
self.providers_config = astrbot_config["provider"]
async def terminate(self) -> None:
if self._mcp_init_task and not self._mcp_init_task.done():
@@ -13,3 +13,11 @@ class ProviderGroq(ProviderOpenAIOfficial):
) -> None:
super().__init__(provider_config, provider_settings)
self.reasoning_key = "reasoning"
def _finally_convert_payload(self, payloads: dict) -> None:
"""Groq rejects assistant history items that include reasoning_content."""
super()._finally_convert_payload(payloads)
for message in payloads.get("messages", []):
if message.get("role") == "assistant":
message.pop("reasoning_content", None)
message.pop("reasoning", None)
@@ -311,7 +311,7 @@ class ProviderOpenAIOfficial(Provider):
state.handle_chunk(chunk)
except Exception as e:
logger.warning("Saving chunk state error: " + str(e))
if len(chunk.choices) == 0:
if not chunk.choices:
continue
delta = chunk.choices[0].delta
# logger.debug(f"chunk delta: {delta}")
@@ -322,7 +322,7 @@ class ProviderOpenAIOfficial(Provider):
if reasoning:
llm_response.reasoning_content = reasoning
_y = True
if delta.content:
if delta and delta.content:
# Don't strip streaming chunks to preserve spaces between words
completion_text = self._normalize_content(delta.content, strip=False)
llm_response.result_chain = MessageChain(
@@ -345,7 +345,7 @@ class ProviderOpenAIOfficial(Provider):
) -> str:
"""Extract reasoning content from OpenAI ChatCompletion if available."""
reasoning_text = ""
if len(completion.choices) == 0:
if not completion.choices:
return reasoning_text
if isinstance(completion, ChatCompletion):
choice = completion.choices[0]
@@ -468,7 +468,7 @@ class ProviderOpenAIOfficial(Provider):
"""Parse OpenAI ChatCompletion into LLMResponse"""
llm_response = LLMResponse("assistant")
if len(completion.choices) == 0:
if not completion.choices:
raise Exception("API 返回的 completion 为空。")
choice = completion.choices[0]
@@ -16,4 +16,7 @@ class ProviderOpenRouter(ProviderOpenAIOfficial):
self.client._custom_headers["HTTP-Referer"] = ( # type: ignore
"https://github.com/AstrBotDevs/AstrBot"
)
self.client._custom_headers["X-TITLE"] = "AstrBot" # type: ignore
self.client._custom_headers["X-OpenRouter-Title"] = "AstrBot" # type: ignore
self.client._custom_headers["X-OpenRouter-Categories"] = (
"general-chat,personal-agent" # type: ignore
)
+16 -6
View File
@@ -25,12 +25,22 @@ class UmopConfigRouter:
)
self.umop_to_conf_id = sp_data
@staticmethod
def _split_umo(umo: str) -> tuple[str, str, str] | None:
"""将 UMO 拆分为 3 个部分,同时保留 session_id 中的 ':'"""
if not isinstance(umo, str):
return None
parts = umo.split(":", 2)
if len(parts) != 3:
return None
return parts[0], parts[1], parts[2]
def _is_umo_match(self, p1: str, p2: str) -> bool:
"""判断 p2 umo 是否逻辑包含于 p1 umo"""
p1_ls = p1.split(":")
p2_ls = p2.split(":")
p1_ls = self._split_umo(p1)
p2_ls = self._split_umo(p2)
if len(p1_ls) != 3 or len(p2_ls) != 3:
if p1_ls is None or p2_ls is None:
return False # 非法格式
return all(p == "" or fnmatch.fnmatchcase(t, p) for p, t in zip(p1_ls, p2_ls))
@@ -62,7 +72,7 @@ class UmopConfigRouter:
"""
for part in new_routing:
if not isinstance(part, str) or len(part.split(":")) != 3:
if self._split_umo(part) is None:
raise ValueError(
"umop keys must be strings in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
)
@@ -81,7 +91,7 @@ class UmopConfigRouter:
ValueError: 如果 umo 格式不正确
"""
if not isinstance(umo, str) or len(umo.split(":")) != 3:
if self._split_umo(umo) is None:
raise ValueError(
"umop must be a string in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
)
@@ -99,7 +109,7 @@ class UmopConfigRouter:
ValueError: 当 umo 格式不正确时抛出
"""
if not isinstance(umo, str) or len(umo.split(":")) != 3:
if self._split_umo(umo) is None:
raise ValueError(
"umop must be a string in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
)
+19 -9
View File
@@ -36,6 +36,20 @@ async def track_conversation(convs: dict, conv_id: str):
convs.pop(conv_id, None)
async def _poll_webchat_stream_result(back_queue, username: str):
try:
result = await asyncio.wait_for(back_queue.get(), timeout=1)
except asyncio.TimeoutError:
return None, False
except asyncio.CancelledError:
logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
return None, True
except Exception as e:
logger.error(f"WebChat stream error: {e}")
return None, False
return result, False
class ChatRoute(Route):
def __init__(
self,
@@ -343,16 +357,12 @@ class ChatRoute(Route):
async with track_conversation(self.running_convs, webchat_conv_id):
while True:
try:
result = await asyncio.wait_for(back_queue.get(), timeout=1)
except asyncio.TimeoutError:
continue
except asyncio.CancelledError:
logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
result, should_break = await _poll_webchat_stream_result(
back_queue, username
)
if should_break:
client_disconnected = True
except Exception as e:
logger.error(f"WebChat stream error: {e}")
break
if not result:
continue
+93
View File
@@ -0,0 +1,93 @@
## 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
View File
@@ -36,7 +36,6 @@
"remixicon": "3.5.0",
"shiki": "^3.20.0",
"stream-markdown": "^0.0.13",
"stream-monaco": "^0.0.17",
"vee-validate": "4.11.3",
"vite-plugin-vuetify": "2.1.3",
"vue": "3.3.4",
+4 -4
View File
@@ -81,9 +81,6 @@ importers:
stream-markdown:
specifier: ^0.0.13
version: 0.0.13(shiki@3.22.0)
stream-monaco:
specifier: ^0.0.17
version: 0.0.17(monaco-editor@0.52.2)
vee-validate:
specifier: 4.11.3
version: 4.11.3(vue@3.3.4)
@@ -3300,6 +3297,7 @@ snapshots:
'@shikijs/core': 3.22.0
'@shikijs/types': 3.22.0
'@shikijs/vscode-textmate': 10.0.2
optional: true
'@shikijs/themes@3.22.0':
dependencies:
@@ -3992,7 +3990,8 @@ snapshots:
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
alien-signals@2.0.8: {}
alien-signals@2.0.8:
optional: true
ansi-regex@5.0.1: {}
@@ -5443,6 +5442,7 @@ snapshots:
alien-signals: 2.0.8
monaco-editor: 0.52.2
shiki: 3.22.0
optional: true
stringify-entities@4.0.4:
dependencies:
+38 -3
View File
@@ -11,6 +11,7 @@
:currSessionId="currSessionId"
:selectedProjectId="selectedProjectId"
:transportMode="transportMode"
:sendShortcut="sendShortcut"
:isDark="isDark"
:chatboxMode="chatboxMode"
:isMobile="isMobile"
@@ -29,6 +30,7 @@
@editProject="showEditProjectDialog"
@deleteProject="handleDeleteProject"
@updateTransportMode="setTransportMode"
@updateSendShortcut="setSendShortcut"
/>
<!-- 右侧聊天内容区域 -->
@@ -72,13 +74,14 @@
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:stagedFiles="stagedNonImageFiles"
:disabled="isStreaming"
:disabled="false"
:is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
:session-id="currSessionId || null"
:current-session="getCurrentSession"
:replyTo="replyTo"
:send-shortcut="sendShortcut"
@send="handleSendMessage"
@stop="handleStopMessage"
@toggleStreaming="toggleStreaming"
@@ -103,13 +106,14 @@
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:stagedFiles="stagedNonImageFiles"
:disabled="isStreaming"
:disabled="false"
:is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
:session-id="currSessionId || null"
:current-session="getCurrentSession"
:replyTo="replyTo"
:send-shortcut="sendShortcut"
@send="handleSendMessage"
@stop="handleStopMessage"
@toggleStreaming="toggleStreaming"
@@ -133,13 +137,14 @@
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:stagedFiles="stagedNonImageFiles"
:disabled="isStreaming"
:disabled="false"
:is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
:session-id="currSessionId || null"
:current-session="getCurrentSession"
:replyTo="replyTo"
:send-shortcut="sendShortcut"
@send="handleSendMessage"
@stop="handleStopMessage"
@toggleStreaming="toggleStreaming"
@@ -226,6 +231,8 @@ import { useToast } from '@/utils/toast';
interface Props {
chatboxMode?: boolean;
}
type SendShortcut = 'enter' | 'shift_enter';
const SEND_SHORTCUT_STORAGE_KEY = 'chat_send_shortcut';
const props = withDefaults(defineProps<Props>(), {
chatboxMode: false
@@ -334,6 +341,18 @@ interface ReplyInfo {
const replyTo = ref<ReplyInfo | null>(null);
const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark');
const sendShortcut = ref<SendShortcut>('shift_enter');
function setSendShortcut(mode: SendShortcut) {
sendShortcut.value = mode;
localStorage.setItem(SEND_SHORTCUT_STORAGE_KEY, mode);
}
function focusChatInput() {
nextTick(() => {
chatInputRef.value?.focusInput?.();
});
}
//
function checkMobile() {
@@ -492,6 +511,7 @@ async function handleSelectConversation(sessionIds: string[]) {
nextTick(() => {
messageList.value?.scrollToBottom();
});
focusChatInput();
}
function handleNewChat() {
@@ -501,6 +521,7 @@ function handleNewChat() {
// 退
selectedProjectId.value = null;
projectSessions.value = [];
focusChatInput();
}
async function handleDeleteConversation(sessionId: string) {
@@ -658,6 +679,11 @@ async function handleSendMessage() {
const selectedProviderId = selection?.providerId || '';
const selectedModelName = selection?.modelName || '';
//
nextTick(() => {
messageList.value?.scrollToBottom();
});
await sendMsg(
promptToSend,
filesToSend,
@@ -667,6 +693,11 @@ async function handleSendMessage() {
replyToSend
);
//
nextTick(() => {
messageList.value?.scrollToBottom();
});
//
if (isCreatingNewSession && currentProjectId && currSessionId.value) {
await addSessionToProject(currSessionId.value, currentProjectId);
@@ -725,6 +756,10 @@ watch(sessions, (newSessions) => {
});
onMounted(() => {
const storedShortcut = localStorage.getItem(SEND_SHORTCUT_STORAGE_KEY);
if (storedShortcut === 'enter' || storedShortcut === 'shift_enter') {
sendShortcut.value = storedShortcut;
}
checkMobile();
window.addEventListener('resize', checkMobile);
getSessions();
+34 -19
View File
@@ -95,7 +95,7 @@
{{ isRecording ? tm('voice.speaking') : tm('voice.startRecording') }}
</v-tooltip>
</v-btn>
<v-btn icon v-if="isRunning" @click="$emit('stop')" variant="tonal" color="primary" class="send-btn">
<v-btn icon v-if="isRunning && !canSend" @click="$emit('stop')" variant="tonal" color="primary" class="send-btn">
<v-icon icon="mdi-stop" variant="text" plain></v-icon>
<v-tooltip activator="parent" location="top">
{{ tm('input.stopGenerating') }}
@@ -173,6 +173,7 @@ interface Props {
currentSession?: Session | null;
configId?: string | null;
replyTo?: ReplyInfo | null;
sendShortcut?: 'enter' | 'shift_enter';
}
const props = withDefaults(defineProps<Props>(), {
@@ -180,7 +181,8 @@ const props = withDefaults(defineProps<Props>(), {
currentSession: null,
configId: null,
stagedFiles: () => [],
replyTo: null
replyTo: null,
sendShortcut: 'shift_enter'
});
const emit = defineEmits<{
@@ -253,9 +255,29 @@ watch(localPrompt, () => {
});
function handleKeyDown(e: KeyboardEvent) {
// Enter
// Shift+Enter Ctrl+Enter / Cmd+Enter
if (e.keyCode === 13 && (e.shiftKey || e.ctrlKey || e.metaKey)) {
const isEnter = e.key === 'Enter';
if (!isEnter) {
// Ctrl+B
if (e.ctrlKey && e.keyCode === 66) {
e.preventDefault();
if (ctrlKeyDown.value) return;
ctrlKeyDown.value = true;
ctrlKeyTimer.value = window.setTimeout(() => {
if (ctrlKeyDown.value && !props.isRecording) {
emit('startRecording');
}
}, ctrlKeyLongPressThreshold);
}
return;
}
const isSendHotkey =
e.ctrlKey ||
e.metaKey ||
(props.sendShortcut === 'enter' ? !e.shiftKey : e.shiftKey);
if (isSendHotkey) {
e.preventDefault();
if (localPrompt.value.trim() === '/astr_live_dev') {
emit('openLiveMode');
@@ -267,19 +289,6 @@ function handleKeyDown(e: KeyboardEvent) {
}
return;
}
// Ctrl+B
if (e.ctrlKey && e.keyCode === 66) {
e.preventDefault();
if (ctrlKeyDown.value) return;
ctrlKeyDown.value = true;
ctrlKeyTimer.value = window.setTimeout(() => {
if (ctrlKeyDown.value && !props.isRecording) {
emit('startRecording');
}
}, ctrlKeyLongPressThreshold);
}
}
function handleKeyUp(e: KeyboardEvent) {
@@ -364,6 +373,11 @@ function getCurrentSelection() {
return providerModelMenuRef.value?.getCurrentSelection();
}
function focusInput() {
if (!inputField.value) return;
inputField.value.focus();
}
onMounted(() => {
if (inputField.value) {
inputField.value.addEventListener('paste', handlePaste);
@@ -379,7 +393,8 @@ onBeforeUnmount(() => {
});
defineExpose({
getCurrentSelection
getCurrentSelection,
focusInput
});
</script>
@@ -231,6 +231,50 @@
</v-card>
</v-menu>
<!-- 发送快捷键分组 -->
<v-menu
:open-on-hover="!isMobile"
:open-on-click="isMobile"
:open-delay="!isMobile ? 60 : 0"
:close-delay="!isMobile ? 120 : 0"
:location="isMobile ? 'bottom' : 'end center'"
offset="8"
close-on-content-click
>
<template v-slot:activator="{ props: sendShortcutMenuProps }">
<v-list-item
v-bind="sendShortcutMenuProps"
class="styled-menu-item chat-settings-group-trigger"
rounded="md"
>
<template v-slot:prepend>
<v-icon>mdi-keyboard-outline</v-icon>
</template>
<v-list-item-title>{{ tm('shortcuts.sendKey.title') }}</v-list-item-title>
<template v-slot:append>
<span class="chat-settings-group-current chat-settings-transport-current">{{ currentSendShortcutLabel }}</span>
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
</template>
</v-list-item>
</template>
<v-card class="styled-menu-card" style="min-width: 220px;" elevation="8" rounded="lg">
<v-list density="compact" class="styled-menu-list pa-1">
<v-list-item
v-for="opt in sendShortcutOptions"
:key="opt.value"
:value="opt.value"
@click="handleSendShortcutChange(opt.value)"
:class="{ 'styled-menu-item-active': props.sendShortcut === opt.value }"
class="styled-menu-item"
rounded="md"
>
<v-list-item-title>{{ opt.label }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<!-- 全屏/退出全屏 -->
<v-list-item class="styled-menu-item" @click="$emit('toggleFullscreen')">
<template v-slot:prepend>
@@ -277,6 +321,7 @@ interface Props {
isMobile: boolean;
mobileMenuOpen: boolean;
projects?: Project[];
sendShortcut: 'enter' | 'shift_enter';
}
const props = withDefaults(defineProps<Props>(), {
@@ -297,6 +342,7 @@ const emit = defineEmits<{
editProject: [project: Project];
deleteProject: [projectId: string];
updateTransportMode: [mode: 'sse' | 'websocket'];
updateSendShortcut: [mode: 'enter' | 'shift_enter'];
}>();
const { t } = useI18n();
@@ -357,6 +403,10 @@ const transportOptions = [
{ label: tm('transport.sse'), value: 'sse' as const },
{ label: tm('transport.websocket'), value: 'websocket' as const }
];
const sendShortcutOptions = [
{ label: tm('shortcuts.sendKey.enterToSend'), value: 'enter' as const },
{ label: tm('shortcuts.sendKey.shiftEnterToSend'), value: 'shift_enter' as const }
];
// Language switcher
const { languageOptions, currentLanguage, switchLanguage, locale } = useLanguageSwitcher();
@@ -376,6 +426,10 @@ const currentTransportLabel = computed(() => {
const found = transportOptions.find(opt => opt.value === props.transportMode);
return found?.label ?? '';
});
const currentSendShortcutLabel = computed(() => {
const found = sendShortcutOptions.find(opt => opt.value === props.sendShortcut);
return found?.label ?? '';
});
// localStorage
const savedCollapsedState = localStorage.getItem('sidebarCollapsed');
@@ -403,6 +457,12 @@ function handleTransportModeChange(mode: string | null) {
emit('updateTransportMode', mode);
}
}
function handleSendShortcutChange(mode: string | null) {
if (mode === 'enter' || mode === 'shift_enter') {
emit('updateSendShortcut', mode);
}
}
</script>
<style scoped>
@@ -180,7 +180,7 @@
<script>
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { enableKatex, enableMermaid, setCustomComponents } from 'markstream-vue'
import { enableKatex, enableMermaid, MarkdownCodeBlockNode, setCustomComponents } from 'markstream-vue'
import 'markstream-vue/index.css'
import 'katex/dist/katex.min.css'
import 'highlight.js/styles/github.css';
@@ -194,8 +194,11 @@ import ActionRef from './message_list_comps/ActionRef.vue';
enableKatex();
enableMermaid();
// ref
setCustomComponents('message-list', { ref: RefNode });
// message-list + Shiki
setCustomComponents('message-list', {
ref: RefNode,
code_block: MarkdownCodeBlockNode
});
export default {
name: 'MessageList',
@@ -22,7 +22,7 @@
v-model:prompt="prompt"
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:disabled="isStreaming"
:disabled="false"
:is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
@@ -63,8 +63,9 @@
<!-- Text (Markdown) -->
<MarkdownRender
v-else-if="renderPart.part.type === 'plain' && renderPart.part.text && renderPart.part.text.trim()"
:key="`${renderPart.key}-${isDark ? 'dark' : 'light'}`"
custom-id="message-list" :custom-html-tags="['ref']" :content="renderPart.part.text" :typewriter="false"
class="markdown-content" :is-dark="isDark" :monacoOptions="{ theme: isDark ? 'vs-dark' : 'vs-light' }" />
class="markdown-content" :is-dark="isDark" />
<!-- Image -->
<div v-else-if="renderPart.part.type === 'image' && renderPart.part.embedded_url" class="embedded-images">
@@ -9,7 +9,7 @@
</span>
</div>
<div v-if="isExpanded" class="reasoning-content animate-fade-in">
<MarkdownRender :content="reasoning" class="reasoning-text markdown-content"
<MarkdownRender :key="`reasoning-${isDark ? 'dark' : 'light'}`" :content="reasoning" class="reasoning-text markdown-content"
:typewriter="false" :is-dark="isDark" :style="isDark ? { opacity: '0.85' } : {}" />
</div>
</div>
@@ -71,10 +71,16 @@
"modes": {
"darkMode": "Switch to Dark Mode",
"lightMode": "Switch to Light Mode"
}, "shortcuts": {
},
"shortcuts": {
"help": "Get Help",
"voiceRecord": "Record Voice",
"pasteImage": "Paste Image"
"pasteImage": "Paste Image",
"sendKey": {
"title": "Send Shortcut",
"enterToSend": "Enter to send",
"shiftEnterToSend": "Shift+Enter to send"
}
},
"streaming": {
"enabled": "Streaming enabled",
@@ -75,7 +75,12 @@
"shortcuts": {
"help": "Справка",
"voiceRecord": "Запись голоса",
"pasteImage": "Вставить изображение"
"pasteImage": "Вставить изображение",
"sendKey": {
"title": "Клавиша отправки",
"enterToSend": "Enter для отправки",
"shiftEnterToSend": "Shift+Enter для отправки"
}
},
"streaming": {
"enabled": "Потоковый ответ включен",
@@ -143,4 +148,4 @@
"sendMessageFailed": "Ошибка отправки сообщения, попробуйте еще раз",
"createSessionFailed": "Ошибка создания сессии, обновите страницу"
}
}
}
@@ -71,10 +71,16 @@
"modes": {
"darkMode": "切换到夜间模式",
"lightMode": "切换到日间模式"
}, "shortcuts": {
},
"shortcuts": {
"help": "获取帮助",
"voiceRecord": "录制语音",
"pasteImage": "粘贴图片"
"pasteImage": "粘贴图片",
"sendKey": {
"title": "发送快捷键",
"enterToSend": "Enter 发送",
"shiftEnterToSend": "Shift+Enter 发送"
}
},
"streaming": {
"enabled": "流式响应已开启",
+26 -26
View File
@@ -9,33 +9,33 @@
*/
export function getProviderIcon(type) {
const icons = {
'openai': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/openai.svg',
'azure': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/azure.svg',
'xai': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/xai.svg',
'anthropic': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/anthropic.svg',
'ollama': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/ollama.svg',
'google': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/gemini-color.svg',
'deepseek': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/deepseek.svg',
'modelscope': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/modelscope.svg',
'zhipu': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/zhipu.svg',
'nvidia': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/nvidia-color.svg',
'siliconflow': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/siliconcloud.svg',
'moonshot': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/kimi.svg',
'ppio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/ppio.svg',
'dify': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/dify-color.svg',
"coze": "https://registry.npmmirror.com/@lobehub/icons-static-svg/1.66.0/files/icons/coze.svg",
'dashscope': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/alibabacloud-color.svg',
'openai': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/openai.svg',
'azure': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/azure.svg',
'xai': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/xai.svg',
'anthropic': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/anthropic.svg',
'ollama': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/ollama.svg',
'google': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/gemini-color.svg',
'deepseek': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/deepseek.svg',
'modelscope': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/modelscope.svg',
'zhipu': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/zhipu.svg',
'nvidia': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/nvidia-color.svg',
'siliconflow': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/siliconcloud.svg',
'moonshot': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/kimi.svg',
'ppio': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/ppio.svg',
'dify': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/dify-color.svg',
"coze": "https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.66.0/icons/coze.svg",
'dashscope': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/alibabacloud-color.svg',
'deerflow': 'https://cdn.jsdelivr.net/gh/bytedance/deer-flow@main/frontend/public/images/deer.svg',
'fastgpt': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/fastgpt-color.svg',
'lm_studio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/lmstudio.svg',
'fishaudio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/fishaudio.svg',
'minimax': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/minimax.svg',
'302ai': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/1.53.0/files/icons/ai302-color.svg',
'microsoft': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/microsoft.svg',
'vllm': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/vllm.svg',
'groq': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/groq.svg',
'aihubmix': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/aihubmix-color.svg',
'openrouter': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/openrouter.svg',
'fastgpt': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/fastgpt-color.svg',
'lm_studio': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/lmstudio.svg',
'fishaudio': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/fishaudio.svg',
'minimax': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/minimax.svg',
'302ai': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.53.0/icons/ai302-color.svg',
'microsoft': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/microsoft.svg',
'vllm': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/vllm.svg',
'groq': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/groq.svg',
'aihubmix': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/aihubmix-color.svg',
'openrouter': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/openrouter.svg',
"tokenpony": "https://tokenpony.cn/tokenpony-web/logo.png",
"compshare": "https://compshare.cn/favicon.ico"
};
+1 -1
View File
@@ -6,7 +6,7 @@ This documentation may not cover all features comprehensively. If you have any q
### Discord
<https://discord.gg/PxgzhmxJ>
<https://discord.gg/hAVk6tgV36>
### GitHub
+1 -1
View File
@@ -21,7 +21,7 @@
### Discord
https://discord.gg/PxgzhmxJ
https://discord.gg/hAVk6tgV36
### Astrbook
+1 -1
View File
@@ -13,5 +13,5 @@
```bash
uv tool install astrbot
astrbot init # 只需要在第一次部署时执行,后续启动不需要执行
astrbot
astrbot run
```
+1 -1
View File
@@ -41,4 +41,4 @@ AstrBot 已经上架至雨云的预装软件列表,支持**一键安装** Astr
![创建NAT端口映射规则](https://files.astrbot.app/docs/source/images/rainyun/image-2.png)
然后,内网端口填写 `6185`,点击 `创建映射规则`,这样就可以通过 `http://IP:上面设置好的外网端口` 访问 AstrBot 的管理面板了。
然后,内网端口填写 `6185`,点击 `创建映射规则`,这样就可以通过 `http://IP:上面设置好的外网端口` 访问 AstrBot 的管理面板了。如果无法打开,请点击`备用地址`,通过备用地址访问管理面板。
+1 -1
View File
@@ -23,7 +23,7 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
- 部署 AstrBot:阅读部署指南,快速在本地机器或云服务器上部署 AstrBot。
- 连接 IM 平台:按照说明将 AstrBot 连接到您喜欢的 IM 平台,如 Discord、Telegram、Slack 等。
- 配置 AI 模型:AstrBot 支持各种 AI 模型。请参阅 [连接模型服务](/config/providers/start)
- 配置 AI 模型:AstrBot 支持各种 AI 模型。请参阅 [连接模型服务](/providers/start)
## 它是如何实现的?
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "4.20.0"
version = "4.20.1"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
requires-python = ">=3.12"
+5
View File
@@ -1,5 +1,7 @@
# user service
[Unit]
Description=AstrBot Service
Documentation=https://github.com/AstrBotDevs/AstrBot
After=network-online.target
Wants=network-online.target
@@ -9,6 +11,9 @@ WorkingDirectory=%h/.local/share/astrbot
ExecStart=/usr/bin/sh -c '/usr/bin/astrbot run || { /usr/bin/astrbot init && /usr/bin/astrbot run; }'
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=astrbot-%u
Environment=PYTHONUNBUFFERED=1
[Install]
-253
View File
@@ -1,253 +0,0 @@
#!/usr/bin/env python3
"""
Auto-generate changelog from git commits using LLM.
Usage: python scripts/generate_changelog.py [--version VERSION]
"""
import argparse
import os
import re
import subprocess
import sys
from pathlib import Path
def get_latest_tag():
"""Get the latest git tag."""
result = subprocess.run(
["git", "describe", "--tags", "--abbrev=0"],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
def get_commits_since_tag(tag):
"""Get all commit messages since the specified tag."""
result = subprocess.run(
["git", "log", f"{tag}..HEAD", "--pretty=format:%H|%s|%b"],
capture_output=True,
text=True,
check=True,
)
commits = []
for line in result.stdout.strip().split("\n"):
if not line:
continue
parts = line.split("|", 2)
if len(parts) >= 2:
commit_hash = parts[0]
subject = parts[1]
body = parts[2] if len(parts) > 2 else ""
commits.append({"hash": commit_hash[:7], "subject": subject, "body": body})
return commits
def extract_issue_number(text):
"""Extract issue number from commit message."""
# Match #1234 or (#1234)
match = re.search(r"#(\d+)", text)
return match.group(1) if match else None
def call_llm_for_changelog(commits, version):
"""Call LLM to generate changelog from commits."""
try:
# Try to use OpenAI API or other LLM providers
import openai
# Build prompt
commits_text = "\n".join([f"- {c['subject']}" for c in commits])
prompt = f"""Based on the following git commit messages, generate a changelog document in BOTH Chinese and English.
Commit messages:
{commits_text}
Please organize the changes into these categories:
- 新增 (New Features)
- 修复 (Bug Fixes)
- 优化 (Improvements)
- 其他 (Others)
Format requirements:
1. Start with Chinese version under "## What's Changed"
2. Follow with English version under "## What's Changed (EN)"
3. Use markdown format with proper bullet points
4. Keep descriptions concise and user-friendly
5. If a commit mentions an issue number (#1234), include it in the format ([#1234](https://github.com/AstrBotDevs/AstrBot/issues/1234))
Example format:
## What's Changed
### 新增
- 支持某某功能 ([#1234](https://github.com/AstrBotDevs/AstrBot/issues/1234))
### 修复
- 修复某某问题
## What's Changed (EN)
### New Features
- Add support for something ([#1234](https://github.com/AstrBotDevs/AstrBot/issues/1234))
### Bug Fixes
- Fix something
"""
client = openai.OpenAI(
api_key=os.getenv("OPENAI_API_KEY"),
base_url=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"),
)
response = client.chat.completions.create(
model=os.getenv("OPENAI_MODEL", "gpt-4"),
messages=[
{
"role": "system",
"content": "You are a helpful assistant that generates well-structured changelogs.",
},
{"role": "user", "content": prompt},
],
temperature=0.3,
)
return response.choices[0].message.content
except ImportError:
print(
"Warning: openai package not installed. Install it with: pip install openai"
)
return generate_simple_changelog(commits)
except Exception as e:
print(f"Warning: Failed to call LLM API: {e}")
print("Falling back to simple changelog generation...")
return generate_simple_changelog(commits)
def generate_simple_changelog(commits):
"""Generate a simple changelog without LLM."""
sections = {
"feat": ("新增", "New Features", []),
"fix": ("修复", "Bug Fixes", []),
"perf": ("优化", "Improvements", []),
"docs": ("文档", "Documentation", []),
"refactor": ("重构", "Refactoring", []),
"test": ("测试", "Tests", []),
"chore": ("其他", "Chore", []),
"other": ("其他", "Others", []),
}
# Categorize commits by conventional commit type
for commit in commits:
subject = commit["subject"]
issue_num = extract_issue_number(subject)
issue_link = (
f" ([#{issue_num}](https://github.com/AstrBotDevs/AstrBot/issues/{issue_num}))"
if issue_num
else ""
)
# Detect conventional commit type
matched = False
for prefix in ["feat", "fix", "perf", "docs", "refactor", "test", "chore"]:
if subject.lower().startswith(f"{prefix}:") or subject.lower().startswith(
f"{prefix}("
):
# Remove prefix for display
clean_subject = re.sub(
r"^[a-z]+(\([^)]+\))?:\s*", "", subject, flags=re.IGNORECASE
)
sections[prefix][2].append(f"- {clean_subject}{issue_link}")
matched = True
break
if not matched:
sections["other"][2].append(f"- {subject}{issue_link}")
# Build Chinese version
changelog_zh = "## What's Changed\n\n"
for section_key in ["feat", "fix", "perf", "docs", "refactor", "test", "other"]:
zh_title, _, items = sections[section_key]
if items:
changelog_zh += f"### {zh_title}\n\n"
changelog_zh += "\n".join(items) + "\n\n"
# Build English version
changelog_en = "## What's Changed (EN)\n\n"
for section_key in ["feat", "fix", "perf", "docs", "refactor", "test", "other"]:
_, en_title, items = sections[section_key]
if items:
changelog_en += f"### {en_title}\n\n"
changelog_en += "\n".join(items) + "\n\n"
return changelog_zh + changelog_en
def main() -> None:
parser = argparse.ArgumentParser(description="Generate changelog from git commits")
parser.add_argument(
"--version", help="Version number for the changelog (e.g., v4.13.3)"
)
parser.add_argument(
"--use-llm",
action="store_true",
help="Use LLM to generate changelog (requires OpenAI API key)",
)
args = parser.parse_args()
# Get latest tag
try:
latest_tag = get_latest_tag()
print(f"Latest tag: {latest_tag}")
except subprocess.CalledProcessError:
print("Error: No tags found in repository")
sys.exit(1)
# Get commits since tag
commits = get_commits_since_tag(latest_tag)
if not commits:
print(f"No commits found since {latest_tag}")
sys.exit(0)
print(f"Found {len(commits)} commits since {latest_tag}")
# Determine version
if args.version:
version = args.version
else:
# Auto-increment patch version
match = re.match(r"v(\d+)\.(\d+)\.(\d+)", latest_tag)
if match:
major, minor, patch = map(int, match.groups())
version = f"v{major}.{minor}.{patch + 1}"
else:
print(f"Warning: Could not parse version from tag {latest_tag}")
version = "vX.X.X"
print(f"Generating changelog for {version}...")
# Generate changelog
if args.use_llm:
changelog_content = call_llm_for_changelog(commits, version)
else:
changelog_content = generate_simple_changelog(commits)
# Save to file
changelog_dir = Path(__file__).parent.parent / "changelogs"
changelog_dir.mkdir(exist_ok=True)
changelog_file = changelog_dir / f"{version}.md"
with open(changelog_file, "w", encoding="utf-8") as f:
f.write(changelog_content)
print(f"\n✓ Changelog generated: {changelog_file}")
print("\nPreview:")
print("=" * 80)
print(changelog_content)
print("=" * 80)
if __name__ == "__main__":
main()
+56
View File
@@ -0,0 +1,56 @@
import asyncio
import pytest
from astrbot.dashboard.routes.chat import _poll_webchat_stream_result
class _QueueThatRaises:
def __init__(self, exc: BaseException):
self._exc = exc
async def get(self):
raise self._exc
class _QueueWithResult:
def __init__(self, result):
self._result = result
async def get(self):
return self._result
@pytest.mark.asyncio
async def test_poll_webchat_stream_result_breaks_on_cancelled_error():
result, should_break = await _poll_webchat_stream_result(
_QueueThatRaises(asyncio.CancelledError()),
"alice",
)
assert result is None
assert should_break is True
@pytest.mark.asyncio
async def test_poll_webchat_stream_result_continues_on_generic_exception():
result, should_break = await _poll_webchat_stream_result(
_QueueThatRaises(RuntimeError("boom")),
"alice",
)
assert result is None
assert should_break is False
@pytest.mark.asyncio
async def test_poll_webchat_stream_result_returns_queue_payload():
payload = {"type": "end", "data": ""}
result, should_break = await _poll_webchat_stream_result(
_QueueWithResult(payload),
"alice",
)
assert result == payload
assert should_break is False
+67
View File
@@ -2,6 +2,7 @@ from types import SimpleNamespace
import pytest
from astrbot.core.provider.sources.groq_source import ProviderGroq
from astrbot.core.provider.sources.openai_source import ProviderOpenAIOfficial
@@ -32,6 +33,21 @@ def _make_provider(overrides: dict | None = None) -> ProviderOpenAIOfficial:
)
def _make_groq_provider(overrides: dict | None = None) -> ProviderGroq:
provider_config = {
"id": "test-groq",
"type": "groq_chat_completion",
"model": "qwen/qwen3-32b",
"key": ["test-key"],
}
if overrides:
provider_config.update(overrides)
return ProviderGroq(
provider_config=provider_config,
provider_settings={},
)
@pytest.mark.asyncio
async def test_handle_api_error_content_moderated_removes_images():
provider = _make_provider(
@@ -198,6 +214,57 @@ def test_extract_error_text_candidates_truncates_long_response_text():
)
@pytest.mark.asyncio
async def test_openai_payload_keeps_reasoning_content_in_assistant_history():
provider = _make_provider()
try:
payloads = {
"messages": [
{
"role": "assistant",
"content": [
{"type": "think", "think": "step 1"},
{"type": "text", "text": "final answer"},
],
}
]
}
provider._finally_convert_payload(payloads)
assistant_message = payloads["messages"][0]
assert assistant_message["content"] == [{"type": "text", "text": "final answer"}]
assert assistant_message["reasoning_content"] == "step 1"
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_groq_payload_drops_reasoning_content_from_assistant_history():
provider = _make_groq_provider()
try:
payloads = {
"messages": [
{
"role": "assistant",
"content": [
{"type": "think", "think": "step 1"},
{"type": "text", "text": "final answer"},
],
}
]
}
provider._finally_convert_payload(payloads)
assistant_message = payloads["messages"][0]
assert assistant_message["content"] == [{"type": "text", "text": "final answer"}]
assert "reasoning_content" not in assistant_message
assert "reasoning" not in assistant_message
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_handle_api_error_content_moderated_without_images_raises():
provider = _make_provider(