Compare commits

...

37 Commits

Author SHA1 Message Date
Soulter 414f98fb5e perf: onebot, satori docs improvement 2026-03-16 11:41:25 +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
Soulter 9363fb824a chore: ruff format 2026-03-14 21:12:00 +08:00
Flartiny 044b361ac5 feat: add conversation batch deletion for webchat (#6160)
* feat: add conversation batch deletion for webchat

* fix: security issues in batch_delete_sessions and better handle batch select

* feat: enhance batch selection UI with animated checkbox visibility in ConversationSidebar

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-03-14 21:09:36 +08:00
Frank 06fd2d2428 fix: preserve subagent handoff tools during plugin filtering (#6155) 2026-03-14 20:55:15 +08:00
eason dd6bc1dcdb fix: add missing spaces in cron prompt and replace deprecated utcnow() (#6192)
1. Fix missing spaces in cron job wake prompt string concatenation.
   Python implicit string concatenation produced:
   "...scheduled taskProceed..." and "...conversation.After..."
   which sent garbled instructions to the LLM agent, causing unreliable
   cron job execution.

2. Replace deprecated datetime.utcnow() with
   datetime.now(datetime.timezone.utc) in JWT generation.
   utcnow() is deprecated since Python 3.12 and returns naive datetime
   which can cause incorrect token expiry on non-UTC systems.

Closes #6103
Closes #6165

Co-authored-by: easonysliu <easonysliu@tencent.com>
2026-03-14 20:52:00 +08:00
Rhonin Wang 52d5258b10 feat: display latency when testing model connection (#6258)
Co-authored-by: RhoninSeiei <RhoninSeiei@users.noreply.github.com>
2026-03-14 20:50:40 +08:00
Anima 91933bbd19 perf: webui theme color improvement (#6263)
* fix: update scrollbar styles to follow theme variables

* fix: update theme colors to use CSS variables for consistency

* fix: change login button color to primary for better visibility

* fix: update theme colors for Dark and Light themes; change login button color to secondary

* fix: update border and theme colors for consistency in DarkTheme

* fix: update sidebar list class to conditionally hide scrollbar in mini sidebar mode

* fix: simplify button visibility logic and remove unnecessary leftPadding style

* fix: refactor language switcher to use grouped menu for better UX

* fix: update theme colors to use primary color for consistency across components

* fix: add preview text for template output in multiple languages
2026-03-14 20:45:55 +08:00
Sakari f8d075b5d3 fix(telegram): avoid treating normal replies as topic threads (#6174) 2026-03-14 18:27:13 +08:00
eason 86ef758a9a fix: prevent ValueError when removing already-removed API key in retry loop (#6193)
In _handle_api_error(), when a 429 rate-limit is encountered, the code
calls available_api_keys.remove(chosen_key). If the same key was already
removed in a previous retry iteration (e.g. the key rotated back to the
same value), this raises ValueError which crashes the entire LLM request
with an opaque error instead of a proper retry/fallback.

Add a membership check before calling remove() to prevent the crash.

Co-authored-by: easonysliu <easonysliu@tencent.com>
2026-03-14 18:22:14 +08:00
Ann-Holmes 1a03180643 Add binding for local temp directory in YAML (#6191)
* Add binding for local temp directory in YAML

Bind the local temp directory to the sandbox for file access.

* Update compose-with-shipyard.yml

Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2026-03-14 18:21:47 +08:00
DroidKali 326183a3fd fix: update startup command to 'astrbot run' in all README files (#6189)
Updated the quick start command from 'astrbot' to 'astrbot run' across all
language versions of README documentation for consistency and correctness.

Co-authored-by: DroidKali <DroidKali@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-14 18:20:48 +08:00
qingyun 08fc657755 fix: preserve whitespace in Plain.toDict() for @ mentions (#6244)
* fix: preserve whitespace in Plain.toDict() for @ mentions

- Remove .strip() from Plain.toDict() to match async to_dict() behavior
- Fixes #6237: QQ @mentions no longer lose trailing spaces
- This ensures '@user message' displays correctly instead of '@usermessage'

* refactor: remove redundant to_dict() from Plain class

- Let Plain inherit to_dict() from BaseMessageComponent
- BaseMessageComponent.to_dict() calls toDict() by default
- Reduces code duplication and prevents future divergence
- Addressed code review feedback from @gemini-code-assist and @sourcery-ai

* feat: add async to_dict method to Plain message component

* fix: add return type hint to Plain.toDict method

---------

Co-authored-by: ccsang <ccsang@users.noreply.github.com>
Co-authored-by: Soulter <905617992@qq.com>
2026-03-14 18:18:14 +08:00
Gao Jinzhe 0ff9539599 Merge pull request #6208 from nuomicici/master
更新(添加)部分文档中已过时的名词
2026-03-14 18:17:14 +08:00
lalala 38f5e077ee fix: remove duplicate dependencies (#6247)
remove duplicate `aiocqhttp` `aiodocker` `aiohttp` in requirements.txt
2026-03-14 18:15:06 +08:00
MousseC 89fbd75e7a perf(OneBot): add a whitespace after At component (#6238)
修复 At 组件后的空格在发送时被 strip 移除的问题。在消息解析时检测 At 组件并在其后额外插入空格。
2026-03-14 18:12:55 +08:00
Salman Chishti 493662524a ci: upgrade GitHub Actions to latest versions (#6251)
Signed-off-by: Salman Muin Kayser Chishti <13schishti@gmail.com>
2026-03-14 18:08:25 +08:00
糯米茨 1afbb357db Update docs/zh/platform/matrix.md
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-13 21:14:00 +08:00
糯米茨 8d2140f607 Update docs/zh/platform/wecom_ai_bot.md
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-13 21:13:45 +08:00
糯米茨 97732987d9 更新部分新版名称 2026-03-13 20:53:41 +08:00
糯米茨 a60a40bca3 更新部分新版本名称
Update the instructions for installing and configuring the Matrix adapter in AstrBot.
2026-03-13 20:51:39 +08:00
エイカク a8ff2b3d9c fix(dashboard): stabilize sidebar hash navigation on startup (#6159)
* fix(dashboard): stabilize sidebar hash navigation on startup

* fix(dashboard): reuse shared extension tab route helpers

* fix(dashboard): avoid leaking extension route query state

* fix(dashboard): preserve route params in tab locations

* fix(dashboard): harden hash tab routing fallbacks

* fix(dashboard): warn on tab route navigation failures

* fix(dashboard): harden extension tab startup routing
2026-03-13 11:53:50 +09:00
82 changed files with 1701 additions and 641 deletions
+1 -1
View File
@@ -50,7 +50,7 @@ jobs:
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Setup pnpm
uses: pnpm/action-setup@v4.3.0
uses: pnpm/action-setup@v4.4.0
with:
version: 10.28.2
+1 -1
View File
@@ -78,7 +78,7 @@ For users who want to quickly experience AstrBot, are familiar with command-line
```bash
uv tool install astrbot
astrbot init # Only execute this command for the first time to initialize the environment
astrbot
astrbot run
```
> Requires [uv](https://docs.astral.sh/uv/) to be installed.
+1 -1
View File
@@ -78,7 +78,7 @@ Pour les utilisateurs qui veulent découvrir AstrBot rapidement, qui sont famili
```bash
uv tool install astrbot
astrbot init # Exécutez cette commande uniquement la première fois pour initialiser l'environnement
astrbot
astrbot run
```
> [uv](https://docs.astral.sh/uv/) doit être installé.
+1 -1
View File
@@ -78,7 +78,7 @@ AstrBot を素早く試したいユーザーで、コマンドラインに慣れ
```bash
uv tool install astrbot
astrbot init # 初回のみ実行して環境を初期化します
astrbot
astrbot run
```
> [uv](https://docs.astral.sh/uv/) のインストールが必要です。
+1 -1
View File
@@ -78,7 +78,7 @@ AstrBot — это универсальная платформа Agent-чатб
```bash
uv tool install astrbot
astrbot init # Выполните эту команду только при первом запуске для инициализации окружения
astrbot
astrbot run
```
> Требуется установленный [uv](https://docs.astral.sh/uv/).
+1 -1
View File
@@ -78,7 +78,7 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
```bash
uv tool install astrbot
astrbot init # 僅首次執行此命令以初始化環境
astrbot
astrbot run
```
> 需要安裝 [uv](https://docs.astral.sh/uv/)。
+1 -1
View File
@@ -78,7 +78,7 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
```bash
uv tool install astrbot
astrbot init # 仅首次执行此命令以初始化环境
astrbot
astrbot run
```
> 需要安装 [uv](https://docs.astral.sh/uv/)。
+5
View File
@@ -778,9 +778,14 @@ def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None:
continue
mp = tool.handler_module_path
if not mp:
# 没有 plugin 归属信息的工具(如 subagent transfer_to_*
# 不应受到会话插件过滤影响。
new_tool_set.add_tool(tool)
continue
plugin = star_map.get(mp)
if not plugin:
# 无法解析插件归属时,保守保留工具,避免误过滤。
new_tool_set.add_tool(tool)
continue
if plugin.name in event.plugins_name or plugin.reserved:
new_tool_set.add_tool(tool)
+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."
+12
View File
@@ -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",
+2 -2
View File
@@ -332,9 +332,9 @@ class CronJobManager:
cron_job=cron_job_str
)
req.prompt = (
"You are now responding to a scheduled task"
"You are now responding to a scheduled task. "
"Proceed according to your system instructions. "
"Output using same language as previous conversation."
"Output using same language as previous conversation. "
"After completing your task, summarize and output your actions and results."
)
if not req.func_tool:
+7
View File
@@ -647,6 +647,13 @@ class BaseDatabase(abc.ABC):
"""Get a Platform session by its ID."""
...
@abc.abstractmethod
async def get_platform_sessions_by_ids(
self, session_ids: list[str]
) -> list[PlatformSession]:
"""Get platform sessions by IDs."""
...
@abc.abstractmethod
async def get_platform_sessions_by_creator(
self,
+15
View File
@@ -1417,6 +1417,21 @@ class SQLiteDatabase(BaseDatabase):
result = await session.execute(query)
return result.scalar_one_or_none()
async def get_platform_sessions_by_ids(
self, session_ids: list[str]
) -> list[PlatformSession]:
"""Get platform sessions by IDs."""
if not session_ids:
return []
async with self.get_db() as session:
session: AsyncSession
query = select(PlatformSession).where(
col(PlatformSession.session_id).in_(session_ids)
)
result = await session.execute(query)
return list(result.scalars().all())
async def get_platform_sessions_by_creator(
self,
creator: str,
+3 -3
View File
@@ -96,10 +96,10 @@ class Plain(BaseMessageComponent):
def __init__(self, text: str, convert: bool = True, **_) -> None:
super().__init__(text=text, convert=convert, **_)
def toDict(self):
return {"type": "text", "data": {"text": self.text.strip()}}
def toDict(self) -> dict:
return {"type": "text", "data": {"text": self.text}}
async def to_dict(self):
async def to_dict(self) -> dict:
return {"type": "text", "data": {"text": self.text}}
@@ -6,6 +6,7 @@ from aiocqhttp import CQHttp, Event
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import (
At,
BaseMessageComponent,
File,
Image,
@@ -70,11 +71,19 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
"""解析成 OneBot json 格式"""
ret = []
for segment in message_chain.chain:
if isinstance(segment, Plain):
if isinstance(segment, At):
# At 组件后插入一个空格,避免与后续文本粘连
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
ret.append(d)
ret.append({"type": "text", "data": {"text": " "}})
elif isinstance(segment, Plain):
if not segment.text.strip():
continue
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
ret.append(d)
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
ret.append(d)
else:
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
ret.append(d)
return ret
@classmethod
@@ -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:
@@ -289,8 +289,8 @@ class TelegramPlatformAdapter(Platform):
else:
message.type = MessageType.GROUP_MESSAGE
message.group_id = str(update.message.chat.id)
if update.message.message_thread_id:
# Topic Group
if update.message.is_topic_message and update.message.message_thread_id:
# Telegram Topic Group: include thread id to isolate per-topic sessions.
message.group_id += "#" + str(update.message.message_thread_id)
message.session_id = message.group_id
message.message_id = str(update.message.message_id)
@@ -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):
@@ -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]
@@ -629,7 +629,8 @@ class ProviderOpenAIOfficial(Provider):
# 最后一次不等待
if retry_cnt < max_retries - 1:
await asyncio.sleep(1)
available_api_keys.remove(chosen_key)
if chosen_key in available_api_keys:
available_api_keys.remove(chosen_key)
if len(available_api_keys) > 0:
chosen_key = random.choice(available_api_keys)
return (
+2 -1
View File
@@ -82,7 +82,8 @@ class AuthRoute(Route):
def generate_jwt(self, username):
payload = {
"username": username,
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=7),
"exp": datetime.datetime.now(datetime.timezone.utc)
+ datetime.timedelta(days=7),
}
jwt_token = self.config["dashboard"].get("jwt_secret", None)
if not jwt_token:
+85 -22
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,
@@ -51,6 +65,7 @@ class ChatRoute(Route):
"/chat/get_session": ("GET", self.get_session),
"/chat/stop": ("POST", self.stop_session),
"/chat/delete_session": ("GET", self.delete_webchat_session),
"/chat/batch_delete_sessions": ("POST", self.batch_delete_sessions),
"/chat/update_session_display_name": (
"POST",
self.update_session_display_name,
@@ -342,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
@@ -578,19 +589,9 @@ class ChatRoute(Route):
return Response().ok(data={"stopped_count": stopped_count}).__dict__
async def delete_webchat_session(self):
"""Delete a Platform session and all its related data."""
session_id = request.args.get("session_id")
if not session_id:
return Response().error("Missing key: session_id").__dict__
username = g.get("username", "guest")
# 验证会话是否存在且属于当前用户
session = await self.db.get_platform_session_by_id(session_id)
if not session:
return Response().error(f"Session {session_id} not found").__dict__
if session.creator != username:
return Response().error("Permission denied").__dict__
async def _delete_session_internal(self, session, username: str) -> None:
"""Delete a single session and all its related data."""
session_id = session.session_id
# 删除该会话下的所有对话
message_type = "GroupMessage" if session.is_group else "FriendMessage"
@@ -632,8 +633,70 @@ class ChatRoute(Route):
# 删除会话
await self.db.delete_platform_session(session_id)
async def delete_webchat_session(self):
"""Delete a Platform session and all its related data."""
session_id = request.args.get("session_id")
if not session_id:
return Response().error("Missing key: session_id").__dict__
username = g.get("username", "guest")
session = await self.db.get_platform_session_by_id(session_id)
if not session:
return Response().error(f"Session {session_id} not found").__dict__
if session.creator != username:
return Response().error("Permission denied").__dict__
await self._delete_session_internal(session, username)
return Response().ok().__dict__
async def batch_delete_sessions(self):
"""Batch delete multiple Platform sessions."""
post_data = await request.json
if post_data is None:
return Response().error("Missing JSON body").__dict__
if not isinstance(post_data, dict):
return Response().error("Invalid JSON body: expected object").__dict__
session_ids = post_data.get("session_ids")
if not session_ids or not isinstance(session_ids, list):
return Response().error("Missing or invalid key: session_ids").__dict__
username = g.get("username", "guest")
sessions = await self.db.get_platform_sessions_by_ids(session_ids)
sessions_by_id = {session.session_id: session for session in sessions}
deleted_count = 0
failed_items = []
for sid in session_ids:
session = sessions_by_id.get(sid)
if not session:
failed_items.append({"session_id": sid, "reason": "not found"})
continue
if session.creator != username:
failed_items.append({"session_id": sid, "reason": "permission denied"})
continue
try:
await self._delete_session_internal(session, username)
deleted_count += 1
sessions_by_id.pop(sid, None)
except Exception:
logger.warning("Failed to delete session %s", sid)
failed_items.append({"session_id": sid, "reason": "internal_error"})
return (
Response()
.ok(
data={
"deleted_count": deleted_count,
"failed_count": len(failed_items),
"failed_items": failed_items,
}
)
.__dict__
)
def _extract_attachment_ids(self, history_list) -> list[str]:
"""从消息历史中提取所有 attachment_id"""
attachment_ids = []
+1
View File
@@ -37,6 +37,7 @@ services:
- DEFAULT_SHIP_MEMORY=512m
volumes:
- ${PWD}/data/shipyard/bay_data:/app/data
- ${PWD}/data/temp:/AstrBot/data/temp # Bind the local temp directory to the sandbox so that the uploaded file can be accessed in the sandbox
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- astrbot_network
-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:
+69 -3
View File
@@ -11,6 +11,7 @@
:currSessionId="currSessionId"
:selectedProjectId="selectedProjectId"
:transportMode="transportMode"
:sendShortcut="sendShortcut"
:isDark="isDark"
:chatboxMode="chatboxMode"
:isMobile="isMobile"
@@ -20,6 +21,7 @@
@selectConversation="handleSelectConversation"
@editTitle="showEditTitleDialog"
@deleteConversation="handleDeleteConversation"
@batchDeleteConversations="handleBatchDeleteConversations"
@closeMobileSidebar="closeMobileSidebar"
@toggleTheme="toggleTheme"
@toggleFullscreen="toggleFullscreen"
@@ -28,6 +30,7 @@
@editProject="showEditProjectDialog"
@deleteProject="handleDeleteProject"
@updateTransportMode="setTransportMode"
@updateSendShortcut="setSendShortcut"
/>
<!-- 右侧聊天内容区域 -->
@@ -71,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"
@@ -102,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"
@@ -132,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"
@@ -220,10 +226,13 @@ import { useMediaHandling } from '@/composables/useMediaHandling';
import { useProjects } from '@/composables/useProjects';
import type { Project } from '@/components/chat/ProjectList.vue';
import { useRecording } from '@/composables/useRecording';
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
@@ -233,6 +242,7 @@ const router = useRouter();
const route = useRoute();
const { t } = useI18n();
const { tm } = useModuleI18n('features/chat');
const { warning: toastWarning } = useToast();
const theme = useTheme();
const customizer = useCustomizerStore();
@@ -257,6 +267,7 @@ const {
getSessions,
newSession,
deleteSession: deleteSessionFn,
batchDeleteSessions,
showEditTitleDialog,
saveTitle,
updateSessionTitle,
@@ -330,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() {
@@ -488,6 +511,7 @@ async function handleSelectConversation(sessionIds: string[]) {
nextTick(() => {
messageList.value?.scrollToBottom();
});
focusChatInput();
}
function handleNewChat() {
@@ -497,6 +521,7 @@ function handleNewChat() {
// 退
selectedProjectId.value = null;
projectSessions.value = [];
focusChatInput();
}
async function handleDeleteConversation(sessionId: string) {
@@ -510,6 +535,33 @@ async function handleDeleteConversation(sessionId: string) {
}
}
async function handleBatchDeleteConversations(sessionIds: string[]) {
try {
const result = await batchDeleteSessions(sessionIds);
//
if (result.currentSessionDeleted) {
messages.value = [];
}
//
if (result.failed_count > 0) {
toastWarning(
tm('batch.partialFailure', { failed: result.failed_count, total: sessionIds.length })
);
}
//
if (selectedProjectId.value) {
const sessions = await getProjectSessions(selectedProjectId.value);
projectSessions.value = sessions;
}
} catch (err) {
console.error('Batch delete sessions failed:', err);
toastWarning(tm('batch.requestFailed'));
}
}
async function handleSelectProject(projectId: string) {
selectedProjectId.value = projectId;
const sessions = await getProjectSessions(projectId);
@@ -627,6 +679,11 @@ async function handleSendMessage() {
const selectedProviderId = selection?.providerId || '';
const selectedModelName = selection?.modelName || '';
//
nextTick(() => {
messageList.value?.scrollToBottom();
});
await sendMsg(
promptToSend,
filesToSend,
@@ -636,6 +693,11 @@ async function handleSendMessage() {
replyToSend
);
//
nextTick(() => {
messageList.value?.scrollToBottom();
});
//
if (isCreatingNewSession && currentProjectId && currSessionId.value) {
await addSessionToProject(currSessionId.value, currentProjectId);
@@ -694,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();
+44 -29
View File
@@ -15,7 +15,7 @@
<transition name="fade">
<div v-if="isDragging" class="drop-overlay">
<div class="drop-overlay-content">
<v-icon size="48" color="deep-purple">mdi-cloud-upload</v-icon>
<v-icon size="48" color="primary">mdi-cloud-upload</v-icon>
<span class="drop-text">{{ tm('input.dropToUpload') }}</span>
</div>
</div>
@@ -41,7 +41,7 @@
<!-- Settings Menu -->
<StyledMenu offset="8" location="top start" :close-on-content-click="false">
<template v-slot:activator="{ props: activatorProps }">
<v-btn v-bind="activatorProps" icon="mdi-plus" variant="text" color="deep-purple" />
<v-btn v-bind="activatorProps" icon="mdi-plus" variant="text" color="primary" />
</template>
<!-- Upload Files -->
@@ -87,7 +87,7 @@
{{ tm('voice.liveMode') }}
</v-tooltip>
</v-btn> -->
<v-btn @click="handleRecordClick" icon variant="text" :color="isRecording ? 'error' : 'deep-purple'"
<v-btn @click="handleRecordClick" icon variant="text" :color="isRecording ? 'error' : 'primary'"
class="record-btn">
<v-icon :icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'" variant="text"
plain></v-icon>
@@ -95,13 +95,13 @@
{{ isRecording ? tm('voice.speaking') : tm('voice.startRecording') }}
</v-tooltip>
</v-btn>
<v-btn icon v-if="isRunning" @click="$emit('stop')" variant="tonal" color="deep-purple" 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') }}
</v-tooltip>
</v-btn>
<v-btn v-else @click="$emit('send')" icon="mdi-send" variant="tonal" color="deep-purple"
<v-btn v-else @click="$emit('send')" icon="mdi-send" variant="tonal" color="primary"
:disabled="!canSend" class="send-btn" />
</div>
</div>
@@ -117,7 +117,7 @@
</div>
<div v-if="stagedAudioUrl" class="audio-preview">
<v-chip color="deep-purple-lighten-4" class="audio-chip">
<v-chip color="primary" variant="tonal" class="audio-chip">
<v-icon start icon="mdi-microphone" size="small"></v-icon>
{{ tm('voice.recording') }}
</v-chip>
@@ -126,7 +126,7 @@
</div>
<div v-for="(file, index) in stagedFiles" :key="'file-' + index" class="file-preview">
<v-chip color="blue-grey-lighten-4" class="file-chip">
<v-chip color="primary" variant="tonal" class="file-chip">
<v-icon start icon="mdi-file-document-outline" size="small"></v-icon>
<span class="file-name-preview">{{ file.original_name }}</span>
</v-chip>
@@ -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>
@@ -399,8 +414,8 @@ defineExpose({
left: 0;
right: 0;
bottom: 0;
background-color: rgba(103, 58, 183, 0.15);
border: 2px dashed rgba(103, 58, 183, 0.5);
background-color: rgba(var(--v-theme-primary), 0.12);
border: 2px dashed rgba(var(--v-theme-primary), 0.45);
border-radius: 24px;
display: flex;
align-items: center;
@@ -419,7 +434,7 @@ defineExpose({
.drop-text {
font-size: 16px;
font-weight: 500;
color: #673ab7;
color: rgb(var(--v-theme-primary));
}
/* Fade transition for drop overlay */
@@ -439,7 +454,7 @@ defineExpose({
justify-content: space-between;
padding: 8px 16px;
margin: 8px 8px 0 8px;
background-color: rgba(103, 58, 183, 0.06);
background-color: rgba(var(--v-theme-primary), 0.06);
border-radius: 12px;
gap: 8px;
max-height: 500px;
@@ -5,7 +5,7 @@
'mobile-sidebar-open': isMobile && mobileMenuOpen,
'mobile-sidebar': isMobile
}"
:style="{ 'background-color': isDark ? sidebarCollapsed ? '#1e1e1e' : '#2d2d2d' : sidebarCollapsed ? '#ffffff' : '#f1f4f9' }">
:style="{ backgroundColor: sidebarCollapsed && !isMobile ? 'rgb(var(--v-theme-surface))' : 'rgb(var(--v-theme-mcpCardBg))' }">
<div class="sidebar-collapse-btn-container" v-if="!isMobile">
<v-btn icon class="sidebar-collapse-btn" @click="toggleSidebar" variant="text" color="deep-purple">
@@ -21,12 +21,31 @@
</div>
<div style="padding: 8px; opacity: 0.6;">
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
v-if="!sidebarCollapsed || isMobile" prepend-icon="mdi-square-edit-outline">{{ tm('actions.newChat') }}</v-btn>
<v-btn icon="mdi-square-edit-outline" rounded="xl" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
<div class="new-chat-row" v-if="!sidebarCollapsed || isMobile">
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
prepend-icon="mdi-square-edit-outline">{{ tm('actions.newChat') }}</v-btn>
<v-btn v-if="sessions.length > 0" icon size="small" variant="text" @click="toggleBatchMode"
:color="batchMode ? 'primary' : undefined">
<v-icon>mdi-checkbox-multiple-marked-outline</v-icon>
</v-btn>
</div>
<v-btn icon="mdi-square-edit-outline" rounded="xl" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn>
</div>
<!-- Batch action bar -->
<div v-if="batchMode && (!sidebarCollapsed || isMobile)" class="batch-action-bar">
<v-btn size="x-small" variant="text" @click="toggleSelectAll">
{{ isAllSelected ? tm('batch.deselectAll') : tm('batch.selectAll') }}
</v-btn>
<span class="batch-selected-count">{{ tm('batch.selected', { count: batchSelected.length }) }}</span>
<v-spacer />
<v-btn size="x-small" variant="text" color="error" :disabled="batchSelected.length === 0"
@click="handleBatchDelete">
{{ tm('batch.delete') }}
</v-btn>
</div>
<!-- 项目列表组件 -->
<ProjectList
v-if="!sidebarCollapsed || isMobile"
@@ -41,19 +60,34 @@
v-if="!sidebarCollapsed || isMobile">
<v-card v-if="sessions.length > 0" flat style="background-color: transparent;">
<v-list density="compact" nav class="conversation-list"
style="background-color: transparent;" :selected="selectedSessions"
@update:selected="$emit('selectConversation', $event)">
style="background-color: transparent;" :selected="batchMode ? [] : selectedSessions"
@update:selected="handleListSelect">
<v-list-item v-for="item in sessions" :key="item.session_id" :value="item.session_id"
rounded="lg" class="conversation-item" active-color="secondary">
rounded="lg" class="conversation-item" active-color="secondary"
@click="batchMode ? toggleBatchItem(item.session_id) : undefined">
<template v-slot:prepend>
<div class="batch-checkbox-slot" :class="{ 'batch-checkbox-slot--active': batchMode }">
<v-checkbox-btn
:model-value="batchSelected.includes(item.session_id)"
@update:model-value="toggleBatchItem(item.session_id)"
@click.stop
density="compact"
hide-details
class="batch-checkbox"
/>
</div>
</template>
<v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title"
:style="{ color: isDark ? '#ffffff' : '#000000' }">
:style="{ color: 'rgb(var(--v-theme-primaryText))' }">
{{ item.display_name || tm('conversation.newConversation') }}
</v-list-item-title>
<!-- <v-list-item-subtitle v-if="!sidebarCollapsed || isMobile" class="timestamp">
{{ new Date(item.updated_at).toLocaleString() }}
</v-list-item-subtitle> -->
<template v-if="!sidebarCollapsed || isMobile" v-slot:append>
<template v-if="!batchMode && (!sidebarCollapsed || isMobile)" v-slot:append>
<div class="conversation-actions">
<v-btn icon="mdi-pencil" size="x-small" variant="text"
class="edit-title-btn"
@@ -98,16 +132,52 @@
</v-btn>
</template>
<!-- 语言切换 -->
<v-list-item class="styled-menu-item">
<template v-slot:prepend>
<v-icon>mdi-translate</v-icon>
<!-- 语言切换分组 -->
<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: languageMenuProps }">
<v-list-item
v-bind="languageMenuProps"
class="styled-menu-item chat-settings-group-trigger"
rounded="md"
>
<template v-slot:prepend>
<v-icon>mdi-translate</v-icon>
</template>
<v-list-item-title>{{ t('core.common.language') }}</v-list-item-title>
<template v-slot:append>
<span class="chat-settings-group-current">{{ currentLanguage?.flag }}</span>
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
</template>
</v-list-item>
</template>
<v-list-item-title>{{ t('core.common.language') }}</v-list-item-title>
<template v-slot:append>
<LanguageSwitcher variant="chatbox" />
</template>
</v-list-item>
<v-card class="styled-menu-card" style="min-width: 180px;" elevation="8" rounded="lg">
<v-list density="compact" class="styled-menu-list pa-1">
<v-list-item
v-for="lang in languages"
:key="lang.code"
:value="lang.code"
@click="changeLanguage(lang.code)"
:class="{ 'styled-menu-item-active': currentLocale === lang.code }"
class="styled-menu-item"
rounded="md"
>
<template v-slot:prepend>
<span class="language-flag">{{ lang.flag }}</span>
</template>
<v-list-item-title>{{ lang.name }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<!-- 主题切换 -->
<v-list-item class="styled-menu-item" @click="$emit('toggleTheme')">
@@ -117,26 +187,93 @@
<v-list-item-title>{{ isDark ? tm('modes.lightMode') : tm('modes.darkMode') }}</v-list-item-title>
</v-list-item>
<!-- 通信传输模式 -->
<v-list-item class="styled-menu-item">
<template v-slot:prepend>
<v-icon>mdi-lan-connect</v-icon>
<!-- 通信传输模式分组 -->
<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: transportMenuProps }">
<v-list-item
v-bind="transportMenuProps"
class="styled-menu-item chat-settings-group-trigger"
rounded="md"
>
<template v-slot:prepend>
<v-icon>mdi-lan-connect</v-icon>
</template>
<v-list-item-title>{{ tm('transport.title') }}</v-list-item-title>
<template v-slot:append>
<span class="chat-settings-group-current chat-settings-transport-current">{{ currentTransportLabel }}</span>
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
</template>
</v-list-item>
</template>
<v-list-item-title>{{ tm('transport.title') }}</v-list-item-title>
<template v-slot:append>
<v-select
:model-value="transportMode"
:items="transportOptions"
item-title="label"
item-value="value"
density="compact"
variant="underlined"
hide-details
class="transport-mode-select"
@update:model-value="handleTransportModeChange"
/>
<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 transportOptions"
:key="opt.value"
:value="opt.value"
@click="handleTransportModeChange(opt.value)"
:class="{ 'styled-menu-item-active': transportMode === 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-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-list-item>
<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')">
@@ -162,15 +299,16 @@
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ref, computed } from 'vue';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import type { Session } from '@/composables/useSessions';
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog';
import LanguageSwitcher from '@/components/shared/LanguageSwitcher.vue';
import StyledMenu from '@/components/shared/StyledMenu.vue';
import ProviderConfigDialog from '@/components/chat/ProviderConfigDialog.vue';
import ProjectList from '@/components/chat/ProjectList.vue';
import type { Project } from '@/components/chat/ProjectList.vue';
import { useLanguageSwitcher } from '@/i18n/composables';
import type { Locale } from '@/i18n/types';
interface Props {
sessions: Session[];
@@ -183,6 +321,7 @@ interface Props {
isMobile: boolean;
mobileMenuOpen: boolean;
projects?: Project[];
sendShortcut: 'enter' | 'shift_enter';
}
const props = withDefaults(defineProps<Props>(), {
@@ -194,6 +333,7 @@ const emit = defineEmits<{
selectConversation: [sessionIds: string[]];
editTitle: [sessionId: string, title: string];
deleteConversation: [sessionId: string];
batchDeleteConversations: [sessionIds: string[]];
closeMobileSidebar: [];
toggleTheme: [];
toggleFullscreen: [];
@@ -202,6 +342,7 @@ const emit = defineEmits<{
editProject: [project: Project];
deleteProject: [projectId: string];
updateTransportMode: [mode: 'sse' | 'websocket'];
updateSendShortcut: [mode: 'enter' | 'shift_enter'];
}>();
const { t } = useI18n();
@@ -211,10 +352,84 @@ const confirmDialog = useConfirmDialog();
const sidebarCollapsed = ref(true);
const showProviderConfigDialog = ref(false);
// Batch mode state
const batchMode = ref(false);
const batchSelected = ref<string[]>([]);
const isAllSelected = computed(() =>
props.sessions.length > 0 && batchSelected.value.length === props.sessions.length
);
function toggleBatchMode() {
batchMode.value = !batchMode.value;
batchSelected.value = [];
}
function toggleBatchItem(sessionId: string) {
const idx = batchSelected.value.indexOf(sessionId);
if (idx >= 0) {
batchSelected.value.splice(idx, 1);
} else {
batchSelected.value.push(sessionId);
}
}
function toggleSelectAll() {
if (isAllSelected.value) {
batchSelected.value = [];
} else {
batchSelected.value = props.sessions.map(s => s.session_id);
}
}
async function handleBatchDelete() {
const count = batchSelected.value.length;
if (count === 0) return;
const message = tm('batch.confirmDelete', { count });
if (await askForConfirmation(message, confirmDialog)) {
emit('batchDeleteConversations', [...batchSelected.value]);
batchSelected.value = [];
batchMode.value = false;
}
}
function handleListSelect(sessionIds: string[]) {
if (!batchMode.value) {
emit('selectConversation', sessionIds);
}
}
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();
const languages = computed(() =>
languageOptions.value.map(lang => ({
code: lang.value,
name: lang.label,
flag: lang.flag
}))
);
const currentLocale = computed(() => locale.value);
const changeLanguage = async (langCode: string) => {
await switchLanguage(langCode as Locale);
};
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');
@@ -242,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>
@@ -310,7 +531,7 @@ function handleTransportModeChange(mode: string | null) {
}
.conversation-item:hover {
background-color: rgba(103, 58, 183, 0.05);
background-color: rgba(var(--v-theme-primary), 0.05);
}
.conversation-item:hover .conversation-actions {
@@ -402,7 +623,74 @@ function handleTransportModeChange(mode: string | null) {
justify-content: center;
}
.transport-mode-select {
min-width: 120px;
.chat-settings-group-trigger :deep(.v-list-item__append) {
display: flex;
align-items: center;
gap: 6px;
}
.chat-settings-group-current {
font-size: 14px;
line-height: 1;
opacity: 0.8;
}
.chat-settings-transport-current {
font-size: 12px;
}
.chat-settings-group-arrow {
opacity: 0.7;
}
.language-flag {
font-size: 16px;
margin-right: 8px;
}
.new-chat-row {
display: flex;
align-items: center;
gap: 4px;
}
.new-chat-row .new-chat-btn {
flex: 1;
min-width: 0;
}
.batch-action-bar {
display: flex;
align-items: center;
padding: 4px 12px;
gap: 4px;
flex-shrink: 0;
}
.batch-selected-count {
font-size: 12px;
opacity: 0.7;
white-space: nowrap;
}
.batch-checkbox {
flex: none;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.batch-checkbox-slot {
width: 0;
opacity: 0;
overflow: hidden;
pointer-events: none;
transform: translateX(-8px);
transition: width 0.2s ease, opacity 0.2s ease, transform 0.2s ease;
}
.batch-checkbox-slot--active {
width: 28px;
opacity: 1;
pointer-events: auto;
transform: translateX(0);
}
</style>
@@ -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>
@@ -1,12 +1,12 @@
<template>
<v-chip v-if="domain" class="ref-chip" size="x-small" variant="flat"
:style="{ backgroundColor: isDark ? '#303030' : '#f4f4f4', color: isDark ? '#999' : '#666' }" :href="url"
:style="chipStyle" :href="url"
target="_blank" clickable>
<v-icon start size="x-small" color>mdi-link-variant</v-icon>
<span>{{ domain }}</span>
</v-chip>
<span v-else class="ref-fallback" :style="{ color: isDark ? '#999' : '#666' }">{{ 'site' }}</span>
<span v-else class="ref-fallback" :style="fallbackStyle">{{ 'site' }}</span>
</template>
<script setup>
@@ -46,6 +46,15 @@ const domain = computed(() => {
return ''
}
})
const chipStyle = computed(() => ({
backgroundColor: isDark ? 'rgba(var(--v-theme-on-surface), 0.08)' : 'rgba(var(--v-theme-on-surface), 0.04)',
color: isDark ? 'rgba(var(--v-theme-on-surface), 0.62)' : 'rgba(var(--v-theme-on-surface), 0.72)'
}))
const fallbackStyle = computed(() => ({
color: isDark ? 'rgba(var(--v-theme-on-surface), 0.62)' : 'rgba(var(--v-theme-on-surface), 0.72)'
}))
</script>
<style scoped>
@@ -12,7 +12,7 @@
>
<v-icon
size="18"
:color="props.variant === 'default' ? (useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa') : undefined"
:color="props.variant === 'default' ? 'rgb(var(--v-theme-primary))' : undefined"
>
mdi-translate
</v-icon>
@@ -42,7 +42,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n, useLanguageSwitcher } from '@/i18n/composables'
import { useCustomizerStore } from '@/stores/customizer'
import type { Locale } from '@/i18n/types'
import StyledMenu from '@/components/shared/StyledMenu.vue'
@@ -90,7 +89,7 @@ const changeLanguage = async (langCode: string) => {
.language-switcher--default:hover {
transform: scale(1.05);
background: rgba(94, 53, 177, 0.08) !important;
background: rgba(var(--v-theme-primary), 0.08) !important;
}
/* Header变体样式 - 完全继承Vuetify和action-btn的默认样式 */
@@ -103,8 +102,4 @@ const changeLanguage = async (langCode: string) => {
/* 继承action-btn样式,与工具栏主题按钮保持一致 */
}
/* 深色模式下的悬停效果(仅对default变体) */
:deep(.v-theme--PurpleThemeDark) .language-switcher--default:hover {
background: rgba(114, 46, 209, 0.12) !important;
}
</style>
+2 -3
View File
@@ -6,11 +6,11 @@
</div>
<div class="logo-text">
<h2
:style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa'}"
:style="{ color: 'rgb(var(--v-theme-primary))' }"
v-html="formatTitle(title || t('core.header.logoTitle'))"
></h2>
<!-- 父子组件传递css变量可能会出错暂时使用十六进制颜色值 -->
<h4 :style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#000000aa' : '#ffffffcc'}"
<h4 :style="{ color: 'rgba(var(--v-theme-on-surface), 0.72)' }"
class="hint-text">{{ subtitle || t('core.header.accountDialog.title') }}</h4>
</div>
</div>
@@ -18,7 +18,6 @@
</template>
<script setup lang="ts">
import { useCustomizerStore } from "@/stores/customizer";
import { useI18n } from '@/i18n/composables';
const { t } = useI18n();
+15 -17
View File
@@ -24,12 +24,12 @@ withDefaults(defineProps<{
})
</script>
<style scoped>
<style>
.styled-menu-card {
min-width: 100px;
width: fit-content;
border: 1px solid rgba(94, 53, 177, 0.15) !important;
background: #f8f6fc !important;
border: 1px solid rgba(var(--v-theme-primary), 0.15) !important;
background: rgba(var(--v-theme-surface), 0.98) !important;
backdrop-filter: blur(10px);
}
@@ -37,43 +37,41 @@ withDefaults(defineProps<{
background: transparent !important;
}
:deep(.styled-menu-item) {
.styled-menu-item {
margin: 2px 0;
transition: all 0.2s ease;
border-radius: 6px;
}
:deep(.styled-menu-item:hover) {
background: rgba(94, 53, 177, 0.08) !important;
.styled-menu-item:hover {
background: rgba(var(--v-theme-primary), 0.08) !important;
}
:deep(.styled-menu-item-active) {
background: rgba(94, 53, 177, 0.15) !important;
.styled-menu-item-active {
background: rgba(var(--v-theme-primary), 0.15) !important;
font-weight: 500;
}
:deep(.styled-menu-item-active:hover) {
background: rgba(94, 53, 177, 0.2) !important;
.styled-menu-item-active:hover {
background: rgba(var(--v-theme-primary), 0.2) !important;
}
</style>
<style>
/* 深色模式下的下拉框样式 - 需要全局样式才能检测主题 */
.v-theme--PurpleThemeDark .styled-menu-card {
background: #2a2733 !important;
border: 1px solid rgba(110, 60, 180, 0.692) !important;
background: rgba(var(--v-theme-surface), 0.98) !important;
border: 1px solid rgba(var(--v-theme-primary), 0.2) !important;
}
/* 深色模式下的列表项悬停效果 */
.v-theme--PurpleThemeDark .styled-menu-item:hover {
background: rgba(114, 46, 209, 0.12) !important;
background: rgba(var(--v-theme-primary), 0.12) !important;
}
.v-theme--PurpleThemeDark .styled-menu-item-active {
background: rgba(114, 46, 209, 0.2) !important;
background: rgba(var(--v-theme-primary), 0.2) !important;
}
.v-theme--PurpleThemeDark .styled-menu-item-active:hover {
background: rgba(114, 46, 209, 0.25) !important;
background: rgba(var(--v-theme-primary), 0.25) !important;
}
</style>
@@ -590,9 +590,11 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
async function testProvider(provider: any) {
testingProviders.value.push(provider.id)
try {
const startTime = performance.now()
const response = await axios.get('/api/config/provider/check_one', { params: { id: provider.id } })
if (response.data.status === 'ok' && response.data.data.error === null) {
showMessage(tm('models.testSuccess', { id: provider.id }))
const latency = Math.max(0, Math.round(performance.now() - startTime))
showMessage(tm('models.testSuccessWithLatency', { id: provider.id, latency }))
} else {
throw new Error(response.data.data.error || tm('models.testError'))
}
+68
View File
@@ -109,6 +109,73 @@ export function useSessions(chatboxMode: boolean = false) {
}
}
interface BatchDeleteFailedItem {
session_id: string;
reason: string;
}
interface BatchDeleteResult {
deleted_count: number;
failed_count: number;
failed_items: BatchDeleteFailedItem[];
currentSessionDeleted: boolean;
}
function isBatchDeleteResponseData(data: unknown): data is {
deleted_count: number;
failed_count: number;
failed_items: BatchDeleteFailedItem[];
} {
if (!data || typeof data !== 'object') {
return false;
}
const payload = data as Record<string, unknown>;
return (
typeof payload.deleted_count === 'number' &&
typeof payload.failed_count === 'number' &&
Array.isArray(payload.failed_items)
);
}
async function batchDeleteSessions(sessionIds: string[]): Promise<BatchDeleteResult> {
try {
const currentSessionId = currSessionId.value;
const response = await axios.post('/api/chat/batch_delete_sessions', { session_ids: sessionIds });
if (response.data?.status !== 'ok') {
throw new Error(response.data?.message || 'Failed to batch delete sessions');
}
const data = response.data?.data;
if (!isBatchDeleteResponseData(data)) {
throw new Error('Invalid batch delete response payload');
}
const failedItems = data.failed_items;
const failedSessionIds = new Set(failedItems.map(item => item.session_id));
const currentSessionDeleted = Boolean(
currentSessionId &&
sessionIds.includes(currentSessionId) &&
!failedSessionIds.has(currentSessionId)
);
if (currentSessionDeleted) {
currSessionId.value = '';
selectedSessions.value = [];
}
await getSessions();
return {
deleted_count: data.deleted_count,
failed_count: data.failed_count,
failed_items: failedItems,
currentSessionDeleted,
};
} catch (err) {
console.error(err);
throw err;
}
}
function showEditTitleDialog(sessionId: string, title: string) {
editingSessionId.value = sessionId;
editingTitle.value = title || '';
@@ -167,6 +234,7 @@ export function useSessions(chatboxMode: boolean = false) {
getSessions,
newSession,
deleteSession,
batchDeleteSessions,
showEditTitleDialog,
saveTitle,
updateSessionTitle,
@@ -96,6 +96,7 @@
"save": "Save",
"livePreview": "Live Preview (may differ)",
"refreshPreview": "Refresh Preview",
"previewText": "This is a sample text used to preview the template output.\n\nIt can contain multiple lines and various formatting.",
"syntaxHint": "Supports jinja2 syntax. Available variables: text | safe (text to render), version (AstrBot version)",
"saveAndApply": "Save and Apply Current Template",
"confirmReset": "Confirm Reset",
@@ -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",
@@ -141,5 +147,15 @@
"errors": {
"sendMessageFailed": "Failed to send message, please try again",
"createSessionFailed": "Failed to create session, please refresh the page"
},
"batch": {
"selected": "{count} selected",
"confirmDelete": "Are you sure you want to delete {count} conversation(s)? This action cannot be undone.",
"selectAll": "Select All",
"deselectAll": "Deselect All",
"delete": "Delete",
"exit": "Exit",
"partialFailure": "{failed} of {total} conversations failed to delete",
"requestFailed": "Failed to delete conversations. Please try again."
}
}
@@ -132,6 +132,7 @@
"deleteSuccess": "Model deleted successfully",
"deleteError": "Failed to delete model",
"testSuccess": "Model {id} test passed",
"testSuccessWithLatency": "Model {id} test passed, latency {latency} ms",
"testError": "Model test failed",
"searchPlaceholder": "Search models or ID",
"manualAddButton": "Custom Model",
@@ -97,6 +97,7 @@
"save": "Сохранить",
"livePreview": "Предпросмотр (может отличаться)",
"refreshPreview": "Обновить",
"previewText": "Это пример текста для предпросмотра результата шаблона.\n\nОн может содержать несколько строк и различные форматы.",
"syntaxHint": "Поддерживается синтаксис jinja2. Переменные: text | safe (текст для рендеринга), version (версия AstrBot)",
"saveAndApply": "Сохранить и применить текущий шаблон",
"confirmReset": "Подтверждение сброса",
@@ -75,7 +75,12 @@
"shortcuts": {
"help": "Справка",
"voiceRecord": "Запись голоса",
"pasteImage": "Вставить изображение"
"pasteImage": "Вставить изображение",
"sendKey": {
"title": "Клавиша отправки",
"enterToSend": "Enter для отправки",
"shiftEnterToSend": "Shift+Enter для отправки"
}
},
"streaming": {
"enabled": "Потоковый ответ включен",
@@ -143,4 +148,4 @@
"sendMessageFailed": "Ошибка отправки сообщения, попробуйте еще раз",
"createSessionFailed": "Ошибка создания сессии, обновите страницу"
}
}
}
@@ -96,6 +96,7 @@
"save": "保存",
"livePreview": "实时预览(可能有差异)",
"refreshPreview": "刷新预览",
"previewText": "这是一个示例文本,用于预览模板效果。\n\n这里可以包含多行文本,支持换行和各种格式。",
"syntaxHint": "支持 jinja2 语法。可用变量:text | safe(要渲染的文本), versionAstrBot 版本)",
"saveAndApply": "保存应用当前编辑模板",
"confirmReset": "确认重置",
@@ -71,10 +71,16 @@
"modes": {
"darkMode": "切换到夜间模式",
"lightMode": "切换到日间模式"
}, "shortcuts": {
},
"shortcuts": {
"help": "获取帮助",
"voiceRecord": "录制语音",
"pasteImage": "粘贴图片"
"pasteImage": "粘贴图片",
"sendKey": {
"title": "发送快捷键",
"enterToSend": "Enter 发送",
"shiftEnterToSend": "Shift+Enter 发送"
}
},
"streaming": {
"enabled": "流式响应已开启",
@@ -141,5 +147,15 @@
"errors": {
"sendMessageFailed": "发送消息失败,请重试",
"createSessionFailed": "创建会话失败,请刷新页面重试"
},
"batch": {
"selected": "已选择 {count} 个",
"confirmDelete": "确定要删除 {count} 个对话吗?此操作无法撤销。",
"selectAll": "全选",
"deselectAll": "取消全选",
"delete": "删除",
"exit": "退出",
"partialFailure": "{total} 个对话中有 {failed} 个删除失败",
"requestFailed": "删除对话失败,请重试。"
}
}
@@ -133,6 +133,7 @@
"deleteSuccess": "模型删除成功",
"deleteError": "模型删除失败",
"testSuccess": "模型 {id} 测试通过",
"testSuccessWithLatency": "模型 {id} 测试通过,延迟 {latency} ms",
"testError": "模型测试失败",
"searchPlaceholder": "搜索模型或 ID",
"manualAddButton": "自定义模型",
@@ -465,23 +465,14 @@ onMounted(async () => {
<v-app-bar elevation="0" height="50" class="top-header">
<!-- 桌面端 menu 按钮 - 仅在 bot 模式下显示 -->
<v-btn v-if="customizer.viewMode === 'bot' && useCustomizerStore().uiTheme === 'PurpleTheme'" style="margin-left: 16px;"
class="hidden-md-and-down" icon rounded="sm" variant="flat"
@click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)">
<v-icon>mdi-menu</v-icon>
</v-btn>
<v-btn v-else-if="customizer.viewMode === 'bot'"
style="margin-left: 22px;"
<v-btn v-if="customizer.viewMode === 'bot'"
style="margin-left: 16px;"
class="hidden-md-and-down" icon rounded="sm" variant="flat"
@click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)">
<v-icon>mdi-menu</v-icon>
</v-btn>
<!-- 移动端 menu 按钮 - 仅在 bot 模式下显示 -->
<v-btn v-if="customizer.viewMode === 'bot' && useCustomizerStore().uiTheme === 'PurpleTheme'" class="hidden-lg-and-up ms-3"
icon rounded="sm" variant="flat" @click.stop="customizer.SET_SIDEBAR_DRAWER">
<v-icon>mdi-menu</v-icon>
</v-btn>
<v-btn v-else-if="customizer.viewMode === 'bot'" class="hidden-lg-and-up ms-3" icon rounded="sm" variant="flat"
<v-btn v-if="customizer.viewMode === 'bot'" class="hidden-lg-and-up ms-3" icon rounded="sm" variant="flat"
@click.stop="customizer.SET_SIDEBAR_DRAWER">
<v-icon>mdi-menu</v-icon>
</v-btn>
@@ -572,21 +563,51 @@ onMounted(async () => {
<v-divider class="my-1" />
</template>
<!-- 语言切换 -->
<v-list-item
v-for="lang in languages"
:key="lang.code"
:value="lang.code"
@click="changeLanguage(lang.code)"
:class="{ 'styled-menu-item-active': currentLocale === lang.code }"
class="styled-menu-item"
rounded="md"
<!-- 语言切换分组 -->
<v-menu
:open-on-hover="!$vuetify.display.xs"
:open-on-click="$vuetify.display.xs"
:open-delay="!$vuetify.display.xs ? 60 : 0"
:close-delay="!$vuetify.display.xs ? 120 : 0"
:location="$vuetify.display.xs ? 'bottom' : 'start center'"
offset="8"
>
<template v-slot:prepend>
<span class="language-flag">{{ lang.flag }}</span>
<template v-slot:activator="{ props: languageMenuProps }">
<v-list-item
v-bind="languageMenuProps"
class="styled-menu-item language-group-trigger"
rounded="md"
>
<template v-slot:prepend>
<v-icon>mdi-translate</v-icon>
</template>
<v-list-item-title>{{ t('core.common.language') }}</v-list-item-title>
<template v-slot:append>
<span class="language-group-current">{{ currentLanguage?.flag }}</span>
<v-icon size="18" class="language-group-arrow">mdi-chevron-right</v-icon>
</template>
</v-list-item>
</template>
<v-list-item-title>{{ lang.name }}</v-list-item-title>
</v-list-item>
<v-card class="styled-menu-card" style="min-width: 180px;" elevation="8" rounded="lg">
<v-list density="compact" class="styled-menu-list pa-1">
<v-list-item
v-for="lang in languages"
:key="lang.code"
:value="lang.code"
@click="changeLanguage(lang.code)"
:class="{ 'styled-menu-item-active': currentLocale === lang.code }"
class="styled-menu-item"
rounded="md"
>
<template v-slot:prepend>
<span class="language-flag">{{ lang.flag }}</span>
</template>
<v-list-item-title>{{ lang.name }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<!-- 主题切换 -->
<v-list-item
@@ -978,6 +999,25 @@ onMounted(async () => {
margin-right: 8px;
}
.language-group-trigger :deep(.v-list-item__append) {
display: flex;
align-items: center;
gap: 6px;
}
.language-group-current {
font-size: 16px;
line-height: 1;
}
.language-group-arrow {
opacity: 0.7;
}
.language-submenu-card {
min-width: 180px;
}
.mobile-mode-toggle-wrapper {
display: flex;
justify-content: center;
@@ -288,7 +288,7 @@ function openChangelogDialog() {
:rail="customizer.mini_sidebar"
>
<div class="sidebar-container">
<v-list class="pa-4 listitem flex-grow-1" v-model:opened="openedItems" :open-strategy="'multiple'">
<v-list :class="['pa-4', 'listitem', 'flex-grow-1', { 'hidden-scrollbar': customizer.mini_sidebar }]" v-model:opened="openedItems" :open-strategy="'multiple'">
<template v-for="(item, i) in sidebarMenu" :key="item.title || item.to || `sidebar-item-${i}`">
<NavItem :item="item" class="leftPadding" />
</template>
+6 -3
View File
@@ -11,19 +11,21 @@ import VueApexCharts from 'vue3-apexcharts';
import print from 'vue3-print-nb';
import { loader } from '@guolao/vue-monaco-editor'
import axios from 'axios';
import { waitForRouterReadyInBackground } from './utils/routerReadiness.mjs';
// 初始化新的i18n系统,等待完成后再挂载应用
setupI18n().then(() => {
setupI18n().then(async () => {
console.log('🌍 新i18n系统初始化完成');
const app = createApp(App);
app.use(router);
const pinia = createPinia();
app.use(pinia);
app.use(router);
app.use(print);
app.use(VueApexCharts);
app.use(vuetify);
app.use(confirmPlugin);
await router.isReady();
app.mount('#app');
// 挂载后同步 Vuetify 主题
@@ -49,14 +51,15 @@ setupI18n().then(() => {
// 即使i18n初始化失败,也要挂载应用(使用回退机制)
const app = createApp(App);
app.use(router);
const pinia = createPinia();
app.use(pinia);
app.use(router);
app.use(print);
app.use(VueApexCharts);
app.use(vuetify);
app.use(confirmPlugin);
app.mount('#app');
waitForRouterReadyInBackground(router);
// 挂载后同步 Vuetify 主题
import('./stores/customizer').then(({ useCustomizerStore }) => {
+3 -1
View File
@@ -1,3 +1,5 @@
import { EXTENSION_ROUTE_NAME } from './routeConstants.mjs';
const MainRoutes = {
path: '/main',
meta: {
@@ -17,7 +19,7 @@ const MainRoutes = {
component: () => import('@/views/WelcomePage.vue')
},
{
name: 'Extensions',
name: EXTENSION_ROUTE_NAME,
path: '/extension',
component: () => import('@/views/ExtensionPage.vue')
},
+1
View File
@@ -0,0 +1 @@
export const EXTENSION_ROUTE_NAME = 'Extensions';
+22 -59
View File
@@ -1,4 +1,13 @@
/* 自定义滚动条样式 - 紫色主题 */
/* 自定义滚动条样式 - 跟随主题 */
:root {
--astrbot-scrollbar-track: rgba(var(--v-theme-primary), 0.08);
--astrbot-scrollbar-thumb: rgba(var(--v-theme-primary), 0.72);
--astrbot-scrollbar-thumb-hover: rgba(var(--v-theme-primary), 0.84);
--astrbot-scrollbar-thumb-active: rgba(var(--v-theme-primary), 0.94);
--astrbot-scrollbar-thumb-border: rgba(var(--v-theme-surface), 0.5);
--astrbot-scrollbar-thumb-shadow: rgba(var(--v-theme-primary), 0.32);
}
/* 全局滚动条样式 */
::-webkit-scrollbar {
@@ -7,52 +16,31 @@
}
::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.05);
background: var(--astrbot-scrollbar-track);
border-radius: 5px;
}
::-webkit-scrollbar-thumb {
background: rgba(160, 60, 254, 0.75);
background: var(--astrbot-scrollbar-thumb);
border-radius: 5px;
transition: all 0.3s ease;
border: 1px solid rgba(255, 255, 255, 0.2);
border: 1px solid var(--astrbot-scrollbar-thumb-border);
}
::-webkit-scrollbar-thumb:hover {
background: rgba(147, 51, 234, 0.85);
background: var(--astrbot-scrollbar-thumb-hover);
transform: scale(1.05);
box-shadow: 0 2px 8px rgba(147, 51, 234, 0.3);
box-shadow: 0 2px 8px var(--astrbot-scrollbar-thumb-shadow);
}
::-webkit-scrollbar-thumb:active {
background: rgba(147, 51, 234, 0.95);
background: var(--astrbot-scrollbar-thumb-active);
}
::-webkit-scrollbar-corner {
background: transparent;
}
/* 深色主题滚动条样式 */
.v-theme--PurpleThemeDark {
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
}
::-webkit-scrollbar-thumb {
background: rgba(192, 132, 252, 0.75);
border: 1px solid rgba(0, 0, 0, 0.2);
}
::-webkit-scrollbar-thumb:hover {
background: rgba(192, 132, 252, 0.85);
box-shadow: 0 2px 8px rgba(192, 132, 252, 0.4);
}
::-webkit-scrollbar-thumb:active {
background: rgba(192, 132, 252, 0.95);
}
}
/* 细滚动条变体 */
.thin-scrollbar {
::-webkit-scrollbar {
@@ -61,17 +49,11 @@
}
::-webkit-scrollbar-thumb {
background: rgba(147, 51, 234, 0.75);
background: var(--astrbot-scrollbar-thumb);
border: none;
}
}
.v-theme--PurpleThemeDark .thin-scrollbar {
::-webkit-scrollbar-thumb {
background: rgba(192, 132, 252, 0.75);
}
}
/* 聊天区域滚动条 */
.chat-scrollbar {
::-webkit-scrollbar {
@@ -79,33 +61,18 @@
}
::-webkit-scrollbar-track {
background: rgba(147, 51, 234, 0.08);
background: var(--astrbot-scrollbar-track);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: rgba(147, 51, 234, 0.75);
background: var(--astrbot-scrollbar-thumb);
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.1);
border: 1px solid var(--astrbot-scrollbar-thumb-border);
}
::-webkit-scrollbar-thumb:hover {
background: rgba(147, 51, 234, 0.85);
}
}
.v-theme--PurpleThemeDark .chat-scrollbar {
::-webkit-scrollbar-track {
background: rgba(192, 132, 252, 0.08);
}
::-webkit-scrollbar-thumb {
background: rgba(192, 132, 252, 0.75);
border: 1px solid rgba(0, 0, 0, 0.1);
}
::-webkit-scrollbar-thumb:hover {
background: rgba(192, 132, 252, 0.85);
background: var(--astrbot-scrollbar-thumb-hover);
}
}
@@ -123,11 +90,7 @@
/* Firefox 兼容性 */
* {
scrollbar-width: thin;
scrollbar-color: rgba(147, 51, 234, 0.75) rgba(0, 0, 0, 0.05);
}
.v-theme--PurpleThemeDark * {
scrollbar-color: rgba(192, 132, 252, 0.75) rgba(255, 255, 255, 0.05);
scrollbar-color: var(--astrbot-scrollbar-thumb) var(--astrbot-scrollbar-track);
}
/* 平滑滚动 */
+6 -9
View File
@@ -28,27 +28,27 @@
.v-list-group__items .v-list-item,
.v-list-item {
&:hover {
color: #b794f6 !important;
color: rgb(var(--v-theme-primary)) !important;
.v-list-item-title {
color: #b794f6 !important;
color: rgb(var(--v-theme-primary)) !important;
}
.v-icon {
color: #b794f6 !important;
color: rgb(var(--v-theme-primary)) !important;
}
}
// 选中状态的样式
&.v-list-item--active {
color: #b794f6 !important;
color: rgb(var(--v-theme-primary)) !important;
.v-list-item-title {
color: #b794f6 !important;
color: rgb(var(--v-theme-primary)) !important;
}
.v-icon {
color: #b794f6 !important;
color: rgb(var(--v-theme-primary)) !important;
}
}
}
@@ -56,9 +56,6 @@
.v-list-item--density-default.v-list-item--one-line {
min-height: 40px;
}
.leftPadding {
margin-left: 4px;
}
}
.v-navigation-drawer--rail {
.scrollnavbar .v-list .v-list-group__items,
+9 -9
View File
@@ -4,26 +4,26 @@ const PurpleThemeDark: ThemeTypes = {
name: 'PurpleThemeDark',
dark: true,
variables: {
'border-color': '#1677ff',
'border-color': '#3c96ca',
'carousel-control-size': 10
},
colors: {
primary: '#1677ff',
secondary: '#722ed1',
primary: '#3c96ca',
secondary: '#4ea4d8',
info: '#03c9d7',
success: '#52c41a',
accent: '#FFAB91',
warning: '#faad14',
error: '#ff4d4f',
lightprimary: '#eef2f6',
lightsecondary: '#ede7f6',
lightprimary: '#e8f3fa',
lightsecondary: '#e8f3fa',
lightsuccess: '#b9f6ca',
lighterror: '#f9d8d8',
lightwarning: '#fff8e1',
primaryText: '#ffffff',
secondaryText: '#ffffffcc',
darkprimary: '#1565c0',
darksecondary: '#4527a0',
darkprimary: '#2f86bd',
darksecondary: '#2f86bd',
borderLight: '#d0d0d0',
border: '#333333ee',
inputBorder: '#787878',
@@ -34,8 +34,8 @@ const PurpleThemeDark: ThemeTypes = {
twitter: '#1da1f2',
linkedin: '#0e76a8',
gray100: '#cccccccc',
primary200: '#90caf9',
secondary200: '#b39ddb',
primary200: '#84c9ea',
secondary200: '#8cc4e1',
background: '#1d1d1d',
overlay: '#111111aa',
codeBg: '#282833',
+4 -4
View File
@@ -9,21 +9,21 @@ const PurpleTheme: ThemeTypes = {
},
colors: {
primary: '#3c96ca',
secondary: '#2288b7',
secondary: '#2f86bd',
info: '#03c9d7',
success: '#00c853',
accent: '#FFAB91',
warning: '#ffc107',
error: '#f44336',
lightprimary: '#eef2f6',
lightsecondary: '#ede7f6',
lightsecondary: '#e8f3fa',
lightsuccess: '#b9f6ca',
lighterror: '#f9d8d8',
lightwarning: '#fff8e1',
primaryText: '#1b1c1d',
secondaryText: '#000000aa',
darkprimary: '#1565c0',
darksecondary: '#4527a0',
darksecondary: '#236b99',
borderLight: '#d0d0d0',
border: '#d0d0d0',
inputBorder: '#787878',
@@ -35,7 +35,7 @@ const PurpleTheme: ThemeTypes = {
linkedin: '#0e76a8',
gray100: '#fafafacc',
primary200: '#90caf9',
secondary200: '#b39ddb',
secondary200: '#8cc4e1',
background: '#ffffff',
overlay: '#ffffffaa',
codeBg: '#ececec',
+46
View File
@@ -0,0 +1,46 @@
import { EXTENSION_ROUTE_NAME } from '../router/routeConstants.mjs';
export function getValidHashTab(routeHash, validTabs) {
const hash = String(routeHash || '');
const tab = hash.includes('#') ? hash.slice(hash.lastIndexOf('#') + 1) : hash;
return validTabs.includes(tab) ? tab : null;
}
export function createTabRouteLocation(route, tab, fallbackRouteName = EXTENSION_ROUTE_NAME) {
const query = route?.query ? { ...route.query } : {};
const params = route?.params ? { ...route.params } : undefined;
if (route?.name) {
return {
name: route.name,
...(params ? { params } : {}),
query,
hash: `#${tab}`,
};
}
if (route?.path) {
return {
path: route.path,
query,
hash: `#${tab}`,
};
}
return {
name: fallbackRouteName,
...(params ? { params } : {}),
query,
hash: `#${tab}`,
};
}
export async function replaceTabRoute(router, route, tab, logger = console) {
try {
await router.replace(createTabRouteLocation(route, tab));
return true;
} catch (error) {
logger.warn?.('Failed to update extension tab route:', error);
return false;
}
}
+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"
};
+5
View File
@@ -0,0 +1,5 @@
export function waitForRouterReadyInBackground(router, logger = console) {
router.isReady().catch((error) => {
logger.warn?.('Router did not become ready after fallback mount:', error);
});
}
+3
View File
@@ -602,12 +602,15 @@ async function testSingleProvider(provider) {
return
}
const startTime = performance.now()
const res = await axios.get(`/api/config/provider/check_one?id=${provider.id}`)
if (res.data && res.data.status === 'ok') {
const index = providerStatuses.value.findIndex(s => s.id === provider.id)
if (index !== -1) {
providerStatuses.value.splice(index, 1, res.data.data)
}
const latency = Math.max(0, Math.round(performance.now() - startTime))
showMessage(tm('models.testSuccessWithLatency', { id: provider.id, latency }))
} else {
throw new Error(res.data?.message || `Failed to check status for ${provider.id}`)
}
@@ -45,9 +45,9 @@ onMounted(() => {
<div class="d-flex align-center gap-1">
<LanguageSwitcher />
<v-divider vertical class="mx-1"
style="height: 24px !important; opacity: 0.9 !important; align-self: center !important; border-color: rgba(180, 148, 246, 0.8) !important;"></v-divider>
style="height: 24px !important; opacity: 0.9 !important; align-self: center !important; border-color: rgba(var(--v-theme-primary), 0.45) !important;"></v-divider>
<v-btn @click="toggleTheme" class="theme-toggle-btn" icon variant="text" size="small">
<v-icon size="18" :color="useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa'">
<v-icon size="18" :color="'rgb(var(--v-theme-primary))'">
mdi-white-balance-sunny
</v-icon>
<v-tooltip activator="parent" location="top">
@@ -10,6 +10,10 @@ import {
toInitials,
toPinyinText,
} from "@/utils/pluginSearch";
import {
getValidHashTab,
replaceTabRoute,
} from "@/utils/hashRouteTabs.mjs";
import { ref, computed, onMounted, onUnmounted, reactive, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useDisplay } from "vuetify";
@@ -103,16 +107,11 @@ export const useExtensionPage = () => {
const activeTab = ref("installed");
const validTabs = ["installed", "market", "mcp", "skills", "components"];
const isValidTab = (tab) => validTabs.includes(tab);
const getLocationHash = () =>
typeof window !== "undefined" ? window.location.hash : "";
const extractTabFromHash = (hash) => {
const lastHashIndex = (hash || "").lastIndexOf("#");
if (lastHashIndex === -1) return "";
return hash.slice(lastHashIndex + 1);
};
const getLocationHash = () => route.hash || "";
const extractTabFromHash = (hash) => getValidHashTab(hash, validTabs);
const syncTabFromHash = (hash) => {
const tab = extractTabFromHash(hash);
if (isValidTab(tab)) {
if (tab) {
activeTab.value = tab;
return true;
}
@@ -1436,9 +1435,7 @@ export const useExtensionPage = () => {
// 生命周期
onMounted(async () => {
if (!syncTabFromHash(getLocationHash())) {
if (typeof window !== "undefined") {
window.location.hash = `#${activeTab.value}`;
}
await replaceTabRoute(router, route, activeTab.value);
}
await getExtensions();
@@ -1446,17 +1443,9 @@ export const useExtensionPage = () => {
loadCustomSources();
// 检查是否有 open_config 参数
let urlParams;
if (window.location.hash) {
// For hash mode (#/path?param=value)
const hashQuery = window.location.hash.split("?")[1] || "";
urlParams = new URLSearchParams(hashQuery);
} else {
// For history mode (/path?param=value)
urlParams = new URLSearchParams(window.location.search);
}
console.log("URL Parameters:", urlParams.toString());
const plugin_name = urlParams.get("open_config");
const plugin_name = Array.isArray(route.query.open_config)
? route.query.open_config[0]
: route.query.open_config;
if (plugin_name) {
console.log(`Opening config for plugin: ${plugin_name}`);
openExtensionConfig(plugin_name);
@@ -1528,10 +1517,10 @@ export const useExtensionPage = () => {
);
watch(
() => route.fullPath,
() => {
const tab = extractTabFromHash(getLocationHash());
if (isValidTab(tab) && tab !== activeTab.value) {
() => route.hash,
(newHash) => {
const tab = extractTabFromHash(newHash);
if (tab && tab !== activeTab.value) {
activeTab.value = tab;
}
},
@@ -1539,15 +1528,8 @@ export const useExtensionPage = () => {
watch(activeTab, (newTab) => {
if (!isValidTab(newTab)) return;
const currentTab = extractTabFromHash(getLocationHash());
if (currentTab === newTab) return;
const hash = getLocationHash();
const lastHashIndex = hash.lastIndexOf("#");
const nextHash =
lastHashIndex > 0 ? `${hash.slice(0, lastHashIndex)}#${newTab}` : `#${newTab}`;
if (typeof window !== "undefined") {
window.location.hash = nextHash;
}
if (route.hash === `#${newTab}`) return;
void replaceTabRoute(router, route, newTab);
});
return {
+123
View File
@@ -0,0 +1,123 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as hashRouteTabs from '../src/utils/hashRouteTabs.mjs';
import { EXTENSION_ROUTE_NAME } from '../src/router/routeConstants.mjs';
const { createTabRouteLocation, getValidHashTab } = hashRouteTabs;
test('getValidHashTab returns the tab name for a valid route hash', () => {
const validTabs = ['installed', 'market', 'mcp'];
assert.equal(getValidHashTab('#market', validTabs), 'market');
});
test('getValidHashTab rejects empty and unknown hashes', () => {
const validTabs = ['installed', 'market', 'mcp'];
assert.equal(getValidHashTab('', validTabs), null);
assert.equal(getValidHashTab('#unknown', validTabs), null);
});
test('getValidHashTab uses the last hash segment when multiple hashes are present', () => {
const validTabs = ['installed', 'market', 'mcp'];
assert.equal(getValidHashTab('#/extension#foo#installed', validTabs), 'installed');
});
test('createTabRouteLocation preserves the current path and query', () => {
const query = { open_config: 'sample-plugin', page: '2' };
const location = createTabRouteLocation(
{
path: '/extension',
query,
},
'market',
);
assert.deepEqual(location, {
path: '/extension',
query: { open_config: 'sample-plugin', page: '2' },
hash: '#market',
});
assert.notEqual(location.query, query);
});
test('createTabRouteLocation falls back to the extension route name', () => {
const location = createTabRouteLocation(undefined, 'installed');
assert.deepEqual(location, {
name: EXTENSION_ROUTE_NAME,
query: {},
hash: '#installed',
});
});
test('createTabRouteLocation prefers route name and preserves params', () => {
const params = { pluginId: 'demo-plugin' };
const location = createTabRouteLocation(
{
name: 'ExtensionDetails',
path: '/extension/demo-plugin',
params,
query: { tab: 'details' },
},
'market',
);
assert.deepEqual(location, {
name: 'ExtensionDetails',
params: { pluginId: 'demo-plugin' },
query: { tab: 'details' },
hash: '#market',
});
assert.notEqual(location.params, params);
});
test('createTabRouteLocation omits params for path-based routes', () => {
const params = { pluginId: 'demo-plugin' };
const location = createTabRouteLocation(
{
path: '/extension/demo-plugin',
params,
},
'installed',
);
assert.deepEqual(location, {
path: '/extension/demo-plugin',
query: {},
hash: '#installed',
});
assert.equal(location.params, undefined);
});
test('replaceTabRoute catches rejected router updates', async () => {
assert.equal(typeof hashRouteTabs.replaceTabRoute, 'function');
const error = new Error('blocked');
let logged;
const router = {
replace: async () => {
throw error;
},
};
const logger = {
warn: (message, cause) => {
logged = { message, cause };
},
};
const result = await hashRouteTabs.replaceTabRoute(
router,
{ name: EXTENSION_ROUTE_NAME, query: { page: '1' } },
'installed',
logger,
);
assert.equal(result, false);
assert.deepEqual(logged, {
message: 'Failed to update extension tab route:',
cause: error,
});
});
+29
View File
@@ -0,0 +1,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
test('waitForRouterReadyInBackground returns immediately and logs failures', async () => {
const module = await import('../src/utils/routerReadiness.mjs').catch(() => null);
assert.ok(module?.waitForRouterReadyInBackground);
const error = new Error('router blocked');
let warned;
const readyPromise = Promise.reject(error);
const logger = {
warn: (message, cause) => {
warned = { message, cause };
},
};
const result = module.waitForRouterReadyInBackground(
{ isReady: () => readyPromise },
logger,
);
assert.equal(result, undefined);
await Promise.resolve();
assert.deepEqual(warned, {
message: 'Router did not become ready after fallback mount:',
cause: error,
});
});
+2 -8
View File
@@ -87,13 +87,7 @@ export default defineConfig({
},
{
text: "OneBot v11",
base: "/platform/aiocqhttp",
collapsed: true,
items: [
{ text: "NapCat", link: "/napcat" },
{ text: "Lagrange", link: "/lagrange" },
{ text: "其他端", link: "/others" },
],
link: "/aiocqhttp"
},
{ text: "企微应用", link: "/wecom" },
{ text: "企微智能机器人", link: "/wecom_ai_bot" },
@@ -111,7 +105,7 @@ export default defineConfig({
base: "/platform/satori",
collapsed: true,
items: [
{ text: "使用 LLOneBot", link: "/llonebot" },
{ text: "接入 Satori", link: "/guide" },
{ text: "使用 server-satori", link: "/server-satori" },
],
},
+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
```
+43
View File
@@ -0,0 +1,43 @@
# 接入 OneBot v11 协议实现
OneBot 是一个聊天机器人应用接口标准,旨在统一不同聊天平台上的机器人应用开发接口,使开发者只需编写一次业务逻辑代码即可应用到多种机器人平台。
AstrBot 支持接入所有适配了 OneBotv11 反向 WebsocketsAstrBot 做服务器端)的机器人协议端。
下文给出一些常见的 OneBot v11 协议实现端项目。
- [NapCat](https://github.com/NapNeko/NapCatQQ)
- [OneDisc](https://github.com/ITCraftDevelopmentTeam/OneDisc)
- [Tele-KiraLink](https://github.com/Echomirix/Tele-KiraLink)
请参阅对应的协议实现端项目的部署文档。
## 1. 配置 OneBot v11
1. 进入 AstrBot 的 WebUI
2. 点击左边栏 `机器人`
3. 然后在右边的界面中,点击 `+ 创建机器人`
4. 选择 `OneBot v11`
在出现的表单中,填写:
- ID(id):随意填写,仅用于区分不同的消息平台实例。
- 启用(enable): 勾选。
- 反向 WebSocket 主机地址:请填写你的机器的 IP 地址,一般情况下请直接填写 `0.0.0.0`
- 反向 WebSocket 端口:填写一个端口,默认为 `6199`
- 反向 Websocket Token:只有当 NapCat 网络配置中配置了 token 才需填写。
点击 `保存`
## 2. 配置协议实现端
请参阅对应的协议实现端项目的部署文档。
一些注意点:
1. 协议实现端需要支持 `反向 WebSocket` 实现,及 AstrBot 端作为服务端,实现端作为客户端。
2. `反向 WebSocket` 的 URL 为 `ws(s)://<your-host>:6199/ws`
## 3. 验证
前往 AstrBot WebUI `控制台`,如果出现 ` aiocqhttp(OneBot v11) 适配器已连接。` 蓝色的日志,说明连接成功。如果没有,若干秒后出现` aiocqhttp 适配器已被关闭` 则为连接超时(失败),请检查配置是否正确。
-61
View File
@@ -1,61 +0,0 @@
# 接入 Lagrange
> [!TIP]
>
> - 请合理控制使用频率。过于频繁地发送消息可能会被判定为异常行为,增加触发风控机制的风险。
> - 本项目严禁用于任何违反法律法规的用途。若您意图将 AstrBot 应用于非法产业或活动,我们**明确反对并拒绝**您使用本项目。
> - 最新的部署方式请以 [Lagrange Doc](https://lagrangedev.github.io/Lagrange.Doc/Lagrange.OneBot/Config/#%E4%B8%8B%E8%BD%BD%E5%AE%89%E8%A3%85) 为准。
## 下载
从 [GitHub Release](https://github.com/LagrangeDev/Lagrange.Core/releases) 下载最新版的 `Lagrange.OneBot`
对于 Windows 设备,请下载 `Lagrange.OneBot_win-x64_xxxx` 压缩包。
对于 X86 的 Linux 用户,下载 `Lagrange.OneBot_linux-x64_xxx` 压缩包。
对于 Arm 的 Linux 用户,下载 `Lagrange.OneBot_linux-arm64_xxx` 压缩包。
对于 M 芯片 Mac 用户,下载 `Lagrange.OneBot_osx-arm64_xxx` 压缩包。
对于 Intel 芯片 Mac 用户,下载 `Lagrange.OneBot_osx-x64_xxx` 压缩包。
## 部署
请参阅 [Lagrange Doc](https://lagrangedev.github.io/Lagrange.Doc/Lagrange.OneBot/Config/#%E8%BF%90%E8%A1%8C)。
运行完成后,请修改 [配置文件](https://lagrangedev.github.io/Lagrange.Doc/Lagrange.OneBot/Config/#%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6)
`Implementations` 字段下添加:
```json
{
"Type": "ReverseWebSocket",
"Host": "127.0.0.1",
"Port": 6199,
"Suffix": "/ws",
"ReconnectInterval": 5000,
"HeartBeatInterval": 5000,
"AccessToken": ""
}
```
一定要保证 `Suffix``/ws`
## 连接到 AstrBot
### 配置 aiocqhttp
1. 进入 AstrBot 的管理面板
2. 点击左边栏 `机器人`
3. 然后在右边的界面中,点击 `+ 创建机器人`
4. 选择 `aiocqhttp(OneBotv11)`
弹出的配置项填写:
配置项填写:
- ID(id):随意填写,用于区分不同的消息平台实例。
- 启用(enable): 勾选。
- 反向 WebSocket 主机地址:请填写你的机器的 IP 地址。一般情况下请直接填写 `0.0.0.0`
- 反向 WebSocket 端口:填写一个端口,例如 `6199`
-134
View File
@@ -1,134 +0,0 @@
# 使用 NapCat
> [!TIP]
>
> - 本项目严禁用于任何违反法律法规的用途。若您意图将 AstrBot 应用于非法产业或活动,我们**明确反对并拒绝**您使用本项目。
> - AstrBot 通过 `aiocqhttp` 适配器接入 OneBot v11 协议。OneBot v11 协议是一个开放的通信协议,并不代表任何具体的软件或服务。
NapCat 的 GitHub 仓库:[NapCat](https://github.com/NapNeko/NapCatQQ)
NapCat 的文档:[NapCat 文档](https://napcat.napneko.icu/)
NapCat 提供了大量的部署方式,包括 Docker、Windows 一键安装包等等。
## 通过一键脚本部署
推荐采用这种方式部署。
### Windows
看这篇文章:[NapCat.Shell - Win手动启动教程](https://napneko.github.io/guide/boot/Shell#napcat-shell-win%E6%89%8B%E5%8A%A8%E5%90%AF%E5%8A%A8%E6%95%99%E7%A8%8B)
### Linux
看这篇文章:[NapCat.Installer - Linux一键使用脚本(支持Ubuntu 20+/Debian 10+/Centos9)](https://napneko.github.io/guide/boot/Shell#napcat-installer-linux%E4%B8%80%E9%94%AE%E4%BD%BF%E7%94%A8%E8%84%9A%E6%9C%AC-%E6%94%AF%E6%8C%81ubuntu-20-debian-10-centos9)
> [!TIP]
> **Napcat WebUI 在哪打开**
> 在 napcat 的日志里会显示 WebUI 链接。
>
> 如果是 linux 命令行一键部署的napcat`docker log <账号>`
>
> Docker部署的 NapCat`docker logs napcat`
## 通过 Docker Compose 部署
1. 下载或复制 [astrbot.yml](https://github.com/NapNeko/NapCat-Docker/blob/main/compose/astrbot.yml) 内容
2. 将刚刚下载的文件重命名为 `astrbot.yml`
3. 编辑 `astrbot.yml`,将 `# - "6199:6199"` 修改为 `- "6199:6199"`,移除开头的 `#`
4. 在 `astrbot.yml` 文件所在目录执行:
```bash
NAPCAT_UID=$(id -u) NAPCAT_GID=$(id -g) docker compose -f ./astrbot.yml up -d
```
## 通过 Docker 部署
此教程默认您安装了 Docker。
在终端执行以下命令即可一键部署。
```bash
docker run -d \
-e NAPCAT_GID=$(id -g) \
-e NAPCAT_UID=$(id -u) \
-p 3000:3000 \
-p 3001:3001 \
-p 6099:6099 \
--name napcat \
--restart=always \
mlikiowa/napcat-docker:latest
```
执行成功后,需要查看日志以得到登录二维码和管理面板的 URL。
```bash
docker logs napcat
```
请复制管理面板的 URL,然后在浏览器中打开备用。
然后使用你要登录的账号扫描出现的二维码,即可登录。
如果登录阶段没有出现问题,即成功部署。
## 连接到 AstrBot
## 在 AstrBot 配置 aiocqhttp
1. 进入 AstrBot 的管理面板
2. 点击左边栏 `机器人`
3. 然后在右边的界面中,点击 `+ 创建机器人`
4. 选择 `OneBot v11`
弹出的配置项填写:
- ID(id):随意填写,仅用于区分不同的消息平台实例。
- 启用(enable): 勾选。
- 反向 WebSocket 主机地址:请填写你的机器的 IP 地址,一般情况下请直接填写 `0.0.0.0`
- 反向 WebSocket 端口:填写一个端口,默认为 `6199`
- 反向 Websocket Token:只有当 NapCat 网络配置中配置了 token 才需填写。
图例:(最快只需要点击启用,然后保存即可)
<img width="818" height="799" alt="xinjianya" src="https://github.com/user-attachments/assets/813ac338-2fd7-4add-bde4-8b0f6d0bda95" />
点击 `保存`
### 配置管理员
填写完毕后,进入 `配置文件` 页,点击 `平台配置` 选项卡,找到 `管理员 ID`,填写你的账号(不是机器人的账号)。
切记点击右下角 `保存`AstrBot 重启并会应用配置。
### 在 NapCat 中添加 WebSocket 客户端
切换回 NapCat 的管理面板,点击 `网络配置->新建->WebSockets客户端`
<img width="649" height="751" alt="jiaochenXJY" src="https://github.com/user-attachments/assets/5044f96a-a81f-407a-a3b1-0c518499eda4" />
在新弹出的窗口中:
- 勾选 `启用`
- `URL` 填写 `ws://宿主机IP:端口/ws`。如 `ws://localhost:6199/ws``ws://127.0.0.1:6199/ws`
> [!IMPORTANT]
> 1. 如果采用 Docker 部署并同时把 AstrBot 和 NapCat 两个容器接入了同一网络,`ws://astrbot:6199/ws`(参考本文档的 Docker 脚本)。
> 2. 由于 Docker 网络隔离的原因,不在同一个网络时请使用内网 IP 地址或公网 IP 地址 ***(不安全)*** 进行连接,即 `ws://(内网/公网):6199/ws`
- 消息格式:`Array`
- 心跳间隔: `5000`
- 重连间隔: `5000`
> [!WARNING]
>
> 1. 切记后面加一个 `/ws`!
> 2. 这里的 IP 不能填为 `0.0.0.0`
点击 `保存`
前往 AstrBot WebUI `控制台`,如果出现 ` aiocqhttp(OneBot v11) 适配器已连接。` 蓝色的日志,说明连接成功。如果没有,若干秒后出现` aiocqhttp 适配器已被关闭` 则为连接超时(失败),请检查配置是否正确。
## 🎉 大功告成
此时,你的 AstrBot 和 NapCat 应该已经连接成功!使用 `私聊` 的方式对机器人发送 `/help` 以检查是否连接成功。
-1
View File
@@ -1 +0,0 @@
支持接入所有适配了 OneBotv11 反向 WebsocketsAstrBot 做服务器端) 的机器人协议端。
+1 -1
View File
@@ -7,7 +7,7 @@
进入 AstrBot WebUI 的插件市场,搜索 `astrbot_plugin_matrix_adapter`,点击安装。
安装完成后,前往 消息平台 → 新增适配器 → 选择 Matrix(若选项缺失,尝试重启 AstrBot 或检查插件安装状态)。
安装完成后,前往 机器人(旧版本为 `消息平台` → 新增适配器 → 选择 Matrix(若选项缺失,尝试重启 AstrBot 或检查插件安装状态)。
在弹出的配置对话框中点击 `启用`
+32
View File
@@ -0,0 +1,32 @@
# 接入 Satori 协议
## Satori 协议简介
> 摘录自:https://satori.chat/zh-CN/introduction.html
Satori 是一个通用的聊天协议。Satori 协议希望能够抹平不同聊天平台之间的差异,让开发者以更低的成本开发出跨平台、可扩展、高性能的聊天应用。
Satori 的名称来源于游戏东方 Project 中的角色 [古明地觉 (Komeiji Satori)](https://zh.touhouwiki.net/wiki/%E5%8F%A4%E6%98%8E%E5%9C%B0%E8%A7%89)。古明地觉能够以心灵感应的方式与各种动物交流,取这个名字是希望 Satori 能够成为各个聊天平台之间的桥梁。
Satori 的开发团队长期从事聊天机器人开发,熟悉各种聊天平台的通信方式。经过长达 4 年的发展,Satori 有了健全的设计和完善的实现。目前,Satori 官方提供了超过 15 个聊天平台的适配器,完全覆盖了世界上主流的聊天平台,如 QQ、Discord、企业微信、KOOK 等等。
## 1. 配置协议实现端
请参阅对应的协议实现端项目的部署文档。
## 2. 配置 Satori 协议
1. 进入 AstrBot 的 WebUI
2. 点击左边栏 `机器人`
3. 然后在右边的界面中,点击 `+ 创建机器人`
4. 选择 `Satori`
弹出的配置项填写:
- 机器人名称 (id): `satori` (随意)
- 启用 (enable): 勾选
- Satori API 终结点 (satori_api_base_url)`http://localhost:5600/v1`(端口和上面配置的协议端端口一致)
- Satori WebSocket 终结点 (satori_endpoint)`ws://localhost:5600/v1/events`(端口和上面配置的协议端端口一致)
- Satori 令牌 (satori_token):根据协议端配置情况选择填写
点击 `保存`
-78
View File
@@ -1,78 +0,0 @@
# 接入 LLTwoBot (Satori)
> [!TIP]
> LLTwoBot 是一个基于 QQNT 的 Onebot v11、Satori 多协议实现端,可以让你在 QQ 平台使用 Satori 协议与 AstrBot 进行通信。
> [!TIP]
>
> - 请合理控制使用频率。过于频繁地发送消息可能会被判定为异常行为,增加触发风控机制的风险。
> - 本项目严禁用于任何违反法律法规的用途。若您意图将 AstrBot 应用于非法产业或活动,我们**明确反对并拒绝**您使用本项目。
## 准备工作
请先参考 LLTwoBot 官方文档完成基础配置:
[LLTwoBot 文档](https://llonebot.com/guide/getting-started)
完成文档中的步骤,确保你已经:
1. 下载并安装了 LLTwoBot
2. 成功登录了 QQ 账号
## 配置 LLTwoBot 的 Satori 服务
在成功登录 QQ 后,先打开 LLTwoBot 的 WebUI 配置界面。
> WebUI 默认地址为:<http://localhost:3080/>
---
在WebUI的配置界面侧边,选择【Satori】选项卡,进行如下配置:
1. 确认【启用 Satori 协议】配置项已开启
2. 端口默认为 5600(如需修改请记住新端口)
3. 如有必要,可填写【Satori Token】
4. 点击右下角的【保存配置】
![image](https://files.astrbot.app/docs/source/images/satori/2025-10-10_15-52-32.png)
## 在 AstrBot 中配置 Satori 适配器
1. 进入 AstrBot 的管理面板
2. 点击左边栏 `机器人`
3. 然后在右边的界面中,点击 `+ 创建机器人`
4. 选择 `satori`
弹出的配置项填写:
- 机器人名称 (id): `LLTwoBot`
- 启用 (enable): 勾选
- Satori API 终结点 (satori_api_base_url)`http://localhost:5600/v1`
- Satori WebSocket 终结点 (satori_endpoint)`ws://localhost:5600/v1/events`
- Satori 令牌 (satori_token):根据 LLTwoBot 配置填写(如有设置)
> [!NOTE]
>
> - LLTwoBot 的 satori协议 默认在 `5600` 端口提供服务
> - 因此完整的 URL 路径为 `http://localhost:5600/v1`
>
> 如果你的 satori协议运行在其他端口,请根据实际情况修改对应的配置!
![image](https://files.astrbot.app/docs/source/images/satori/2025-10-10_16-10-54.png)
点击右下角 `保存` 完成配置。
## 🎉 大功告成
此时,你的 AstrBot 应该已经通过 Satori 协议成功连接到 LLTwoBot。
在 QQ 中发送 `/help` 以检查是否连接成功。
如果成功回复,则配置成功。
## 常见问题
如果遇到连接问题,请检查:
1. LLTwoBot 是否正常运行
2. Satori 服务是否已启用
3. 端口配置是否正确
4. Token 是否匹配(如设置了 Token)
+1 -1
View File
@@ -30,7 +30,7 @@
## 配置 AstrBot
1. 进入 AstrBot 的管理面板,点击左侧栏 `消息平台`,然后在右侧的界面中,点击 `+ 新增适配器`,选择 `企业微信智能机器人`,进入配置页面。
1. 进入 AstrBot 的管理面板,点击左侧栏 `机器人`(旧版本为 `消息平台`,然后在右侧的界面中,点击 `+ 新增适配器`,选择 `企业微信智能机器人`,进入配置页面。
![新增适配器](https://files.astrbot.app/docs/source/images/wecom_ai_bot/image-2.png)
+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)
## 它是如何实现的?
-3
View File
@@ -1,9 +1,6 @@
aiocqhttp>=1.4.4
aiodocker>=0.24.0
aiohttp>=3.11.18
aiocqhttp>=1.4.4
aiodocker>=0.24.0
aiohttp>=3.11.18
aiosqlite>=0.21.0
anthropic>=0.51.0
apscheduler>=3.11.0
@@ -0,0 +1,196 @@
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class Issue:
number: int
title: str
created_at: datetime
url: str
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Close duplicate open plugin-publish issues while keeping the latest one."
)
)
parser.add_argument(
"--repo",
default="AstrBotDevs/AstrBot",
help="GitHub repository in owner/name format.",
)
parser.add_argument(
"--label",
default="plugin-publish",
help="Issue label to target.",
)
parser.add_argument(
"--limit",
type=int,
default=1000,
help="Maximum number of open issues to inspect.",
)
parser.add_argument(
"--apply",
action="store_true",
help="Actually close duplicate issues. Defaults to dry-run.",
)
return parser.parse_args()
def run_gh_command(args: list[str]) -> str:
try:
completed = subprocess.run(
args,
check=True,
capture_output=True,
text=True,
)
except FileNotFoundError as exc:
raise RuntimeError("GitHub CLI `gh` is not installed or not in PATH.") from exc
except subprocess.CalledProcessError as exc:
stderr = exc.stderr.strip()
stdout = exc.stdout.strip()
details = stderr or stdout or str(exc)
raise RuntimeError(f"`{' '.join(args)}` failed: {details}") from exc
return completed.stdout
def load_open_issues(repo: str, label: str, limit: int) -> list[Issue]:
output = run_gh_command(
[
"gh",
"issue",
"list",
"--repo",
repo,
"--label",
label,
"--state",
"open",
"--limit",
str(limit),
"--json",
"number,title,createdAt,url",
]
)
items = json.loads(output)
return [
Issue(
number=item["number"],
title=item["title"],
created_at=datetime.fromisoformat(item["createdAt"].replace("Z", "+00:00")),
url=item["url"],
)
for item in items
]
def normalize_title(title: str) -> str:
return " ".join(title.split()).strip()
def find_duplicates(
issues: list[Issue],
) -> list[tuple[Issue, list[Issue]]]:
grouped: dict[str, list[Issue]] = defaultdict(list)
for issue in issues:
grouped[normalize_title(issue.title)].append(issue)
duplicate_groups: list[tuple[Issue, list[Issue]]] = []
for group in grouped.values():
if len(group) < 2:
continue
ordered = sorted(
group,
key=lambda issue: (issue.created_at, issue.number),
reverse=True,
)
keep = ordered[0]
close_candidates = ordered[1:]
duplicate_groups.append((keep, close_candidates))
duplicate_groups.sort(
key=lambda item: (item[0].created_at, item[0].number),
reverse=True,
)
return duplicate_groups
def print_plan(duplicate_groups: list[tuple[Issue, list[Issue]]], apply: bool) -> None:
action = "Will close" if apply else "Would close"
if not duplicate_groups:
print("No duplicate open issues found.")
return
total_to_close = sum(len(close_list) for _, close_list in duplicate_groups)
print(f"Found {len(duplicate_groups)} duplicate title groups.")
print(
f"{action} {total_to_close} issues and keep {len(duplicate_groups)} latest issues."
)
for keep, close_list in duplicate_groups:
print()
print(f'Keep #{keep.number} [{keep.created_at.isoformat()}] "{keep.title}"')
print(f" {keep.url}")
for issue in close_list:
print(
f'Close #{issue.number} [{issue.created_at.isoformat()}] "{issue.title}"'
)
print(f" {issue.url}")
def close_duplicates(
repo: str, duplicate_groups: list[tuple[Issue, list[Issue]]]
) -> None:
for keep, close_list in duplicate_groups:
reason = (
f"Closing as duplicate of #{keep.number}. "
"Keeping the latest open issue with this title."
)
for issue in close_list:
print(f"Closing #{issue.number} as duplicate of #{keep.number}...")
run_gh_command(
[
"gh",
"issue",
"close",
str(issue.number),
"--repo",
repo,
"--comment",
reason,
]
)
def main() -> int:
args = parse_args()
try:
issues = load_open_issues(args.repo, args.label, args.limit)
duplicate_groups = find_duplicates(issues)
print_plan(duplicate_groups, apply=args.apply)
if args.apply and duplicate_groups:
print()
close_duplicates(args.repo, duplicate_groups)
print("Done.")
elif not args.apply:
print()
print("Dry-run only. Re-run with `--apply` to close the duplicates.")
except RuntimeError as exc:
print(str(exc), file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(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
+103
View File
@@ -106,6 +106,109 @@ async def test_get_stat(app: Quart, authenticated_header: dict):
assert data["status"] == "ok" and "platform" in data["data"]
@pytest.mark.asyncio
@pytest.mark.parametrize("payload", [[], "x"])
async def test_batch_delete_sessions_rejects_non_object_payload(
app: Quart, authenticated_header: dict, payload
):
test_client = app.test_client()
response = await test_client.post(
"/api/chat/batch_delete_sessions",
json=payload,
headers=authenticated_header,
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "error"
assert data["message"] == "Invalid JSON body: expected object"
@pytest.mark.asyncio
async def test_batch_delete_sessions_masks_internal_error(
app: Quart, authenticated_header: dict, monkeypatch
):
test_client = app.test_client()
create_session_response = await test_client.get(
"/api/chat/new_session", headers=authenticated_header
)
assert create_session_response.status_code == 200
create_session_data = await create_session_response.get_json()
session_id = create_session_data["data"]["session_id"]
async def _raise_error(*args, **kwargs):
raise RuntimeError("secret-internal-error")
monkeypatch.setattr(
"astrbot.dashboard.routes.chat.ChatRoute._delete_session_internal",
_raise_error,
)
response = await test_client.post(
"/api/chat/batch_delete_sessions",
json={"session_ids": [session_id]},
headers=authenticated_header,
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
assert data["data"]["deleted_count"] == 0
assert data["data"]["failed_count"] == 1
assert data["data"]["failed_items"][0]["session_id"] == session_id
assert data["data"]["failed_items"][0]["reason"] == "internal_error"
@pytest.mark.asyncio
async def test_batch_delete_sessions_uses_batch_lookup(
app: Quart,
authenticated_header: dict,
core_lifecycle_td: AstrBotCoreLifecycle,
monkeypatch,
):
test_client = app.test_client()
db = core_lifecycle_td.db
create_session_response = await test_client.get(
"/api/chat/new_session", headers=authenticated_header
)
assert create_session_response.status_code == 200
create_session_data = await create_session_response.get_json()
session_id = create_session_data["data"]["session_id"]
original_batch_lookup = db.get_platform_sessions_by_ids
called = {"batch_lookup_count": 0}
async def _wrapped_batch_lookup(session_ids: list[str]):
called["batch_lookup_count"] += 1
return await original_batch_lookup(session_ids)
# 不应单个查询
async def _should_not_call_single_lookup(session_id: str):
raise AssertionError(
f"single-session lookup should not be called: {session_id}"
)
monkeypatch.setattr(db, "get_platform_sessions_by_ids", _wrapped_batch_lookup)
monkeypatch.setattr(
db, "get_platform_session_by_id", _should_not_call_single_lookup
)
response = await test_client.post(
"/api/chat/batch_delete_sessions",
json={"session_ids": [session_id]},
headers=authenticated_header,
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
assert data["data"]["deleted_count"] == 1
assert data["data"]["failed_count"] == 0
assert called["batch_lookup_count"] == 1
@pytest.mark.asyncio
async def test_plugins(
app: Quart,
+22
View File
@@ -804,6 +804,28 @@ class TestPluginToolFix:
assert "mcp_tool" in req.func_tool.names()
def test_plugin_tool_fix_preserves_tools_without_plugin_origin(self, mock_event):
"""Tools without handler_module_path should not be filtered out."""
module = ama
handoff_tool = FunctionTool(
name="transfer_to_demo_agent",
description="Delegate to demo agent",
parameters={"type": "object", "properties": {}},
handler_module_path=None,
active=True,
)
tool_set = ToolSet()
tool_set.add_tool(handoff_tool)
req = ProviderRequest(func_tool=tool_set)
mock_event.plugins_name = ["other_plugin"]
with patch("astrbot.core.astr_main_agent.star_map"):
module._plugin_tool_fix(mock_event, req)
assert "transfer_to_demo_agent" in req.func_tool.names()
class TestBuildMainAgent:
"""Tests for build_main_agent function."""