Compare commits

...

34 Commits

Author SHA1 Message Date
Soulter
81932f4a05 chore: ruff format 2026-04-14 18:47:29 +08:00
Soulter
6965a1d7d6 feat: rename event types to include 'Event' suffix for consistency 2026-04-14 18:46:43 +08:00
Soulter
b692674c52 Update astrbot/core/astr_agent_hooks.py
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-14 18:43:52 +08:00
Soulter
284f83bcb0 Update astrbot/core/star/star_handler.py
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-14 18:43:36 +08:00
Soulter
748ed1495b docs: add version requirement for event hooks in message event guide 2026-04-14 18:35:22 +08:00
Soulter
6e517ff096 feat: add on_agent_begin, on_using_llm_tool, on_llm_tool_respond, on_agent_done event hooks 2026-04-14 18:32:20 +08:00
若月千鸮
625eab223f feat: enable shiki highlighting for t2i templates and add a template (#7501)
* fix: enable shiki highlighting for t2i templates

* fix: t2i templates cr

* feat: add new t2i template astrbot_vitepress.html
2026-04-14 16:46:32 +08:00
Ruochen Pan
207eb34ba2 fix: improve error handling for knowledge base upload (#7536)
* fix: improve error handling for knowledge base upload

- Log details field in KnowledgeBaseUploadError for better debugging
- Distinguish between empty pre-chunked text and empty chunking result
  with appropriate error messages

* style: format code
2026-04-14 16:44:14 +08:00
Gargantua
cc72c01c0e fix: improve knowledge base upload error messages (#7534)
* fix: improve knowledge base upload error messages

* fix: deduplicate knowledge base upload logs

* fix: handle type errors in kb embedding validation
2026-04-14 16:27:06 +08:00
Kangyang Ji
11dedf3802 improve dashboard and docs ci to pnpm and cache (#7522) 2026-04-14 16:10:43 +08:00
Soulter
631e5fe152 docs: update supported IM platforms 2026-04-14 10:27:47 +08:00
dependabot[bot]
b342cf9997 chore(deps): bump the github-actions group with 3 updates (#7524)
Bumps the github-actions group with 3 updates: [docker/build-push-action](https://github.com/docker/build-push-action), [actions/github-script](https://github.com/actions/github-script) and [pnpm/action-setup](https://github.com/pnpm/action-setup).


Updates `docker/build-push-action` from 7.0.0 to 7.1.0
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v7.0.0...v7.1.0)

Updates `actions/github-script` from 8 to 9
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v8...v9)

Updates `pnpm/action-setup` from 5.0.0 to 6.0.0
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v5.0.0...v6.0.0)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-14 08:48:15 +08:00
Soulter
1292faa446 chore: bump version to 4.23.1 2026-04-13 23:39:34 +08:00
Soulter
abd11d5579 fix: routing not displayed when session id includes : (#7517)
* fix: routing not displayed when session id includes `:`

fixes: #7515
2026-04-13 23:32:40 +08:00
Soulter
afeda9b82a fix: downgrade python-ripgrep version to 0.0.8 in dependencies (#7514)
* fix: downgrade python-ripgrep version to 0.0.8 in dependencies

* fix: update smoke test workflow to support multiple OS and Python versions
2026-04-13 20:54:18 +08:00
エイカク
533a0bde6a fix: align deerflow runner with deerflow 2.0 (#7500)
* fix: align deerflow runner with deerflow 2.0

* fix: address deerflow review feedback
2026-04-13 12:47:27 +09:00
LunaRain_079
35ce281cbe fix: remove unnecessary margins from v-main for consistent layout (#7481)
* fix: remove unnecessary margins from v-main for consistent layout

* fix: remove media query for v-main margin to simplify layout
2026-04-13 08:47:37 +08:00
Waterwzy
80c7ebae8a fix: inconsistent format issue when checking if the plugin is installed (#7493)
* fix: inconsistent format issue when checking if the plugin is installed

* Update dashboard/src/views/extension/useExtensionPage.js

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

* Update dashboard/src/views/extension/useExtensionPage.js

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

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-13 08:45:27 +08:00
若月千鸮
5f0178bc73 chore: switch dashboard code blocks highlight to shiki (#7497)
* fix: switch dashboard code blocks to shiki and sync theme rendering

* fix: harden and optimize dashboard shiki highlighting
2026-04-13 08:43:36 +08:00
Soulter
6131386893 chore: bump version to 4.23.0 2026-04-13 00:36:04 +08:00
Kangyang Ji
3b2435875c fix: type use of defineStore in @/stores (#7490) 2026-04-12 23:47:40 +08:00
Kangyang Ji
2a229c4beb fix: wrong image name in compose (#7488)
* fix: wrong image name in compose
2026-04-12 22:07:58 +08:00
Soulter
d1913b5950 fix: update tool call icons from mdi-code-braces to mdi-code-json 2026-04-12 22:06:08 +08:00
Soulter
7172281436 feat: add MessageList component and update MDI icon subset 2026-04-12 21:55:10 +08:00
Soulter
bd08273640 refactor: chatui style (#7485) 2026-04-12 20:47:51 +08:00
Soulter
baaad2a69e perf: add 'dashboard_update' to the list of ignored effective commands in HelpCommand 2026-04-12 17:34:57 +08:00
Sascha Buehrle
9a65873424 fix: use UMO-bound config for group_icl_enable in on_message (#7397)
* fix: read group_icl_enable from UMO-bound config (fixes #7305)

* chore: ruff format

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-04-12 16:42:48 +08:00
Soulter
f50f6cd49f refactor: remove rarely-used builtin commands and consolidate functionality (#7478)
* refactor: remove rarely-used builtin commands and consolidate functionality

* docs: update docs

* Update astrbot/builtin_stars/builtin_commands/commands/admin.py

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

* chore: remove /op, /deop

* chore: ruff format

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-12 16:40:08 +08:00
Soulter
5d2b29f8f8 perf: add validation for MCP stdio configuration (#7477)
* perf: add validation for MCP stdio configuration

* Update astrbot/core/agent/mcp_client.py

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

* Update astrbot/core/agent/mcp_client.py

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

* chore: ruff format

* fix: correct regex pattern for shell meta characters

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-12 15:04:46 +08:00
Soulter
68a195e12b perf: make no-new-privileges true when use docker 2026-04-12 14:37:33 +08:00
NekoYukari
2274e0efc9 fix: support both Bailian Rerank API formats based on URL endpoint (#7250)
* fix: support both Bailian Rerank API formats based on URL endpoint

阿里云百炼有两个不同的 rerank API 端点:
- /compatible-api/v1/reranks: 使用扁平请求格式 {model, query, documents}
- /api/v1/services/rerank/...: 需要 input 包装 {model, input: {...}}

之前代码只根据模型名判断格式,导致 qwen3-rerank + compatible-api 组合失败。

修复内容:
- _build_payload(): 根据 URL 是否含 'compatible-api' 决定请求格式
- _parse_results(): 根据 URL 判断响应中 results 的位置

Fixes #7161

* refactor: reduce duplication in bailian rerank payload and results handling

- Extract params building outside the if-else branch
- Add back empty results warning log
- Simplify error handling variable assignment

* fix: simplify bailian rerank payload to use model-based logic only

qwen3-rerank always uses flat format regardless of API endpoint.
Other models (gte-rerank-v2, etc.) use input wrapper format.

This simplifies the logic and correctly handles all model/URL combinations.
Tested: qwen3-rerank accepts both formats, gte-rerank-v2 only supports input wrapper.

---------

Co-authored-by: root <root@localhost.localdomain>
Co-authored-by: Fix Bot <fix@example.com>
2026-04-12 14:23:15 +08:00
Sagiri777
f1f1720c58 feat(weixin_oc): support reply components (#7380)
* feat(weixin_oc): support reply parsing

* fix: harden weixin oc reply parsing

* fix: correct weixin oc reply cache matching

* fix: harden weixin oc reply parsing

* chore: remove test

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-04-12 14:22:07 +08:00
yuanqiuye
6691411550 fix(discord): prevent 10062 Unknown interaction error by deferring slash commands immediately (#7474)
* fix(discord): prevent 10062 Unknown interaction error by deferring slash commands immediately

* fix(discord): early return if deferral fails

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

* chore: 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-04-12 14:12:07 +08:00
Soulter
8d28693e32 fix: ensure JSON response is encoded with non-ASCII characters for shell command execution (#7475)
fixes: #7452
2026-04-12 14:01:20 +08:00
119 changed files with 11361 additions and 8844 deletions

View File

@@ -12,15 +12,21 @@ jobs:
steps:
- name: checkout
uses: actions/checkout@v6
- name: nodejs installation
- name: Setup pnpm
uses: pnpm/action-setup@v5.0.0
with:
version: 10.28.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "18"
- name: npm install
run: npm add -D vitepress
working-directory: './docs' # working-directory 指定 shell 命令运行目录
- name: npm run build
run: npm run docs:build
node-version: "24.13.0"
cache: "pnpm"
cache-dependency-path: docs/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
working-directory: './docs'
- name: Build docs
run: pnpm run docs:build
working-directory: './docs'
- name: scp
uses: appleboy/scp-action@v1.0.0

View File

@@ -14,18 +14,22 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v5.0.0
with:
version: 10.28.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24.13.0'
cache: "pnpm"
cache-dependency-path: dashboard/pnpm-lock.yaml
- name: npm install, build
- name: Install and Build
run: |
cd dashboard
npm install pnpm -g
pnpm install
pnpm i --save-dev @types/markdown-it
pnpm run build
pnpm --dir dashboard install --frozen-lockfile
pnpm --dir dashboard run build
- name: Inject Commit SHA
id: get_sha

View File

@@ -98,7 +98,7 @@ jobs:
echo "EOF" >> $GITHUB_OUTPUT
- name: Build and Push Nightly Image
uses: docker/build-push-action@v7.0.0
uses: docker/build-push-action@v7.1.0
with:
context: .
platforms: linux/amd64,linux/arm64
@@ -183,7 +183,7 @@ jobs:
password: ${{ secrets.GHCR_GITHUB_TOKEN }}
- name: Build and Push Release Image
uses: docker/build-push-action@v7.0.0
uses: docker/build-push-action@v7.1.0
with:
context: .
platforms: linux/amd64,linux/arm64

View File

@@ -14,7 +14,7 @@ jobs:
steps:
- name: Validate PR title
uses: actions/github-script@v8
uses: actions/github-script@v9
with:
script: |
const title = (context.payload.pull_request.title || "").trim();

View File

@@ -51,7 +51,7 @@ jobs:
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Setup pnpm
uses: pnpm/action-setup@v5.0.0
uses: pnpm/action-setup@v6.0.0
with:
version: 10.28.2

View File

@@ -13,10 +13,23 @@ on:
jobs:
smoke-test:
name: Run smoke tests
runs-on: ubuntu-latest
name: Smoke test (${{ matrix.os }}, Python ${{ matrix.python-version }})
runs-on: ${{ matrix.os }}
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
- macos-latest
- windows-latest
python-version:
- '3.10'
- '3.11'
- '3.12'
- '3.13'
- '3.14'
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -26,33 +39,21 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Install UV package manager
python-version: ${{ matrix.python-version }}
cache: 'pip'
cache-dependency-path: requirements.txt
- name: Install uv
run: |
pip install uv
python -m pip install --upgrade pip
python -m pip install uv
- name: Install dependencies
run: |
uv sync
uv pip install --system -r requirements.txt
timeout-minutes: 15
- name: Run smoke tests
run: |
uv run main.py &
APP_PID=$!
echo "Waiting for application to start..."
for i in {1..60}; do
if curl -f http://localhost:6185 > /dev/null 2>&1; then
echo "Application started successfully!"
kill $APP_PID
exit 0
fi
sleep 1
done
echo "Application failed to start within 30 seconds"
kill $APP_PID 2>/dev/null || true
exit 1
python scripts/smoke_startup_check.py
timeout-minutes: 2

View File

@@ -157,11 +157,12 @@ Connect AstrBot to your favorite chat platform.
| Discord | Official |
| LINE | Official |
| Satori | Official |
| KOOK | Official |
| Misskey | Official |
| Mattermost | Official |
| WhatsApp (Coming Soon) | Official |
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Community |
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Community |
| [Rocket.Chat](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | Community |
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Community |
## Supported Model Services

View File

@@ -156,10 +156,12 @@ Connectez AstrBot à vos plateformes de chat préférées.
| Discord | Officielle |
| LINE | Officielle |
| Satori | Officielle |
| KOOK | Officielle |
| Misskey | Officielle |
| Mattermost | Officielle |
| WhatsApp (Bientôt disponible) | Officielle |
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Communauté |
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Communauté |
| [Rocket.Chat](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | Communauté |
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Communauté |
## Services de modèles pris en charge

View File

@@ -156,10 +156,12 @@ AstrBot をよく使うチャットプラットフォームに接続できます
| Discord | 公式 |
| LINE | 公式 |
| Satori | 公式 |
| KOOK | 公式 |
| Misskey | 公式 |
| Mattermost | 公式 |
| WhatsApp (近日対応予定) | 公式 |
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | コミュニティ |
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | コミュニティ |
| [Rocket.Chat](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | コミュニティ |
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | コミュニティ |

View File

@@ -156,10 +156,12 @@ yay -S astrbot-git
| Discord | Официальная |
| LINE | Официальная |
| Satori | Официальная |
| KOOK | Официальная |
| Misskey | Официальная |
| Mattermost | Официальная |
| WhatsApp (Скоро) | Официальная |
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Сообщество |
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Сообщество |
| [Rocket.Chat](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | Сообщество |
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | Сообщество |
## Поддерживаемые сервисы моделей

View File

@@ -156,10 +156,12 @@ yay -S astrbot-git
| Discord | 官方維護 |
| LINE | 官方維護 |
| Satori | 官方維護 |
| KOOK | 官方維護 |
| Misskey | 官方維護 |
| Mattermost | 官方維護 |
| Whatsapp即將支援 | 官方維護 |
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | 社群維護 |
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | 社群維護 |
| [Rocket.Chat](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | 社群維護 |
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | 社群維護 |
## 支援的模型服務

View File

@@ -156,10 +156,12 @@ yay -S astrbot-git
| **Discord** | 官方维护 |
| **LINE** | 官方维护 |
| **Satori** | 官方维护 |
| **KOOK** | 官方维护 |
| **Misskey** | 官方维护 |
| **Mattermost** | 官方维护 |
| **Whatsapp (将支持)** | 官方维护 |
| [**Matrix**](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | 社区维护 |
| [**KOOK**](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | 社区维护 |
| [**Rocket.Chat**](https://github.com/NET-Homeless/astrbot_plugin_rocket_chat_adapter) | 社区维护 |
| [**VoceChat**](https://github.com/HikariFroya/astrbot_plugin_vocechat) | 社区维护 |
## 支持的模型提供商

View File

@@ -14,6 +14,8 @@ from astrbot.core.star.register import register_command_group as command_group
from astrbot.core.star.register import register_custom_filter as custom_filter
from astrbot.core.star.register import register_event_message_type as event_message_type
from astrbot.core.star.register import register_llm_tool as llm_tool
from astrbot.core.star.register import register_on_agent_begin as on_agent_begin
from astrbot.core.star.register import register_on_agent_done as on_agent_done
from astrbot.core.star.register import register_on_astrbot_loaded as on_astrbot_loaded
from astrbot.core.star.register import (
register_on_decorating_result as on_decorating_result,
@@ -51,6 +53,8 @@ __all__ = [
"custom_filter",
"event_message_type",
"llm_tool",
"on_agent_begin",
"on_agent_done",
"on_astrbot_loaded",
"on_decorating_result",
"on_llm_request",

View File

@@ -36,9 +36,9 @@ class Main(star.Star):
if self.ltm_enabled(event) and self.ltm and has_image_or_plain:
need_active = await self.ltm.need_active_reply(event)
group_icl_enable = self.context.get_config()["provider_ltm_settings"][
"group_icl_enable"
]
group_icl_enable = self.context.get_config(umo=event.unified_msg_origin)[
"provider_ltm_settings"
]["group_icl_enable"]
if group_icl_enable:
"""记录对话"""
try:

View File

@@ -1,29 +1,15 @@
# Commands module
from .admin import AdminCommands
from .alter_cmd import AlterCmdCommands
from .conversation import ConversationCommands
from .help import HelpCommand
from .llm import LLMCommands
from .persona import PersonaCommands
from .plugin import PluginCommands
from .provider import ProviderCommands
from .setunset import SetUnsetCommands
from .sid import SIDCommand
from .t2i import T2ICommand
from .tts import TTSCommand
__all__ = [
"AdminCommands",
"AlterCmdCommands",
"ConversationCommands",
"HelpCommand",
"LLMCommands",
"PersonaCommands",
"PluginCommands",
"ProviderCommands",
"SIDCommand",
"SetUnsetCommands",
"T2ICommand",
"TTSCommand",
"SIDCommand",
]

View File

@@ -1,5 +1,5 @@
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, MessageChain, MessageEventResult
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.core.config.default import VERSION
from astrbot.core.utils.io import download_dashboard
@@ -8,70 +8,8 @@ class AdminCommands:
def __init__(self, context: star.Context) -> None:
self.context = context
async def op(self, event: AstrMessageEvent, admin_id: str = "") -> None:
"""授权管理员。op <admin_id>"""
if not admin_id:
event.set_result(
MessageEventResult().message(
"使用方法: /op <id> 授权管理员;/deop <id> 取消管理员。可通过 /sid 获取 ID。",
),
)
return
self.context.get_config()["admins_id"].append(str(admin_id))
self.context.get_config().save_config()
event.set_result(MessageEventResult().message("授权成功。"))
async def deop(self, event: AstrMessageEvent, admin_id: str = "") -> None:
"""取消授权管理员。deop <admin_id>"""
if not admin_id:
event.set_result(
MessageEventResult().message(
"使用方法: /deop <id> 取消管理员。可通过 /sid 获取 ID。",
),
)
return
try:
self.context.get_config()["admins_id"].remove(str(admin_id))
self.context.get_config().save_config()
event.set_result(MessageEventResult().message("取消授权成功。"))
except ValueError:
event.set_result(
MessageEventResult().message("此用户 ID 不在管理员名单内。"),
)
async def wl(self, event: AstrMessageEvent, sid: str = "") -> None:
"""添加白名单。wl <sid>"""
if not sid:
event.set_result(
MessageEventResult().message(
"使用方法: /wl <id> 添加白名单;/dwl <id> 删除白名单。可通过 /sid 获取 ID。",
),
)
return
cfg = self.context.get_config(umo=event.unified_msg_origin)
cfg["platform_settings"]["id_whitelist"].append(str(sid))
cfg.save_config()
event.set_result(MessageEventResult().message("添加白名单成功。"))
async def dwl(self, event: AstrMessageEvent, sid: str = "") -> None:
"""删除白名单。dwl <sid>"""
if not sid:
event.set_result(
MessageEventResult().message(
"使用方法: /dwl <id> 删除白名单。可通过 /sid 获取 ID。",
),
)
return
try:
cfg = self.context.get_config(umo=event.unified_msg_origin)
cfg["platform_settings"]["id_whitelist"].remove(str(sid))
cfg.save_config()
event.set_result(MessageEventResult().message("删除白名单成功。"))
except ValueError:
event.set_result(MessageEventResult().message("此 SID 不在白名单内。"))
async def update_dashboard(self, event: AstrMessageEvent) -> None:
"""更新管理面板"""
await event.send(MessageChain().message("正在尝试更新管理面板..."))
await event.send(MessageChain().message("⏳ Updating dashboard..."))
await download_dashboard(version=f"v{VERSION}", latest=False)
await event.send(MessageChain().message("管理面板更新完成。"))
await event.send(MessageChain().message("✅ Dashboard updated successfully."))

View File

@@ -1,173 +0,0 @@
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.core.star.filter.command import CommandFilter
from astrbot.core.star.filter.command_group import CommandGroupFilter
from astrbot.core.star.filter.permission import PermissionTypeFilter
from astrbot.core.star.star import star_map
from astrbot.core.star.star_handler import StarHandlerMetadata, star_handlers_registry
from astrbot.core.utils.command_parser import CommandParserMixin
from .utils.rst_scene import RstScene
class AlterCmdCommands(CommandParserMixin):
def __init__(self, context: star.Context) -> None:
self.context = context
async def update_reset_permission(self, scene_key: str, perm_type: str) -> None:
"""更新reset命令在特定场景下的权限设置"""
from astrbot.api import sp
alter_cmd_cfg = await sp.global_get("alter_cmd", {})
plugin_cfg = alter_cmd_cfg.get("astrbot", {})
reset_cfg = plugin_cfg.get("reset", {})
reset_cfg[scene_key] = perm_type
plugin_cfg["reset"] = reset_cfg
alter_cmd_cfg["astrbot"] = plugin_cfg
await sp.global_put("alter_cmd", alter_cmd_cfg)
async def alter_cmd(self, event: AstrMessageEvent) -> None:
token = self.parse_commands(event.message_str)
if token.len < 3:
await event.send(
MessageChain().message(
"该指令用于设置指令或指令组的权限。\n"
"格式: /alter_cmd <cmd_name> <admin/member>\n"
"例1: /alter_cmd c1 admin 将 c1 设为管理员指令\n"
"例2: /alter_cmd g1 c1 admin 将 g1 指令组的 c1 子指令设为管理员指令\n"
"/alter_cmd reset config 打开 reset 权限配置",
),
)
return
# 兼容 reset scene 的专门配置
cmd_name = token.get(1)
cmd_type = token.get(2)
if cmd_name == "reset" and cmd_type == "config":
from astrbot.api import sp
alter_cmd_cfg = await sp.global_get("alter_cmd", {})
plugin_ = alter_cmd_cfg.get("astrbot", {})
reset_cfg = plugin_.get("reset", {})
group_unique_on = reset_cfg.get("group_unique_on", "admin")
group_unique_off = reset_cfg.get("group_unique_off", "admin")
private = reset_cfg.get("private", "member")
config_menu = f"""reset命令权限细粒度配置
当前配置:
1. 群聊+会话隔离开: {group_unique_on}
2. 群聊+会话隔离关: {group_unique_off}
3. 私聊: {private}
修改指令格式:
/alter_cmd reset scene <场景编号> <admin/member>
例如: /alter_cmd reset scene 2 member"""
await event.send(MessageChain().message(config_menu))
return
if cmd_name == "reset" and cmd_type == "scene" and token.len >= 4:
scene_num = token.get(3)
perm_type = token.get(4)
if scene_num is None or perm_type is None:
await event.send(MessageChain().message("场景编号和权限类型不能为空"))
return
if not scene_num.isdigit() or int(scene_num) < 1 or int(scene_num) > 3:
await event.send(
MessageChain().message("场景编号必须是 1-3 之间的数字"),
)
return
if perm_type not in ["admin", "member"]:
await event.send(
MessageChain().message("权限类型错误,只能是 admin 或 member"),
)
return
scene_num = int(scene_num)
scene = RstScene.from_index(scene_num)
scene_key = scene.key
await self.update_reset_permission(scene_key, perm_type)
await event.send(
MessageChain().message(
f"已将 reset 命令在{scene.name}场景下的权限设为{perm_type}",
),
)
return
if cmd_type not in ["admin", "member"]:
await event.send(
MessageChain().message("指令类型错误,可选类型有 admin, member"),
)
return
# 查找指令
cmd_name = " ".join(token.tokens[1:-1])
cmd_type = token.get(-1)
found_command = None
cmd_group = False
for handler in star_handlers_registry:
assert isinstance(handler, StarHandlerMetadata)
for filter_ in handler.event_filters:
if isinstance(filter_, CommandFilter):
if filter_.equals(cmd_name):
found_command = handler
break
elif isinstance(filter_, CommandGroupFilter):
if filter_.equals(cmd_name):
found_command = handler
cmd_group = True
break
if not found_command:
await event.send(MessageChain().message("未找到该指令"))
return
found_plugin = star_map[found_command.handler_module_path]
from astrbot.api import sp
alter_cmd_cfg = await sp.global_get("alter_cmd", {})
plugin_ = alter_cmd_cfg.get(found_plugin.name, {})
cfg = plugin_.get(found_command.handler_name, {})
cfg["permission"] = cmd_type
plugin_[found_command.handler_name] = cfg
alter_cmd_cfg[found_plugin.name] = plugin_
await sp.global_put("alter_cmd", alter_cmd_cfg)
# 注入权限过滤器
found_permission_filter = False
for filter_ in found_command.event_filters:
if isinstance(filter_, PermissionTypeFilter):
if cmd_type == "admin":
from astrbot.api.event import filter
filter_.permission_type = filter.PermissionType.ADMIN
else:
from astrbot.api.event import filter
filter_.permission_type = filter.PermissionType.MEMBER
found_permission_filter = True
break
if not found_permission_filter:
from astrbot.api.event import filter
found_command.event_filters.insert(
0,
PermissionTypeFilter(
filter.PermissionType.ADMIN
if cmd_type == "admin"
else filter.PermissionType.MEMBER,
),
)
cmd_group_str = "指令组" if cmd_group else "指令"
await event.send(
MessageChain().message(
f"已将「{cmd_name}{cmd_group_str} 的权限级别调整为 {cmd_type}",
),
)

View File

@@ -1,13 +1,12 @@
import datetime
from astrbot.api import sp, star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
from astrbot.core import logger
from astrbot.core.agent.runners.deerflow.constants import (
DEERFLOW_AGENT_RUNNER_PROVIDER_ID_KEY,
DEERFLOW_PROVIDER_TYPE,
DEERFLOW_THREAD_ID_KEY,
)
from astrbot.core.platform.astr_message_event import MessageSession
from astrbot.core.platform.message_type import MessageType
from astrbot.core.agent.runners.deerflow.deerflow_api_client import DeerFlowAPIClient
from astrbot.core.utils.active_event_registry import active_event_registry
from .utils.rst_scene import RstScene
@@ -21,6 +20,85 @@ THIRD_PARTY_AGENT_RUNNER_KEY = {
THIRD_PARTY_AGENT_RUNNER_STR = ", ".join(THIRD_PARTY_AGENT_RUNNER_KEY.keys())
async def _cleanup_deerflow_thread_if_present(
context: star.Context,
umo: str,
) -> None:
try:
thread_id = await sp.get_async(
scope="umo",
scope_id=umo,
key=DEERFLOW_THREAD_ID_KEY,
default="",
)
if not thread_id:
return
cfg = context.get_config(umo=umo)
provider_id = cfg["provider_settings"].get(
DEERFLOW_AGENT_RUNNER_PROVIDER_ID_KEY,
"",
)
if not provider_id:
return
merged_provider_config = context.provider_manager.get_provider_config_by_id(
provider_id,
merged=True,
)
if not merged_provider_config:
logger.warning(
"Failed to resolve DeerFlow provider config for remote thread cleanup: provider_id=%s",
provider_id,
)
return
client = DeerFlowAPIClient(
api_base=merged_provider_config.get(
"deerflow_api_base",
"http://127.0.0.1:2026",
),
api_key=merged_provider_config.get("deerflow_api_key", ""),
auth_header=merged_provider_config.get("deerflow_auth_header", ""),
proxy=merged_provider_config.get("proxy", ""),
)
try:
await client.delete_thread(thread_id)
finally:
try:
await client.close()
except Exception as e:
logger.warning(
"Failed to close DeerFlow API client after thread cleanup: %s",
e,
)
except Exception as e:
logger.warning(
"Failed to clean up DeerFlow thread for session %s: %s",
umo,
e,
)
async def _clear_third_party_agent_runner_state(
context: star.Context,
umo: str,
agent_runner_type: str,
) -> None:
session_key = THIRD_PARTY_AGENT_RUNNER_KEY.get(agent_runner_type)
if not session_key:
return
if agent_runner_type == DEERFLOW_PROVIDER_TYPE:
await _cleanup_deerflow_thread_if_present(context, umo)
await sp.remove_async(
scope="umo",
scope_id=umo,
key=session_key,
)
class ConversationCommands:
def __init__(self, context: star.Context) -> None:
self.context = context
@@ -60,8 +138,8 @@ class ConversationCommands:
if required_perm == "admin" and message.role != "admin":
message.set_result(
MessageEventResult().message(
f"{scene.name}场景下reset命令需要管理员权限"
f" (ID {message.get_sender_id()}) 不是管理员,无法执行此操作。",
f"Reset command requires admin permission in {scene.name} scenario, "
f"you (ID {message.get_sender_id()}) are not admin, cannot perform this action.",
),
)
return
@@ -69,17 +147,21 @@ class ConversationCommands:
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
active_event_registry.stop_all(umo, exclude=message)
await sp.remove_async(
scope="umo",
scope_id=umo,
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
await _clear_third_party_agent_runner_state(
self.context,
umo,
agent_runner_type,
)
message.set_result(
MessageEventResult().message("✅ Conversation reset successfully.")
)
message.set_result(MessageEventResult().message("重置对话成功。"))
return
if not self.context.get_using_provider(umo):
message.set_result(
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
MessageEventResult().message(
"😕 Cannot find any LLM provider. Configure one first."
),
)
return
@@ -88,7 +170,7 @@ class ConversationCommands:
if not cid:
message.set_result(
MessageEventResult().message(
"当前未处于对话状态,请 /switch 切换或者 /new 创建。",
"😕 You are not in a conversation. Use /new to create one.",
),
)
return
@@ -101,7 +183,7 @@ class ConversationCommands:
[],
)
ret = "清除聊天历史成功!"
ret = "✅ Conversation reset successfully."
message.set_extra("_clean_ltm_session", True)
@@ -124,160 +206,29 @@ class ConversationCommands:
if stopped_count > 0:
message.set_result(
MessageEventResult().message(
f"已请求停止 {stopped_count} 个运行中的任务。"
f"✅ Requested to stop {stopped_count} running tasks."
)
)
return
message.set_result(MessageEventResult().message("当前会话没有运行中的任务。"))
async def his(self, message: AstrMessageEvent, page: int = 1) -> None:
"""查看对话记录"""
if not self.context.get_using_provider(message.unified_msg_origin):
message.set_result(
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
)
return
size_per_page = 6
conv_mgr = self.context.conversation_manager
umo = message.unified_msg_origin
session_curr_cid = await conv_mgr.get_curr_conversation_id(umo)
if not session_curr_cid:
session_curr_cid = await conv_mgr.new_conversation(
umo,
message.get_platform_id(),
)
contexts, total_pages = await conv_mgr.get_human_readable_context(
umo,
session_curr_cid,
page,
size_per_page,
message.set_result(
MessageEventResult().message("✅ No running tasks in the current session.")
)
parts = []
for context in contexts:
if len(context) > 150:
context = context[:150] + "..."
parts.append(f"{context}\n")
history = "".join(parts)
ret = (
f"当前对话历史记录:"
f"{history or '无历史记录'}\n\n"
f"{page} 页 | 共 {total_pages}\n"
f"*输入 /history 2 跳转到第 2 页"
)
message.set_result(MessageEventResult().message(ret).use_t2i(False))
async def convs(self, message: AstrMessageEvent, page: int = 1) -> None:
"""查看对话列表"""
cfg = self.context.get_config(umo=message.unified_msg_origin)
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
message.set_result(
MessageEventResult().message(
f"{THIRD_PARTY_AGENT_RUNNER_STR} 对话列表功能暂不支持。",
),
)
return
size_per_page = 6
"""获取所有对话列表"""
conversations_all = await self.context.conversation_manager.get_conversations(
message.unified_msg_origin,
)
"""计算总页数"""
total_pages = (len(conversations_all) + size_per_page - 1) // size_per_page
"""确保页码有效"""
page = max(1, min(page, total_pages))
"""分页处理"""
start_idx = (page - 1) * size_per_page
end_idx = start_idx + size_per_page
conversations_paged = conversations_all[start_idx:end_idx]
parts = ["对话列表:\n---\n"]
"""全局序号从当前页的第一个开始"""
global_index = start_idx + 1
"""生成所有对话的标题字典"""
_titles = {}
for conv in conversations_all:
title = conv.title if conv.title else "新对话"
_titles[conv.cid] = title
"""遍历分页后的对话生成列表显示"""
provider_settings = cfg.get("provider_settings", {})
platform_name = message.get_platform_name()
for conv in conversations_paged:
(
persona_id,
_,
force_applied_persona_id,
_,
) = await self.context.persona_manager.resolve_selected_persona(
umo=message.unified_msg_origin,
conversation_persona_id=conv.persona_id,
platform_name=platform_name,
provider_settings=provider_settings,
)
if persona_id == "[%None]":
persona_name = ""
elif persona_id:
persona_name = persona_id
else:
persona_name = ""
if force_applied_persona_id:
persona_name = f"{persona_name} (自定义规则)"
title = _titles.get(conv.cid, "新对话")
parts.append(
f"{global_index}. {title}({conv.cid[:4]})\n 人格情景: {persona_name}\n 上次更新: {datetime.datetime.fromtimestamp(conv.updated_at).strftime('%m-%d %H:%M')}\n"
)
global_index += 1
parts.append("---\n")
ret = "".join(parts)
curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
message.unified_msg_origin,
)
if curr_cid:
"""从所有对话的标题字典中获取标题"""
title = _titles.get(curr_cid, "新对话")
ret += f"\n当前对话: {title}({curr_cid[:4]})"
else:
ret += "\n当前对话: 无"
cfg = self.context.get_config(umo=message.unified_msg_origin)
unique_session = cfg["platform_settings"]["unique_session"]
if unique_session:
ret += "\n会话隔离粒度: 个人"
else:
ret += "\n会话隔离粒度: 群聊"
ret += f"\n{page} 页 | 共 {total_pages}"
ret += "\n*输入 /ls 2 跳转到第 2 页"
message.set_result(MessageEventResult().message(ret).use_t2i(False))
return
async def new_conv(self, message: AstrMessageEvent) -> None:
"""创建新对话"""
cfg = self.context.get_config(umo=message.unified_msg_origin)
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
active_event_registry.stop_all(message.unified_msg_origin, exclude=message)
await sp.remove_async(
scope="umo",
scope_id=message.unified_msg_origin,
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
await _clear_third_party_agent_runner_state(
self.context,
message.unified_msg_origin,
agent_runner_type,
)
message.set_result(
MessageEventResult().message("✅ New conversation created.")
)
message.set_result(MessageEventResult().message("已创建新对话。"))
return
active_event_registry.stop_all(message.unified_msg_origin, exclude=message)
@@ -291,130 +242,7 @@ class ConversationCommands:
message.set_extra("_clean_ltm_session", True)
message.set_result(
MessageEventResult().message(f"切换到新对话: 新对话({cid[:4]})。"),
MessageEventResult().message(
f"✅ Switched to new conversation: {cid[:4]}"
),
)
async def groupnew_conv(self, message: AstrMessageEvent, sid: str = "") -> None:
"""创建新群聊对话"""
if sid:
session = str(
MessageSession(
platform_name=message.platform_meta.id,
message_type=MessageType("GroupMessage"),
session_id=sid,
),
)
cpersona = await self._get_current_persona_id(session)
cid = await self.context.conversation_manager.new_conversation(
session,
message.get_platform_id(),
persona_id=cpersona,
)
message.set_result(
MessageEventResult().message(
f"群聊 {session} 已切换到新对话: 新对话({cid[:4]})。",
),
)
else:
message.set_result(
MessageEventResult().message("请输入群聊 ID。/groupnew 群聊ID。"),
)
async def switch_conv(
self,
message: AstrMessageEvent,
index: int | None = None,
) -> None:
"""通过 /ls 前面的序号切换对话"""
if not isinstance(index, int):
message.set_result(
MessageEventResult().message("类型错误,请输入数字对话序号。"),
)
return
if index is None:
message.set_result(
MessageEventResult().message(
"请输入对话序号。/switch 对话序号。/ls 查看对话 /new 新建对话",
),
)
return
conversations = await self.context.conversation_manager.get_conversations(
message.unified_msg_origin,
)
if index > len(conversations) or index < 1:
message.set_result(
MessageEventResult().message("对话序号错误,请使用 /ls 查看"),
)
else:
conversation = conversations[index - 1]
title = conversation.title if conversation.title else "新对话"
await self.context.conversation_manager.switch_conversation(
message.unified_msg_origin,
conversation.cid,
)
message.set_result(
MessageEventResult().message(
f"切换到对话: {title}({conversation.cid[:4]})。",
),
)
async def rename_conv(self, message: AstrMessageEvent, new_name: str = "") -> None:
"""重命名对话"""
if not new_name:
message.set_result(MessageEventResult().message("请输入新的对话名称。"))
return
await self.context.conversation_manager.update_conversation_title(
message.unified_msg_origin,
new_name,
)
message.set_result(MessageEventResult().message("重命名对话成功。"))
async def del_conv(self, message: AstrMessageEvent) -> None:
"""删除当前对话"""
umo = message.unified_msg_origin
cfg = self.context.get_config(umo=umo)
is_unique_session = cfg["platform_settings"]["unique_session"]
if message.get_group_id() and not is_unique_session and message.role != "admin":
# 群聊,没开独立会话,发送人不是管理员
message.set_result(
MessageEventResult().message(
f"会话处于群聊,并且未开启独立会话,并且您 (ID {message.get_sender_id()}) 不是管理员,因此没有权限删除当前对话。",
),
)
return
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
active_event_registry.stop_all(umo, exclude=message)
await sp.remove_async(
scope="umo",
scope_id=umo,
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
)
message.set_result(MessageEventResult().message("重置对话成功。"))
return
session_curr_cid = (
await self.context.conversation_manager.get_curr_conversation_id(umo)
)
if not session_curr_cid:
message.set_result(
MessageEventResult().message(
"当前未处于对话状态,请 /switch 序号 切换或 /new 创建。",
),
)
return
active_event_registry.stop_all(umo, exclude=message)
await self.context.conversation_manager.delete_conversation(
umo,
session_curr_cid,
)
ret = "删除当前对话成功。不再处于对话状态,使用 /switch 序号 切换到其他对话或 /new 创建。"
message.set_extra("_clean_ltm_session", True)
message.set_result(MessageEventResult().message(ret))

View File

@@ -32,7 +32,6 @@ class HelpCommand:
return []
lines: list[str] = []
hidden_commands = {"set", "unset", "websearch"}
def walk(items: list[dict], indent: int = 0) -> None:
for item in items:
@@ -49,9 +48,12 @@ class HelpCommand:
or item.get("original_command")
or item.get("handler_name")
)
if not effective:
continue
if effective in hidden_commands:
if not effective or effective in [
"set",
"unset",
"help",
"dashboard_update",
]:
continue
description = item.get("description") or ""
@@ -73,12 +75,13 @@ class HelpCommand:
dashboard_version = await get_dashboard_version()
command_lines = await self._build_reserved_command_lines()
commands_section = (
"\n".join(command_lines) if command_lines else "暂无启用的内置指令"
"\n".join(command_lines)
if command_lines
else "No enabled built-in commands."
)
msg_parts = [
f"AstrBot v{VERSION}(WebUI: {dashboard_version})",
"内置指令:",
commands_section,
]
if notice:

View File

@@ -1,20 +0,0 @@
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, MessageChain
class LLMCommands:
def __init__(self, context: star.Context) -> None:
self.context = context
async def llm(self, event: AstrMessageEvent) -> None:
"""开启/关闭 LLM"""
cfg = self.context.get_config(umo=event.unified_msg_origin)
enable = cfg["provider_settings"].get("enable", True)
if enable:
cfg["provider_settings"]["enable"] = False
status = "关闭"
else:
cfg["provider_settings"]["enable"] = True
status = "开启"
cfg.save_config()
await event.send(MessageChain().message(f"{status} LLM 聊天功能。"))

View File

@@ -1,216 +0,0 @@
import builtins
from typing import TYPE_CHECKING
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
if TYPE_CHECKING:
from astrbot.core.db.po import Persona
class PersonaCommands:
def __init__(self, context: star.Context) -> None:
self.context = context
def _build_tree_output(
self,
folder_tree: list[dict],
all_personas: list["Persona"],
depth: int = 0,
) -> list[str]:
"""递归构建树状输出,使用短线条表示层级"""
lines: list[str] = []
# 使用短线条作为缩进前缀,每层只用 "│" 加一个空格
prefix = "" * depth
for folder in folder_tree:
# 输出文件夹
lines.append(f"{prefix}├ 📁 {folder['name']}/")
# 获取该文件夹下的人格
folder_personas = [
p for p in all_personas if p.folder_id == folder["folder_id"]
]
child_prefix = "" * (depth + 1)
# 输出该文件夹下的人格
for persona in folder_personas:
lines.append(f"{child_prefix}├ 👤 {persona.persona_id}")
# 递归处理子文件夹
children = folder.get("children", [])
if children:
lines.extend(
self._build_tree_output(
children,
all_personas,
depth + 1,
)
)
return lines
async def persona(self, message: AstrMessageEvent) -> None:
l = message.message_str.split(" ") # noqa: E741
umo = message.unified_msg_origin
curr_persona_name = ""
cid = await self.context.conversation_manager.get_curr_conversation_id(umo)
default_persona = await self.context.persona_manager.get_default_persona_v3(
umo=umo,
)
force_applied_persona_id = None
curr_cid_title = ""
if cid:
conv = await self.context.conversation_manager.get_conversation(
unified_msg_origin=umo,
conversation_id=cid,
create_if_not_exists=True,
)
if conv is None:
message.set_result(
MessageEventResult().message(
"当前对话不存在,请先使用 /new 新建一个对话。",
),
)
return
provider_settings = self.context.get_config(umo=umo).get(
"provider_settings",
{},
)
(
persona_id,
_,
force_applied_persona_id,
_,
) = await self.context.persona_manager.resolve_selected_persona(
umo=umo,
conversation_persona_id=conv.persona_id,
platform_name=message.get_platform_name(),
provider_settings=provider_settings,
)
if persona_id == "[%None]":
curr_persona_name = ""
elif persona_id:
curr_persona_name = persona_id
if force_applied_persona_id:
curr_persona_name = f"{curr_persona_name} (自定义规则)"
curr_cid_title = conv.title if conv.title else "新对话"
curr_cid_title += f"({cid[:4]})"
if len(l) == 1:
message.set_result(
MessageEventResult()
.message(
f"""[Persona]
- 人格情景列表: `/persona list`
- 设置人格情景: `/persona 人格`
- 人格情景详细信息: `/persona view 人格`
- 取消人格: `/persona unset`
默认人格情景: {default_persona["name"]}
当前对话 {curr_cid_title} 的人格情景: {curr_persona_name}
配置人格情景请前往管理面板-配置页
""",
)
.use_t2i(False),
)
elif l[1] == "list":
# 获取文件夹树和所有人格
folder_tree = await self.context.persona_manager.get_folder_tree()
all_personas = self.context.persona_manager.personas
lines = ["📂 人格列表:\n"]
# 构建树状输出
tree_lines = self._build_tree_output(folder_tree, all_personas)
lines.extend(tree_lines)
# 输出根目录下的人格(没有文件夹的)
root_personas = [p for p in all_personas if p.folder_id is None]
if root_personas:
if tree_lines: # 如果有文件夹内容,加个空行
lines.append("")
for persona in root_personas:
lines.append(f"👤 {persona.persona_id}")
# 统计信息
total_count = len(all_personas)
lines.append(f"\n{total_count} 个人格")
lines.append("\n*使用 `/persona <人格名>` 设置人格")
lines.append("*使用 `/persona view <人格名>` 查看详细信息")
msg = "\n".join(lines)
message.set_result(MessageEventResult().message(msg).use_t2i(False))
elif l[1] == "view":
if len(l) == 2:
message.set_result(MessageEventResult().message("请输入人格情景名"))
return
ps = l[2].strip()
if persona := next(
builtins.filter(
lambda persona: persona["name"] == ps,
self.context.provider_manager.personas,
),
None,
):
msg = f"人格{ps}的详细信息:\n"
msg += f"{persona['prompt']}\n"
else:
msg = f"人格{ps}不存在"
message.set_result(MessageEventResult().message(msg))
elif l[1] == "unset":
if not cid:
message.set_result(
MessageEventResult().message("当前没有对话,无法取消人格。"),
)
return
await self.context.conversation_manager.update_conversation_persona_id(
message.unified_msg_origin,
"[%None]",
)
message.set_result(MessageEventResult().message("取消人格成功。"))
else:
ps = "".join(l[1:]).strip()
if not cid:
message.set_result(
MessageEventResult().message(
"当前没有对话,请先开始对话或使用 /new 创建一个对话。",
),
)
return
if persona := next(
builtins.filter(
lambda persona: persona["name"] == ps,
self.context.provider_manager.personas,
),
None,
):
await self.context.conversation_manager.update_conversation_persona_id(
message.unified_msg_origin,
ps,
)
force_warn_msg = ""
if force_applied_persona_id:
force_warn_msg = (
"提醒:由于自定义规则,您现在切换的人格将不会生效。"
)
message.set_result(
MessageEventResult().message(
f"设置成功。如果您正在切换到不同的人格,请注意使用 /reset 来清空上下文,防止原人格对话影响现人格。{force_warn_msg}",
),
)
else:
message.set_result(
MessageEventResult().message(
"不存在该人格情景。使用 /persona list 查看所有。",
),
)

View File

@@ -1,120 +0,0 @@
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
from astrbot.core import DEMO_MODE, logger
from astrbot.core.star.filter.command import CommandFilter
from astrbot.core.star.filter.command_group import CommandGroupFilter
from astrbot.core.star.star_handler import StarHandlerMetadata, star_handlers_registry
from astrbot.core.star.star_manager import PluginManager
class PluginCommands:
def __init__(self, context: star.Context) -> None:
self.context = context
async def plugin_ls(self, event: AstrMessageEvent) -> None:
"""获取已经安装的插件列表。"""
parts = ["已加载的插件:\n"]
for plugin in self.context.get_all_stars():
line = f"- `{plugin.name}` By {plugin.author}: {plugin.desc}"
if not plugin.activated:
line += " (未启用)"
parts.append(line + "\n")
if len(parts) == 1:
plugin_list_info = "没有加载任何插件。"
else:
plugin_list_info = "".join(parts)
plugin_list_info += "\n使用 /plugin help <插件名> 查看插件帮助和加载的指令。\n使用 /plugin on/off <插件名> 启用或者禁用插件。"
event.set_result(
MessageEventResult().message(f"{plugin_list_info}").use_t2i(False),
)
async def plugin_off(self, event: AstrMessageEvent, plugin_name: str = "") -> None:
"""禁用插件"""
if DEMO_MODE:
event.set_result(MessageEventResult().message("演示模式下无法禁用插件。"))
return
if not plugin_name:
event.set_result(
MessageEventResult().message("/plugin off <插件名> 禁用插件。"),
)
return
await self.context._star_manager.turn_off_plugin(plugin_name) # type: ignore
event.set_result(MessageEventResult().message(f"插件 {plugin_name} 已禁用。"))
async def plugin_on(self, event: AstrMessageEvent, plugin_name: str = "") -> None:
"""启用插件"""
if DEMO_MODE:
event.set_result(MessageEventResult().message("演示模式下无法启用插件。"))
return
if not plugin_name:
event.set_result(
MessageEventResult().message("/plugin on <插件名> 启用插件。"),
)
return
await self.context._star_manager.turn_on_plugin(plugin_name) # type: ignore
event.set_result(MessageEventResult().message(f"插件 {plugin_name} 已启用。"))
async def plugin_get(self, event: AstrMessageEvent, plugin_repo: str = "") -> None:
"""安装插件"""
if DEMO_MODE:
event.set_result(MessageEventResult().message("演示模式下无法安装插件。"))
return
if not plugin_repo:
event.set_result(
MessageEventResult().message("/plugin get <插件仓库地址> 安装插件"),
)
return
logger.info(f"准备从 {plugin_repo} 安装插件。")
if self.context._star_manager:
star_mgr: PluginManager = self.context._star_manager
try:
await star_mgr.install_plugin(plugin_repo) # type: ignore
event.set_result(MessageEventResult().message("安装插件成功。"))
except Exception as e:
logger.error(f"安装插件失败: {e}")
event.set_result(MessageEventResult().message(f"安装插件失败: {e}"))
return
async def plugin_help(self, event: AstrMessageEvent, plugin_name: str = "") -> None:
"""获取插件帮助"""
if not plugin_name:
event.set_result(
MessageEventResult().message("/plugin help <插件名> 查看插件信息。"),
)
return
plugin = self.context.get_registered_star(plugin_name)
if plugin is None:
event.set_result(MessageEventResult().message("未找到此插件。"))
return
help_msg = ""
help_msg += f"\n\n✨ 作者: {plugin.author}\n✨ 版本: {plugin.version}"
command_handlers = []
command_names = []
for handler in star_handlers_registry:
assert isinstance(handler, StarHandlerMetadata)
if handler.handler_module_path != plugin.module_path:
continue
for filter_ in handler.event_filters:
if isinstance(filter_, CommandFilter):
command_handlers.append(handler)
command_names.append(filter_.command_name)
break
if isinstance(filter_, CommandGroupFilter):
command_handlers.append(handler)
command_names.append(filter_.group_name)
if len(command_handlers) > 0:
parts = ["\n\n🔧 指令列表:\n"]
for i in range(len(command_handlers)):
line = f"- {command_names[i]}"
if command_handlers[i].desc:
line += f": {command_handlers[i].desc}"
parts.append(line + "\n")
parts.append("\nTip: 指令的触发需要添加唤醒前缀,默认为 /。")
help_msg += "".join(parts)
ret = f"🧩 插件 {plugin_name} 帮助信息:\n" + help_msg
ret += "更多帮助信息请查看插件仓库 README。"
event.set_result(MessageEventResult().message(ret).use_t2i(False))

View File

@@ -1,736 +0,0 @@
from __future__ import annotations
import asyncio
import time
from collections.abc import Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING
from astrbot import logger
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
from astrbot.core.provider.entities import ProviderType
from astrbot.core.utils.error_redaction import safe_error
if TYPE_CHECKING:
from astrbot.core.provider.provider import Provider
MODEL_LIST_CACHE_TTL_SECONDS_DEFAULT = 30.0
MODEL_LOOKUP_MAX_CONCURRENCY_DEFAULT = 4
MODEL_LOOKUP_MAX_CONCURRENCY_UPPER_BOUND = 16
MODEL_LIST_CACHE_TTL_KEY = "model_list_cache_ttl_seconds"
MODEL_LOOKUP_MAX_CONCURRENCY_KEY = "model_lookup_max_concurrency"
MODEL_CACHE_MAX_ENTRIES = 512
@dataclass(frozen=True)
class _ModelLookupConfig:
umo: str | None
cache_ttl_seconds: float
max_concurrency: int
class _ModelCache:
def __init__(self) -> None:
self._store: dict[tuple[str, str | None], tuple[float, list[str]]] = {}
def get(self, provider_id: str, umo: str | None, ttl: float) -> list[str] | None:
if ttl <= 0:
return None
entry = self._store.get((provider_id, umo))
if not entry:
return None
timestamp, models = entry
if time.monotonic() - timestamp > ttl:
self._store.pop((provider_id, umo), None)
return None
return models
def set(
self, provider_id: str, umo: str | None, models: list[str], ttl: float
) -> None:
if ttl <= 0:
return
self._store[(provider_id, umo)] = (time.monotonic(), list(models))
self._evict_if_needed()
def _evict_if_needed(self) -> None:
if len(self._store) <= MODEL_CACHE_MAX_ENTRIES:
return
# Drop oldest entries first when cache grows too large.
overflow = len(self._store) - MODEL_CACHE_MAX_ENTRIES
for key, _ in sorted(
self._store.items(),
key=lambda item: item[1][0],
)[:overflow]:
self._store.pop(key, None)
def invalidate(
self, provider_id: str | None = None, *, umo: str | None = None
) -> None:
if provider_id is None:
self._store.clear()
return
if umo is not None:
self._store.pop((provider_id, umo), None)
return
stale_keys = [
cache_key for cache_key in self._store if cache_key[0] == provider_id
]
for cache_key in stale_keys:
self._store.pop(cache_key, None)
class ProviderCommands:
def __init__(self, context: star.Context) -> None:
self.context = context
self._model_cache = _ModelCache()
self._register_provider_change_hook()
def _register_provider_change_hook(self) -> None:
set_change_callback = getattr(
self.context.provider_manager,
"set_provider_change_callback",
None,
)
if callable(set_change_callback):
set_change_callback(self._on_provider_manager_changed)
return
register_change_hook = getattr(
self.context.provider_manager,
"register_provider_change_hook",
None,
)
if callable(register_change_hook):
register_change_hook(self._on_provider_manager_changed)
def invalidate_provider_models_cache(
self, provider_id: str | None = None, *, umo: str | None = None
) -> None:
"""Public hook for cache invalidation on external provider config changes."""
self._model_cache.invalidate(provider_id, umo=umo)
def _on_provider_manager_changed(
self,
provider_id: str,
provider_type: ProviderType,
umo: str | None,
) -> None:
if provider_type == ProviderType.CHAT_COMPLETION:
self.invalidate_provider_models_cache(provider_id, umo=umo)
def _get_provider_settings(self, umo: str | None) -> dict:
if not umo:
return {}
try:
return self.context.get_config(umo).get("provider_settings", {}) or {}
except Exception as e:
logger.debug(
"读取 provider_settings 失败,使用默认值: %s",
safe_error("", e),
)
return {}
def _get_model_cache_ttl(self, umo: str | None) -> float:
settings = self._get_provider_settings(umo)
raw = settings.get(
MODEL_LIST_CACHE_TTL_KEY,
MODEL_LIST_CACHE_TTL_SECONDS_DEFAULT,
)
try:
return max(float(raw), 0.0)
except Exception as e:
logger.debug(
"读取 %s 失败,回退默认值 %r: %s",
MODEL_LIST_CACHE_TTL_KEY,
MODEL_LIST_CACHE_TTL_SECONDS_DEFAULT,
safe_error("", e),
)
return MODEL_LIST_CACHE_TTL_SECONDS_DEFAULT
def _get_model_lookup_concurrency(self, umo: str | None) -> int:
settings = self._get_provider_settings(umo)
raw = settings.get(
MODEL_LOOKUP_MAX_CONCURRENCY_KEY,
MODEL_LOOKUP_MAX_CONCURRENCY_DEFAULT,
)
try:
value = int(raw)
except Exception as e:
logger.debug(
"读取 %s 失败,回退默认值 %r: %s",
MODEL_LOOKUP_MAX_CONCURRENCY_KEY,
MODEL_LOOKUP_MAX_CONCURRENCY_DEFAULT,
safe_error("", e),
)
value = MODEL_LOOKUP_MAX_CONCURRENCY_DEFAULT
return min(max(value, 1), MODEL_LOOKUP_MAX_CONCURRENCY_UPPER_BOUND)
def _get_model_lookup_config(self, umo: str | None) -> _ModelLookupConfig:
return _ModelLookupConfig(
umo=umo,
cache_ttl_seconds=self._get_model_cache_ttl(umo),
max_concurrency=self._get_model_lookup_concurrency(umo),
)
def _resolve_model_name(
self,
model_name: str,
models: Sequence[str],
) -> str | None:
"""Resolve model name with precedence:
exact > case-insensitive > provider-qualified suffix.
"""
requested = model_name.strip()
if not requested:
return None
requested_norm = requested.casefold()
# exact / case-insensitive match
for candidate in models:
if candidate == requested or candidate.casefold() == requested_norm:
return candidate
# provider-qualified suffix match:
# e.g. candidate `openai/gpt-4o` should match requested `gpt-4o`.
for candidate in models:
cand_norm = candidate.casefold()
if cand_norm.endswith(f"/{requested_norm}") or cand_norm.endswith(
f":{requested_norm}"
):
return candidate
return None
def _apply_model(
self, prov: Provider, model_name: str, *, umo: str | None = None
) -> str:
prov.set_model(model_name)
self.invalidate_provider_models_cache(prov.meta().id, umo=umo)
return f"切换模型成功。当前提供商: [{prov.meta().id}] 当前模型: [{prov.get_model()}]"
async def _get_provider_models(
self,
provider: Provider,
*,
config: _ModelLookupConfig,
use_cache: bool = True,
) -> list[str]:
provider_id = provider.meta().id
ttl_seconds = config.cache_ttl_seconds
umo = config.umo
if use_cache:
cached = self._model_cache.get(provider_id, umo, ttl_seconds)
if cached is not None:
return cached
models = list(await provider.get_models())
if use_cache:
self._model_cache.set(provider_id, umo, models, ttl_seconds)
return models
async def _get_models_or_reply_error(
self,
message: AstrMessageEvent,
prov: Provider,
config: _ModelLookupConfig,
*,
error_prefix: str,
disable_t2i: bool = False,
warning_log: str | None = None,
) -> list[str] | None:
try:
return await self._get_provider_models(prov, config=config)
except asyncio.CancelledError:
raise
except Exception as e:
if warning_log is not None:
logger.warning(
warning_log,
prov.meta().id,
safe_error("", e),
)
result = MessageEventResult().message(safe_error(error_prefix, e))
if disable_t2i:
result = result.use_t2i(False)
message.set_result(result)
return None
def _log_reachability_failure(
self,
provider,
provider_capability_type: ProviderType | None,
err_code: str,
err_reason: str,
) -> None:
"""记录不可达原因到日志。"""
meta = provider.meta()
logger.warning(
"Provider reachability check failed: id=%s type=%s code=%s reason=%s",
meta.id,
provider_capability_type.name if provider_capability_type else "unknown",
err_code,
err_reason,
)
async def _test_provider_capability(self, provider):
"""测试单个 provider 的可用性"""
meta = provider.meta()
provider_capability_type = meta.provider_type
try:
await provider.test()
return True, None, None
except Exception as e:
err_code = "TEST_FAILED"
err_reason = safe_error("", e)
self._log_reachability_failure(
provider, provider_capability_type, err_code, err_reason
)
return False, err_code, err_reason
async def _find_provider_for_model(
self,
model_name: str,
*,
exclude_provider_id: str | None = None,
config: _ModelLookupConfig,
use_cache: bool = True,
) -> tuple[Provider | None, str | None]:
all_providers = []
for provider in self.context.get_all_providers():
provider_meta = provider.meta()
if provider_meta.provider_type != ProviderType.CHAT_COMPLETION:
continue
if (
exclude_provider_id is not None
and provider_meta.id == exclude_provider_id
):
continue
all_providers.append(provider)
if not all_providers:
return None, None
semaphore = asyncio.Semaphore(config.max_concurrency)
async def fetch_models(
provider: Provider,
) -> tuple[Provider, list[str] | None, str | None]:
async with semaphore:
try:
models = await self._get_provider_models(
provider,
config=config,
use_cache=use_cache,
)
return provider, models, None
except asyncio.CancelledError:
raise
except Exception as e:
err = safe_error("", e)
logger.debug(
"跨提供商查找模型 %s 获取 %s 模型列表失败: %s",
model_name,
provider.meta().id,
err,
)
return provider, None, err
results = await asyncio.gather(
*(fetch_models(provider) for provider in all_providers)
)
failed_provider_errors: list[tuple[str, str]] = []
for provider, models, err in results:
if err is not None:
failed_provider_errors.append((provider.meta().id, err))
continue
if models is None:
continue
matched_model_name = self._resolve_model_name(model_name, models)
if matched_model_name is not None:
return provider, matched_model_name
if failed_provider_errors and len(failed_provider_errors) == len(all_providers):
failed_ids = ",".join(
provider_id for provider_id, _ in failed_provider_errors
)
logger.error(
"跨提供商查找模型 %s 时,所有 %d 个提供商的 get_models() 均失败: %s。请检查配置或网络",
model_name,
len(all_providers),
failed_ids,
)
elif failed_provider_errors:
logger.debug(
"跨提供商查找模型 %s 时有 %d 个提供商获取模型失败: %s",
model_name,
len(failed_provider_errors),
",".join(
f"{provider_id}({error})"
for provider_id, error in failed_provider_errors
),
)
return None, None
async def provider(
self,
event: AstrMessageEvent,
idx: str | int | None = None,
idx2: int | None = None,
) -> None:
"""查看或者切换 LLM Provider"""
umo = event.unified_msg_origin
cfg = self.context.get_config(umo).get("provider_settings", {})
reachability_check_enabled = cfg.get("reachability_check", True)
if idx is None:
parts = ["## 载入的 LLM 提供商\n"]
# 获取所有类型的提供商
llms = list(self.context.get_all_providers())
ttss = self.context.get_all_tts_providers()
stts = self.context.get_all_stt_providers()
# 构造待检测列表: [(provider, type_label), ...]
all_providers = []
all_providers.extend([(p, "llm") for p in llms])
all_providers.extend([(p, "tts") for p in ttss])
all_providers.extend([(p, "stt") for p in stts])
# 并发测试连通性
if reachability_check_enabled:
if all_providers:
await event.send(
MessageEventResult().message(
"正在进行提供商可达性测试,请稍候..."
)
)
check_results = await asyncio.gather(
*[self._test_provider_capability(p) for p, _ in all_providers],
return_exceptions=True,
)
else:
# 用 None 表示未检测
check_results = [None for _ in all_providers]
# 整合结果
display_data = []
for (p, p_type), reachable in zip(all_providers, check_results):
meta = p.meta()
id_ = meta.id
error_code = None
if isinstance(reachable, asyncio.CancelledError):
raise reachable
if isinstance(reachable, Exception):
# 异常情况下兜底处理,避免单个 provider 导致列表失败
self._log_reachability_failure(
p,
None,
reachable.__class__.__name__,
safe_error("", reachable),
)
reachable_flag = False
error_code = reachable.__class__.__name__
elif isinstance(reachable, tuple):
reachable_flag, error_code, _ = reachable
else:
reachable_flag = reachable
# 根据类型构建显示名称
if p_type == "llm":
info = f"{id_} ({meta.model})"
else:
info = f"{id_}"
# 确定状态标记
if reachable_flag is True:
mark = ""
elif reachable_flag is False:
if error_code:
mark = f" ❌(错误码: {error_code})"
else:
mark = ""
else:
mark = "" # 不支持检测时不显示标记
display_data.append(
{
"type": p_type,
"info": info,
"mark": mark,
"provider": p,
}
)
# 分组输出
# 1. LLM
llm_data = [d for d in display_data if d["type"] == "llm"]
for i, d in enumerate(llm_data):
line = f"{i + 1}. {d['info']}{d['mark']}"
provider_using = self.context.get_using_provider(umo=umo)
if (
provider_using
and provider_using.meta().id == d["provider"].meta().id
):
line += " (当前使用)"
parts.append(line + "\n")
# 2. TTS
tts_data = [d for d in display_data if d["type"] == "tts"]
if tts_data:
parts.append("\n## 载入的 TTS 提供商\n")
for i, d in enumerate(tts_data):
line = f"{i + 1}. {d['info']}{d['mark']}"
tts_using = self.context.get_using_tts_provider(umo=umo)
if tts_using and tts_using.meta().id == d["provider"].meta().id:
line += " (当前使用)"
parts.append(line + "\n")
# 3. STT
stt_data = [d for d in display_data if d["type"] == "stt"]
if stt_data:
parts.append("\n## 载入的 STT 提供商\n")
for i, d in enumerate(stt_data):
line = f"{i + 1}. {d['info']}{d['mark']}"
stt_using = self.context.get_using_stt_provider(umo=umo)
if stt_using and stt_using.meta().id == d["provider"].meta().id:
line += " (当前使用)"
parts.append(line + "\n")
parts.append("\n使用 /provider <序号> 切换 LLM 提供商。")
ret = "".join(parts)
if ttss:
ret += "\n使用 /provider tts <序号> 切换 TTS 提供商。"
if stts:
ret += "\n使用 /provider stt <序号> 切换 STT 提供商。"
if not reachability_check_enabled:
ret += "\n已跳过提供商可达性检测,如需检测请在配置文件中开启。"
event.set_result(MessageEventResult().message(ret))
elif idx == "tts":
if idx2 is None:
event.set_result(MessageEventResult().message("请输入序号。"))
return
if idx2 > len(self.context.get_all_tts_providers()) or idx2 < 1:
event.set_result(MessageEventResult().message("无效的提供商序号。"))
return
provider = self.context.get_all_tts_providers()[idx2 - 1]
id_ = provider.meta().id
await self.context.provider_manager.set_provider(
provider_id=id_,
provider_type=ProviderType.TEXT_TO_SPEECH,
umo=umo,
)
event.set_result(MessageEventResult().message(f"成功切换到 {id_}"))
elif idx == "stt":
if idx2 is None:
event.set_result(MessageEventResult().message("请输入序号。"))
return
if idx2 > len(self.context.get_all_stt_providers()) or idx2 < 1:
event.set_result(MessageEventResult().message("无效的提供商序号。"))
return
provider = self.context.get_all_stt_providers()[idx2 - 1]
id_ = provider.meta().id
await self.context.provider_manager.set_provider(
provider_id=id_,
provider_type=ProviderType.SPEECH_TO_TEXT,
umo=umo,
)
event.set_result(MessageEventResult().message(f"成功切换到 {id_}"))
elif isinstance(idx, int):
if idx > len(self.context.get_all_providers()) or idx < 1:
event.set_result(MessageEventResult().message("无效的提供商序号。"))
return
provider = self.context.get_all_providers()[idx - 1]
id_ = provider.meta().id
await self.context.provider_manager.set_provider(
provider_id=id_,
provider_type=ProviderType.CHAT_COMPLETION,
umo=umo,
)
event.set_result(MessageEventResult().message(f"成功切换到 {id_}"))
else:
event.set_result(MessageEventResult().message("无效的参数。"))
async def _switch_model_by_name(
self, message: AstrMessageEvent, model_name: str, prov: Provider
) -> None:
model_name = model_name.strip()
if not model_name:
message.set_result(MessageEventResult().message("模型名不能为空。"))
return
umo = message.unified_msg_origin
config = self._get_model_lookup_config(umo)
curr_provider_id = prov.meta().id
models = await self._get_models_or_reply_error(
message,
prov,
config,
error_prefix="获取当前提供商模型列表失败: ",
warning_log="获取当前提供商 %s 模型列表失败,停止跨提供商查找: %s",
)
if models is None:
return
matched_model_name = self._resolve_model_name(model_name, models)
if matched_model_name is not None:
message.set_result(
MessageEventResult().message(
self._apply_model(prov, matched_model_name, umo=umo)
),
)
return
target_prov, matched_target_model_name = await self._find_provider_for_model(
model_name,
exclude_provider_id=curr_provider_id,
config=config,
)
if target_prov is None or matched_target_model_name is None:
message.set_result(
MessageEventResult().message(
f"模型 [{model_name}] 未在任何已配置的提供商中找到,或所有提供商模型列表获取失败,请检查配置或网络后重试。",
),
)
return
target_id = target_prov.meta().id
try:
await self.context.provider_manager.set_provider(
provider_id=target_id,
provider_type=ProviderType.CHAT_COMPLETION,
umo=umo,
)
self._apply_model(target_prov, matched_target_model_name, umo=umo)
message.set_result(
MessageEventResult().message(
f"检测到模型 [{matched_target_model_name}] 属于提供商 [{target_id}],已自动切换提供商并设置模型。",
),
)
except asyncio.CancelledError:
raise
except Exception as e:
message.set_result(
MessageEventResult().message(
safe_error("跨提供商切换并设置模型失败: ", e)
),
)
async def model_ls(
self,
message: AstrMessageEvent,
idx_or_name: int | str | None = None,
) -> None:
"""查看或者切换模型"""
prov = self.context.get_using_provider(message.unified_msg_origin)
if not prov:
message.set_result(
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
)
return
config = self._get_model_lookup_config(message.unified_msg_origin)
if idx_or_name is None:
models = await self._get_models_or_reply_error(
message,
prov,
config,
error_prefix="获取模型列表失败: ",
disable_t2i=True,
)
if models is None:
return
parts = ["下面列出了此模型提供商可用模型:"]
for i, model in enumerate(models, 1):
parts.append(f"\n{i}. {model}")
curr_model = prov.get_model() or ""
parts.append(f"\n当前模型: [{curr_model}]")
parts.append(
"\nTips: 使用 /model <模型名/编号> 切换模型。输入模型名时可自动跨提供商查找并切换;跨提供商也可使用 /provider 切换。"
)
ret = "".join(parts)
message.set_result(MessageEventResult().message(ret).use_t2i(False))
elif isinstance(idx_or_name, int):
models = await self._get_models_or_reply_error(
message,
prov,
config,
error_prefix="获取模型列表失败: ",
)
if models is None:
return
if idx_or_name > len(models) or idx_or_name < 1:
message.set_result(MessageEventResult().message("模型序号错误。"))
else:
try:
new_model = models[idx_or_name - 1]
message.set_result(
MessageEventResult().message(
self._apply_model(
prov,
new_model,
umo=message.unified_msg_origin,
)
),
)
except Exception as e:
message.set_result(
MessageEventResult().message(
safe_error("切换模型未知错误: ", e)
),
)
return
else:
await self._switch_model_by_name(message, idx_or_name, prov)
async def key(self, message: AstrMessageEvent, index: int | None = None) -> None:
prov = self.context.get_using_provider(message.unified_msg_origin)
if not prov:
message.set_result(
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
)
return
if index is None:
keys_data = prov.get_keys()
curr_key = prov.get_current_key()
parts = ["Key:"]
for i, k in enumerate(keys_data, 1):
parts.append(f"\n{i}. {k[:8]}")
parts.append(f"\n当前 Key: {curr_key[:8]}")
parts.append("\n当前模型: " + prov.get_model())
parts.append("\n使用 /key <idx> 切换 Key。")
ret = "".join(parts)
message.set_result(MessageEventResult().message(ret).use_t2i(False))
else:
keys_data = prov.get_keys()
if index > len(keys_data) or index < 1:
message.set_result(MessageEventResult().message("Key 序号错误。"))
else:
try:
new_key = keys_data[index - 1]
prov.set_key(new_key)
self.invalidate_provider_models_cache(
prov.meta().id,
umo=message.unified_msg_origin,
)
message.set_result(MessageEventResult().message("切换 Key 成功。"))
except Exception as e:
message.set_result(
MessageEventResult().message(
safe_error("切换 Key 未知错误: ", e)
),
)
return

View File

@@ -18,19 +18,19 @@ class SIDCommand:
umo_msg_type = event.session.message_type.value
umo_session_id = event.session.session_id
ret = (
f"UMO: 「{sid} 此值可用于设置白名单。\n"
f"UID: 「{user_id} 此值可用于设置管理员。\n"
f"消息会话来源信息:\n"
f" 机器人 ID: 「{umo_platform}\n"
f" 消息类型: 「{umo_msg_type}\n"
f" 会话 ID: 「{umo_session_id}\n"
f"消息来源可用于配置机器人的配置文件路由。"
f"UMO: 「{sid}\n"
f"UID: 「{user_id}\n"
"*Use UMO to set whitelist and configure routing, use UID to set admin list(UMO 可用于设置白名单和配置文件路由UID 可用于设置管理员列表)\n\n"
f"Your session information:\n"
f"Bot ID: 「{umo_platform}\n"
f"Message Type: 「{umo_msg_type}\n"
f"Session ID: 「{umo_session_id}\n\n"
)
if (
self.context.get_config()["platform_settings"]["unique_session"]
and event.get_group_id()
):
ret += f"\n\n当前处于独立会话模式, 此群 ID: 「{event.get_group_id()}, 也可将此 ID 加入白名单来放行整个群聊。"
ret += f"\n\nThe group's ID: 「{event.get_group_id()}. Set this ID to whitelist to allow the entire group."
event.set_result(MessageEventResult().message(ret).use_t2i(False))

View File

@@ -1,23 +0,0 @@
"""文本转图片命令"""
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
class T2ICommand:
"""文本转图片命令类"""
def __init__(self, context: star.Context) -> None:
self.context = context
async def t2i(self, event: AstrMessageEvent) -> None:
"""开关文本转图片"""
config = self.context.get_config(umo=event.unified_msg_origin)
if config["t2i"]:
config["t2i"] = False
config.save_config()
event.set_result(MessageEventResult().message("已关闭文本转图片模式。"))
return
config["t2i"] = True
config.save_config()
event.set_result(MessageEventResult().message("已开启文本转图片模式。"))

View File

@@ -1,36 +0,0 @@
"""文本转语音命令"""
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
from astrbot.core.star.session_llm_manager import SessionServiceManager
class TTSCommand:
"""文本转语音命令类"""
def __init__(self, context: star.Context) -> None:
self.context = context
async def tts(self, event: AstrMessageEvent) -> None:
"""开关文本转语音(会话级别)"""
umo = event.unified_msg_origin
ses_tts = await SessionServiceManager.is_tts_enabled_for_session(umo)
cfg = self.context.get_config(umo=umo)
tts_enable = cfg["provider_tts_settings"]["enable"]
# 切换状态
new_status = not ses_tts
await SessionServiceManager.set_tts_status_for_session(umo, new_status)
status_text = "已开启" if new_status else "已关闭"
if new_status and not tts_enable:
event.set_result(
MessageEventResult().message(
f"{status_text}当前会话的文本转语音。但 TTS 功能在配置中未启用,请前往 WebUI 开启。",
),
)
else:
event.set_result(
MessageEventResult().message(f"{status_text}当前会话的文本转语音。"),
)

View File

@@ -3,17 +3,10 @@ from astrbot.api.event import AstrMessageEvent, filter
from .commands import (
AdminCommands,
AlterCmdCommands,
ConversationCommands,
HelpCommand,
LLMCommands,
PersonaCommands,
PluginCommands,
ProviderCommands,
SetUnsetCommands,
SIDCommand,
T2ICommand,
TTSCommand,
)
@@ -21,198 +14,49 @@ class Main(star.Star):
def __init__(self, context: star.Context) -> None:
self.context = context
self.help_c = HelpCommand(self.context)
self.llm_c = LLMCommands(self.context)
self.plugin_c = PluginCommands(self.context)
self.admin_c = AdminCommands(self.context)
self.conversation_c = ConversationCommands(self.context)
self.provider_c = ProviderCommands(self.context)
self.persona_c = PersonaCommands(self.context)
self.alter_cmd_c = AlterCmdCommands(self.context)
self.help_c = HelpCommand(self.context)
self.setunset_c = SetUnsetCommands(self.context)
self.t2i_c = T2ICommand(self.context)
self.tts_c = TTSCommand(self.context)
self.sid_c = SIDCommand(self.context)
@filter.command("help")
async def help(self, event: AstrMessageEvent) -> None:
"""查看帮助"""
"""Show help message"""
await self.help_c.help(event)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("llm")
async def llm(self, event: AstrMessageEvent) -> None:
"""开启/关闭 LLM"""
await self.llm_c.llm(event)
@filter.command_group("plugin")
def plugin(self) -> None:
"""插件管理"""
@plugin.command("ls")
async def plugin_ls(self, event: AstrMessageEvent) -> None:
"""获取已经安装的插件列表。"""
await self.plugin_c.plugin_ls(event)
@filter.permission_type(filter.PermissionType.ADMIN)
@plugin.command("off")
async def plugin_off(self, event: AstrMessageEvent, plugin_name: str = "") -> None:
"""禁用插件"""
await self.plugin_c.plugin_off(event, plugin_name)
@filter.permission_type(filter.PermissionType.ADMIN)
@plugin.command("on")
async def plugin_on(self, event: AstrMessageEvent, plugin_name: str = "") -> None:
"""启用插件"""
await self.plugin_c.plugin_on(event, plugin_name)
@filter.permission_type(filter.PermissionType.ADMIN)
@plugin.command("get")
async def plugin_get(self, event: AstrMessageEvent, plugin_repo: str = "") -> None:
"""安装插件"""
await self.plugin_c.plugin_get(event, plugin_repo)
@plugin.command("help")
async def plugin_help(self, event: AstrMessageEvent, plugin_name: str = "") -> None:
"""获取插件帮助"""
await self.plugin_c.plugin_help(event, plugin_name)
@filter.command("t2i")
async def t2i(self, event: AstrMessageEvent) -> None:
"""开关文本转图片"""
await self.t2i_c.t2i(event)
@filter.command("tts")
async def tts(self, event: AstrMessageEvent) -> None:
"""开关文本转语音(会话级别)"""
await self.tts_c.tts(event)
@filter.command("sid")
async def sid(self, event: AstrMessageEvent) -> None:
"""获取会话 ID 和 管理员 ID"""
"""Get session ID and other related information"""
await self.sid_c.sid(event)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("op")
async def op(self, event: AstrMessageEvent, admin_id: str = "") -> None:
"""授权管理员。op <admin_id>"""
await self.admin_c.op(event, admin_id)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("deop")
async def deop(self, event: AstrMessageEvent, admin_id: str) -> None:
"""取消授权管理员。deop <admin_id>"""
await self.admin_c.deop(event, admin_id)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("wl")
async def wl(self, event: AstrMessageEvent, sid: str = "") -> None:
"""添加白名单。wl <sid>"""
await self.admin_c.wl(event, sid)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("dwl")
async def dwl(self, event: AstrMessageEvent, sid: str) -> None:
"""删除白名单。dwl <sid>"""
await self.admin_c.dwl(event, sid)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("provider")
async def provider(
self,
event: AstrMessageEvent,
idx: str | int | None = None,
idx2: int | None = None,
) -> None:
"""查看或者切换 LLM Provider"""
await self.provider_c.provider(event, idx, idx2)
@filter.command("reset")
async def reset(self, message: AstrMessageEvent) -> None:
"""重置 LLM 会话"""
"""Reset conversation history"""
await self.conversation_c.reset(message)
@filter.command("stop")
async def stop(self, message: AstrMessageEvent) -> None:
"""停止当前会话中正在运行的 Agent"""
"""Stop agent execution"""
await self.conversation_c.stop(message)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("model")
async def model_ls(
self,
message: AstrMessageEvent,
idx_or_name: int | str | None = None,
) -> None:
"""查看或者切换模型"""
await self.provider_c.model_ls(message, idx_or_name)
@filter.command("history")
async def his(self, message: AstrMessageEvent, page: int = 1) -> None:
"""查看对话记录"""
await self.conversation_c.his(message, page)
@filter.command("ls")
async def convs(self, message: AstrMessageEvent, page: int = 1) -> None:
"""查看对话列表"""
await self.conversation_c.convs(message, page)
@filter.command("new")
async def new_conv(self, message: AstrMessageEvent) -> None:
"""创建新对话"""
"""Create new conversation"""
await self.conversation_c.new_conv(message)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("groupnew")
async def groupnew_conv(self, message: AstrMessageEvent, sid: str) -> None:
"""创建新群聊对话"""
await self.conversation_c.groupnew_conv(message, sid)
@filter.command("switch")
async def switch_conv(
self, message: AstrMessageEvent, index: int | None = None
) -> None:
"""通过 /ls 前面的序号切换对话"""
await self.conversation_c.switch_conv(message, index)
@filter.command("rename")
async def rename_conv(self, message: AstrMessageEvent, new_name: str) -> None:
"""重命名对话"""
await self.conversation_c.rename_conv(message, new_name)
@filter.command("del")
async def del_conv(self, message: AstrMessageEvent) -> None:
"""删除当前对话"""
await self.conversation_c.del_conv(message)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("key")
async def key(self, message: AstrMessageEvent, index: int | None = None) -> None:
"""查看或者切换 Key"""
await self.provider_c.key(message, index)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("persona")
async def persona(self, message: AstrMessageEvent) -> None:
"""查看或者切换 Persona"""
await self.persona_c.persona(message)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("dashboard_update")
async def update_dashboard(self, event: AstrMessageEvent) -> None:
"""更新管理面板"""
"""Update AstrBot WebUI"""
await self.admin_c.update_dashboard(event)
@filter.command("set")
async def set_variable(self, event: AstrMessageEvent, key: str, value: str) -> None:
"""Set session variable"""
await self.setunset_c.set_variable(event, key, value)
@filter.command("unset")
async def unset_variable(self, event: AstrMessageEvent, key: str) -> None:
"""Unset session variable"""
await self.setunset_c.unset_variable(event, key)
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("alter_cmd", alias={"alter"})
async def alter_cmd(self, event: AstrMessageEvent) -> None:
"""修改命令权限"""
await self.alter_cmd_c.alter_cmd(event)

View File

@@ -1 +1 @@
__version__ = "4.23.0-beta.1"
__version__ = "4.23.1"

View File

@@ -1,9 +1,11 @@
import asyncio
import logging
import os
import re
import sys
from contextlib import AsyncExitStack
from datetime import timedelta
from pathlib import Path, PureWindowsPath
from typing import Generic
from tenacity import (
@@ -21,6 +23,75 @@ from astrbot.core.utils.log_pipe import LogPipe
from .run_context import TContext
from .tool import FunctionTool
_DEFAULT_STDIO_COMMAND_ALLOWLIST = frozenset(
{
"python",
"python3",
"py",
"node",
"npx",
"npm",
"pnpm",
"yarn",
"bun",
"bunx",
"deno",
"uv",
"uvx",
}
)
_DENIED_STDIO_COMMANDS = frozenset(
{
"bash",
"sh",
"zsh",
"fish",
"cmd",
"cmd.exe",
"powershell",
"powershell.exe",
"pwsh",
"pwsh.exe",
"osascript",
"open",
"curl",
"wget",
"nc",
"netcat",
"telnet",
"ssh",
"scp",
"rm",
"mv",
"cp",
"dd",
"mkfs",
"sudo",
"su",
"chmod",
"chown",
"kill",
"killall",
"shutdown",
"reboot",
"poweroff",
"halt",
}
)
_SHELL_META_RE = re.compile(r"[\r\n\x00;&|<>`$]")
_PYTHON_INLINE_CODE_FLAGS = frozenset({"-c"})
_JS_INLINE_CODE_FLAGS = frozenset({"-e", "--eval", "-p", "--print"})
_DENIED_DOCKER_ARGS = frozenset(
{
"--privileged",
"--pid=host",
"--network=host",
"--net=host",
"--ipc=host",
}
)
_STDIO_ALLOWLIST_ENV = "ASTRBOT_MCP_STDIO_ALLOWED_COMMANDS"
try:
import anyio
import mcp
@@ -42,11 +113,129 @@ def _prepare_config(config: dict) -> dict:
"""Prepare configuration, handle nested format"""
if config.get("mcpServers"):
first_key = next(iter(config["mcpServers"]))
config = config["mcpServers"][first_key]
config = dict(config["mcpServers"][first_key])
else:
config = dict(config)
config.pop("active", None)
return config
def _normalize_stdio_command_name(command: str) -> str:
command = command.strip()
if "\\" in command:
command_name = PureWindowsPath(command).name
else:
command_name = Path(command).name
command_name = command_name.lower()
for suffix in (".exe", ".cmd", ".bat"):
if command_name.endswith(suffix):
return command_name[: -len(suffix)]
return command_name
def _get_stdio_command_allowlist() -> set[str]:
allowed = set(_DEFAULT_STDIO_COMMAND_ALLOWLIST)
configured = os.environ.get(_STDIO_ALLOWLIST_ENV, "")
if configured.strip():
allowed = {
_normalize_stdio_command_name(item)
for item in configured.split(",")
if item.strip()
}
return allowed
def _is_stdio_config(config: dict) -> bool:
cfg = _prepare_config(config.copy())
return "url" not in cfg
def _validate_stdio_args(command_name: str, args: object) -> None:
if args is None:
return
if not isinstance(args, list) or not all(isinstance(arg, str) for arg in args):
raise ValueError("MCP stdio args must be a list of strings.")
for arg in args:
if "\x00" in arg or "\r" in arg or "\n" in arg:
raise ValueError("MCP stdio args cannot contain control characters.")
if command_name.startswith("python") or command_name == "py":
if any(
arg == "-c"
or (arg.startswith("-") and not arg.startswith("--") and "c" in arg)
for arg in args
):
raise ValueError(
"MCP stdio Python servers must be launched from a module or file; inline code flags such as -c are not allowed."
)
elif command_name in {"node", "deno", "bun"} or command_name.startswith("node"):
if any(
arg in _JS_INLINE_CODE_FLAGS
or arg == "eval"
or (
arg.startswith("-")
and not arg.startswith("--")
and any(c in arg for c in "ep")
)
for arg in args
):
raise ValueError(
"MCP stdio JavaScript servers must be launched from a package or file; inline eval flags are not allowed."
)
elif command_name == "docker":
denied = []
for i, arg in enumerate(args):
if arg in _DENIED_DOCKER_ARGS:
denied.append(arg)
elif (
arg in {"--network", "--net", "--pid", "--ipc"}
and i + 1 < len(args)
and args[i + 1] == "host"
):
denied.append(f"{arg} {args[i + 1]}")
if denied:
raise ValueError(
f"MCP stdio Docker args are unsafe and not allowed: {', '.join(denied)}."
)
def validate_mcp_stdio_config(config: dict) -> None:
"""Validate stdio MCP config before any subprocess can be spawned."""
cfg = _prepare_config(config.copy())
if "url" in cfg:
return
command = cfg.get("command")
if not isinstance(command, str) or not command.strip():
raise ValueError("MCP stdio server requires a non-empty command.")
if _SHELL_META_RE.search(command):
raise ValueError("MCP stdio command contains unsafe shell metacharacters.")
command_name = _normalize_stdio_command_name(command)
if command_name in _DENIED_STDIO_COMMANDS:
raise ValueError(f"MCP stdio command `{command_name}` is not allowed.")
allowed = _get_stdio_command_allowlist()
if command_name not in allowed:
allowed_display = ", ".join(sorted(allowed))
raise ValueError(
f"MCP stdio command `{command_name}` is not allowed. "
f"Allowed commands: {allowed_display}. "
f"Set {_STDIO_ALLOWLIST_ENV} to override this list if you trust another launcher."
)
_validate_stdio_args(command_name, cfg.get("args"))
env = cfg.get("env")
if env is not None and not isinstance(env, dict):
raise ValueError("MCP stdio env must be an object.")
if isinstance(env, dict) and not all(
isinstance(key, str) and isinstance(value, str) for key, value in env.items()
):
raise ValueError("MCP stdio env keys and values must be strings.")
def _prepare_stdio_env(config: dict) -> dict:
"""Preserve Windows executable resolution for stdio subprocesses."""
if sys.platform != "win32":
@@ -243,6 +432,7 @@ class MCPClient:
)
else:
validate_mcp_stdio_config(cfg)
cfg = _prepare_stdio_env(cfg)
server_params = mcp.StdioServerParameters(
**cfg,

View File

@@ -410,18 +410,20 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]):
)
return messages
def _build_runtime_context(self, thread_id: str) -> dict[str, T.Any]:
runtime_context: dict[str, T.Any] = {
def _build_runtime_configurable(self, thread_id: str) -> dict[str, T.Any]:
runtime_configurable: dict[str, T.Any] = {
"thread_id": thread_id,
"thinking_enabled": self.thinking_enabled,
"is_plan_mode": self.plan_mode,
"subagent_enabled": self.subagent_enabled,
}
if self.subagent_enabled:
runtime_context["max_concurrent_subagents"] = self.max_concurrent_subagents
runtime_configurable["max_concurrent_subagents"] = (
self.max_concurrent_subagents
)
if self.model_name:
runtime_context["model_name"] = self.model_name
return runtime_context
runtime_configurable["model_name"] = self.model_name
return runtime_configurable
def _build_payload(
self,
@@ -430,16 +432,19 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]):
image_urls: list[str],
system_prompt: str | None,
) -> dict[str, T.Any]:
runtime_configurable = self._build_runtime_configurable(thread_id)
return {
"assistant_id": self.assistant_id,
"input": {
"messages": self._build_messages(prompt, image_urls, system_prompt),
},
"stream_mode": ["values", "messages-tuple", "custom"],
# LangGraph 0.6+ prefers context instead of configurable.
"context": self._build_runtime_context(thread_id),
# DeerFlow 2.0 consumes runtime overrides from config.configurable.
# Keep the legacy context mirror for older compat paths.
"context": dict(runtime_configurable),
"config": {
"recursion_limit": self.recursion_limit,
"configurable": runtime_configurable,
},
}

View File

@@ -10,6 +10,33 @@ from astrbot.core import logger
SSE_MAX_BUFFER_CHARS = 1_048_576
class DeerFlowAPIError(Exception):
def __init__(
self,
*,
operation: str,
status: int,
body: str,
url: str,
thread_id: str | None = None,
) -> None:
self.operation = operation
self.status = status
self.body = body
self.url = url
self.thread_id = thread_id
message = (
f"DeerFlow {operation} failed: status={status}, url={url}, body={body}"
)
if thread_id is not None:
message = (
f"DeerFlow {operation} failed: thread_id={thread_id}, "
f"status={status}, url={url}, body={body}"
)
super().__init__(message)
def _normalize_sse_newlines(text: str) -> str:
"""Normalize CRLF/CR to LF so SSE block splitting works reliably."""
return text.replace("\r\n", "\n").replace("\r", "\n")
@@ -152,11 +179,33 @@ class DeerFlowAPIClient:
) as resp:
if resp.status not in (200, 201):
text = await resp.text()
raise Exception(
f"DeerFlow create thread failed: {resp.status}. {text}",
raise DeerFlowAPIError(
operation="create thread",
status=resp.status,
body=text,
url=url,
)
return await resp.json()
async def delete_thread(self, thread_id: str, timeout: float = 20) -> None:
session = self._get_session()
url = f"{self.api_base}/api/threads/{thread_id}"
async with session.delete(
url,
headers=self.headers,
timeout=timeout,
proxy=self.proxy,
) as resp:
if resp.status not in (200, 202, 204, 404):
text = await resp.text()
raise DeerFlowAPIError(
operation="delete thread",
status=resp.status,
body=text,
url=url,
thread_id=thread_id,
)
async def stream_run(
self,
thread_id: str,
@@ -200,8 +249,12 @@ class DeerFlowAPIClient:
) as resp:
if resp.status != 200:
text = await resp.text()
raise Exception(
f"DeerFlow runs/stream request failed: {resp.status}. {text}",
raise DeerFlowAPIError(
operation="runs/stream request",
status=resp.status,
body=text,
url=url,
thread_id=thread_id,
)
async for event in _stream_sse(resp):
yield event

View File

@@ -12,6 +12,15 @@ from astrbot.core.star.star_handler import EventType
class MainAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
async def on_agent_begin(
self, run_context: ContextWrapper[AstrAgentContext]
) -> None:
await call_event_hook(
run_context.context.event,
EventType.OnAgentBeginEvent,
run_context,
)
async def on_agent_done(self, run_context, llm_response) -> None:
# 执行事件钩子
if llm_response and llm_response.reasoning_content:
@@ -25,6 +34,12 @@ class MainAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
EventType.OnLLMResponseEvent,
llm_response,
)
await call_event_hook(
run_context.context.event,
EventType.OnAgentDoneEvent,
run_context,
llm_response,
)
async def on_tool_start(
self,

View File

@@ -5,7 +5,7 @@ from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.23.0-beta.1"
VERSION = "4.23.1"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
PERSONAL_WECHAT_CONFIG_METADATA = {
"weixin_oc_base_url": {
@@ -2671,12 +2671,12 @@ CONFIG_METADATA_2 = {
"deerflow_assistant_id": {
"description": "Assistant ID",
"type": "string",
"hint": "LangGraph assistant_id默认为 lead_agent。",
"hint": "DeerFlow 2.0 LangGraph assistant_id默认为 lead_agent。",
},
"deerflow_model_name": {
"description": "模型名称覆盖",
"type": "string",
"hint": "可选。覆盖 DeerFlow 默认模型(对应 runtime context 的 model_name",
"hint": "可选。覆盖 DeerFlow 默认模型(对应运行时 configurable 的 model_name",
},
"deerflow_thinking_enabled": {
"description": "启用思考模式",
@@ -2685,17 +2685,17 @@ CONFIG_METADATA_2 = {
"deerflow_plan_mode": {
"description": "启用计划模式",
"type": "bool",
"hint": "对应 DeerFlow 的 is_plan_mode。",
"hint": "对应 DeerFlow 2.0 运行时 configurable 的 is_plan_mode。",
},
"deerflow_subagent_enabled": {
"description": "启用子智能体",
"type": "bool",
"hint": "对应 DeerFlow 的 subagent_enabled。",
"hint": "对应 DeerFlow 2.0 运行时 configurable 的 subagent_enabled。",
},
"deerflow_max_concurrent_subagents": {
"description": "子智能体最大并发数",
"type": "int",
"hint": "对应 DeerFlow 的 max_concurrent_subagents。仅在启用子智能体时生效默认 3。",
"hint": "对应 DeerFlow 2.0 运行时 configurable 的 max_concurrent_subagents。仅在启用子智能体时生效默认 3。",
},
"deerflow_recursion_limit": {
"description": "递归深度上限",

View File

@@ -4,6 +4,7 @@ import uuid
import numpy as np
from astrbot import logger
from astrbot.core.exceptions import KnowledgeBaseUploadError
from astrbot.core.provider.provider import EmbeddingProvider, RerankProvider
from ..base import BaseVecDB, Result
@@ -80,6 +81,32 @@ class FaissVecDB(BaseVecDB):
)
return []
content_count = len(contents)
if len(metadatas) != content_count:
raise KnowledgeBaseUploadError(
stage="storage",
user_message=(
f"存储失败:文本分块数量与元数据数量不一致(期望 {content_count}"
f"实际 {len(metadatas)})。"
),
details={
"expected_contents": content_count,
"actual_metadatas": len(metadatas),
},
)
if len(ids) != content_count:
raise KnowledgeBaseUploadError(
stage="storage",
user_message=(
f"存储失败:文本分块数量与文档 ID 数量不一致(期望 {content_count}"
f"实际 {len(ids)})。"
),
details={
"expected_contents": content_count,
"actual_ids": len(ids),
},
)
start = time.time()
logger.debug(f"Generating embeddings for {len(contents)} contents...")
vectors = await self.embedding_provider.get_embeddings_batch(
@@ -93,6 +120,20 @@ class FaissVecDB(BaseVecDB):
logger.debug(
f"Generated embeddings for {len(contents)} contents in {end - start:.2f} seconds.",
)
if len(vectors) != content_count:
raise KnowledgeBaseUploadError(
stage="embedding",
user_message=(
"向量化失败:嵌入模型返回的向量数量与文本分块数量不一致"
f"(期望 {content_count},实际 {len(vectors)})。"
"这通常说明当前 Embedding 接口未完整返回批量结果,"
"或该服务不兼容当前批量请求格式。"
),
details={
"expected_contents": content_count,
"actual_vectors": len(vectors),
},
)
# 使用 DocumentStorage 的批量插入方法
int_ids = await self.document_storage.insert_documents_batch(
@@ -100,9 +141,52 @@ class FaissVecDB(BaseVecDB):
contents,
metadatas,
)
if len(int_ids) != content_count:
raise KnowledgeBaseUploadError(
stage="storage",
user_message=(
f"存储失败:写入文档索引后返回的内部 ID 数量与文本分块数量不一致"
f"(期望 {content_count},实际 {len(int_ids)})。"
),
details={
"expected_contents": content_count,
"actual_int_ids": len(int_ids),
},
)
# 批量插入向量到 FAISS
vectors_array = np.array(vectors).astype("float32")
try:
vectors_array = np.asarray(vectors, dtype=np.float32)
except (TypeError, ValueError) as exc:
raise KnowledgeBaseUploadError(
stage="embedding",
user_message=(
"向量化失败:嵌入模型返回的向量格式不正确,"
"无法转换为统一的浮点向量矩阵。"
),
details={"vector_count": len(vectors)},
) from exc
if vectors_array.ndim != 2:
raise KnowledgeBaseUploadError(
stage="embedding",
user_message=(
"向量化失败:嵌入模型返回的向量格式不正确,无法构造成二维向量矩阵。"
),
details={"actual_ndim": int(vectors_array.ndim)},
)
if vectors_array.shape[1] != self.embedding_storage.dimension:
raise KnowledgeBaseUploadError(
stage="embedding",
user_message=(
"向量化失败:返回向量维度与当前知识库索引维度不一致"
f"(期望 {self.embedding_storage.dimension}"
f"实际 {vectors_array.shape[1]})。"
),
details={
"expected_dimension": self.embedding_storage.dimension,
"actual_dimension": int(vectors_array.shape[1]),
},
)
await self.embedding_storage.insert_batch(vectors_array, int_ids)
return int_ids

View File

@@ -11,3 +11,22 @@ class ProviderNotFoundError(AstrBotError):
class EmptyModelOutputError(AstrBotError):
"""Raised when the model response contains no usable assistant output."""
class KnowledgeBaseUploadError(AstrBotError):
"""Raised when knowledge base upload fails with a user-facing message."""
def __init__(
self,
*,
stage: str,
user_message: str,
details: dict | None = None,
) -> None:
super().__init__(user_message)
self.stage = stage
self.user_message = user_message
self.details = details or {}
def __str__(self) -> str:
return self.user_message

View File

@@ -10,6 +10,7 @@ import aiofiles
from astrbot.core import logger
from astrbot.core.db.vec_db.base import BaseVecDB
from astrbot.core.exceptions import KnowledgeBaseUploadError
from astrbot.core.provider.manager import ProviderManager
from astrbot.core.provider.provider import (
EmbeddingProvider,
@@ -264,10 +265,31 @@ class KBHelper:
if progress_callback:
await progress_callback("parsing", 0, 100)
parser = await select_parser(f".{file_type}")
parse_result = await parser.parse(file_content, file_name)
try:
parser = await select_parser(f".{file_type}")
parse_result = await parser.parse(file_content, file_name)
except KnowledgeBaseUploadError:
raise
except Exception as exc:
raise KnowledgeBaseUploadError(
stage="parsing",
user_message=(
"文档解析失败:无法读取或解析上传文件。"
"请确认文件格式受支持且文件内容未损坏。"
),
details={"file_name": file_name},
) from exc
text_content = parse_result.text
media_items = parse_result.media
if not text_content or not text_content.strip():
raise KnowledgeBaseUploadError(
stage="parsing",
user_message=(
"文档解析失败:未能从文件中提取可索引文本。"
"该文件可能是扫描件、纯图片 PDF或格式暂不受支持。"
),
details={"file_name": file_name},
)
if progress_callback:
await progress_callback("parsing", 100, 100)
@@ -288,11 +310,40 @@ class KBHelper:
if progress_callback:
await progress_callback("chunking", 0, 100)
chunks_text = await self.chunker.chunk(
text_content,
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
)
try:
chunks_text = await self.chunker.chunk(
text_content,
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
)
except KnowledgeBaseUploadError:
raise
except Exception as exc:
raise KnowledgeBaseUploadError(
stage="chunking",
user_message=(
"分块失败:文档内容在切分文本块时发生错误。"
"请稍后重试,或调整分块参数后再次上传。"
),
details={"file_name": file_name},
) from exc
if not chunks_text or not any(chunk.strip() for chunk in chunks_text):
if pre_chunked_text is not None:
raise KnowledgeBaseUploadError(
stage="validation",
user_message=("预分块文本为空,未提供任何可索引文本块。"),
details={"file_name": file_name},
)
else:
raise KnowledgeBaseUploadError(
stage="chunking",
user_message=(
"分块失败:文档内容为空,未生成任何可索引文本块。"
),
details={"file_name": file_name},
)
contents = []
metadatas = []
for idx, chunk_text in enumerate(chunks_text):
@@ -313,14 +364,23 @@ class KBHelper:
if progress_callback:
await progress_callback("embedding", current, total)
await self.vec_db.insert_batch(
contents=contents,
metadatas=metadatas,
batch_size=batch_size,
tasks_limit=tasks_limit,
max_retries=max_retries,
progress_callback=embedding_progress_callback,
)
try:
await self.vec_db.insert_batch(
contents=contents,
metadatas=metadatas,
batch_size=batch_size,
tasks_limit=tasks_limit,
max_retries=max_retries,
progress_callback=embedding_progress_callback,
)
except KnowledgeBaseUploadError:
raise
except Exception as exc:
raise KnowledgeBaseUploadError(
stage="storage",
user_message=("存储失败:文本块已生成,但写入知识库索引时出错。"),
details={"file_name": file_name},
) from exc
# 保存文档的元数据
doc = KBDocument(
@@ -334,22 +394,47 @@ class KBHelper:
chunk_count=len(chunks_text),
media_count=0,
)
async with self.kb_db.get_db() as session:
async with session.begin():
session.add(doc)
for media in saved_media:
session.add(media)
await session.commit()
try:
async with self.kb_db.get_db() as session:
async with session.begin():
session.add(doc)
for media in saved_media:
session.add(media)
await session.commit()
await session.refresh(doc)
await session.refresh(doc)
except KnowledgeBaseUploadError:
raise
except Exception as exc:
raise KnowledgeBaseUploadError(
stage="metadata",
user_message=(
"元数据保存失败:文本块已写入知识库,但文档记录保存失败。"
),
details={"file_name": file_name, "doc_id": doc_id},
) from exc
vec_db: FaissVecDB = self.vec_db # type: ignore
await self.kb_db.update_kb_stats(kb_id=self.kb.kb_id, vec_db=vec_db)
await self.refresh_kb()
await self.refresh_document(doc_id)
try:
await self.kb_db.update_kb_stats(kb_id=self.kb.kb_id, vec_db=vec_db)
await self.refresh_kb()
await self.refresh_document(doc_id)
except KnowledgeBaseUploadError:
raise
except Exception as exc:
raise KnowledgeBaseUploadError(
stage="metadata",
user_message=(
"元数据更新失败:文档已上传,但知识库统计信息刷新失败。"
),
details={"file_name": file_name, "doc_id": doc_id},
) from exc
return doc
except Exception as e:
logger.error(f"上传文档失败: {e}")
if isinstance(e, KnowledgeBaseUploadError):
logger.warning(f"上传文档失败: {e}", extra={"details": e.details})
else:
logger.error(f"上传文档失败: {e}", exc_info=True)
# if file_path.exists():
# file_path.unlink()
@@ -360,7 +445,7 @@ class KBHelper:
except Exception as me:
logger.warning(f"清理多媒体文件失败 {media_path}: {me}")
raise e
raise
async def list_documents(
self,

View File

@@ -436,6 +436,21 @@ class DiscordPlatformAdapter(Platform):
async def dynamic_callback(
ctx: discord.ApplicationContext, params: str | None = None
) -> None:
# 1. 嘗試立即响应,防止超时 (移到最前面)
followup_webhook = None
try:
# 設定 2.5 秒超時,避免卡死整個 event loop
await asyncio.wait_for(ctx.defer(), timeout=2.5)
followup_webhook = ctx.followup
except asyncio.TimeoutError:
logger.warning(
f"[Discord] Defer command '{cmd_name}' timeout. Network might be too slow."
)
return
except Exception as e:
logger.warning(f"[Discord] Failed to defer command '{cmd_name}': {e}")
return
# 将平台特定的前缀'/'剥离以适配通用的CommandFilter
logger.debug(f"[Discord] Callback triggered: {cmd_name}")
logger.debug(f"[Discord] Callback context: {ctx}")
@@ -450,14 +465,6 @@ class DiscordPlatformAdapter(Platform):
f"Built command string: '{message_str_for_filter}'",
)
# 尝试立即响应,防止超时
followup_webhook = None
try:
await ctx.defer()
followup_webhook = ctx.followup
except Exception as e:
logger.warning(f"[Discord] Failed to defer command '{cmd_name}': {e}")
# 2. 构建 AstrBotMessage
channel = ctx.channel
abm = AstrBotMessage()

View File

@@ -6,6 +6,7 @@ import hashlib
import io
import time
import uuid
from collections import deque
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
@@ -15,7 +16,7 @@ import qrcode as qrcode_lib
from astrbot import logger
from astrbot.api.event import MessageChain
from astrbot.api.message_components import File, Image, Plain, Record, Video
from astrbot.api.message_components import File, Image, Plain, Record, Reply, Video
from astrbot.api.platform import (
AstrBotMessage,
MessageMember,
@@ -60,6 +61,34 @@ class TypingSessionState:
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
@dataclass
class WeixinOCRecentMessage:
message_id: str
sender_id: str
sender_nickname: str
timestamp: int
timestamp_ms: int
components: list[Any]
message_str: str
message_kind: str
@dataclass
class WeixinOCRecentSessionCache:
messages: deque[WeixinOCRecentMessage]
updated_at: float
@dataclass
class WeixinOCReplyMeta:
is_reply: bool = False
ref_msg: dict[str, Any] | None = None
reply_kind: str | None = None
quoted_item_type: int | None = None
quoted_text: str | None = None
reply_to: dict[str, Any] = field(default_factory=lambda: {"matched": False})
@register_platform_adapter(
"weixin_oc",
"个人微信",
@@ -73,6 +102,10 @@ class WeixinOCAdapter(Platform):
IMAGE_UPLOAD_TYPE = 1
VIDEO_UPLOAD_TYPE = 2
FILE_UPLOAD_TYPE = 3
RECENT_MESSAGE_CACHE_SIZE = 100
REPLY_MATCH_WINDOW_MS = 60_000
RECENT_SESSION_CACHE_TTL_S = 1_800
MAX_RECENT_MESSAGE_SESSIONS = 500
def __init__(
self,
@@ -118,6 +151,22 @@ class WeixinOCAdapter(Platform):
self._context_tokens: dict[str, str] = {}
self._typing_states: dict[str, TypingSessionState] = {}
self._last_inbound_error = ""
self._recent_message_cache_size = self._get_int_config(
"weixin_oc_recent_message_cache_size",
self.RECENT_MESSAGE_CACHE_SIZE,
1,
)
self._recent_session_cache_ttl_s = self._get_int_config(
"weixin_oc_recent_session_cache_ttl_s",
self.RECENT_SESSION_CACHE_TTL_S,
60,
)
self._max_recent_message_sessions = self._get_int_config(
"weixin_oc_max_recent_message_sessions",
self.MAX_RECENT_MESSAGE_SESSIONS,
1,
)
self._recent_messages: dict[str, WeixinOCRecentSessionCache] = {}
self._typing_keepalive_interval_s = max(
1,
int(platform_config.get("weixin_oc_typing_keepalive_interval", 5)),
@@ -152,6 +201,18 @@ class WeixinOCAdapter(Platform):
self.client.api_timeout_ms = self.api_timeout_ms
self.client.token = self.token
def _get_int_config(
self,
key: str,
default: int,
minimum: int,
) -> int:
try:
value = int(self.config.get(key, default))
except (TypeError, ValueError):
value = default
return max(minimum, value)
def _get_typing_state(self, user_id: str) -> TypingSessionState:
state = self._typing_states.get(user_id)
if state is None:
@@ -201,12 +262,12 @@ class WeixinOCAdapter(Platform):
return state.ticket
payload = await self.client.get_typing_config(user_id, context_token)
if int(payload.get("ret") or 0) != 0:
if not self._is_successful_api_payload(payload):
logger.warning(
"weixin_oc(%s): getconfig failed for %s: %s",
self.meta().id,
user_id,
payload.get("errmsg", ""),
self._format_api_error(payload),
)
return None
@@ -227,9 +288,9 @@ class WeixinOCAdapter(Platform):
cancel: bool,
) -> None:
payload = await self.client.send_typing_state(user_id, ticket, cancel=cancel)
if int(payload.get("ret") or 0) != 0:
if not self._is_successful_api_payload(payload):
raise RuntimeError(
f"sendtyping failed for {user_id}: {payload.get('errmsg', '')}"
f"sendtyping failed for {user_id}: {self._format_api_error(payload)}"
)
async def _run_typing_keepalive(self, user_id: str) -> None:
@@ -769,6 +830,9 @@ class WeixinOCAdapter(Platform):
self,
user_id: str,
item_list: list[dict[str, Any]],
*,
cache_components: list[Any] | None = None,
cache_message_str: str | None = None,
) -> bool:
if not self.token:
logger.warning("weixin_oc(%s): missing token, skip send", self.meta().id)
@@ -787,7 +851,7 @@ class WeixinOCAdapter(Platform):
user_id,
)
return False
await self.client.request_json(
payload = await self.client.request_json(
"POST",
"ilink/bot/sendmessage",
payload={
@@ -807,8 +871,65 @@ class WeixinOCAdapter(Platform):
token_required=True,
headers={},
)
if not self._is_successful_api_payload(payload):
logger.warning(
"weixin_oc(%s): sendmessage failed for %s: %s",
self.meta().id,
user_id,
self._format_api_error(payload),
)
return False
resolved_cache_components = (
list(cache_components)
if cache_components is not None
else self._build_cache_components_from_items(item_list)
)
sender_id = str(self.account_id or self.meta().id)
sent_at_ms = time.time_ns() // 1_000_000
self._cache_recent_message(
user_id,
message_id=uuid.uuid4().hex,
sender_id=sender_id,
sender_nickname=sender_id,
timestamp=sent_at_ms // 1000,
timestamp_ms=sent_at_ms,
components=resolved_cache_components,
message_str=cache_message_str
if cache_message_str is not None
else self._message_text_from_item_list(
item_list,
include_ref_text=False,
),
)
return True
def _build_cache_components_from_items(
self,
item_list: list[dict[str, Any]],
) -> list[Any]:
components: list[Any] = []
for item in item_list:
item_type = int(item.get("type") or 0)
if item_type != 1:
continue
text = str(item.get("text_item", {}).get("text", "")).strip()
if text:
components.append(Plain(text))
return components
@staticmethod
def _is_successful_api_payload(payload: dict[str, Any]) -> bool:
ret = payload.get("ret", 0)
errcode = payload.get("errcode", 0)
return int(ret or 0) == 0 and int(errcode or 0) == 0
@staticmethod
def _format_api_error(payload: dict[str, Any]) -> str:
ret = int(payload.get("ret") or 0)
errcode = int(payload.get("errcode") or 0)
errmsg = str(payload.get("errmsg", ""))
return f"ret={ret}, errcode={errcode}, errmsg={errmsg}"
async def _send_media_segment(
self,
user_id: str,
@@ -858,8 +979,18 @@ class WeixinOCAdapter(Platform):
await self._send_items_to_session(
user_id,
[self._build_plain_text_item(text)],
cache_components=[Plain(text)],
cache_message_str=text,
)
return await self._send_items_to_session(user_id, [media_item])
return await self._send_items_to_session(
user_id,
[media_item],
cache_components=[segment],
cache_message_str=self._message_text_from_item_list(
[media_item],
include_ref_text=False,
),
)
async def _start_login_session(self) -> OpenClawLoginSession:
endpoint = "ilink/bot/get_bot_qrcode"
@@ -961,7 +1092,10 @@ class WeixinOCAdapter(Platform):
await self._save_account_state()
def _message_text_from_item_list(
self, item_list: list[dict[str, Any]] | None
self,
item_list: list[dict[str, Any]] | None,
*,
include_ref_text: bool = False,
) -> str:
if not item_list:
return ""
@@ -985,15 +1119,298 @@ class WeixinOCAdapter(Platform):
elif item_type == 5:
text_parts.append("[视频]")
else:
ref = item.get("ref_msg")
if isinstance(ref, dict):
ref_item = ref.get("message_item")
if isinstance(ref_item, dict):
ref_text = str(self._message_text_from_item_list([ref_item]))
if ref_text:
text_parts.append(f"[引用:{ref_text}]")
if include_ref_text:
ref = item.get("ref_msg")
if isinstance(ref, dict):
ref_item = ref.get("message_item")
if isinstance(ref_item, dict):
ref_text = str(
self._message_text_from_item_list(
[ref_item],
include_ref_text=True,
)
)
if ref_text:
text_parts.append(f"[引用:{ref_text}]")
return "\n".join(text_parts).strip()
def _item_type_to_kind(self, item_type: int | None) -> str:
match int(item_type or 0):
case 1:
return "text"
case self.IMAGE_ITEM_TYPE:
return "image"
case self.VOICE_ITEM_TYPE:
return "voice"
case self.FILE_ITEM_TYPE:
return "file"
case self.VIDEO_ITEM_TYPE:
return "video"
case _:
return "unknown"
def _get_recent_message_cache(
self,
session_id: str,
) -> deque[WeixinOCRecentMessage]:
now = time.monotonic()
self._prune_recent_message_caches(now=now)
cache_entry = self._recent_messages.get(session_id)
if cache_entry is None:
cache_entry = WeixinOCRecentSessionCache(
messages=deque(maxlen=self._recent_message_cache_size),
updated_at=now,
)
self._recent_messages[session_id] = cache_entry
else:
cache_entry.updated_at = now
return cache_entry.messages
def _prune_recent_message_caches(self, *, now: float | None = None) -> None:
if not self._recent_messages:
return
current = now if now is not None else time.monotonic()
expired_session_ids = [
session_id
for session_id, cache_entry in self._recent_messages.items()
if current - cache_entry.updated_at > self._recent_session_cache_ttl_s
]
for session_id in expired_session_ids:
self._recent_messages.pop(session_id, None)
overflow = len(self._recent_messages) - self._max_recent_message_sessions
if overflow <= 0:
return
oldest_session_ids = sorted(
self._recent_messages,
key=lambda session_id: self._recent_messages[session_id].updated_at,
)[:overflow]
for session_id in oldest_session_ids:
self._recent_messages.pop(session_id, None)
def _infer_message_kind_from_components(self, components: list[Any]) -> str:
if not components:
return "unknown"
for component in components:
if isinstance(component, Plain) and component.text.strip():
return "text"
if isinstance(component, Image):
return "image"
if isinstance(component, Record):
return "voice"
if isinstance(component, File):
return "file"
if isinstance(component, Video):
return "video"
return "unknown"
def _cache_recent_message(
self,
session_id: str,
*,
message_id: str,
sender_id: str,
sender_nickname: str,
timestamp: int,
timestamp_ms: int | None = None,
components: list[Any],
message_str: str,
message_kind: str | None = None,
) -> None:
if not session_id or not message_id:
return
resolved_timestamp_ms = (
timestamp_ms if timestamp_ms is not None else timestamp * 1000
)
cache = self._get_recent_message_cache(session_id)
cache.append(
WeixinOCRecentMessage(
message_id=message_id,
sender_id=sender_id,
sender_nickname=sender_nickname,
timestamp=timestamp,
timestamp_ms=resolved_timestamp_ms,
components=list(components),
message_str=message_str,
message_kind=message_kind
or self._infer_message_kind_from_components(components),
)
)
def _match_recent_reply(
self,
session_id: str,
*,
ref_create_time_ms: int | None,
) -> tuple[WeixinOCRecentMessage | None, dict[str, Any] | None]:
if not session_id or ref_create_time_ms is None:
return None, None
best_match: WeixinOCRecentMessage | None = None
best_distance: int | None = None
self._prune_recent_message_caches()
cache_entry = self._recent_messages.get(session_id)
if cache_entry is None:
return None, None
for candidate in cache_entry.messages:
distance = abs(candidate.timestamp_ms - ref_create_time_ms)
if distance > self.REPLY_MATCH_WINDOW_MS:
continue
if best_distance is None or distance < best_distance:
best_match = candidate
best_distance = distance
if best_match is None or best_distance is None:
return None, None
confidence = max(
0.0,
min(1.0, 1.0 - (best_distance / self.REPLY_MATCH_WINDOW_MS)),
)
return best_match, {
"matched": True,
"strategy": "nearest-message-by-timestamp",
"ref_create_time_ms": ref_create_time_ms,
"matched_message_id": best_match.message_id,
"matched_kind": best_match.message_kind,
"distance_ms": best_distance,
"confidence": round(confidence, 4),
}
async def _build_reply_component_from_ref(
self,
*,
session_id: str,
ref_msg: dict[str, Any],
) -> tuple[Reply | None, WeixinOCReplyMeta]:
metadata = WeixinOCReplyMeta(ref_msg=ref_msg)
message_item = ref_msg.get("message_item")
if not isinstance(message_item, dict):
return None, metadata
quoted_item_type_raw = message_item.get("type")
try:
quoted_item_type = (
int(quoted_item_type_raw)
if quoted_item_type_raw not in (None, "")
else None
)
except (TypeError, ValueError):
quoted_item_type = None
metadata.quoted_item_type = quoted_item_type
metadata.reply_kind = self._item_type_to_kind(quoted_item_type)
ref_create_time_ms_raw = message_item.get("create_time_ms")
try:
ref_create_time_ms = (
int(ref_create_time_ms_raw)
if ref_create_time_ms_raw not in (None, "")
else None
)
except (TypeError, ValueError):
ref_create_time_ms = None
quoted_components: list[Any] = []
quoted_text = ""
if quoted_item_type is not None:
quoted_components = await self._item_list_to_components([message_item])
quoted_text = self._message_text_from_item_list(
[message_item],
include_ref_text=False,
)
if quoted_text:
metadata.quoted_text = quoted_text
metadata.reply_to = {
"matched": True,
"strategy": "direct-ref-msg",
"matched_kind": metadata.reply_kind,
"matched_text": quoted_text,
"confidence": 1.0,
}
matched_message = None
matched_reply_to = None
if not quoted_text or not quoted_components:
matched_message, matched_reply_to = self._match_recent_reply(
session_id,
ref_create_time_ms=ref_create_time_ms,
)
if matched_message is not None:
quoted_components = list(matched_message.components)
quoted_text = matched_message.message_str
metadata.quoted_text = quoted_text or None
metadata.reply_kind = matched_message.message_kind
metadata.reply_to = matched_reply_to or {"matched": True}
if not quoted_text and not quoted_components:
return None, metadata
metadata.is_reply = True
reply_message_id = (
matched_message.message_id
if matched_message is not None
else str(
message_item.get("message_id")
or message_item.get("msg_id")
or f"weixin_oc_ref_{ref_create_time_ms or uuid.uuid4().hex}"
)
)
quoted_sender_id_raw = str(message_item.get("from_user_id") or "unknown")
reply_sender_id_raw = (
matched_message.sender_id
if matched_message is not None
else quoted_sender_id_raw
)
normalized_reply_sender_id = self._normalize_reply_sender_id(
reply_sender_id_raw
)
reply_sender_id = (
normalized_reply_sender_id
if normalized_reply_sender_id
else reply_sender_id_raw
)
reply_sender_nickname = (
matched_message.sender_nickname
if matched_message is not None
else quoted_sender_id_raw
)
reply_time = (
matched_message.timestamp
if matched_message is not None
else (
int(ref_create_time_ms / 1000)
if isinstance(ref_create_time_ms, int)
else int(time.time())
)
)
return (
Reply(
id=reply_message_id,
chain=quoted_components,
sender_id=reply_sender_id,
sender_nickname=reply_sender_nickname,
time=reply_time,
message_str=quoted_text,
text=quoted_text,
),
metadata,
)
def _normalize_reply_sender_id(self, sender_id: str) -> str:
normalized_sender_id = sender_id.strip()
if not normalized_sender_id:
return normalized_sender_id
if self.account_id and normalized_sender_id == str(self.account_id):
return self.meta().id
return normalized_sender_id
async def _item_list_to_components(
self, item_list: list[dict[str, Any]] | None
) -> list[Any]:
@@ -1031,16 +1448,36 @@ class WeixinOCAdapter(Platform):
self._context_tokens[from_user_id] = context_token
item_list = cast(list[dict[str, Any]], msg.get("item_list", []))
components = await self._item_list_to_components(item_list)
text = self._message_text_from_item_list(item_list)
reply_component = None
reply_metadata = WeixinOCReplyMeta()
for item in item_list:
ref_msg = item.get("ref_msg")
if isinstance(ref_msg, dict):
(
reply_component,
reply_metadata,
) = await self._build_reply_component_from_ref(
session_id=from_user_id,
ref_msg=ref_msg,
)
break
cached_components = await self._item_list_to_components(item_list)
components = list(cached_components)
if reply_component is not None:
components.insert(0, reply_component)
text = self._message_text_from_item_list(item_list, include_ref_text=False)
message_id = str(msg.get("message_id") or msg.get("msg_id") or uuid.uuid4().hex)
create_time = msg.get("create_time_ms") or msg.get("create_time")
create_time_ms: int | None = None
if isinstance(create_time, (int, float)) and create_time > 1_000_000_000_000:
create_time_ms = int(create_time)
ts = int(float(create_time) / 1000)
elif isinstance(create_time, (int, float)):
ts = int(create_time)
create_time_ms = ts * 1000
else:
ts = int(time.time())
create_time_ms = ts * 1000
abm = AstrBotMessage()
abm.self_id = self.meta().id
@@ -1052,6 +1489,23 @@ class WeixinOCAdapter(Platform):
abm.message_str = text
abm.timestamp = ts
abm.raw_message = msg
abm.is_reply = reply_metadata.is_reply
abm.ref_msg = reply_metadata.ref_msg
abm.reply_kind = reply_metadata.reply_kind
abm.quoted_item_type = reply_metadata.quoted_item_type
abm.quoted_text = reply_metadata.quoted_text
abm.reply_to = reply_metadata.reply_to
self._cache_recent_message(
from_user_id,
message_id=message_id,
sender_id=from_user_id,
sender_nickname=from_user_id,
timestamp=ts,
timestamp_ms=create_time_ms,
components=cached_components,
message_str=text,
)
self.commit_event(
WeixinOCMessageEvent(
@@ -1076,20 +1530,8 @@ class WeixinOCAdapter(Platform):
token_required=True,
timeout_ms=self.long_poll_timeout_ms,
)
ret = int(data.get("ret") or 0)
errcode = data.get("errcode", 0)
if ret != 0 and ret is not None:
errmsg = str(data.get("errmsg", ""))
self._last_inbound_error = f"ret={ret}, errcode={errcode}, errmsg={errmsg}"
logger.warning(
"weixin_oc(%s): getupdates error: %s",
self.meta().id,
self._last_inbound_error,
)
return
if errcode and int(errcode) != 0:
errmsg = str(data.get("errmsg", ""))
self._last_inbound_error = f"ret={ret}, errcode={errcode}, errmsg={errmsg}"
if not self._is_successful_api_payload(data):
self._last_inbound_error = self._format_api_error(data)
logger.warning(
"weixin_oc(%s): getupdates error: %s",
self.meta().id,

View File

@@ -505,6 +505,26 @@ class ProviderManager:
pc = merged_config
return pc
def get_provider_config_by_id(
self,
provider_id: str,
*,
merged: bool = False,
) -> dict | None:
"""Get a provider config by id.
Args:
provider_id: Provider id to resolve.
merged: Whether to merge provider_source config into the provider config.
"""
for provider_config in self.providers_config:
if provider_config.get("id") != provider_id:
continue
if merged:
return self.get_merged_provider_config(provider_config)
return copy.deepcopy(provider_config)
return None
def _resolve_env_key_list(self, provider_config: dict) -> dict:
keys = provider_config.get("key", [])
if not isinstance(keys, list):

View File

@@ -1,4 +1,5 @@
import os
from typing import Any
import aiohttp
@@ -88,8 +89,6 @@ class BailianRerankProvider(RerankProvider):
normalized_model = self.model.strip().lower()
normalized_top_n = top_n if top_n is not None and top_n > 0 else None
# qwen3-rerank follows a model-specific payload:
# query/documents/top_n/instruct should be at the top level.
if normalized_model == self.QWEN3_RERANK_MODEL:
payload = {
"model": self.model,
@@ -107,8 +106,7 @@ class BailianRerankProvider(RerankProvider):
)
return payload
base = {"model": self.model, "input": {"query": query, "documents": documents}}
payload_input = {"query": query, "documents": documents}
params = {
k: v
for k, v in [
@@ -118,6 +116,7 @@ class BailianRerankProvider(RerankProvider):
if v is not None
}
base: dict[str, Any] = {"model": self.model, "input": payload_input}
if params:
base["parameters"] = params
@@ -136,14 +135,23 @@ class BailianRerankProvider(RerankProvider):
BailianAPIError: API返回错误
KeyError: 结果缺少必要字段
"""
# 检查响应状态
if data.get("code", "200") != "200":
raise BailianAPIError(
f"百炼 API 错误: {data.get('code')} {data.get('message', '')}"
)
is_compatible_api = "compatible-api" in self.base_url
if is_compatible_api:
code = data.get("code")
if code:
raise BailianAPIError(
f"百炼 API 错误: {code} {data.get('message', '')}"
)
results = data.get("results", [])
else:
code = data.get("code", "200")
if code != "200":
raise BailianAPIError(
f"百炼 API 错误: {code} {data.get('message', '')}"
)
results = data.get("output", {}).get("results", [])
# 兼容旧版 API (output.results) 和新版 compatible API (results)
results = (data.get("output") or {}).get("results") or data.get("results") or []
if not results:
logger.warning(f"百炼 Rerank 返回空结果: {data}")
return []

View File

@@ -7,6 +7,8 @@ from .star_handler import (
register_custom_filter,
register_event_message_type,
register_llm_tool,
register_on_agent_begin,
register_on_agent_done,
register_on_astrbot_loaded,
register_on_decorating_result,
register_on_llm_request,
@@ -31,6 +33,8 @@ __all__ = [
"register_custom_filter",
"register_event_message_type",
"register_llm_tool",
"register_on_agent_begin",
"register_on_agent_done",
"register_on_astrbot_loaded",
"register_on_decorating_result",
"register_on_llm_request",

View File

@@ -460,6 +460,64 @@ def register_on_llm_response(**kwargs):
return decorator
def register_on_agent_begin(**kwargs):
"""当 Agent 开始运行时的事件
Examples:
```py
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.astr_agent_context import AstrAgentContext
@on_agent_begin()
async def test(
self,
event: AstrMessageEvent,
run_context: ContextWrapper[AstrAgentContext],
) -> None:
...
```
请务必接收两个参数event, run_context
"""
def decorator(awaitable):
_ = get_handler_or_create(awaitable, EventType.OnAgentBeginEvent, **kwargs)
return awaitable
return decorator
def register_on_agent_done(**kwargs):
"""当 Agent 运行完成后的事件
Examples:
```py
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.api.provider import LLMResponse
@on_agent_done()
async def test(
self,
event: AstrMessageEvent,
run_context: ContextWrapper[AstrAgentContext],
response: LLMResponse,
) -> None:
...
```
请务必接收三个参数event, run_context, response
"""
def decorator(awaitable):
_ = get_handler_or_create(awaitable, EventType.OnAgentDoneEvent, **kwargs)
return awaitable
return decorator
def register_on_using_llm_tool(**kwargs):
"""当调用函数工具前的事件。
会传入 tool 和 tool_args 参数。

View File

@@ -71,6 +71,22 @@ class StarHandlerRegistry(Generic[T]):
plugins_name: list[str] | None = None,
) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ...
@overload
def get_handlers_by_event_type(
self,
event_type: Literal[EventType.OnAgentBeginEvent],
only_activated=True,
plugins_name: list[str] | None = None,
) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ...
@overload
def get_handlers_by_event_type(
self,
event_type: Literal[EventType.OnAgentDoneEvent],
only_activated=True,
plugins_name: list[str] | None = None,
) -> list[StarHandlerMetadata[Callable[..., Awaitable[Any]]]]: ...
@overload
def get_handlers_by_event_type(
self,
@@ -213,6 +229,8 @@ class EventType(enum.Enum):
OnWaitingLLMRequestEvent = enum.auto() # 等待调用 LLM在获取锁之前仅通知
OnLLMRequestEvent = enum.auto() # 收到 LLM 请求(可以是用户也可以是插件)
OnLLMResponseEvent = enum.auto() # LLM 响应后
OnAgentBeginEvent = enum.auto() # Agent 开始运行
OnAgentDoneEvent = enum.auto() # Agent 运行完成
OnDecoratingResultEvent = enum.auto() # 发送消息前
OnCallingFuncToolEvent = enum.auto() # 调用函数工具
OnUsingLLMToolEvent = enum.auto() # 使用 LLM 工具

View File

@@ -73,6 +73,6 @@ class ExecuteShellTool(FunctionTool):
background=background,
env=env,
)
return json.dumps(result)
return json.dumps(result, ensure_ascii=False)
except Exception as e:
return f"Error executing command: {str(e)}"

View File

@@ -1,6 +1,9 @@
import asyncio
import base64
import logging
import random
from functools import lru_cache
from pathlib import Path
import aiohttp
@@ -16,6 +19,31 @@ ASTRBOT_T2I_DEFAULT_ENDPOINT = "https://t2i.soulter.top/text2img"
logger = logging.getLogger("astrbot")
@lru_cache(maxsize=1)
def get_shiki_runtime() -> str:
runtime_path = (
Path(__file__).resolve().parent / "template" / "shiki_runtime.iife.js"
)
if not runtime_path.exists():
logger.error(
"T2I Shiki runtime not found at %s. Run `cd dashboard && pnpm run build:t2i-shiki-runtime` to regenerate it. Continuing without code highlighting.",
runtime_path,
)
return ""
try:
runtime = runtime_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as err:
logger.warning(
"Failed to load T2I Shiki runtime from %s: %s. Continuing without code highlighting.",
runtime_path,
err,
)
return ""
return runtime.replace("</script", "<\\/script")
class NetworkRenderStrategy(RenderStrategy):
def __init__(self, base_url: str | None = None) -> None:
super().__init__()
@@ -77,6 +105,7 @@ class NetworkRenderStrategy(RenderStrategy):
if options:
default_options |= options
tmpl_data = {"shiki_runtime": get_shiki_runtime()} | tmpl_data
post_data = {
"tmpl": tmpl_str,
"json": return_url,
@@ -129,9 +158,9 @@ class NetworkRenderStrategy(RenderStrategy):
if not template_name:
template_name = "base"
tmpl_str = await self.get_template(name=template_name)
text = text.replace("`", "\\`")
text_base64 = base64.b64encode(text.encode("utf-8")).decode("ascii")
return await self.render_custom_template(
tmpl_str,
{"text": text, "version": f"v{VERSION}"},
{"text_base64": text_base64, "version": f"v{VERSION}"},
return_url,
)

View File

@@ -2,20 +2,15 @@
<html>
<head>
<meta charset="utf-8"/>
<title>Astrbot PowerShell {{ version }} </title>
<title>Astrbot PowerShell {{ version }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.css" integrity="sha384-wcIxkf4k558AjM3Yz3BBFQUbk/zgIYC2R0QpeeYb+TwlBVMrlgLqwRjRtGZiK7ww" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/lib/common.min.js"></script>
<script>hljs.highlightAll();</script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.js" integrity="sha384-hIoBPJpTUs74ddyc4bFZSM1TVlQDA60VBbJS0oA934VSz82sBx1X7kSx2ATBDIyd" crossorigin="anonymous"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/contrib/auto-render.min.js" integrity="sha384-43gviWU0YVjaDtb/GhzOouOXtZMP/7XUzwPTstBeZFe/+rCMvRwr4yROQP43s0Xk" crossorigin="anonymous"
onload="renderMathInElement(document.getElementById('content'),{delimiters: [{left: '$$', right: '$$', display: true},{left: '$', right: '$', display: false}]});"></script>
<style>
:root {
--bg-color: #010409;
--text-color: #e6edf3;
--title-bar-color: #161b22;
--title-text-color: #e6edf3;
--font-family: 'Consolas', 'Microsoft YaHei Mono', 'Dengxian Mono', 'Courier New', monospace;
--font-family: "Consolas", "Microsoft YaHei Mono", "Dengxian Mono", "Courier New", monospace;
--glow-color: rgba(200, 220, 255, 0.7);
}
@@ -36,7 +31,6 @@
padding: 0;
line-height: 1.6;
font-size: 18px;
/* The CRT glow effect from the image */
text-shadow: 0 0 15px var(--glow-color), 0 0 7px rgba(255, 255, 255, 1);
position: relative;
overflow: hidden;
@@ -63,9 +57,9 @@
color: var(--title-text-color);
font-size: 16px;
border-bottom: 1px solid #30363d;
text-shadow: none; /* No glow for title bar */
text-shadow: none;
}
.header .title {
font-weight: bold;
font-size: 28px;
@@ -78,13 +72,10 @@
main {
padding: 1rem 1.5rem;
position: relative;
z-index: 1;
}
#content {
/* min-width and max-width removed as per request */
}
/* --- Markdown Styles adjusted for terminal look --- */
h1, h2, h3, h4, h5, h6 {
line-height: 1.4;
margin-top: 20px;
@@ -144,7 +135,16 @@
font-size: 100%;
background-color: transparent;
border-radius: 0;
text-shadow: none; /* Disable glow inside code blocks for clarity */
text-shadow: none;
}
pre.shiki {
padding: 1rem;
}
pre.shiki > code,
pre.shiki span {
text-shadow: none;
}
a {
@@ -165,9 +165,8 @@
</style>
</head>
<body>
<div class="header">
<span class="title">> Astrbot PowerShell</span>
<span class="title">&gt; Astrbot PowerShell</span>
<span class="version">{{ version }}</span>
</div>
@@ -175,10 +174,45 @@
<div id="content"></div>
</main>
<script>{{ shiki_runtime | safe }}</script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.js" integrity="sha384-hIoBPJpTUs74ddyc4bFZSM1TVlQDA60VBbJS0oA934VSz82sBx1X7kSx2ATBDIyd" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/contrib/auto-render.min.js" integrity="sha384-43gviWU0YVjaDtb/GhzOouOXtZMP/7XUzwPTstBeZFe/+rCMvRwr4yROQP43s0Xk" crossorigin="anonymous"></script>
<script>
document.getElementById('content').innerHTML = marked.parse(`{{ text | safe }}`);
</script>
(function () {
const contentElement = document.getElementById("content");
const source = decodeBase64Utf8("{{ text_base64 }}");
contentElement.innerHTML = marked.parse(source);
if (window.AstrBotT2IShiki) {
window.AstrBotT2IShiki.highlightAllCodeBlocks(contentElement, "github-dark");
}
if (window.renderMathInElement) {
window.renderMathInElement(contentElement, {
delimiters: [
{ left: "$$", right: "$$", display: true },
{ left: "$", right: "$", display: false }
]
});
}
function decodeBase64Utf8(base64Text) {
const binary = window.atob(base64Text || "");
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
if (window.TextDecoder) {
return new TextDecoder().decode(bytes);
}
let fallback = "";
bytes.forEach((byte) => {
fallback += String.fromCharCode(byte);
});
return decodeURIComponent(escape(fallback));
}
})();
</script>
</body>
</html>
</html>

View File

@@ -0,0 +1,552 @@
<!doctype html>
<html lang="zh-CN" class="dark">
<head>
<meta charset="utf-8"/>
<title>AstrBot Docs {{ version }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.css" integrity="sha384-wcIxkf4k558AjM3Yz3BBFQUbk/zgIYC2R0QpeeYb+TwlBVMrlgLqwRjRtGZiK7ww" crossorigin="anonymous">
<style>
:root {
--vp-c-bg: #1b1b1f;
--vp-c-bg-soft: #202127;
--vp-c-bg-alt: #161618;
--vp-c-bg-elv: #202127;
--vp-c-border: #3c3f44;
--vp-c-divider: #2e2e32;
--vp-c-gutter: #000000;
--vp-c-text-1: #dfdfd6;
--vp-c-text-2: #98989f;
--vp-c-text-3: #6a6a71;
--vp-c-brand-1: #a8b1ff;
--vp-c-brand-2: #5c73e7;
--vp-c-brand-3: #3e63dd;
--vp-c-brand-soft: rgba(100, 108, 255, 0.16);
--vp-c-default-soft: rgba(101, 117, 133, 0.16);
--vp-code-bg: var(--vp-c-default-soft);
--vp-code-block-bg: var(--vp-c-bg-alt);
--vp-code-line-height: 1.7;
--vp-code-font-size: 0.875em;
--vp-shadow-2: 0 3px 12px rgba(0, 0, 0, 0.07), 0 1px 4px rgba(0, 0, 0, 0.07);
--vp-shadow-3: 0 12px 32px rgba(0, 0, 0, 0.1), 0 2px 6px rgba(0, 0, 0, 0.08);
--vp-layout-max-width: 1440px;
--vp-nav-height: 64px;
--vp-radius: 12px;
--vp-font-family: "Inter", -apple-system, BlinkMacSystemFont, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--vp-font-family-cjk: "Inter4CJK", -apple-system, BlinkMacSystemFont, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--vp-code-font-family: ui-monospace, "Menlo", "Monaco", "Consolas", "Liberation Mono", "Courier New", monospace;
}
* {
box-sizing: border-box;
}
html {
background: var(--vp-c-bg);
}
body {
margin: 0;
min-height: 100vh;
color: var(--vp-c-text-1);
font-family: var(--vp-font-family);
background: linear-gradient(180deg, rgba(27, 27, 31, 0.96), rgba(27, 27, 31, 1));
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
body:lang(zh),
body:lang(ja) {
font-family: var(--vp-font-family-cjk);
}
a {
color: var(--vp-c-brand-1);
text-decoration: underline;
text-underline-offset: 2px;
transition: color 0.25s, opacity 0.25s;
}
a:hover {
color: var(--vp-c-brand-2);
}
#app {
max-width: var(--vp-layout-max-width);
margin: 0 auto;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.vp-nav {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
height: var(--vp-nav-height);
padding: 0 32px;
backdrop-filter: blur(18px);
background: rgba(27, 27, 31, 0.9);
border-bottom: 1px solid var(--vp-c-gutter);
}
.vp-brand {
display: flex;
align-items: center;
gap: 12px;
font-weight: 600;
font-size: 20px;
letter-spacing: -0.01em;
}
.vp-brand-logo {
width: 28px;
height: 28px;
object-fit: contain;
filter: drop-shadow(0 6px 16px rgba(62, 99, 221, 0.24));
}
.vp-brand-name {
color: #ffffff;
}
.vp-nav-actions {
display: flex;
align-items: center;
gap: 12px;
color: var(--vp-c-text-2);
font-size: 14px;
}
.vp-search {
display: none;
}
.vp-search kbd {
display: none;
}
.vp-shell {
padding: 42px 32px 56px;
flex: 1 0 auto;
}
.vp-main {
min-width: 0;
}
.vp-content-frame {
max-width: 980px;
margin: 0 auto;
}
.vp-hero {
display: block;
margin-bottom: 36px;
}
.vp-hero.is-hidden {
display: none;
}
.vp-hero-copy h1 {
margin: 0;
font-size: 48px;
line-height: 1.05;
letter-spacing: -0.04em;
color: #ffffff;
}
.vp-hero-copy p {
max-width: 720px;
margin: 16px 0 0;
font-size: 18px;
line-height: 1.78;
color: var(--vp-c-text-2);
}
.vp-doc {
color: var(--vp-c-text-1);
font-size: 16px;
line-height: 28px;
}
.vp-doc > *:first-child {
margin-top: 0;
}
.vp-doc h1,
.vp-doc h2,
.vp-doc h3,
.vp-doc h4 {
position: relative;
scroll-margin-top: 100px;
color: #ffffff;
font-weight: 600;
letter-spacing: -0.02em;
}
.vp-doc h1 {
margin: 0 0 20px;
font-size: 32px;
line-height: 40px;
}
.vp-doc h2 {
margin: 48px 0 16px;
padding-top: 24px;
font-size: 24px;
line-height: 32px;
border-top: 1px solid var(--vp-c-divider);
}
.vp-doc h3 {
margin: 32px 0 0;
font-size: 20px;
line-height: 28px;
}
.vp-doc p,
.vp-doc ul,
.vp-doc ol,
.vp-doc table,
.vp-doc blockquote,
.vp-doc [class*="language-"],
.vp-doc .math {
margin: 16px 0;
}
.vp-doc strong {
color: #ffffff;
}
.vp-doc code {
font-family: var(--vp-code-font-family);
padding: 3px 6px;
border-radius: 4px;
color: var(--vp-c-brand-1);
background: var(--vp-code-bg);
}
.vp-doc pre code {
padding: 0;
background: transparent;
color: inherit;
}
.vp-doc [class*="language-"],
.vp-block {
position: relative;
overflow: hidden;
background-color: var(--vp-code-block-bg);
transition: background-color 0.5s;
border-radius: 8px;
}
.vp-doc [class*="language-"] > span.lang,
.vp-block > span.lang {
position: absolute;
top: 2px;
right: 8px;
z-index: 2;
font-size: 12px;
font-weight: 500;
user-select: none;
color: var(--vp-c-text-2);
background: transparent;
transition: color 0.4s, opacity 0.4s;
}
.vp-doc [class*="language-"] pre.shiki,
.vp-doc [class*="language-"] pre,
.vp-block pre.shiki,
.vp-block pre {
margin: 0;
padding: 20px 0;
border: 0;
border-radius: 8px;
overflow-x: auto;
background: var(--vp-code-block-bg) !important;
line-height: var(--vp-code-line-height);
font-size: var(--vp-code-font-size);
}
.vp-doc [class*="language-"] code,
.vp-block code {
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
}
.vp-doc [class*="language-"] pre.shiki code,
.vp-doc [class*="language-"] pre code,
.vp-block pre.shiki code,
.vp-block pre code {
font-family: var(--vp-code-font-family);
display: block;
width: fit-content;
min-width: 100%;
padding: 0 24px;
font-size: 14px;
color: inherit;
}
.vp-doc [class*="language-"] pre.shiki,
.vp-block pre.shiki {
color: var(--shiki-dark, #e1e4e8) !important;
background-color: var(--shiki-dark-bg, var(--vp-code-block-bg)) !important;
}
.vp-doc [class*="language-"] pre.shiki > code,
.vp-doc [class*="language-"] pre.shiki span,
.vp-block pre.shiki > code,
.vp-block pre.shiki span {
text-shadow: none;
}
.vp-doc blockquote {
margin: 16px 0;
padding-left: 16px;
border-left: 2px solid var(--vp-c-divider);
color: var(--vp-c-text-2);
}
.vp-doc blockquote p {
margin: 0;
font-size: 16px;
}
.vp-doc hr {
height: 1px;
border: 0;
margin: 38px 0;
background: var(--vp-c-divider);
}
.vp-doc img {
max-width: 100%;
display: block;
margin: 22px auto;
border-radius: 8px;
border: 1px solid var(--vp-c-divider);
box-shadow: var(--vp-shadow-3);
}
.vp-doc ul,
.vp-doc ol {
padding-left: 1.35rem;
color: var(--vp-c-text-1);
}
.vp-doc li {
margin: 8px 0;
}
.vp-doc table {
width: 100%;
border-collapse: collapse;
overflow: hidden;
border-radius: 8px;
border: 1px solid var(--vp-c-border);
background: rgba(255, 255, 255, 0.02);
}
.vp-doc thead {
background: rgba(255, 255, 255, 0.04);
}
.vp-doc th,
.vp-doc td {
padding: 8px 16px;
border-bottom: 1px solid var(--vp-c-divider);
text-align: left;
}
.vp-doc tr:last-child td {
border-bottom: 0;
}
.vp-footer {
height: 72px;
padding: 0 32px;
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 72px;
color: var(--vp-c-text-3);
font-size: 14px;
border-top: 1px solid var(--vp-c-gutter);
}
@media (max-width: 1180px) {
.vp-shell {
padding-inline: 28px;
}
}
</style>
</head>
<body>
<div id="app">
<header class="vp-nav">
<div class="vp-brand">
<img class="vp-brand-logo" src="https://cf.s3.soulter.top/astrbot-logo.svg" alt="AstrBot logo" />
<div class="vp-brand-name">AstrBot</div>
</div>
<div class="vp-nav-actions">
<span>{{ version }}</span>
</div>
</header>
<div class="vp-shell">
<main class="vp-main">
<div class="vp-content-frame">
<div class="vp-hero is-hidden" id="heroBlock">
<div class="vp-hero-copy">
<h1 id="heroTitle">AstrBot Docs</h1>
<p id="heroLead">将长文本内容整理为单页文档,参考 VitePress 默认主题的深色配色、正文排版与代码块节奏,适合技术说明与发布页。</p>
</div>
</div>
<article class="vp-doc" id="content"></article>
</div>
</main>
</div>
<footer class="vp-footer">Rendered by AstrBot {{ version }}</footer>
</div>
<script>{{ shiki_runtime | safe }}</script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.js" integrity="sha384-hIoBPJpTUs74ddyc4bFZSM1TVlQDA60VBbJS0oA934VSz82sBx1X7kSx2ATBDIyd" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/contrib/auto-render.min.js" integrity="sha384-43gviWU0YVjaDtb/GhzOouOXtZMP/7XUzwPTstBeZFe/+rCMvRwr4yROQP43s0Xk" crossorigin="anonymous"></script>
<script>
(function () {
const contentElement = document.getElementById("content");
const source = decodeBase64Utf8("{{ text_base64 }}");
marked.setOptions({
gfm: true,
breaks: false,
});
contentElement.innerHTML = marked.parse(source);
assignHeadingIds(contentElement);
enhanceCodeBlocks(contentElement);
if (window.renderMathInElement) {
window.renderMathInElement(contentElement, {
delimiters: [
{ left: "$$", right: "$$", display: true },
{ left: "$", right: "$", display: false }
]
});
}
const headings = collectHeadings(contentElement);
populateHero(contentElement, headings);
function decodeBase64Utf8(base64Text) {
const binary = window.atob(base64Text || "");
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
if (window.TextDecoder) {
return new TextDecoder().decode(bytes);
}
let fallback = "";
bytes.forEach((byte) => {
fallback += String.fromCharCode(byte);
});
return decodeURIComponent(escape(fallback));
}
function escapeHtml(value) {
return String(value || "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function assignHeadingIds(root) {
Array.from(root.querySelectorAll("h1, h2, h3")).forEach((heading, index) => {
heading.id = `section-${index + 1}`;
});
}
function collectHeadings(root) {
return Array.from(root.querySelectorAll("h1, h2, h3")).map((heading) => ({
element: heading,
id: heading.id,
level: Number(heading.tagName.slice(1)),
text: heading.textContent.trim(),
}));
}
function populateHero(root, headings) {
const heroBlock = document.getElementById("heroBlock");
const heroTitle = document.getElementById("heroTitle");
const heroLead = document.getElementById("heroLead");
const firstHeading = headings.find((heading) => heading.level === 1) || headings[0];
if (!firstHeading) {
return;
}
heroBlock.classList.remove("is-hidden");
const leadParagraph = firstHeading.element.nextElementSibling;
const title = firstHeading.text;
heroTitle.textContent = title;
if (leadParagraph && leadParagraph.tagName === "P") {
heroLead.textContent = leadParagraph.textContent.trim();
leadParagraph.remove();
}
if (firstHeading.element.parentElement === root) {
firstHeading.element.remove();
}
}
function extractLanguage(codeElement) {
const className = codeElement.className || "";
const match = className.match(/language-([\\w+#.-]+)/i);
return match ? match[1] : "";
}
function enhanceCodeBlocks(root) {
const blocks = Array.from(root.querySelectorAll("pre > code")).map((codeElement) => ({
rawLanguage: extractLanguage(codeElement),
displayLanguage: extractLanguage(codeElement).trim().split(/\\s+/, 1)[0].toLowerCase(),
}));
if (window.AstrBotT2IShiki) {
window.AstrBotT2IShiki.highlightAllCodeBlocks(root, "github-dark");
}
Array.from(root.querySelectorAll("pre")).forEach((preElement, index) => {
if (preElement.parentElement && preElement.parentElement.classList.contains("vp-code-block")) {
return;
}
const block = blocks[index] || { displayLanguage: "" };
const wrapper = document.createElement("div");
wrapper.className = `language-${block.displayLanguage || "text"}`;
if (block.displayLanguage) {
wrapper.innerHTML = `<span class="lang">${escapeHtml(block.displayLanguage)}</span>`;
}
preElement.replaceWith(wrapper);
wrapper.appendChild(preElement);
});
}
})();
</script>
</body>
</html>

View File

@@ -2,246 +2,285 @@
<html>
<head>
<meta charset="utf-8"/>
<title>AstrBot {{ version }}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.css" integrity="sha384-wcIxkf4k558AjM3Yz3BBFQUbk/zgIYC2R0QpeeYb+TwlBVMrlgLqwRjRtGZiK7ww" crossorigin="anonymous">
<link rel="stylesheet" href="/path/to/styles/default.min.css">
<script src="/path/to/highlight.min.js"></script>
<script>hljs.highlightAll();</script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.js" integrity="sha384-hIoBPJpTUs74ddyc4bFZSM1TVlQDA60VBbJS0oA934VSz82sBx1X7kSx2ATBDIyd" crossorigin="anonymous"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/contrib/auto-render.min.js" integrity="sha384-43gviWU0YVjaDtb/GhzOouOXtZMP/7XUzwPTstBeZFe/+rCMvRwr4yROQP43s0Xk" crossorigin="anonymous"
onload="renderMathInElement(document.getElementById('content'),{delimiters: [{left: '$$', right: '$$', display: true},{left: '$', right: '$', display: false}]});"></script>
<style>
#content {
min-width: 200px;
max-width: 85%;
margin: 0 auto;
padding: 2rem 1em 1em;
}
body {
word-break: break-word;
line-height: 1.75;
font-weight: 400;
font-size: 32px;
margin: 0;
padding: 0;
overflow-x: hidden;
color: #333;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji;
}
h1, h2, h3, h4, h5, h6 {
line-height: 1.5;
margin-top: 35px;
margin-bottom: 10px;
padding-bottom: 5px;
}
h1:first-child, h2:first-child, h3:first-child, h4:first-child, h5:first-child, h6:first-child {
margin-top: -1.5rem;
margin-bottom: 1rem;
}
h1::before, h2::before, h3::before, h4::before, h5::before, h6::before {
content: "#";
display: inline-block;
color: #3eaf7c;
padding-right: 0.23em;
}
h1 {
position: relative;
font-size: 2.5rem;
margin-bottom: 5px;
}
h1::before {
font-size: 2.5rem;
}
h2 {
padding-bottom: 0.5rem;
font-size: 2.2rem;
border-bottom: 1px solid #ececec;
}
h3 {
font-size: 1.5rem;
padding-bottom: 0;
}
h4 {
font-size: 1.25rem;
}
h5 {
font-size: 1rem;
}
h6 {
margin-top: 5px;
}
p {
line-height: inherit;
margin-top: 22px;
margin-bottom: 22px;
}
strong {
color: #3eaf7c;
}
img {
max-width: 100%;
border-radius: 2px;
display: block;
margin: auto;
border: 3px solid rgba(62, 175, 124, 0.2);
}
hr {
border-top: 1px solid #3eaf7c;
border-bottom: none;
border-left: none;
border-right: none;
margin-top: 32px;
margin-bottom: 32px;
}
code {
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
word-break: break-word;
overflow-x: auto;
padding: 0.2rem 0.5rem;
margin: 0;
color: #3eaf7c;
font-size: 0.85em;
background-color: rgba(27, 31, 35, 0.05);
border-radius: 3px;
}
pre {
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
overflow: auto;
position: relative;
line-height: 1.75;
border-radius: 6px;
border: 2px solid #3eaf7c;
}
pre > code {
font-size: 12px;
padding: 15px 12px;
margin: 0;
word-break: normal;
display: block;
overflow-x: auto;
color: #333;
background: #f8f8f8;
}
pre.shiki {
padding: 15px 12px;
}
pre.shiki > code {
padding: 0;
background: transparent !important;
color: inherit;
font-size: 12px;
}
a {
font-weight: 500;
text-decoration: none;
color: #3eaf7c;
}
a:hover, a:active {
border-bottom: 1.5px solid #3eaf7c;
}
a:before {
content: "⇲";
}
table {
display: inline-block !important;
font-size: 12px;
width: auto;
max-width: 100%;
overflow: auto;
border: solid 1px #3eaf7c;
}
thead {
background: #3eaf7c;
color: #fff;
text-align: left;
}
tr:nth-child(2n) {
background-color: rgba(62, 175, 124, 0.2);
}
th, td {
padding: 12px 7px;
line-height: 24px;
}
td {
min-width: 120px;
}
blockquote {
color: #666;
padding: 1px 23px;
margin: 22px 0;
border-left: 0.5rem solid rgba(62, 175, 124, 0.6);
border-color: #42b983;
background-color: #f8f8f8;
}
blockquote::after {
display: block;
content: "";
}
blockquote > p {
margin: 10px 0;
}
details {
border: none;
outline: none;
border-left: 4px solid #3eaf7c;
padding-left: 10px;
margin-left: 4px;
}
details summary {
cursor: pointer;
border: none;
outline: none;
background: white;
margin: 0 -17px;
}
details summary::-webkit-details-marker {
color: #3eaf7c;
}
ol, ul {
padding-left: 28px;
}
ol li, ul li {
margin-bottom: 0;
list-style: inherit;
}
ol li .task-list-item, ul li .task-list-item {
list-style: none;
}
ol li .task-list-item ul, ul li .task-list-item ul, ol li .task-list-item ol, ul li .task-list-item ol {
margin-top: 0;
}
ol ul, ul ul, ol ol, ul ol {
margin-top: 3px;
}
ol li {
padding-left: 6px;
}
ol li::marker {
color: #3eaf7c;
}
ul li {
list-style: none;
}
ul li:before {
content: "•";
margin-right: 4px;
color: #3eaf7c;
}
@media (max-width: 720px) {
h1 {
font-size: 24px;
}
h2 {
font-size: 20px;
}
h3 {
font-size: 18px;
}
}
</style>
</head>
<body>
<div style="background-color: #3276dc; color: #fff; font-size: 64px; ">
<div style="background-color: #3276dc; color: #fff; font-size: 64px;">
<span style="font-weight: bold; margin-left: 16px"># AstrBot</span>
<span>{{ version }}</span>
</div>
<article style="margin-top: 32px" id="content"></article>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
document.getElementById('content').innerHTML = marked.parse(`{{ text | safe}}`);
</script>
<script>{{ shiki_runtime | safe }}</script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.js" integrity="sha384-hIoBPJpTUs74ddyc4bFZSM1TVlQDA60VBbJS0oA934VSz82sBx1X7kSx2ATBDIyd" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/contrib/auto-render.min.js" integrity="sha384-43gviWU0YVjaDtb/GhzOouOXtZMP/7XUzwPTstBeZFe/+rCMvRwr4yROQP43s0Xk" crossorigin="anonymous"></script>
<script>
(function () {
const contentElement = document.getElementById("content");
const source = decodeBase64Utf8("{{ text_base64 }}");
contentElement.innerHTML = marked.parse(source);
if (window.AstrBotT2IShiki) {
window.AstrBotT2IShiki.highlightAllCodeBlocks(contentElement, "github-light");
}
if (window.renderMathInElement) {
window.renderMathInElement(contentElement, {
delimiters: [
{ left: "$$", right: "$$", display: true },
{ left: "$", right: "$", display: false }
]
});
}
function decodeBase64Utf8(base64Text) {
const binary = window.atob(base64Text || "");
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
if (window.TextDecoder) {
return new TextDecoder().decode(bytes);
}
let fallback = "";
bytes.forEach((byte) => {
fallback += String.fromCharCode(byte);
});
return decodeURIComponent(escape(fallback));
}
})();
</script>
</body>
</html>
<style>
#content {
min-width: 200px;
max-width: 85%;
margin: 0 auto;
padding: 2rem 1em 1em;
}
body {
word-break: break-word;
line-height: 1.75;
font-weight: 400;
font-size: 32px;
margin: 0;
padding: 0;
overflow-x: hidden;
color: #333;
font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji;
}
h1, h2, h3, h4, h5, h6 {
line-height: 1.5;
margin-top: 35px;
margin-bottom: 10px;
padding-bottom: 5px;
}
h1:first-child, h2:first-child, h3:first-child, h4:first-child, h5:first-child, h6:first-child {
margin-top: -1.5rem;
margin-bottom: 1rem;
}
h1::before, h2::before, h3::before, h4::before, h5::before, h6::before {
content: "#";
display: inline-block;
color: #3eaf7c;
padding-right: 0.23em;
}
h1 {
position: relative;
font-size: 2.5rem;
margin-bottom: 5px;
}
h1::before {
font-size: 2.5rem;
}
h2 {
padding-bottom: 0.5rem;
font-size: 2.2rem;
border-bottom: 1px solid #ececec;
}
h3 {
font-size: 1.5rem;
padding-bottom: 0;
}
h4 {
font-size: 1.25rem;
}
h5 {
font-size: 1rem;
}
h6 {
margin-top: 5px;
}
p {
line-height: inherit;
margin-top: 22px;
margin-bottom: 22px;
}
strong {
color: #3eaf7c;
}
img {
max-width: 100%;
border-radius: 2px;
display: block;
margin: auto;
border: 3px solid rgba(62, 175, 124, 0.2);
}
hr {
border-top: 1px solid #3eaf7c;
border-bottom: none;
border-left: none;
border-right: none;
margin-top: 32px;
margin-bottom: 32px;
}
code {
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
word-break: break-word;
overflow-x: auto;
padding: 0.2rem 0.5rem;
margin: 0;
color: #3eaf7c;
font-size: 0.85em;
background-color: rgba(27, 31, 35, 0.05);
border-radius: 3px;
}
pre {
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
overflow: auto;
position: relative;
line-height: 1.75;
border-radius: 6px;
border: 2px solid #3eaf7c;
}
pre > code {
font-size: 12px;
padding: 15px 12px;
margin: 0;
word-break: normal;
display: block;
overflow-x: auto;
color: #333;
background: #f8f8f8;
}
a {
font-weight: 500;
text-decoration: none;
color: #3eaf7c;
}
a:hover, a:active {
border-bottom: 1.5px solid #3eaf7c;
}
a:before {
content: "⇲";
}
table {
display: inline-block !important;
font-size: 12px;
width: auto;
max-width: 100%;
overflow: auto;
border: solid 1px #3eaf7c;
}
thead {
background: #3eaf7c;
color: #fff;
text-align: left;
}
tr:nth-child(2n) {
background-color: rgba(62, 175, 124, 0.2);
}
th, td {
padding: 12px 7px;
line-height: 24px;
}
td {
min-width: 120px;
}
blockquote {
color: #666;
padding: 1px 23px;
margin: 22px 0;
border-left: 0.5rem solid rgba(62, 175, 124, 0.6);
border-color: #42b983;
background-color: #f8f8f8;
}
blockquote::after {
display: block;
content: "";
}
blockquote > p {
margin: 10px 0;
}
details {
border: none;
outline: none;
border-left: 4px solid #3eaf7c;
padding-left: 10px;
margin-left: 4px;
}
details summary {
cursor: pointer;
border: none;
outline: none;
background: white;
margin: 0px -17px;
}
details summary::-webkit-details-marker {
color: #3eaf7c;
}
ol, ul {
padding-left: 28px;
}
ol li, ul li {
margin-bottom: 0;
list-style: inherit;
}
ol li .task-list-item, ul li .task-list-item {
list-style: none;
}
ol li .task-list-item ul, ul li .task-list-item ul, ol li .task-list-item ol, ul li .task-list-item ol {
margin-top: 0;
}
ol ul, ul ul, ol ol, ul ol {
margin-top: 3px;
}
ol li {
padding-left: 6px;
}
ol li::marker {
color: #3eaf7c;
}
ul li {
list-style: none;
}
ul li:before {
content: "•";
margin-right: 4px;
color: #3eaf7c;
}
@media (max-width: 720px) {
h1 {
font-size: 24px;
}
h2 {
font-size: 20px;
}
h3 {
font-size: 18px;
}
}
</style>

File diff suppressed because one or more lines are too long

View File

@@ -12,7 +12,11 @@ class TemplateManager:
所有创建、更新、删除操作仅影响用户目录,以确保更新框架时用户数据安全。
"""
CORE_TEMPLATES = ["base.html", "astrbot_powershell.html"]
CORE_TEMPLATES = [
"base.html",
"astrbot_powershell.html",
"astrbot_vitepress.html",
]
def __init__(self) -> None:
self.builtin_template_dir = os.path.join(

View File

@@ -128,6 +128,13 @@ class KnowledgeBaseRoute(Route):
return _callback
@staticmethod
def _format_failed_doc_error(file_name: str, error: Exception) -> str:
message = str(error).strip() or "上传失败:发生未知错误。"
if message.startswith(file_name):
return message
return f"{file_name}: {message}"
async def _background_upload_task(
self,
task_id: str,
@@ -189,7 +196,12 @@ class KnowledgeBaseRoute(Route):
except Exception as e:
logger.error(f"上传文档 {file_info['file_name']} 失败: {e}")
failed_docs.append(
{"file_name": file_info["file_name"], "error": str(e)},
{
"file_name": file_info["file_name"],
"error": self._format_failed_doc_error(
file_info["file_name"], e
),
},
)
# 更新任务完成状态
@@ -276,7 +288,10 @@ class KnowledgeBaseRoute(Route):
except Exception as e:
logger.error(f"导入文档 {file_name} 失败: {e}")
failed_docs.append(
{"file_name": file_name, "error": str(e)},
{
"file_name": file_name,
"error": self._format_failed_doc_error(file_name, e),
},
)
# 更新任务完成状态

View File

@@ -79,6 +79,8 @@ class PluginRoute(Route):
EventType.AdapterMessageEvent: "平台消息下发时",
EventType.OnLLMRequestEvent: "LLM 请求时",
EventType.OnLLMResponseEvent: "LLM 响应后",
EventType.OnAgentBeginEvent: "Agent 开始运行时",
EventType.OnAgentDoneEvent: "Agent 运行完成后",
EventType.OnDecoratingResultEvent: "回复消息前",
EventType.OnCallingFuncToolEvent: "函数工具",
EventType.OnAfterMessageSentEvent: "发送消息后",

View File

@@ -3,7 +3,7 @@ import traceback
from quart import request
from astrbot.core import logger
from astrbot.core.agent.mcp_client import MCPTool
from astrbot.core.agent.mcp_client import MCPTool, validate_mcp_stdio_config
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.star import star_map
from astrbot.core.tools.registry import get_builtin_tool_config_statuses
@@ -153,6 +153,11 @@ class ToolsRoute(Route):
.__dict__
)
try:
validate_mcp_stdio_config(server_config)
except ValueError as e:
return Response().error(f"{e!s}").__dict__
config = self.tool_mgr.load_mcp_config()
if name in config["mcpServers"]:
@@ -256,6 +261,11 @@ class ToolsRoute(Route):
if key != "active": # 除了active之外的所有字段都保留
server_config[key] = value
try:
validate_mcp_stdio_config(server_config)
except ValueError as e:
return Response().error(f"{e!s}").__dict__
# config["mcpServers"][name] = server_config
if is_rename:
config["mcpServers"].pop(old_name)
@@ -415,6 +425,11 @@ class ToolsRoute(Route):
.__dict__
)
try:
validate_mcp_stdio_config(config)
except ValueError as e:
return Response().error(f"{e!s}").__dict__
tools_name = await self.tool_mgr.test_mcp_server_connection(config)
return (
Response()

125
changelogs/v4.23.0.md Normal file
View File

@@ -0,0 +1,125 @@
- [更新日志(简体中文)](#chinese)
- [Changelog(English)](#english)
<a id="chinese"></a>
## What's Changed
### 新增
- 为电脑使用能力支持文件读取(read)、写入(write)、编辑(edit)、Grep 搜索(ripgrep)与按会话隔离的 workspace。[#7402](https://github.com/AstrBotDevs/AstrBot/pull/7402)
- 微信个人号适配器支持引用消息的解析。([#7380](https://github.com/AstrBotDevs/AstrBot/pull/7380)
- 新增 Brave Search 网页搜索工具,移除旧的默认网页搜索实现。([#6847](https://github.com/AstrBotDevs/AstrBot/pull/6847)
- 新增 Mattermost 平台适配器支持。([#7369](https://github.com/AstrBotDevs/AstrBot/pull/7369)
- 新增 NVIDIA Rerank Provider。[#7227](https://github.com/AstrBotDevs/AstrBot/pull/7227)
- 新增 LongCat LLM Provider。[#7360](https://github.com/AstrBotDevs/AstrBot/pull/7360)
- 新增 OpenAI、Gemini 音频输入模态支持,并修复 ChatUI 录音相关问题。([#7378](https://github.com/AstrBotDevs/AstrBot/pull/7378)
- Discord 平台新增 Bot 消息过滤配置,允许控制是否接收其他 Bot 的消息。([#6505](https://github.com/AstrBotDevs/AstrBot/pull/6505)
- 新增 LLM 对重复工具调用的引导能力,减少模型陷入重复调用工具的情况。([#7388](https://github.com/AstrBotDevs/AstrBot/pull/7388)
- QQ 官方 API 文件上传新增重试机制,提升文件发送稳定性。([#7430](https://github.com/AstrBotDevs/AstrBot/pull/7430)
### 优化
- 重构 ChatUI 样式、消息渲染与输入体验,改善聊天界面的整体可维护性与交互一致性。([#7485](https://github.com/AstrBotDevs/AstrBot/pull/7485)
- 合并 Cron 相关工具为统一管理工具,并新增 Cron 任务编辑能力。([#7445](https://github.com/AstrBotDevs/AstrBot/pull/7445)
- 重构内置工具管理逻辑,改善 AstrBot 内置工具注册与调用维护性。([#7418](https://github.com/AstrBotDevs/AstrBot/pull/7418)
- 移除了大部分低频内置命令并整合命令功能,将这些指令挪至 [builtin-command-extension](https://github.com/AstrBotDevs/builtin_commands_extension) 插件,同时更新文档。([#7478](https://github.com/AstrBotDevs/AstrBot/pull/7478)
- 移除默认网页搜索实现,改由新的搜索工具链路提供能力。([#7416](https://github.com/AstrBotDevs/AstrBot/pull/7416)
- 移除 `lxml``beautifulsoup4` 依赖,降低安装体积与依赖复杂度。([#7449](https://github.com/AstrBotDevs/AstrBot/pull/7449)
- 新增 MCP stdio 配置校验,降低无效配置导致的启动失败与排查成本。([#7477](https://github.com/AstrBotDevs/AstrBot/pull/7477)
- Docker 运行配置启用 `no-new-privileges`,提升容器默认安全性。([commit](https://github.com/AstrBotDevs/AstrBot/commit/68a195e12)
- 降低 MCP Server 状态轮询频率,减少后台请求开销。([#7399](https://github.com/AstrBotDevs/AstrBot/pull/7399)
- 帮助命令忽略 `dashboard_update` 等内部有效命令,减少帮助列表噪音。([commit](https://github.com/AstrBotDevs/AstrBot/commit/baaad2a69)
- 修正文档中的路径拼接示例,避免插件开发存储文档误导使用者。([#7448](https://github.com/AstrBotDevs/AstrBot/pull/7448)
- 补充 Matrix 平台常量、平台图标与相关文档。([#7368](https://github.com/AstrBotDevs/AstrBot/pull/7368)
### 修复
- 修复 STT 或 TTS Provider 在配置中禁用时仍可能被取用的问题。([#7363](https://github.com/AstrBotDevs/AstrBot/pull/7363)
- 修复 Gemini 空模型输出误触发错误处理的问题。([#7377](https://github.com/AstrBotDevs/AstrBot/pull/7377)
- 修复 Gemini FunctionResponse 中不支持的 `id` 字段导致请求失败的问题。([#7386](https://github.com/AstrBotDevs/AstrBot/pull/7386)
- 修复仅存在原生工具时仍传递 `FunctionCallingConfig` 的问题。([#7407](https://github.com/AstrBotDevs/AstrBot/pull/7407)
- 修复工具结果断言与动态阈值不一致的问题。([commit](https://github.com/AstrBotDevs/AstrBot/commit/8c6c00ae6)
- 修复 Bailian Rerank 对不同 URL 端点返回格式的兼容性问题。([#7250](https://github.com/AstrBotDevs/AstrBot/pull/7250)
- 修复 QQ 官方 WebSocket 关闭流程清理不完整的问题。([#7395](https://github.com/AstrBotDevs/AstrBot/pull/7395)
- 修复 Telegram 网络异常后的轮询恢复问题,并补充相关测试。([#7468](https://github.com/AstrBotDevs/AstrBot/pull/7468)
- 修复 Telegram 长消息最终分段过长的问题。([#7432](https://github.com/AstrBotDevs/AstrBot/pull/7432)
- 修复 Telegram `sendMessageDraft` 发送空文本导致 400 错误刷屏的问题。([#7398](https://github.com/AstrBotDevs/AstrBot/pull/7398)
- 修复 Telegram 收集命令时插件 handler 不在 `star_map` 中导致 `KeyError` 的问题。([#7405](https://github.com/AstrBotDevs/AstrBot/pull/7405)
- 修复 Discord Slash Command 未及时 defer 导致 `10062 Unknown interaction` 的问题。([#7474](https://github.com/AstrBotDevs/AstrBot/pull/7474)
- 修复微信个人号适配器缺少上下文 token 时的警告信息。([commit](https://github.com/AstrBotDevs/AstrBot/commit/dfca5cdb7)
- 修复 `group_icl_enable` 在消息处理时未使用 UMO 绑定配置的问题。([#7397](https://github.com/AstrBotDevs/AstrBot/pull/7397)
- 修复插件函数工具模块路径与插件主模块路径不一致的问题。([#7462](https://github.com/AstrBotDevs/AstrBot/pull/7462)
- 修复插件版本检查逻辑对预发布版本的支持问题。([commit](https://github.com/AstrBotDevs/AstrBot/commit/5f95bbc42)
- 修复 Shell 命令执行 JSON 响应中非 ASCII 字符被转义的问题。([#7475](https://github.com/AstrBotDevs/AstrBot/pull/7475)
- 修复 Windows 桌面端插件依赖加载不安全或失败的问题。([#7446](https://github.com/AstrBotDevs/AstrBot/pull/7446)
- 修复 `faiss` 在启动阶段过早导入导致部分环境启动失败的问题。([#7400](https://github.com/AstrBotDevs/AstrBot/pull/7400)
- 修复 WebUI 暗色模式渲染与多处交互问题。([#7173](https://github.com/AstrBotDevs/AstrBot/pull/7173)
- 修复 ChatUI 项目常量缺失,并补充相关测试用例。([#7414](https://github.com/AstrBotDevs/AstrBot/pull/7414)
- 修复页面切换时浮动按钮跳动的问题。([#7214](https://github.com/AstrBotDevs/AstrBot/pull/7214)
- 修复对话管理页依赖的 `MessageList.vue` 缺失导致 Linux 环境 Dashboard 构建失败的问题。([commit](https://github.com/AstrBotDevs/AstrBot/commit/717228143)
- 修复工具调用图标使用了 MDI 子集缺失图标导致的显示问题。([commit](https://github.com/AstrBotDevs/AstrBot/commit/d1913b595)
- 修复 Dashboard store 中 `defineStore` 的类型用法问题。([#7490](https://github.com/AstrBotDevs/AstrBot/pull/7490)
- 修复 compose 文件中的 Docker 镜像名称错误。([#7488](https://github.com/AstrBotDevs/AstrBot/pull/7488)
<a id="english"></a>
## What's Changed (EN)
### New Features
- Added the LongCat LLM Provider. ([#7360](https://github.com/AstrBotDevs/AstrBot/pull/7360))
- Added missing Matrix platform constants, platform logo, and documentation updates. ([#7368](https://github.com/AstrBotDevs/AstrBot/pull/7368))
- Added local Computer Use filesystem tools, including file read, write, edit, Grep search, and per-session workspace support. ([#7402](https://github.com/AstrBotDevs/AstrBot/pull/7402))
- Added the Brave Search web search tool, replacing the old default web search implementation. ([#6847](https://github.com/AstrBotDevs/AstrBot/pull/6847))
- Added Mattermost platform support. ([#7369](https://github.com/AstrBotDevs/AstrBot/pull/7369))
- Added the NVIDIA Rerank Provider. ([#7227](https://github.com/AstrBotDevs/AstrBot/pull/7227))
- Added audio input support across OpenAI and Gemini providers, and fixed ChatUI recording issues. ([#7378](https://github.com/AstrBotDevs/AstrBot/pull/7378))
- Added reply component support for the Weixin OC adapter. ([#7380](https://github.com/AstrBotDevs/AstrBot/pull/7380))
- Added configurable Discord bot-message filtering, including support for receiving messages from other bots. ([#6505](https://github.com/AstrBotDevs/AstrBot/pull/6505))
- Added LLM guidance for repeated tool calls to reduce repetitive tool-call loops. ([#7388](https://github.com/AstrBotDevs/AstrBot/pull/7388))
- Added retry handling for QQ Official API file uploads to improve file-send reliability. ([#7430](https://github.com/AstrBotDevs/AstrBot/pull/7430))
### Improvements
- Refactored ChatUI styling, message rendering, and input interactions for better maintainability and UI consistency. ([#7485](https://github.com/AstrBotDevs/AstrBot/pull/7485))
- Merged Cron tools into a unified management tool and added Cron task editing. ([#7445](https://github.com/AstrBotDevs/AstrBot/pull/7445))
- Refactored built-in tool management to improve registration and maintenance. ([#7418](https://github.com/AstrBotDevs/AstrBot/pull/7418))
- Removed rarely used built-in commands, consolidated command functionality, and updated command documentation. ([#7478](https://github.com/AstrBotDevs/AstrBot/pull/7478))
- Removed the old default web search implementation in favor of the new search tool flow. ([#7416](https://github.com/AstrBotDevs/AstrBot/pull/7416))
- Removed `lxml` and `beautifulsoup4` dependencies to reduce dependency weight and installation complexity. ([#7449](https://github.com/AstrBotDevs/AstrBot/pull/7449))
- Added MCP stdio configuration validation to reduce startup failures caused by invalid configs. ([#7477](https://github.com/AstrBotDevs/AstrBot/pull/7477))
- Enabled `no-new-privileges` in Docker runtime configuration to improve default container security. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/68a195e12))
- Reduced MCP Server status polling frequency to lower background request overhead. ([#7399](https://github.com/AstrBotDevs/AstrBot/pull/7399))
- Ignored internal effective commands such as `dashboard_update` in HelpCommand to reduce help-list noise. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/baaad2a69))
- Fixed a path concatenation example in the plugin storage docs. ([#7448](https://github.com/AstrBotDevs/AstrBot/pull/7448))
- Updated the README logo. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/e34d9504e))
### Bug Fixes
- Returned `None` when STT or TTS providers are disabled in config instead of still resolving them. ([#7363](https://github.com/AstrBotDevs/AstrBot/pull/7363))
- Fixed empty model output handling that could misfire when using Gemini. ([#7377](https://github.com/AstrBotDevs/AstrBot/pull/7377))
- Removed the unsupported `id` field from Gemini FunctionResponse payloads. ([#7386](https://github.com/AstrBotDevs/AstrBot/pull/7386))
- Skipped `FunctionCallingConfig` when only native tools are present. ([#7407](https://github.com/AstrBotDevs/AstrBot/pull/7407))
- Updated tool-result assertions to match dynamic threshold values. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/8c6c00ae6))
- Fixed Bailian Rerank compatibility with different response formats based on URL endpoint. ([#7250](https://github.com/AstrBotDevs/AstrBot/pull/7250))
- Cleaned up QQ Official WebSocket shutdown handling. ([#7395](https://github.com/AstrBotDevs/AstrBot/pull/7395))
- Fixed Telegram polling recovery after network failures and added related tests. ([#7468](https://github.com/AstrBotDevs/AstrBot/pull/7468))
- Fixed overly long final Telegram message segments. ([#7432](https://github.com/AstrBotDevs/AstrBot/pull/7432))
- Fixed Telegram `sendMessageDraft` 400 spam caused by empty text. ([#7398](https://github.com/AstrBotDevs/AstrBot/pull/7398))
- Fixed a `KeyError` in Telegram command collection when a plugin handler is missing from `star_map`. ([#7405](https://github.com/AstrBotDevs/AstrBot/pull/7405))
- Fixed Discord `10062 Unknown interaction` errors by deferring slash commands immediately. ([#7474](https://github.com/AstrBotDevs/AstrBot/pull/7474))
- Improved the missing-context-token warning message in the Weixin OC adapter. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/dfca5cdb7))
- Fixed `group_icl_enable` to use UMO-bound config during message handling. ([#7397](https://github.com/AstrBotDevs/AstrBot/pull/7397))
- Aligned function tool module paths with plugin main module paths. ([#7462](https://github.com/AstrBotDevs/AstrBot/pull/7462))
- Fixed plugin version checks for pre-release versions. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/5f95bbc42))
- Preserved non-ASCII characters in JSON responses from shell command execution. ([#7475](https://github.com/AstrBotDevs/AstrBot/pull/7475))
- Fixed safer desktop plugin dependency loading on Windows. ([#7446](https://github.com/AstrBotDevs/AstrBot/pull/7446))
- Deferred `faiss` imports during startup to avoid startup failures in some environments. ([#7400](https://github.com/AstrBotDevs/AstrBot/pull/7400))
- Fixed WebUI dark-mode rendering and multiple interaction bugs. ([#7173](https://github.com/AstrBotDevs/AstrBot/pull/7173))
- Added missing ChatUI project constants and related tests. ([#7414](https://github.com/AstrBotDevs/AstrBot/pull/7414))
- Prevented floating buttons from jumping during page transitions. ([#7214](https://github.com/AstrBotDevs/AstrBot/pull/7214))
- Restored `MessageList.vue` for the conversation management page to fix Dashboard production builds on Linux. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/717228143))
- Updated tool-call icons to use an icon included in the MDI subset. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/d1913b595))
- Fixed `defineStore` type usage in Dashboard stores. ([#7490](https://github.com/AstrBotDevs/AstrBot/pull/7490))
- Fixed the Docker image name in the compose file. ([#7488](https://github.com/AstrBotDevs/AstrBot/pull/7488))

29
changelogs/v4.23.1.md Normal file
View File

@@ -0,0 +1,29 @@
- [更新日志(简体中文)](#chinese)
- [Changelog(English)](#english)
<a id="chinese"></a>
## What's Changed
*hotfix of v4.23.0*
-`python-ripgrep` 依赖降级到 `0.0.8`,修复 Python 3.13, 3.14 版本无法正常启动的问题。([#7514](https://github.com/AstrBotDevs/AstrBot/pull/7514)
- 修复会话 ID 包含冒号 `:` 时,平台路由在 Dashboard 中无法显示的问题。([#7517](https://github.com/AstrBotDevs/AstrBot/pull/7517)
- 适配 DeerFlow 2.0,更新 DeerFlow Runner、API Client、会话命令、Provider 管理逻辑、配置文档与相关测试。([#7500](https://github.com/AstrBotDevs/AstrBot/pull/7500)
- 移除 Dashboard `v-main` 不必要的 margin额外的外边距会把背景色显露出来因此视觉上看起来像右侧多了一条边框。[#7481](https://github.com/AstrBotDevs/AstrBot/pull/7481)
- 修复插件安装状态检查时格式不一致导致的判断问题。([#7493](https://github.com/AstrBotDevs/AstrBot/pull/7493)
- Dashboard 代码块高亮切换到 Shiki并同步暗色/亮色主题渲染,优化 README、更新日志与聊天消息中的代码块显示效果。[#7497](https://github.com/AstrBotDevs/AstrBot/pull/7497)
<a id="english"></a>
## What's Changed (EN)
*hotfix of v4.23.0*
- Fixed inconsistent format handling when checking whether a plugin is installed. ([#7493](https://github.com/AstrBotDevs/AstrBot/pull/7493))
- Downgraded `python-ripgrep` to `0.0.8` to fix dependency compatibility issues. ([#7514](https://github.com/AstrBotDevs/AstrBot/pull/7514))
- Fixed platform routes not being displayed in Dashboard when the session ID contains a colon `:`. ([#7517](https://github.com/AstrBotDevs/AstrBot/pull/7517))
- Switched Dashboard code-block highlighting to Shiki, synchronized dark/light theme rendering, and improved code-block display in README, changelog, and chat messages. ([#7497](https://github.com/AstrBotDevs/AstrBot/pull/7497))
- Aligned the DeerFlow runner with DeerFlow 2.0, including updates to the runner, API client, conversation commands, provider management, documentation, and tests. ([#7500](https://github.com/AstrBotDevs/AstrBot/pull/7500))
- Removed unnecessary `v-main` margins in Dashboard for more consistent layout across viewports. ([#7481](https://github.com/AstrBotDevs/AstrBot/pull/7481))
- Updated the smoke test workflow to cover multiple operating systems and Python versions, and added a startup check script. ([#7514](https://github.com/AstrBotDevs/AstrBot/pull/7514))

View File

@@ -4,10 +4,7 @@ version: '3.8'
services:
astrbot:
build:
context: .
dockerfile: Dockerfile
image: astrbot:kimi-code
image: soulter/astrbot:latest
container_name: astrbot
restart: always
ports: # mappings description: https://github.com/AstrBotDevs/AstrBot/issues/497

View File

@@ -1,15 +1,17 @@
# 当接入 QQ NapCat 时,请使用这个 compose 文件一键部署: https://github.com/NapNeko/NapCat-Docker/blob/main/compose/astrbot.yml
version: '3.8'
# When connecting to OneBot v11 Napcat, please use this compose file for one-click deployment: https://github.com/NapNeko/NapCat-Docker/blob/main/compose/astrbot.yml
services:
astrbot:
image: soulter/astrbot:latest
container_name: astrbot
restart: always
ports: # mappings description: https://github.com/AstrBotDevs/AstrBot/issues/497
- "6185:6185" # 必选AstrBot WebUI 端口
- "6199:6199" # 可选, QQ 个人号 WebSocket 端口
# - "6195:6195" # 可选, 企业微信 Webhook 端口
# - "6196:6196" # 可选, QQ 官方接口 Webhook 端口
security_opt:
- no-new-privileges:true
ports:
- "6185:6185" # AstrBot WebUI
- "6199:6199" # Optional. OneBot v11 Napcat Websocket Port
environment:
- TZ=Asia/Shanghai
volumes:

View File

@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite --host",
"subset-icons": "node scripts/subset-mdi-font.mjs",
"build:t2i-shiki-runtime": "node scripts/build-t2i-shiki-runtime.mjs",
"build": "vue-tsc --noEmit && vite build",
"build-stage": "vue-tsc --noEmit && vite build --base=/vue/free/stage/",
"build-prod": "vue-tsc --noEmit && vite build --base=/vue/free/",
@@ -14,7 +15,6 @@
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
"dependencies": {
"qrcode": "^1.5.4",
"@guolao/vue-monaco-editor": "^1.5.4",
"@tiptap/starter-kit": "2.1.7",
"@tiptap/vue-3": "2.1.7",
@@ -25,7 +25,6 @@
"date-fns": "2.30.0",
"dompurify": "^3.3.2",
"event-source-polyfill": "^1.0.31",
"highlight.js": "^11.11.1",
"js-md5": "^0.8.3",
"katex": "^0.16.27",
"lodash": "4.17.23",
@@ -35,6 +34,7 @@
"monaco-editor": "^0.52.2",
"pinia": "2.1.6",
"pinyin-pro": "^3.26.0",
"qrcode": "^1.5.4",
"shiki": "^3.20.0",
"stream-markdown": "^0.0.13",
"vee-validate": "4.11.3",

View File

@@ -42,9 +42,6 @@ importers:
event-source-polyfill:
specifier: ^1.0.31
version: 1.0.31
highlight.js:
specifier: ^11.11.1
version: 11.11.1
js-md5:
specifier: ^0.8.3
version: 0.8.3
@@ -540,79 +537,66 @@ packages:
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.59.0':
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.59.0':
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.59.0':
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.59.0':
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.59.0':
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.59.0':
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.59.0':
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.59.0':
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.59.0':
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.59.0':
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
@@ -1919,10 +1903,6 @@ packages:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
highlight.js@11.11.1:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
hookified@1.15.1:
resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==}
@@ -4933,8 +4913,6 @@ snapshots:
he@1.2.0: {}
highlight.js@11.11.1: {}
hookified@1.15.1: {}
html-void-elements@3.0.0: {}

View File

@@ -0,0 +1,232 @@
import { createRequire } from "node:module";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { build } from "vite";
const require = createRequire(import.meta.url);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dashboardRoot = path.resolve(__dirname, "..");
const runtimeOutputFile = path.resolve(
dashboardRoot,
"..",
"astrbot",
"core",
"utils",
"t2i",
"template",
"shiki_runtime.iife.js",
);
const shikiRequire = createRequire(require.resolve("shiki/package.json"));
const languageSpecs = [
["bash", "bash"],
["css", "css"],
["html", "html"],
["javascript", "javascript"],
["json", "json"],
["jsx", "jsx"],
["markdown", "markdown"],
["powershell", "powershell"],
["python", "python"],
["sql", "sql"],
["tsx", "tsx"],
["typescript", "typescript"],
["xml", "xml"],
["yaml", "yaml"],
];
const themeSpecs = [
["github-light", "github-light"],
["github-dark", "github-dark"],
];
// Shiki exposes plain text as a built-in special language, so we keep it
// in the supported language list without importing a package for it.
const builtInLanguageSpecs = ["text"];
const languageAliases = {
bat: "powershell",
cjs: "javascript",
console: "bash",
cts: "typescript",
dockerfile: "bash",
env: "bash",
htm: "html",
js: "javascript",
md: "markdown",
mjs: "javascript",
mts: "typescript",
plain: "text",
plaintext: "text",
ps1: "powershell",
pwsh: "powershell",
py: "python",
shell: "bash",
shellscript: "bash",
sh: "bash",
svg: "xml",
text: "text",
ts: "typescript",
txt: "text",
vue: "html",
xhtml: "html",
xml: "xml",
yml: "yaml",
zsh: "bash",
};
function resolveShikiModule(specifier) {
return pathToFileURL(shikiRequire.resolve(specifier)).href;
}
function buildVirtualSource() {
const shikiImport = JSON.stringify(
pathToFileURL(require.resolve("shiki")).href,
);
const languageImports = languageSpecs
.map(
([, packageName], index) =>
`import lang${index} from ${JSON.stringify(resolveShikiModule(`@shikijs/langs/${packageName}`))};`,
)
.join("\n");
const themeImports = themeSpecs
.map(
([, packageName], index) =>
`import theme${index} from ${JSON.stringify(resolveShikiModule(`@shikijs/themes/${packageName}`))};`,
)
.join("\n");
const supportedLanguages = [
...builtInLanguageSpecs,
...languageSpecs.map(([runtimeName]) => runtimeName),
];
return `import { createHighlighterCoreSync, createJavaScriptRegexEngine } from ${shikiImport};
${languageImports}
${themeImports}
const highlighter = createHighlighterCoreSync({
engine: createJavaScriptRegexEngine(),
langs: [${languageSpecs.map((_, index) => `...lang${index}`).join(", ")}],
themes: [${themeSpecs.map((_, index) => `theme${index}`).join(", ")}],
});
const supportedLanguages = new Set(${JSON.stringify(supportedLanguages)});
const languageAliases = ${JSON.stringify(languageAliases)};
const supportedThemes = new Set(${JSON.stringify(themeSpecs.map(([theme]) => theme))});
function normalizeLanguage(language) {
const normalized = String(language || "").trim().toLowerCase();
if (!normalized) {
return "text";
}
if (normalized in languageAliases) {
return languageAliases[normalized];
}
return supportedLanguages.has(normalized) ? normalized : "text";
}
function normalizeTheme(theme) {
const normalized = String(theme || "").trim();
return supportedThemes.has(normalized) ? normalized : "github-light";
}
function extractLanguage(codeElement) {
const className = codeElement.className || "";
const match = className.match(/language-([\\w+#.-]+)/i);
return match ? match[1] : "";
}
function renderCodeToHtml(code, language, theme) {
const normalizedTheme = normalizeTheme(theme);
try {
return highlighter.codeToHtml(String(code || ""), {
lang: normalizeLanguage(language),
theme: normalizedTheme,
});
} catch (error) {
console.warn("Failed to render T2I code block with Shiki.", error);
return highlighter.codeToHtml(String(code || ""), {
lang: "text",
theme: normalizedTheme,
});
}
}
function highlightAllCodeBlocks(root, theme) {
if (!root) {
return;
}
root.querySelectorAll("pre > code").forEach((codeElement) => {
const preElement = codeElement.parentElement;
if (!preElement || preElement.classList.contains("shiki")) {
return;
}
preElement.outerHTML = renderCodeToHtml(
codeElement.textContent || "",
extractLanguage(codeElement),
theme,
);
});
}
window.AstrBotT2IShiki = Object.freeze({
highlightAllCodeBlocks,
normalizeLanguage,
renderCodeToHtml,
});
`;
}
async function main() {
const tempDir = mkdtempSync(path.join(tmpdir(), "astrbot-t2i-shiki-runtime-"));
const entryPath = path.join(tempDir, "entry.mjs");
writeFileSync(entryPath, buildVirtualSource(), "utf-8");
try {
await build({
configFile: false,
logLevel: "info",
publicDir: false,
build: {
chunkSizeWarningLimit: 1500,
cssCodeSplit: false,
emptyOutDir: false,
lib: {
entry: entryPath,
fileName: () => path.basename(runtimeOutputFile),
formats: ["iife"],
name: "AstrBotT2IShikiRuntime",
},
minify: "esbuild",
outDir: path.dirname(runtimeOutputFile),
rollupOptions: {
output: {
inlineDynamicImports: true,
},
},
sourcemap: false,
target: "es2018",
},
});
} finally {
rmSync(tempDir, { force: true, recursive: true });
}
console.log(`Built ${runtimeOutputFile}`);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -1,4 +1,4 @@
/* Auto-generated MDI subset 256 icons */
/* Auto-generated MDI subset 247 icons */
/* Do not edit manually. Run: pnpm run subset-icons */
@font-face {
@@ -180,10 +180,6 @@
content: "\F0132";
}
.mdi-checkbox-multiple-marked-outline::before {
content: "\F0139";
}
.mdi-chevron-double-left::before {
content: "\F013D";
}
@@ -440,10 +436,6 @@
content: "\F0A4D";
}
.mdi-file-upload-outline::before {
content: "\F0A4E";
}
.mdi-file-word-box::before {
content: "\F022D";
}
@@ -456,14 +448,6 @@
content: "\F0236";
}
.mdi-flash::before {
content: "\F0241";
}
.mdi-flash-off::before {
content: "\F0243";
}
.mdi-folder::before {
content: "\F024B";
}
@@ -576,18 +560,10 @@
content: "\F0309";
}
.mdi-keyboard-outline::before {
content: "\F097B";
}
.mdi-label::before {
content: "\F0315";
}
.mdi-lan-connect::before {
content: "\F0318";
}
.mdi-language-markdown::before {
content: "\F0354";
}
@@ -652,10 +628,6 @@
content: "\F035F";
}
.mdi-message-off-outline::before {
content: "\F164E";
}
.mdi-message-outline::before {
content: "\F0365";
}
@@ -664,10 +636,6 @@
content: "\F0369";
}
.mdi-message-text-outline::before {
content: "\F036A";
}
.mdi-microphone::before {
content: "\F036C";
}
@@ -1036,10 +1004,6 @@
content: "\F05B7";
}
.mdi-wrench-outline::before {
content: "\F0BE0";
}
.mdi-zip-box::before {
content: "\F05C4";
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,696 +0,0 @@
<template>
<div class="sidebar-panel"
:class="{
'sidebar-collapsed': sidebarCollapsed && !isMobile,
'mobile-sidebar-open': isMobile && mobileMenuOpen,
'mobile-sidebar': isMobile
}"
: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">
<v-icon>{{ sidebarCollapsed ? 'mdi-chevron-right' : 'mdi-chevron-left' }}</v-icon>
</v-btn>
</div>
<div class="sidebar-collapse-btn-container" v-if="isMobile">
<v-btn icon class="sidebar-collapse-btn" @click="$emit('closeMobileSidebar')" variant="text"
color="deep-purple">
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
<div style="padding: 8px; opacity: 0.6;">
<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"
:projects="projects"
@selectProject="$emit('selectProject', $event)"
@createProject="$emit('createProject')"
@editProject="$emit('editProject', $event)"
@deleteProject="$emit('deleteProject', $event)"
/>
<div style="overflow-y: auto; flex-grow: 1; overscroll-behavior-y: contain;"
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="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"
@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: '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="!batchMode && (!sidebarCollapsed || isMobile)" v-slot:append>
<div class="conversation-actions">
<v-btn icon="mdi-pencil" size="x-small" variant="text"
class="edit-title-btn"
@click.stop="$emit('editTitle', item.session_id, item.display_name ?? '')" />
<v-btn icon="mdi-delete" size="x-small" variant="text"
class="delete-conversation-btn" color="error"
@click.stop="handleDeleteConversation(item)" />
</div>
</template>
</v-list-item>
</v-list>
</v-card>
<v-fade-transition>
<div class="no-conversations" v-if="sessions.length === 0">
<v-icon icon="mdi-message-text-outline" size="large" color="grey-lighten-1"></v-icon>
<div class="no-conversations-text" v-if="!sidebarCollapsed || isMobile">
{{ tm('conversation.noHistory') }}
</div>
</div>
</v-fade-transition>
</div>
<!-- 收起时的占位元素 -->
<div class="sidebar-spacer" v-if="sidebarCollapsed && !isMobile"></div>
<!-- 底部设置按钮 -->
<div class="sidebar-footer">
<StyledMenu location="top" :close-on-content-click="false">
<template v-slot:activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
:icon="sidebarCollapsed && !isMobile"
:block="!sidebarCollapsed || isMobile"
variant="text"
class="settings-btn"
:class="{ 'settings-btn-collapsed': sidebarCollapsed && !isMobile }"
:prepend-icon="(!sidebarCollapsed || isMobile) ? 'mdi-cog-outline' : undefined"
>
<v-icon v-if="sidebarCollapsed && !isMobile">mdi-cog-outline</v-icon>
<template v-if="!sidebarCollapsed || isMobile">{{ t('core.common.settings') }}</template>
</v-btn>
</template>
<!-- 语言切换分组 -->
<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-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')">
<template v-slot:prepend>
<v-icon>{{ isDark ? 'mdi-weather-night' : 'mdi-white-balance-sunny' }}</v-icon>
</template>
<v-list-item-title>{{ isDark ? tm('modes.lightMode') : tm('modes.darkMode') }}</v-list-item-title>
</v-list-item>
<!-- 通信传输模式分组 -->
<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-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-card class="styled-menu-card" style="min-width: 220px;" elevation="8" rounded="lg">
<v-list density="compact" class="styled-menu-list pa-1">
<v-list-item
v-for="opt in sendShortcutOptions"
:key="opt.value"
:value="opt.value"
@click="handleSendShortcutChange(opt.value)"
:class="{ 'styled-menu-item-active': props.sendShortcut === opt.value }"
class="styled-menu-item"
rounded="md"
>
<v-list-item-title>{{ opt.label }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<!-- 全屏/退出全屏 -->
<v-list-item class="styled-menu-item" @click="$emit('toggleFullscreen')">
<template v-slot:prepend>
<v-icon>{{ chatboxMode ? 'mdi-fullscreen-exit' : 'mdi-fullscreen' }}</v-icon>
</template>
<v-list-item-title>{{ chatboxMode ? tm('actions.exitFullscreen') : tm('actions.fullscreen') }}</v-list-item-title>
</v-list-item>
<!-- 提供商配置 -->
<v-list-item class="styled-menu-item" @click="showProviderConfigDialog = true">
<template v-slot:prepend>
<v-icon>mdi-creation</v-icon>
</template>
<v-list-item-title>{{ tm('actions.providerConfig') }}</v-list-item-title>
</v-list-item>
</StyledMenu>
</div>
<!-- 提供商配置对话框 -->
<ProviderConfigDialog v-model="showProviderConfigDialog" />
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import type { Session } from '@/composables/useSessions';
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog';
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[];
selectedSessions: string[];
currSessionId: string;
selectedProjectId?: string | null;
transportMode: 'sse' | 'websocket';
isDark: boolean;
chatboxMode: boolean;
isMobile: boolean;
mobileMenuOpen: boolean;
projects?: Project[];
sendShortcut: 'enter' | 'shift_enter';
}
const props = withDefaults(defineProps<Props>(), {
projects: () => []
});
const emit = defineEmits<{
newChat: [];
selectConversation: [sessionIds: string[]];
editTitle: [sessionId: string, title: string];
deleteConversation: [sessionId: string];
batchDeleteConversations: [sessionIds: string[]];
closeMobileSidebar: [];
toggleTheme: [];
toggleFullscreen: [];
selectProject: [projectId: string];
createProject: [];
editProject: [project: Project];
deleteProject: [projectId: string];
updateTransportMode: [mode: 'sse' | 'websocket'];
updateSendShortcut: [mode: 'enter' | 'shift_enter'];
}>();
const { t } = useI18n();
const { tm } = useModuleI18n('features/chat');
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');
if (savedCollapsedState !== null) {
sidebarCollapsed.value = JSON.parse(savedCollapsedState);
} else {
sidebarCollapsed.value = true;
}
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value;
localStorage.setItem('sidebarCollapsed', JSON.stringify(sidebarCollapsed.value));
}
async function handleDeleteConversation(session: Session) {
const sessionTitle = session.display_name || tm('conversation.newConversation');
const message = tm('conversation.confirmDelete', { name: sessionTitle });
if (await askForConfirmation(message, confirmDialog)) {
emit('deleteConversation', session.session_id);
}
}
function handleTransportModeChange(mode: string | null) {
if (mode === 'sse' || mode === 'websocket') {
emit('updateTransportMode', mode);
}
}
function handleSendShortcutChange(mode: string | null) {
if (mode === 'enter' || mode === 'shift_enter') {
emit('updateSendShortcut', mode);
}
}
</script>
<style scoped>
.sidebar-panel {
max-width: 270px;
min-width: 240px;
display: flex;
flex-direction: column;
padding: 0;
height: 100%;
max-height: 100%;
position: relative;
transition: all 0.3s ease;
overflow: hidden;
}
.sidebar-collapsed {
max-width: 60px;
min-width: 60px;
transition: all 0.3s ease;
}
.mobile-sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
max-width: 280px !important;
min-width: 280px !important;
transform: translateX(-100%);
transition: transform 0.3s ease;
z-index: 1000;
}
.mobile-sidebar-open {
transform: translateX(0) !important;
}
.sidebar-collapse-btn-container {
margin: 8px;
margin-bottom: 0px;
z-index: 10;
}
.sidebar-collapse-btn {
opacity: 0.6;
max-height: none;
overflow-y: visible;
padding: 0;
}
.new-chat-btn {
justify-content: flex-start;
background-color: transparent !important;
border-radius: 20px;
padding: 8px 16px !important;
}
.conversation-item {
/* margin-bottom: 4px; */
border-radius: 20px !important;
height: auto !important;
/* min-height: 56px; */
padding: 0px 16px !important;
position: relative;
}
.conversation-item:hover {
background-color: rgba(var(--v-theme-primary), 0.05);
}
.conversation-item:hover .conversation-actions {
opacity: 1;
visibility: visible;
}
.conversation-actions {
display: flex;
gap: 4px;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
}
@media (max-width: 768px) {
.conversation-actions {
opacity: 1 !important;
visibility: visible !important;
}
}
.edit-title-btn,
.delete-conversation-btn {
opacity: 0.7;
transition: opacity 0.2s ease;
}
.edit-title-btn:hover,
.delete-conversation-btn:hover {
opacity: 1;
}
.conversation-title {
font-weight: 500;
font-size: 14px;
line-height: 1.3;
margin-bottom: 2px;
transition: opacity 0.25s ease;
}
.timestamp {
font-size: 11px;
color: var(--v-theme-secondaryText);
line-height: 1;
transition: opacity 0.25s ease;
}
.no-conversations {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 150px;
opacity: 0.6;
gap: 12px;
}
.no-conversations-text {
font-size: 14px;
color: var(--v-theme-secondaryText);
transition: opacity 0.25s ease;
}
.sidebar-spacer {
flex-grow: 1;
}
.sidebar-footer {
padding: 8px 8px;
padding-bottom: 16px;
flex-shrink: 0;
}
.settings-btn {
opacity: 0.6;
justify-content: flex-start;
padding: 8px 16px !important;
border-radius: 20px !important;
}
.settings-btn:hover {
opacity: 1;
}
.settings-btn-collapsed {
width: 100%;
display: flex;
justify-content: center;
}
.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>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,162 +1,228 @@
<template>
<div>
<!-- 项目按钮 -->
<div style="padding: 0 8px 0px 8px; opacity: 0.6;">
<v-btn block variant="text" class="project-btn" @click="toggleExpanded" prepend-icon="mdi-folder-outline">
{{ tm('project.title') }}
<template v-slot:append>
<v-icon size="small">{{ expanded ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
</template>
</v-btn>
</div>
<!-- 项目列表 -->
<v-expand-transition>
<div v-show="expanded" style="padding: 0 8px;">
<v-list density="compact" nav class="project-list" style="background-color: transparent;">
<v-list-item @click="$emit('createProject')" class="create-project-item" rounded="lg">
<template v-slot:prepend>
<span class="project-emoji"><v-icon size="small">mdi-plus</v-icon></span>
</template>
<v-list-item-title style="font-size: 13px;">{{ tm('project.create') }}</v-list-item-title>
</v-list-item>
<v-list-item v-for="project in projects" :key="project.project_id"
@click="$emit('selectProject', project.project_id)" rounded="lg" class="project-item">
<template v-slot:prepend>
<span class="project-emoji">{{ project.emoji || '📁' }}</span>
</template>
<v-list-item-title class="project-title">{{ project.title }}</v-list-item-title>
<template v-slot:append>
<div class="project-actions">
<v-btn icon="mdi-pencil" size="x-small" variant="text" class="edit-project-btn"
@click.stop="$emit('editProject', project)" />
<v-btn icon="mdi-delete" size="x-small" variant="text" class="delete-project-btn"
color="error" @click.stop="handleDeleteProject(project)" />
</div>
</template>
</v-list-item>
</v-list>
</div>
</v-expand-transition>
<div class="project-list-shell">
<!-- 项目按钮 -->
<div class="project-button-wrap">
<v-btn block variant="text" class="project-btn" @click="toggleExpanded">
<v-icon size="20" class="project-action-icon mr-2">
mdi-folder-outline
</v-icon>
<span class="project-btn-title">{{ tm("project.title") }}</span>
<v-spacer />
<v-icon size="18" class="project-toggle-icon">
{{ expanded ? "mdi-chevron-up" : "mdi-chevron-down" }}
</v-icon>
</v-btn>
</div>
<!-- 项目列表 -->
<v-expand-transition>
<div v-show="expanded" class="project-list-wrap">
<button
class="project-row create-project-item"
type="button"
@click="$emit('createProject')"
>
<span class="project-emoji">
<v-icon size="18">mdi-plus</v-icon>
</span>
<span class="project-title">{{ tm("project.create") }}</span>
</button>
<button
v-for="project in projects"
:key="project.project_id"
class="project-row project-item"
:class="{ active: selectedProjectId === project.project_id }"
type="button"
@click="$emit('selectProject', project.project_id)"
>
<span class="project-emoji">{{ project.emoji || "📁" }}</span>
<span class="project-title">{{ project.title }}</span>
<span class="project-actions">
<v-btn
icon="mdi-pencil"
size="x-small"
variant="text"
class="edit-project-btn"
@click.stop="$emit('editProject', project)"
/>
<v-btn
icon="mdi-delete"
size="x-small"
variant="text"
class="delete-project-btn"
color="error"
@click.stop="handleDeleteProject(project)"
/>
</span>
</button>
</div>
</v-expand-transition>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog';
import { ref } from "vue";
import { useModuleI18n } from "@/i18n/composables";
import { askForConfirmation, useConfirmDialog } from "@/utils/confirmDialog";
export interface Project {
project_id: string;
title: string;
emoji?: string;
description?: string;
created_at: string;
updated_at: string;
project_id: string;
title: string;
emoji?: string;
description?: string;
created_at: string;
updated_at: string;
}
interface Props {
projects: Project[];
initialExpanded?: boolean;
projects: Project[];
initialExpanded?: boolean;
selectedProjectId?: string | null;
}
const props = withDefaults(defineProps<Props>(), {
initialExpanded: false
initialExpanded: false,
selectedProjectId: null,
});
const emit = defineEmits<{
selectProject: [projectId: string];
createProject: [];
editProject: [project: Project];
deleteProject: [projectId: string];
selectProject: [projectId: string];
createProject: [];
editProject: [project: Project];
deleteProject: [projectId: string];
}>();
const { tm } = useModuleI18n('features/chat');
const { tm } = useModuleI18n("features/chat");
const confirmDialog = useConfirmDialog();
const expanded = ref(props.initialExpanded);
// 从 localStorage 读取项目展开状态
const savedProjectsExpandedState = localStorage.getItem('projectsExpanded');
const savedProjectsExpandedState = localStorage.getItem("projectsExpanded");
if (savedProjectsExpandedState !== null) {
expanded.value = JSON.parse(savedProjectsExpandedState);
expanded.value = JSON.parse(savedProjectsExpandedState);
}
function toggleExpanded() {
expanded.value = !expanded.value;
localStorage.setItem('projectsExpanded', JSON.stringify(expanded.value));
expanded.value = !expanded.value;
localStorage.setItem("projectsExpanded", JSON.stringify(expanded.value));
}
async function handleDeleteProject(project: Project) {
const message = tm('project.confirmDelete', { title: project.title });
if (await askForConfirmation(message, confirmDialog)) {
emit('deleteProject', project.project_id);
}
const message = tm("project.confirmDelete", { title: project.title });
if (await askForConfirmation(message, confirmDialog)) {
emit("deleteProject", project.project_id);
}
}
</script>
<style scoped>
.project-list-shell {
margin-top: 6px;
}
.project-button-wrap {
opacity: 0.6;
}
.project-btn {
justify-content: flex-start;
background-color: transparent !important;
border-radius: 20px;
padding: 8px 16px !important;
text-transform: none;
justify-content: flex-start;
background-color: transparent !important;
border-radius: 8px;
padding: 8px 12px !important;
text-transform: none;
font-weight: 500;
}
.project-item {
border-radius: 16px !important;
padding: 4px 12px !important;
margin-bottom: 2px;
.project-action-icon {
color: currentcolor;
}
.project-item:hover {
background-color: rgba(103, 58, 183, 0.05);
.project-btn-title {
min-width: 0;
}
.project-toggle-icon {
margin-left: 10px;
}
.project-list-wrap {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 8px;
}
.project-row {
width: 100%;
min-height: 38px;
border: 0;
border-radius: 8px;
background: transparent;
color: inherit;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
text-align: left;
}
.project-row:hover,
.project-row.active {
background: var(--chat-session-active-bg);
}
.project-item:hover .project-actions {
opacity: 1;
visibility: visible;
opacity: 1;
visibility: visible;
}
.project-emoji {
font-size: 16px;
margin-right: 6px;
width: 20px;
flex: 0 0 20px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.project-title {
font-size: 13px;
font-weight: 500;
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
font-weight: 500;
}
.project-actions {
display: flex;
gap: 2px;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
display: flex;
gap: 2px;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
}
.edit-project-btn,
.delete-project-btn {
opacity: 0.7;
transition: opacity 0.2s ease;
opacity: 0.7;
transition: opacity 0.2s ease;
}
.edit-project-btn:hover,
.delete-project-btn:hover {
opacity: 1;
opacity: 1;
}
.create-project-item {
border-radius: 16px !important;
padding: 4px 12px !important;
opacity: 0.7;
opacity: 0.7;
}
.create-project-item:hover {
background-color: rgba(103, 58, 183, 0.08);
opacity: 1;
opacity: 1;
}
</style>

View File

@@ -1,189 +1,214 @@
<template>
<div class="project-sessions-container fade-in">
<div class="project-header">
<div class="project-header-info">
<span class="project-header-emoji">{{ project?.emoji || '📁' }}</span>
<h2 class="project-header-title">{{ project?.title }}</h2>
</div>
<p class="project-header-description" v-if="project?.description">
{{ project.description }}
</p>
</div>
<div class="project-input-slot">
<slot></slot>
</div>
<v-card flat class="project-sessions-list">
<v-list v-if="sessions.length > 0">
<v-list-item v-for="session in sessions" :key="session.session_id"
@click="$emit('selectSession', session.session_id)" class="project-session-item" rounded="lg">
<v-list-item-title>
{{ session.display_name || tm('conversation.newConversation') }}
</v-list-item-title>
<v-list-item-subtitle>
{{ formatDate(session.updated_at) }}
</v-list-item-subtitle>
<template v-slot:append>
<div class="session-actions">
<v-btn icon="mdi-pencil" size="x-small" variant="text"
class="edit-session-btn"
@click.stop="$emit('editSessionTitle', session.session_id, session.display_name ?? '')" />
<v-btn icon="mdi-delete" size="x-small" variant="text"
class="delete-session-btn" color="error"
@click.stop="handleDeleteSession(session)" />
</div>
</template>
</v-list-item>
</v-list>
<div v-else class="no-sessions-in-project">
<v-icon icon="mdi-message-off-outline" size="large" color="grey-lighten-1"></v-icon>
<p>{{ tm('project.noSessions') }}</p>
</div>
</v-card>
<div class="project-sessions-container fade-in">
<div class="project-header">
<div class="project-header-info">
<span class="project-header-emoji">{{ project?.emoji || "📁" }}</span>
<h2 class="project-header-title">{{ project?.title }}</h2>
</div>
<p class="project-header-description" v-if="project?.description">
{{ project.description }}
</p>
</div>
<div class="project-input-slot">
<slot></slot>
</div>
<v-card flat class="project-sessions-list">
<v-list v-if="sessions.length > 0">
<v-list-item
v-for="session in sessions"
:key="session.session_id"
@click="$emit('selectSession', session.session_id)"
class="project-session-item"
rounded="lg"
>
<v-list-item-title>
{{ session.display_name || tm("conversation.newConversation") }}
</v-list-item-title>
<v-list-item-subtitle>
{{ formatDate(session.updated_at) }}
</v-list-item-subtitle>
<template v-slot:append>
<div class="session-actions">
<v-btn
icon="mdi-pencil"
size="x-small"
variant="text"
class="edit-session-btn"
@click.stop="
$emit(
'editSessionTitle',
session.session_id,
session.display_name ?? '',
)
"
/>
<v-btn
icon="mdi-delete"
size="x-small"
variant="text"
class="delete-session-btn"
color="error"
@click.stop="handleDeleteSession(session)"
/>
</div>
</template>
</v-list-item>
</v-list>
<div v-else class="no-sessions-in-project">
<v-icon
icon="mdi-message-outline"
size="large"
color="grey-lighten-1"
></v-icon>
<p>{{ tm("project.noSessions") }}</p>
</div>
</v-card>
</div>
</template>
<script setup lang="ts">
import { useModuleI18n } from '@/i18n/composables';
import type { Project } from '@/components/chat/ProjectList.vue';
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog';
import { useModuleI18n } from "@/i18n/composables";
import type { Project } from "@/components/chat/ProjectList.vue";
import { askForConfirmation, useConfirmDialog } from "@/utils/confirmDialog";
interface Session {
session_id: string;
display_name?: string;
updated_at: string;
session_id: string;
display_name?: string | null;
updated_at: string;
}
interface Props {
project?: Project | null;
sessions: Session[];
project?: Project | null;
sessions: Session[];
}
defineProps<Props>();
const emit = defineEmits<{
selectSession: [sessionId: string];
editSessionTitle: [sessionId: string, title: string];
deleteSession: [sessionId: string];
selectSession: [sessionId: string];
editSessionTitle: [sessionId: string, title: string];
deleteSession: [sessionId: string];
}>();
const { tm } = useModuleI18n('features/chat');
const { tm } = useModuleI18n("features/chat");
const confirmDialog = useConfirmDialog();
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleString();
return new Date(dateString).toLocaleString();
}
async function handleDeleteSession(session: Session) {
const sessionTitle = session.display_name || tm('conversation.newConversation');
const message = tm('conversation.confirmDelete', { name: sessionTitle });
if (await askForConfirmation(message, confirmDialog)) {
emit('deleteSession', session.session_id);
}
const sessionTitle =
session.display_name || tm("conversation.newConversation");
const message = tm("conversation.confirmDelete", { name: sessionTitle });
if (await askForConfirmation(message, confirmDialog)) {
emit("deleteSession", session.session_id);
}
}
</script>
<style scoped>
.project-sessions-container {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding: 32px;
overflow-y: auto;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
padding: 32px;
overflow-y: auto;
}
.project-header {
text-align: center;
margin-bottom: 32px;
max-width: 600px;
text-align: center;
margin-bottom: 32px;
max-width: 600px;
}
.project-header-info {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 12px;
}
.project-header-emoji {
font-size: 48px;
font-size: 48px;
}
.project-header-title {
font-size: 32px;
font-weight: 600;
font-size: 32px;
font-weight: 600;
}
.project-header-description {
font-size: 14px;
color: var(--v-theme-secondaryText);
margin: 0;
font-size: 14px;
color: var(--v-theme-secondaryText);
margin: 0;
}
.project-input-slot {
width: 100%;
max-width: 800px;
margin-bottom: 24px;
width: 100%;
max-width: 800px;
margin-bottom: 24px;
}
.project-sessions-list {
width: 100%;
max-width: 680px;
background-color: transparent !important;
width: 100%;
max-width: 680px;
background-color: transparent !important;
}
.project-session-item {
margin-bottom: 8px;
border-radius: 12px !important;
cursor: pointer;
margin-bottom: 8px;
border-radius: 12px !important;
cursor: pointer;
}
.project-session-item:hover {
background-color: rgba(103, 58, 183, 0.05);
background-color: rgba(103, 58, 183, 0.05);
}
.project-session-item:hover .session-actions {
opacity: 1;
visibility: visible;
opacity: 1;
visibility: visible;
}
.session-actions {
display: flex;
gap: 2px;
opacity: 1;
display: flex;
gap: 2px;
opacity: 1;
}
.no-sessions-in-project {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
opacity: 0.6;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
opacity: 0.6;
}
.no-sessions-in-project p {
margin-top: 12px;
font-size: 14px;
margin-top: 12px;
font-size: 14px;
}
.fade-in {
animation: fadeIn 0.3s ease-in-out;
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -1,356 +1,617 @@
<template>
<v-card class="standalone-chat-card" elevation="0" rounded="0">
<v-card-text class="standalone-chat-container">
<div class="chat-layout">
<!-- 聊天内容区域 -->
<div class="chat-content-panel">
<MessageList v-if="messages && messages.length > 0" :messages="messages" :isDark="isDark"
:isStreaming="isStreaming || isConvRunning" @openImagePreview="openImagePreview"
ref="messageList" />
<div class="welcome-container fade-in" v-else>
<div class="welcome-title">
<span>Hello, I'm</span>
<span class="bot-name">AstrBot ⭐</span>
</div>
<p class="text-caption text-medium-emphasis mt-2">
测试配置: {{ configId || 'default' }}
</p>
</div>
<div class="standalone-chat">
<section ref="messagesContainer" class="standalone-messages">
<div v-if="initializing" class="standalone-state">
<v-progress-circular indeterminate size="28" width="3" />
</div>
<!-- 输入区域 -->
<ChatInput
v-model:prompt="prompt"
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:disabled="false"
:is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
:session-id="currSessionId || null"
:current-session="getCurrentSession"
:config-id="configId"
@send="handleSendMessage"
@stop="handleStopMessage"
@toggleStreaming="toggleStreaming"
@removeImage="removeImage"
@removeAudio="removeAudio"
@startRecording="handleStartRecording"
@stopRecording="handleStopRecording"
@pasteImage="handlePaste"
@fileSelect="handleFileSelect"
@openLiveMode=""
ref="chatInputRef"
/>
</div>
<div v-else-if="!activeMessages.length" class="standalone-state">
<div class="welcome-title">{{ tm("welcome.title") }}</div>
</div>
<div v-else class="message-list">
<div
v-for="(msg, msgIndex) in activeMessages"
:key="msg.id || `${msgIndex}-${msg.created_at || ''}`"
class="message-row"
:class="isUserMessage(msg) ? 'from-user' : 'from-bot'"
>
<div class="message-stack">
<div
class="message-bubble"
:class="{ user: isUserMessage(msg), bot: !isUserMessage(msg) }"
>
<div v-if="messageContent(msg).isLoading" class="loading-message">
{{ tm("message.loading") }}
</div>
<template v-else>
<ReasoningBlock
v-if="messageContent(msg).reasoning"
:reasoning="messageContent(msg).reasoning || ''"
:is-dark="isDark"
:initial-expanded="false"
:is-streaming="isMessageStreaming(msg, msgIndex)"
:has-non-reasoning-content="hasNonReasoningContent(msg)"
/>
<template
v-for="(part, partIndex) in messageParts(msg)"
:key="`${msgIndex}-${partIndex}-${part.type}`"
>
<div
v-if="part.type === 'plain' && isUserMessage(msg)"
class="plain-content"
>
{{ part.text || "" }}
</div>
<MarkdownMessagePart
v-else-if="part.type === 'plain'"
:content="part.text || ''"
:refs="messageRefs(msg)"
:is-dark="isDark"
:custom-html-tags="customMarkdownTags"
/>
<button
v-else-if="part.type === 'image'"
class="image-part"
type="button"
@click="openImage(partUrl(part))"
>
<img :src="partUrl(part)" :alt="part.filename || 'image'" />
</button>
<audio
v-else-if="part.type === 'record'"
class="audio-part"
controls
:src="partUrl(part)"
/>
<video
v-else-if="part.type === 'video'"
class="video-part"
controls
:src="partUrl(part)"
/>
<div v-else-if="part.type === 'file'" class="file-part">
<v-icon size="20">mdi-file-document-outline</v-icon>
<span>{{ part.filename || "file" }}</span>
</div>
<div
v-else-if="part.type === 'tool_call'"
class="tool-call-block"
>
<template
v-for="tool in part.tool_calls || []"
:key="tool.id || tool.name"
>
<ToolCallItem
v-if="isIPythonToolCall(tool)"
:is-dark="isDark"
>
<template #label>
<v-icon size="16">mdi-code-json</v-icon>
<span>{{ tool.name || "python" }}</span>
<span class="tool-call-inline-status">
{{ toolCallStatusText(tool) }}
</span>
</template>
<template #details>
<IPythonToolBlock
:tool-call="normalizeToolCall(tool)"
:is-dark="isDark"
:show-header="false"
:force-expanded="true"
/>
</template>
</ToolCallItem>
<ToolCallCard
v-else
:tool-call="normalizeToolCall(tool)"
:is-dark="isDark"
/>
</template>
</div>
<pre v-else class="unknown-part">{{ formatJson(part) }}</pre>
</template>
</template>
</div>
</v-card-text>
</v-card>
</div>
</div>
</div>
</section>
<!-- 图片预览对话框 -->
<v-dialog v-model="imagePreviewDialog" max-width="90vw" max-height="90vh">
<v-card class="image-preview-card" elevation="8">
<v-card-title class="d-flex justify-space-between align-center pa-4">
<span>{{ t('core.common.imagePreview') }}</span>
<v-btn icon="mdi-close" variant="text" @click="imagePreviewDialog = false" />
</v-card-title>
<v-card-text class="text-center pa-4">
<img :src="previewImageUrl" class="preview-image-large" />
</v-card-text>
</v-card>
</v-dialog>
<section class="standalone-composer">
<ChatInput
ref="inputRef"
v-model:prompt="draft"
:staged-images-url="stagedImagesUrl"
:staged-audio-url="stagedAudioUrl"
:staged-files="stagedNonImageFiles"
:disabled="sending || initializing"
:enable-streaming="enableStreaming"
:is-recording="false"
:is-running="Boolean(currSessionId && isSessionRunning(currSessionId))"
:session-id="currSessionId || null"
:current-session="currentSession"
:config-id="configId || 'default'"
send-shortcut="enter"
@send="sendCurrentMessage"
@stop="stopCurrentSession"
@toggle-streaming="enableStreaming = !enableStreaming"
@remove-image="removeImage"
@remove-audio="removeAudio"
@remove-file="removeFile"
@paste-image="handlePaste"
@file-select="handleFilesSelected"
/>
</section>
<v-overlay
v-model="imagePreview.visible"
class="image-preview-overlay"
scrim="rgba(0, 0, 0, 0.86)"
@click="closeImage"
>
<img class="preview-image" :src="imagePreview.url" alt="preview" />
</v-overlay>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
import axios from 'axios';
import { useCustomizerStore } from '@/stores/customizer';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { useTheme } from 'vuetify';
import MessageList from '@/components/chat/MessageList.vue';
import ChatInput from '@/components/chat/ChatInput.vue';
import { useMessages } from '@/composables/useMessages';
import { useMediaHandling } from '@/composables/useMediaHandling';
import { useRecording } from '@/composables/useRecording';
import { useToast } from '@/utils/toast';
import { buildWebchatUmoDetails } from '@/utils/chatConfigBinding';
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
reactive,
ref,
} from "vue";
import axios from "axios";
import { setCustomComponents } from "markstream-vue";
import "markstream-vue/index.css";
import ChatInput from "@/components/chat/ChatInput.vue";
import IPythonToolBlock from "@/components/chat/message_list_comps/IPythonToolBlock.vue";
import MarkdownMessagePart from "@/components/chat/message_list_comps/MarkdownMessagePart.vue";
import ReasoningBlock from "@/components/chat/message_list_comps/ReasoningBlock.vue";
import RefNode from "@/components/chat/message_list_comps/RefNode.vue";
import ToolCallCard from "@/components/chat/message_list_comps/ToolCallCard.vue";
import ToolCallItem from "@/components/chat/message_list_comps/ToolCallItem.vue";
import ThemeAwareMarkdownCodeBlock from "@/components/shared/ThemeAwareMarkdownCodeBlock.vue";
import { useMediaHandling } from "@/composables/useMediaHandling";
import {
useMessages,
type ChatRecord,
type MessagePart,
type TransportMode,
} from "@/composables/useMessages";
import type { Session } from "@/composables/useSessions";
import { useModuleI18n } from "@/i18n/composables";
import { useCustomizerStore } from "@/stores/customizer";
import { buildWebchatUmoDetails } from "@/utils/chatConfigBinding";
interface Props {
configId?: string | null;
}
const props = withDefaults(defineProps<Props>(), {
configId: null
const props = withDefaults(defineProps<{ configId?: string | null }>(), {
configId: "default",
});
const { t } = useI18n();
const { error: showError } = useToast();
setCustomComponents("chat-message", {
ref: RefNode,
code_block: ThemeAwareMarkdownCodeBlock,
});
const { tm } = useModuleI18n("features/chat");
const customizer = useCustomizerStore();
const currSessionId = ref("");
const currentSession = ref<Session | null>(null);
const draft = ref("");
const initializing = ref(false);
const enableStreaming = ref(true);
const shouldStickToBottom = ref(true);
const messagesContainer = ref<HTMLElement | null>(null);
const inputRef = ref<InstanceType<typeof ChatInput> | null>(null);
const imagePreview = reactive({ visible: false, url: "" });
// UI 状态
const imagePreviewDialog = ref(false);
const previewImageUrl = ref('');
// 会话管理(不使用 useSessions 避免路由跳转)
const currSessionId = ref('');
const getCurrentSession = computed(() => null); // 独立测试模式不需要会话信息
async function bindConfigToSession(sessionId: string) {
const confId = (props.configId || '').trim();
if (!confId || confId === 'default') {
return;
}
const umoDetails = buildWebchatUmoDetails(sessionId, false);
await axios.post('/api/config/umo_abconf_route/update', {
umo: umoDetails.umo,
conf_id: confId
});
}
async function newSession() {
try {
const response = await axios.get('/api/chat/new_session');
const sessionId = response.data.data.session_id;
try {
await bindConfigToSession(sessionId);
} catch (err) {
console.error('Failed to bind config to session', err);
}
currSessionId.value = sessionId;
return sessionId;
} catch (err) {
console.error(err);
throw err;
}
}
function updateSessionTitle(sessionId: string, title: string) {
// 独立模式不需要更新会话标题
}
function getSessions() {
// 独立模式不需要加载会话列表
}
const isDark = computed(() => customizer.uiTheme === "PurpleThemeDark");
const customMarkdownTags = ["ref"];
const {
stagedImagesUrl,
stagedAudioUrl,
stagedFiles,
getMediaFile,
processAndUploadImage,
handlePaste,
removeImage,
removeAudio,
clearStaged,
cleanupMediaCache
stagedFiles,
stagedImagesUrl,
stagedAudioUrl,
stagedNonImageFiles,
processAndUploadImage,
processAndUploadFile,
handlePaste,
removeImage,
removeAudio,
removeFile,
clearStaged,
cleanupMediaCache,
} = useMediaHandling();
const { isRecording, startRecording: startRec, stopRecording: stopRec } = useRecording();
const {
messages,
isStreaming,
isConvRunning,
enableStreaming,
getSessionMessages: getSessionMsg,
sendMessage: sendMsg,
stopMessage: stopMsg,
toggleStreaming
} = useMessages(currSessionId, getMediaFile, updateSessionTitle, getSessions);
// 组件引用
const messageList = ref<InstanceType<typeof MessageList> | null>(null);
const chatInputRef = ref<InstanceType<typeof ChatInput> | null>(null);
// 输入状态
const prompt = ref('');
const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark');
function openImagePreview(imageUrl: string) {
previewImageUrl.value = imageUrl;
imagePreviewDialog.value = true;
}
async function handleStartRecording() {
await startRec();
}
async function handleStopRecording() {
const audioFilename = await stopRec();
stagedAudioUrl.value = audioFilename;
}
async function handleFileSelect(files: FileList) {
for (const file of files) {
await processAndUploadImage(file);
sending,
activeMessages,
isSessionRunning,
isMessageStreaming,
isUserMessage,
messageContent,
messageParts,
createLocalExchange,
sendMessageStream,
stopSession,
} = useMessages({
currentSessionId: currSessionId,
onStreamUpdate: () => {
if (shouldStickToBottom.value) {
scrollToBottom();
}
}
},
});
async function handleSendMessage() {
if (!prompt.value.trim() && stagedFiles.value.length === 0 && !stagedAudioUrl.value) {
return;
}
try {
if (!currSessionId.value) {
await newSession();
}
const promptToSend = prompt.value.trim();
const audioNameToSend = stagedAudioUrl.value;
const filesToSend = stagedFiles.value.map(f => ({
attachment_id: f.attachment_id,
url: f.url,
original_name: f.original_name,
type: f.type
}));
// 清空输入和附件
prompt.value = '';
clearStaged();
// 获取选择的提供商和模型
const selection = chatInputRef.value?.getCurrentSelection();
const selectedProviderId = selection?.providerId || '';
const selectedModelName = selection?.modelName || '';
await sendMsg(
promptToSend,
filesToSend,
audioNameToSend,
selectedProviderId,
selectedModelName
);
// 滚动到底部
nextTick(() => {
messageList.value?.scrollToBottom();
});
} catch (err) {
console.error('Failed to send message:', err);
showError(t('features.chat.errors.sendMessageFailed'));
// 恢复输入内容,让用户可以重试
// 注意:附件已经上传到服务器,所以不恢复附件
}
}
async function handleStopMessage() {
await stopMsg();
}
const transportMode = computed<TransportMode>(() =>
(localStorage.getItem("chat.transportMode") as TransportMode) === "websocket"
? "websocket"
: "sse",
);
onMounted(async () => {
// 独立模式在挂载时创建新会话
try {
await newSession();
} catch (err) {
console.error('Failed to create initial session:', err);
showError(t('features.chat.errors.createSessionFailed'));
}
await ensureSession();
inputRef.value?.focusInput();
});
onBeforeUnmount(() => {
cleanupMediaCache();
cleanupMediaCache();
});
async function ensureSession() {
if (currSessionId.value) return currSessionId.value;
initializing.value = true;
try {
const response = await axios.get("/api/chat/new_session");
const session = response.data?.data as Session;
currSessionId.value = session.session_id;
currentSession.value = session;
await bindConfigToSession(session.session_id);
return session.session_id;
} finally {
initializing.value = false;
}
}
async function bindConfigToSession(sessionId: string) {
const confId = props.configId || "default";
const umo = buildWebchatUmoDetails(sessionId, false).umo;
await axios.post("/api/config/umo_abconf_route/update", {
umo,
conf_id: confId,
});
}
async function sendCurrentMessage() {
if (!draft.value.trim() && !stagedFiles.value.length) return;
const sessionId = await ensureSession();
const text = draft.value.trim();
const parts = buildOutgoingParts(text);
const messageId = crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`;
const selection = inputRef.value?.getCurrentSelection();
const { botRecord } = createLocalExchange({ sessionId, messageId, parts });
draft.value = "";
clearStaged();
scrollToBottom();
sendMessageStream({
sessionId,
messageId,
parts,
transport: transportMode.value,
enableStreaming: enableStreaming.value,
selectedProvider: selection?.providerId || "",
selectedModel: selection?.modelName || "",
botRecord,
});
}
function buildOutgoingParts(text: string): MessagePart[] {
const parts: MessagePart[] = [];
if (text) {
parts.push({ type: "plain", text });
}
stagedFiles.value.forEach((file) => {
parts.push({
type: file.type,
attachment_id: file.attachment_id,
filename: file.filename,
embedded_url: file.url,
});
});
return parts;
}
function hasNonReasoningContent(message: ChatRecord) {
return messageParts(message).some((part) => {
if (part.type === "reply") return false;
if (part.type === "plain") return Boolean(String(part.text || "").trim());
return true;
});
}
async function stopCurrentSession() {
if (!currSessionId.value) return;
await stopSession(currSessionId.value);
}
async function handleFilesSelected(files: FileList) {
const selectedFiles = Array.from(files || []);
for (const file of selectedFiles) {
if (file.type.startsWith("image/")) {
await processAndUploadImage(file);
} else {
await processAndUploadFile(file);
}
}
}
function scrollToBottom() {
nextTick(() => {
const container = messagesContainer.value;
if (!container) return;
container.scrollTop = container.scrollHeight;
shouldStickToBottom.value = true;
});
}
function messageRefs(message: ChatRecord) {
const refs = messageContent(message).refs;
if (refs && typeof refs === "object" && Array.isArray(refs.used)) {
return refs as { used?: Array<Record<string, unknown>> };
}
return null;
}
function partUrl(part: MessagePart) {
if (part.embedded_url) return part.embedded_url;
if (part.embedded_file?.url) return part.embedded_file.url;
if (part.attachment_id)
return `/api/chat/get_attachment?attachment_id=${encodeURIComponent(
part.attachment_id,
)}`;
if (part.filename)
return `/api/chat/get_file?filename=${encodeURIComponent(part.filename)}`;
return "";
}
function normalizeToolCall(tool: Record<string, unknown>) {
const normalized = { ...tool };
normalized.args = parseJsonSafe(normalized.args || normalized.arguments);
normalized.result = parseJsonSafe(normalized.result);
if (!normalized.ts) normalized.ts = Date.now() / 1000;
if (normalized.result && typeof normalized.result === "object") {
normalized.result = JSON.stringify(normalized.result, null, 2);
}
return normalized;
}
function isIPythonToolCall(tool: Record<string, unknown>) {
const name = String(tool.name || "").toLowerCase();
return name.includes("python") || name.includes("ipython");
}
function toolCallStatusText(tool: Record<string, unknown>) {
if (tool.finished_ts) return tm("toolStatus.done");
return tm("toolStatus.running");
}
function formatJson(value: unknown) {
if (typeof value === "string") return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value ?? "");
}
}
function parseJsonSafe(value: unknown) {
if (typeof value !== "string") return value;
try {
return JSON.parse(value);
} catch {
return value;
}
}
function openImage(url: string) {
imagePreview.url = url;
imagePreview.visible = true;
}
function closeImage() {
imagePreview.visible = false;
imagePreview.url = "";
}
</script>
<style scoped>
/* 基础动画 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
.standalone-chat {
--standalone-muted: rgba(var(--v-theme-on-surface), 0.62);
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
background: rgb(var(--v-theme-background));
}
.standalone-chat-card {
width: 100%;
height: 100%;
max-height: 100%;
overflow: hidden;
.standalone-messages {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 20px 22px 14px;
}
.standalone-chat-container {
width: 100%;
height: 100%;
max-height: 100%;
padding: 0;
overflow: hidden;
}
.chat-layout {
height: 100%;
max-height: 100%;
display: flex;
overflow: hidden;
}
.chat-content-panel {
height: 100%;
max-height: 100%;
width: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.conversation-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
padding-left: 16px;
border-bottom: 1px solid var(--v-theme-border);
width: 100%;
padding-right: 32px;
flex-shrink: 0;
}
.conversation-header-info h4 {
margin: 0;
font-weight: 500;
}
.conversation-header-actions {
display: flex;
gap: 8px;
align-items: center;
}
.welcome-container {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
.standalone-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.welcome-title {
font-size: 28px;
margin-bottom: 8px;
font-size: 24px;
font-weight: 700;
}
.bot-name {
font-weight: 700;
margin-left: 8px;
color: var(--v-theme-secondary);
.message-list {
display: flex;
flex-direction: column;
gap: 18px;
}
.fade-in {
animation: fadeIn 0.3s ease-in-out;
.message-row {
display: flex;
}
.preview-image-large {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
.message-row.from-user {
justify-content: flex-end;
}
.message-stack {
max-width: 88%;
}
.from-user .message-stack {
max-width: 70%;
}
.message-bubble {
border-radius: 8px;
padding: 10px 14px;
line-height: 1.65;
overflow-wrap: anywhere;
}
.message-bubble.user {
padding: 12px 18px;
border-radius: 1.5rem;
background: rgba(var(--v-theme-primary), 0.12);
}
.message-bubble.bot {
padding-left: 0;
background: transparent;
}
.plain-content {
white-space: pre-wrap;
}
.loading-message,
.tool-call-inline-status {
color: var(--standalone-muted);
}
.image-part {
display: block;
border: 0;
padding: 0;
margin-top: 8px;
background: transparent;
cursor: zoom-in;
}
.image-part img {
max-width: min(360px, 100%);
max-height: 320px;
border-radius: 8px;
object-fit: contain;
}
.audio-part,
.video-part {
display: block;
max-width: 100%;
margin-top: 8px;
}
.video-part {
max-height: 320px;
border-radius: 8px;
}
.file-part {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.tool-call-block {
margin: 8px 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.message-bubble.bot
> .tool-call-block:first-child
:deep(.tool-call-card:first-child) {
margin-top: 0;
}
.unknown-part {
max-width: 100%;
overflow-x: auto;
border-radius: 8px;
padding: 10px;
background: rgba(var(--v-theme-on-surface), 0.06);
font-size: 13px;
line-height: 1.5;
}
.standalone-composer {
position: relative;
z-index: 1;
padding-bottom: 10px;
background: rgb(var(--v-theme-background));
}
.standalone-composer::before {
content: "";
position: absolute;
z-index: -1;
left: 0;
right: 0;
top: -32px;
height: 32px;
pointer-events: none;
background: linear-gradient(
to bottom,
rgba(var(--v-theme-background), 0),
rgb(var(--v-theme-background))
);
}
.standalone-composer :deep(.input-area) {
border-top: 0;
}
.image-preview-overlay {
display: flex;
align-items: center;
justify-content: center;
}
.preview-image {
max-width: min(92vw, 1000px);
max-height: 88vh;
border-radius: 8px;
object-fit: contain;
}
</style>

View File

@@ -1,144 +0,0 @@
<template>
<div class="welcome-container fade-in">
<div v-if="isLoading" class="loading-overlay-welcome">
<v-progress-circular
indeterminate
size="48"
width="4"
color="primary"
></v-progress-circular>
</div>
<template v-else>
<div class="welcome-content">
<div class="welcome-title">
<span class="bot-name-container">
<span class="bot-name-text">
Hello, I'm <span class="highlight-name">AstrBot</span>
</span>
<span class="bot-name-star"></span>
</span>
</div>
</div>
<div class="welcome-input">
<slot></slot>
</div>
</template>
</div>
</template>
<script setup lang="ts">
interface Props {
isLoading?: boolean;
}
withDefaults(defineProps<Props>(), {
isLoading: false
});
</script>
<style scoped>
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.welcome-container {
height: 100%;
width: 100%;
justify-content: center;
display: flex;
align-items: center;
flex-direction: column;
position: relative;
}
.welcome-content {
padding: 24px 0px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.welcome-title {
font-size: 28px;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.welcome-input {
width: 75%;
}
.loading-overlay-welcome {
display: flex;
justify-content: center;
align-items: center;
}
.bot-name-container {
display: flex;
align-items: center;
}
.highlight-name {
color: var(--v-theme-secondary);
font-weight: 700;
}
.bot-name-text {
overflow: hidden;
white-space: nowrap;
width: 0;
opacity: 0;
animation: revealText 1.2s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
animation-delay: 0.2s;
}
.bot-name-star {
margin-left: 0;
display: inline-block;
transform-origin: center;
animation: rotateStar 1.2s cubic-bezier(0.34, 1, 0.64, 1) forwards;
animation-delay: 0.2s;
padding-left: 4px;
}
@keyframes revealText {
from {
width: 0;
opacity: 0;
}
to {
width: 9.2em;
opacity: 1;
}
}
@keyframes rotateStar {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.fade-in {
animation: fadeIn 0.3s ease-in-out;
}
@media (max-width: 600px) {
.welcome-input {
width: 100%;
}
}
</style>

View File

@@ -1,109 +1,121 @@
<template>
<div v-if="refs && refs.used && refs.used.length > 0" class="refs-container" @click="handleClick">
<div class="refs-avatars">
<div v-for="(ref, refIdx) in refs.used.slice(0, 3)" :key="refIdx" class="ref-avatar"
:style="{ zIndex: 3 - refIdx }">
<img v-if="ref.favicon" :src="ref.favicon" class="ref-favicon"
@error="(e) => e.target.style.display = 'none'" />
<span v-else class="ref-initial">{{ getRefInitial(ref.title) }}</span>
</div>
<span v-if="refs.used.length > 3" class="refs-more">
+{{ refs.used.length - 3 }}
</span>
<span class="ml-2" style="color: gray;">
{{ tm('refs.sources') }}
</span>
</div>
<div
v-if="refs && refs.used && refs.used.length > 0"
class="refs-container"
@click="handleClick"
>
<div class="refs-avatars">
<div
v-for="(ref, refIdx) in refs.used.slice(0, 3)"
:key="refIdx"
class="ref-avatar"
:style="{ zIndex: 3 - refIdx }"
>
<img
v-if="ref.favicon"
:src="ref.favicon"
class="ref-favicon"
@error="(e) => (e.target.style.display = 'none')"
/>
<span v-else class="ref-initial">{{ getRefInitial(ref.title) }}</span>
</div>
<span v-if="refs.used.length > 3" class="refs-more">
+{{ refs.used.length - 3 }}
</span>
<span class="ml-2" style="color: gray">
{{ tm("refs.sources") }}
</span>
</div>
</div>
</template>
<script>
import { useModuleI18n } from '@/i18n/composables';
import { useModuleI18n } from "@/i18n/composables";
export default {
name: 'ActionRef',
props: {
refs: {
type: Object,
default: null
}
name: "ActionRef",
props: {
refs: {
type: Object,
default: null,
},
emits: ['open-refs'],
setup() {
const { tm } = useModuleI18n('features/chat');
return { tm };
},
emits: ["open-refs"],
setup() {
const { tm } = useModuleI18n("features/chat");
return { tm };
},
methods: {
// Get first character of ref title for fallback display
getRefInitial(title) {
if (!title) return "?";
return title.charAt(0).toUpperCase();
},
methods: {
// Get first character of ref title for fallback display
getRefInitial(title) {
if (!title) return '?';
return title.charAt(0).toUpperCase();
},
// Handle click to open refs sidebar
handleClick() {
this.$emit('open-refs', this.refs);
}
}
}
// Handle click to open refs sidebar
handleClick() {
this.$emit("open-refs", this.refs);
},
},
};
</script>
<style scoped>
.refs-container {
display: flex;
align-items: center;
margin-left: 8px;
padding: 4px 8px;
border-radius: 12px;
cursor: pointer;
transition: background-color;
display: flex;
align-items: center;
margin-left: 8px;
padding: 4px 8px;
border-radius: 12px;
cursor: pointer;
transition: background-color;
}
.refs-container:hover {
background-color: rgba(103, 58, 183, 0.08);
background-color: rgba(103, 58, 183, 0.08);
}
.refs-avatars {
display: flex;
align-items: center;
position: relative;
display: flex;
align-items: center;
position: relative;
}
.ref-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
opacity: 0.9;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
width: 20px;
height: 20px;
border-radius: 50%;
opacity: 0.9;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
}
.ref-avatar:not(:first-child) {
margin-left: -8px;
margin-left: -8px;
}
.ref-favicon {
width: 100%;
height: 100%;
object-fit: cover;
width: 100%;
height: 100%;
object-fit: cover;
}
.ref-initial {
font-size: 10px;
font-weight: 600;
color: white;
user-select: none;
font-size: 10px;
font-weight: 600;
color: white;
user-select: none;
}
.refs-more {
margin-left: 6px;
font-size: 11px;
color: var(--v-theme-secondaryText);
opacity: 0.7;
font-weight: 500;
margin-left: 6px;
font-size: 11px;
color: var(--v-theme-secondaryText);
opacity: 0.7;
font-weight: 500;
}
</style>

View File

@@ -24,7 +24,7 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import { createHighlighter } from 'shiki';
import { ensureShikiLanguages, escapeHtml, renderShikiCode } from '@/utils/shiki';
const props = defineProps({
toolCall: {
@@ -82,13 +82,15 @@ const highlightedCode = computed(() => {
return '';
}
try {
return shikiHighlighter.value.codeToHtml(code.value, {
lang: 'python',
theme: props.isDark ? 'min-dark' : 'github-light'
});
return renderShikiCode(
shikiHighlighter.value,
code.value,
'python',
props.isDark ? 'dark' : 'light'
);
} catch (err) {
console.error('Failed to highlight code:', err);
return `<pre><code>${code.value}</code></pre>`;
return `<pre><code>${escapeHtml(code.value)}</code></pre>`;
}
});
@@ -101,10 +103,7 @@ const displayExpanded = computed(() => {
onMounted(async () => {
try {
shikiHighlighter.value = await createHighlighter({
themes: ['min-dark', 'github-light'],
langs: ['python']
});
shikiHighlighter.value = await ensureShikiLanguages(['python']);
shikiReady.value = true;
} catch (err) {
console.error('Failed to initialize Shiki:', err);
@@ -139,6 +138,20 @@ onMounted(async () => {
overflow-x: auto;
}
:deep(.code-highlighted pre.shiki) {
margin: 0;
padding: 16px;
border-radius: 6px;
overflow: auto;
}
:deep(.code-highlighted pre.shiki code) {
display: block;
padding: 0;
background: transparent;
border-radius: 0;
}
.code-fallback {
margin: 0;
padding: 12px;

View File

@@ -0,0 +1,40 @@
<template>
<div class="markdown-content">
<MarkdownRender
custom-id="chat-message"
:content="content"
:custom-html-tags="customHtmlTags"
:is-dark="isDark"
:typewriter="false"
:max-live-nodes="0"
/>
</div>
</template>
<script setup lang="ts">
import { computed, provide } from "vue";
import { MarkdownRender } from "markstream-vue";
const props = defineProps<{
content: string;
refs: { used?: Array<Record<string, unknown>> } | null;
isDark: boolean;
customHtmlTags: string[];
}>();
const isDarkRef = computed(() => props.isDark);
const refsByIndex = computed(() => {
const messageRefs = props.refs;
const refs =
messageRefs && Array.isArray(messageRefs.used) ? messageRefs.used : [];
return refs.reduce<Record<string, Record<string, unknown>>>((acc, item) => {
if (item.index != null) {
acc[String(item.index)] = item;
}
return acc;
}, {});
});
provide("isDark", isDarkRef);
provide("webSearchResults", () => refsByIndex.value);
</script>

View File

@@ -1,433 +0,0 @@
<template>
<template v-for="(renderPart, renderIndex) in getRenderParts(parts)" :key="renderPart.key">
<!-- Grouped Tool Calls (consecutive tool_call parts) -->
<div v-if="renderPart.type === 'tool_group'" class="tool-call-compact">
<transition-group name="tool-call-item" tag="div" class="tool-call-items">
<ToolCallItem v-for="(toolCall, tcIndex) in renderPart.toolCalls" :key="toolCall.id" :is-dark="isDark">
<template #label="{ expanded }">
<v-icon size="x-small" v-if="toolCall.name.includes('web_search') || toolCall.name.includes('tavily')">
mdi-web
</v-icon>
<v-icon size="x-small" v-else-if="toolCall.name === 'astrbot_execute_shell'">
mdi-console-line
</v-icon>
<v-icon size="x-small" v-else>
mdi-wrench
</v-icon>
{{ tm('actions.toolCallUsed', { name: toolCall.name }) }}
<span style="opacity: 0.6;">{{ toolCall.finished_ts ? formatDuration(toolCall.finished_ts -
toolCall.ts) : getElapsedTime(toolCall.ts) }}</span>
<v-icon size="x-small" class="tool-call-chevron" :class="{ rotated: expanded }">
mdi-chevron-right
</v-icon>
</template>
<template #details>
<div class="tool-call-detail-row">
<span class="detail-label">ID:</span>
<code class="detail-value">{{ toolCall.id }}</code>
</div>
<div class="tool-call-detail-row">
<span class="detail-label">Args:</span>
<pre class="detail-value detail-json">{{ formatToolArgs(toolCall.args) }}</pre>
</div>
<div v-if="toolCall.result" class="tool-call-detail-row">
<span class="detail-label">Result:</span>
<pre
class="detail-value detail-json detail-result">{{ formatToolResult(toolCall.result) }}</pre>
</div>
</template>
</ToolCallItem>
</transition-group>
</div>
<!-- iPython Tool Block -->
<ToolCallItem v-else-if="renderPart.type === 'ipython'" :is-dark="isDark" style="margin: 8px 0 4px;">
<template #label="{ expanded }">
<v-icon size="x-small">
mdi-code-json
</v-icon>
<span class="ipython-label">{{ tm('actions.pythonCodeAnalysis') }}</span>
<span style="opacity: 0.6;">{{ renderPart.toolCall.finished_ts ?
formatDuration(renderPart.toolCall.finished_ts -
renderPart.toolCall.ts) : getElapsedTime(renderPart.toolCall.ts) }}</span>
<v-icon size="small" class="ipython-icon" :class="{ rotated: expanded }">
mdi-chevron-right
</v-icon>
</template>
<template #details>
<IPythonToolBlock :tool-call="renderPart.toolCall" :is-dark="isDark" :show-header="false"
:force-expanded="true" />
</template>
</ToolCallItem>
<!-- Text (Markdown) -->
<MarkdownRender
v-else-if="renderPart.part.type === 'plain' && renderPart.part.text && renderPart.part.text.trim()"
custom-id="message-list" :custom-html-tags="['ref']"
:content="normalizeMarkdownContent(renderPart.part.text)" :typewriter="false"
class="markdown-content" :is-dark="isDark" :monacoOptions="{ theme: isDark ? 'vs-dark' : 'vs-light' }"
:key="`${renderPart.key}-${isDark ? 'dark' : 'light'}`"/>
<!-- Image -->
<div v-else-if="renderPart.part.type === 'image' && renderPart.part.embedded_url" class="embedded-images">
<div class="embedded-image">
<img :src="renderPart.part.embedded_url" class="bot-embedded-image"
@click="emitOpenImage(renderPart.part.embedded_url)" />
</div>
</div>
<!-- Audio -->
<div v-else-if="renderPart.part.type === 'record' && renderPart.part.embedded_url" class="embedded-audio">
<audio controls class="audio-player">
<source :src="renderPart.part.embedded_url" type="audio/wav">
{{ t('messages.errors.browser.audioNotSupported') }}
</audio>
</div>
<!-- Files -->
<div v-else-if="renderPart.part.type === 'file' && renderPart.part.embedded_file" class="embedded-files">
<div class="embedded-file">
<a v-if="renderPart.part.embedded_file.url" :href="renderPart.part.embedded_file.url"
:download="renderPart.part.embedded_file.filename" class="file-link" :class="{ 'is-dark': isDark }"
:style="isDark ? {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderColor: 'rgba(255, 255, 255, 0.1)',
color: 'var(--v-theme-secondary)'
} : {}">
<v-icon size="small" class="file-icon"
:style="isDark ? { color: 'var(--v-theme-secondary)' } : {}">mdi-file-document-outline</v-icon>
<span class="file-name">{{ renderPart.part.embedded_file.filename }}</span>
</a>
<a v-else @click="emitDownloadFile(renderPart.part.embedded_file)" class="file-link file-link-download"
:class="{ 'is-dark': isDark }" :style="isDark ? {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderColor: 'rgba(255, 255, 255, 0.1)',
color: 'var(--v-theme-secondary)'
} : {}">
<v-icon size="small" class="file-icon"
:style="isDark ? { color: 'var(--v-theme-secondary)' } : {}">mdi-file-document-outline</v-icon>
<span class="file-name">{{ renderPart.part.embedded_file.filename }}</span>
<v-icon v-if="downloadingFiles?.has(renderPart.part.embedded_file.attachment_id)" size="small"
class="download-icon">mdi-loading mdi-spin</v-icon>
<v-icon v-else size="small" class="download-icon">mdi-download</v-icon>
</a>
</div>
</div>
</template>
</template>
<script setup>
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { MarkdownRender } from 'markstream-vue';
import IPythonToolBlock from './IPythonToolBlock.vue';
import ToolCallItem from './ToolCallItem.vue';
const props = defineProps({
parts: {
type: Array,
required: true
},
isDark: {
type: Boolean,
default: false
},
currentTime: {
type: Number,
default: 0
},
downloadingFiles: {
type: Object,
default: () => new Set()
}
});
const emit = defineEmits(['open-image-preview', 'download-file']);
const { t } = useI18n();
const { tm } = useModuleI18n('features/chat');
const emitOpenImage = (url) => {
emit('open-image-preview', url);
};
const emitDownloadFile = (file) => {
emit('download-file', file);
};
const isMarkdownCodeFence = (text) => /^(```|~~~)/.test(text.trim());
const looksLikeStandaloneHtml = (text) => {
const normalized = text.trim();
if (!normalized) return false;
if (!/(<!doctype\s+html|<html\b|<head\b|<body\b)/i.test(normalized)) return false;
return /(<\/html>|<\/body>|<\/head>|<form\b|<input\b|<button\b)/i.test(normalized);
};
const normalizeMarkdownContent = (text) => {
if (typeof text !== 'string') return text;
if (isMarkdownCodeFence(text) || !looksLikeStandaloneHtml(text)) return text;
return `\`\`\`\`html\n${text}\n\`\`\`\``;
};
const formatDuration = (seconds) => {
if (seconds < 1) {
return `${Math.round(seconds * 1000)}ms`;
}
if (seconds < 60) {
return `${seconds.toFixed(1)}s`;
}
const minutes = Math.floor(seconds / 60);
const secs = Math.round(seconds % 60);
return `${minutes}m ${secs}s`;
};
const getElapsedTime = (startTs) => {
const elapsed = props.currentTime - startTs;
return formatDuration(elapsed);
};
const formatToolResult = (result) => {
if (!result) return '';
if (typeof result === 'string') {
try {
const parsed = JSON.parse(result);
return JSON.stringify(parsed, null, 2);
} catch {
return result;
}
}
return JSON.stringify(result, null, 2);
};
const formatToolArgs = (args) => {
if (!args) return '';
if (typeof args === 'string') {
try {
const parsed = JSON.parse(args);
return JSON.stringify(parsed, null, 2);
} catch {
return args;
}
}
return JSON.stringify(args, null, 2);
};
const isIPythonTool = (toolCall) => {
return toolCall.name === 'astrbot_execute_ipython' || toolCall.name === 'astrbot_execute_python';
};
const getRenderParts = (messageParts) => {
if (!Array.isArray(messageParts)) return [];
const rendered = [];
let pendingToolCalls = [];
let groupIndex = 0;
const flushPending = (endIndex) => {
if (!pendingToolCalls.length) return;
rendered.push({
type: 'tool_group',
toolCalls: pendingToolCalls,
key: `tool-group-${groupIndex}-${endIndex}`
});
pendingToolCalls = [];
groupIndex += 1;
};
messageParts.forEach((part, idx) => {
if (part?.type === 'tool_call' && Array.isArray(part.tool_calls) && part.tool_calls.length) {
part.tool_calls.forEach((toolCall, tcIndex) => {
if (isIPythonTool(toolCall)) {
flushPending(idx - 1);
rendered.push({
type: 'ipython',
toolCall,
key: `ipython-${idx}-${tcIndex}`
});
return;
}
pendingToolCalls.push(toolCall);
});
return;
}
flushPending(idx - 1);
rendered.push({
type: 'part',
part,
key: `part-${idx}`
});
});
flushPending(messageParts.length - 1);
return rendered;
};
</script>
<style scoped>
.tool-call-compact {
display: flex;
flex-direction: column;
gap: 8px;
margin: 8px 0 4px;
}
.tool-call-group-title {
font-size: 13px;
color: var(--v-theme-secondaryText);
opacity: 0.7;
}
.tool-call-items {
display: flex;
flex-direction: column;
gap: 8px;
}
.tool-call-detail-row {
display: flex;
flex-direction: column;
margin-bottom: 6px;
}
.tool-call-detail-row:last-child {
margin-bottom: 0;
}
.detail-label {
font-size: 11px;
font-weight: 600;
color: var(--v-theme-secondaryText);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.detail-value {
font-size: 12px;
color: var(--v-theme-primaryText);
background-color: transparent;
padding: 2px 6px;
border-radius: 4px;
word-break: break-word;
}
.detail-json {
font-family: 'Fira Code', 'Consolas', monospace;
white-space: pre-wrap;
max-height: 220px;
overflow-y: auto;
margin: 0;
}
.detail-result {
max-height: 320px;
background-color: transparent;
}
.tool-call-item-enter-active,
.tool-call-item-leave-active {
transition: all 0.2s ease;
}
.tool-call-item-enter-from,
.tool-call-item-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.ipython-icon,
.tool-call-chevron {
margin-left: 6px;
transition: transform 0.2s ease;
}
.ipython-icon.rotated {
transform: rotate(90deg);
}
.tool-call-chevron.rotated {
transform: rotate(90deg);
}
.embedded-images {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.embedded-image {
display: flex;
justify-content: flex-start;
}
.bot-embedded-image {
max-width: 55%;
width: auto;
height: auto;
border-radius: 4px;
cursor: pointer;
transition: transform 0.2s ease;
}
.embedded-audio {
width: 300px;
margin-top: 8px;
}
.embedded-audio .audio-player {
width: 100%;
max-width: 300px;
}
/* 文件附件样式 */
.file-attachments,
.embedded-files {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.file-attachment,
.embedded-file {
display: flex;
align-items: center;
}
/* 文件附件样式 */
.file-attachments,
.embedded-files {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.file-attachment,
.embedded-file {
display: flex;
align-items: center;
}
.file-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background-color: rgba(var(--v-theme-primary), 0.08);
border: 1px solid rgba(var(--v-theme-primary), 0.2);
border-radius: 8px;
text-decoration: none;
font-size: 13px;
transition: all 0.2s ease;
max-width: 320px;
}
.file-link-download {
cursor: pointer;
}
</style>

View File

@@ -1,122 +1,288 @@
<template>
<div class="reasoning-block" :class="{ 'reasoning-block--dark': isDark }">
<div class="reasoning-header" @click="toggleExpanded">
<v-icon size="small" class="reasoning-icon" :class="{ 'rotate-90': isExpanded }">
mdi-chevron-right
</v-icon>
<span class="reasoning-title">
{{ tm('reasoning.thinking') }}
</span>
</div>
<div v-if="isExpanded" class="reasoning-content animate-fade-in">
<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 class="reasoning-block" :class="{ 'reasoning-block--dark': isDark }">
<button class="reasoning-header" type="button" @click="toggleExpanded">
<span class="reasoning-title">
{{ tm("reasoning.thinking") }}
</span>
<v-icon
size="22"
class="reasoning-icon"
:class="{ 'rotate-90': isExpanded }"
>
mdi-chevron-right
</v-icon>
</button>
<div v-if="isExpanded" class="reasoning-content animate-fade-in">
<MarkdownRender
:key="`reasoning-${isDark ? 'dark' : 'light'}`"
:content="reasoning"
class="reasoning-text markdown-content"
:typewriter="false"
:is-dark="isDark"
/>
</div>
<transition :name="previewTransitionName" mode="out-in">
<div
v-if="showStreamingPreview"
:key="previewKey"
class="reasoning-preview"
>
{{ previewText }}
</div>
</transition>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import { MarkdownRender } from 'markstream-vue';
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { useModuleI18n } from "@/i18n/composables";
import { MarkdownRender } from "markstream-vue";
const props = defineProps({
reasoning: {
type: String,
required: true
},
isDark: {
type: Boolean,
default: false
},
initialExpanded: {
type: Boolean,
default: false
}
reasoning: {
type: String,
required: true,
},
isDark: {
type: Boolean,
default: false,
},
initialExpanded: {
type: Boolean,
default: false,
},
isStreaming: {
type: Boolean,
default: false,
},
hasNonReasoningContent: {
type: Boolean,
default: false,
},
});
const { tm } = useModuleI18n('features/chat');
const { tm } = useModuleI18n("features/chat");
const isExpanded = ref(props.initialExpanded);
const previewText = ref("");
const previewKey = ref(0);
let previewTimer = null;
let previewStartTimer = null;
const showStreamingPreview = computed(
() =>
props.isStreaming &&
!isExpanded.value &&
!props.hasNonReasoningContent &&
previewText.value,
);
const previewTransitionName = computed(() =>
props.hasNonReasoningContent
? "reasoning-preview-collapse"
: "reasoning-preview-fade",
);
const toggleExpanded = () => {
isExpanded.value = !isExpanded.value;
isExpanded.value = !isExpanded.value;
};
const latestReasoningPreview = () => {
const lines = props.reasoning
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
return lines.slice(-3).join("\n");
};
const updatePreviewLine = () => {
const nextText = latestReasoningPreview();
if (!nextText || nextText === previewText.value) return;
previewText.value = nextText;
previewKey.value += 1;
};
const stopPreviewTimer = () => {
if (!previewTimer) return;
clearInterval(previewTimer);
previewTimer = null;
};
const stopPreviewStartTimer = () => {
if (!previewStartTimer) return;
clearTimeout(previewStartTimer);
previewStartTimer = null;
};
const startPreviewTimer = () => {
updatePreviewLine();
if (!previewTimer) {
previewTimer = setInterval(updatePreviewLine, 2000);
}
};
const syncPreviewTimer = () => {
if (props.isStreaming && !isExpanded.value && !props.hasNonReasoningContent) {
if (!previewTimer && !previewStartTimer) {
previewStartTimer = setTimeout(() => {
previewStartTimer = null;
if (
props.isStreaming &&
!isExpanded.value &&
!props.hasNonReasoningContent
) {
startPreviewTimer();
}
}, 2000);
}
return;
}
stopPreviewStartTimer();
stopPreviewTimer();
if (!props.isStreaming) {
previewText.value = "";
}
};
watch(
() => [props.isStreaming, isExpanded.value, props.hasNonReasoningContent],
syncPreviewTimer,
{
immediate: true,
},
);
onBeforeUnmount(() => {
stopPreviewStartTimer();
stopPreviewTimer();
});
</script>
<style scoped>
/* Reasoning 区块样式 */
.reasoning-container {
margin-bottom: 12px;
margin-top: 6px;
border: 1px solid var(--v-theme-border);
border-radius: 20px;
overflow: hidden;
width: fit-content;
.reasoning-block {
margin: 6px 0;
max-width: 100%;
color: rgba(var(--v-theme-on-surface), 0.7);
font-size: inherit;
line-height: inherit;
}
.reasoning-header {
display: inline-flex;
align-items: center;
padding: 8px 8px;
cursor: pointer;
user-select: none;
transition: background-color 0.2s ease;
border-radius: 20px;
max-width: 100%;
border: 0;
padding: 0;
background: transparent;
color: inherit;
cursor: pointer;
user-select: none;
display: inline-flex;
align-items: center;
gap: 8px;
font: inherit;
text-align: left;
}
.reasoning-header:hover {
background-color: rgba(103, 58, 183, 0.08);
}
.reasoning-header.is-dark:hover {
background-color: rgba(103, 58, 183, 0.15);
color: rgba(var(--v-theme-on-surface), 0.88);
}
.reasoning-icon {
margin-right: 6px;
color: var(--v-theme-secondary);
transition: transform 0.2s ease;
color: currentcolor;
transition: transform 0.2s ease;
flex-shrink: 0;
}
.reasoning-label {
font-size: 13px;
font-weight: 500;
color: var(--v-theme-secondary);
letter-spacing: 0.3px;
.reasoning-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reasoning-content {
padding: 0px 12px;
border-top: 1px solid var(--v-theme-border);
color: gray;
animation: fadeIn 0.2s ease-in-out;
font-style: italic;
margin-top: 8px;
padding: 0;
color: rgba(var(--v-theme-on-surface), 0.7);
animation: fadeIn 0.2s ease-in-out;
font-style: italic;
}
.reasoning-preview {
max-width: 100%;
margin-top: 4px;
color: rgba(var(--v-theme-on-surface), 0.52);
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
white-space: pre-line;
font: inherit;
font-style: italic;
}
.reasoning-text {
font-size: 14px;
line-height: 1.6;
color: var(--v-theme-secondaryText);
font-size: inherit;
line-height: inherit;
color: inherit;
}
.animate-fade-in {
animation: fadeIn 0.2s ease-in-out;
animation: fadeIn 0.2s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
from {
opacity: 0;
}
to {
opacity: 1;
}
to {
opacity: 1;
}
}
.rotate-90 {
transform: rotate(90deg);
transform: rotate(90deg);
}
.reasoning-preview-fade-enter-active {
transition: opacity 0.25s ease;
}
.reasoning-preview-fade-leave-active {
transition: opacity 0.25s ease;
}
.reasoning-preview-fade-enter-from,
.reasoning-preview-fade-leave-to {
opacity: 0;
}
.reasoning-preview-collapse-enter-active {
transition: opacity 0.25s ease;
}
.reasoning-preview-collapse-leave-active {
overflow: hidden;
transition:
max-height 0.45s cubic-bezier(0.55, 0, 1, 0.45),
margin-top 0.45s cubic-bezier(0.55, 0, 1, 0.45),
opacity 0.35s ease-in,
transform 0.45s cubic-bezier(0.55, 0, 1, 0.45);
}
.reasoning-preview-collapse-enter-from {
opacity: 0;
}
.reasoning-preview-collapse-leave-from {
max-height: 5rem;
opacity: 1;
transform: translateY(0);
}
.reasoning-preview-collapse-leave-to {
max-height: 0;
margin-top: 0;
opacity: 0;
transform: translateY(-8px);
}
</style>

View File

@@ -1,76 +1,91 @@
<template>
<v-chip v-if="domain" class="ref-chip" size="x-small" variant="flat"
: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="fallbackStyle">{{ 'site' }}</span>
<v-chip
v-if="resultData"
class="ref-chip"
size="x-small"
variant="flat"
:style="chipStyle"
:href="url"
target="_blank"
clickable
>
<v-icon start size="x-small" color>mdi-link-variant</v-icon>
<span>{{ domain || resultData.title || refIndex }}</span>
</v-chip>
</template>
<script setup>
import { computed, inject } from 'vue'
import { computed, inject, unref, useSlots } from "vue";
const props = defineProps({
node: {
type: Object,
required: true
}
})
node: {
type: Object,
default: null,
},
});
console.log('RefNode node:', props.node);
const slots = useSlots();
const injectedIsDark = inject("isDark", false);
const webSearchResults = inject("webSearchResults", () => ({}));
// 从父组件注入的暗黑模式状态和搜索结果
const isDark = inject('isDark', false)
const webSearchResults = inject('webSearchResults', () => ({}))
const isDark = computed(() => Boolean(unref(injectedIsDark)));
const refIndex = computed(() => {
const nodeContent = props.node?.content?.trim();
if (nodeContent) return nodeContent;
return slotText(slots.default?.()).trim();
});
// 从 node.content 中提取 ref index (格式: uuid.idx)
const refIndex = computed(() => props.node?.content?.trim() || '')
// 根据 refIndex 查找对应的 URL
const resultData = computed(() => {
if (!refIndex.value) return null
const results = typeof webSearchResults === 'function' ? webSearchResults() : webSearchResults
return results?.[refIndex.value] || null
})
if (!refIndex.value) return null;
const results =
typeof webSearchResults === "function"
? webSearchResults()
: webSearchResults;
return results?.[refIndex.value] || null;
});
const url = computed(() => resultData.value?.url || '')
const url = computed(() => resultData.value?.url || "");
const domain = computed(() => {
if (!url.value) return ''
try {
const urlObj = new URL(url.value)
return urlObj.hostname.replace(/^www\./, '')
} catch (e) {
return ''
}
})
if (!url.value) return "";
try {
const urlObj = new URL(url.value);
return urlObj.hostname.replace(/^www\./, "");
} catch (e) {
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)'
}))
backgroundColor: isDark.value
? "rgba(var(--v-theme-on-surface), 0.08)"
: "rgba(var(--v-theme-on-surface), 0.04)",
color: isDark.value
? "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)'
}))
function slotText(nodes = []) {
return nodes
.map((node) => {
if (typeof node.children === "string") return node.children;
if (Array.isArray(node.children)) return slotText(node.children);
return "";
})
.join("");
}
</script>
<style scoped>
.ref-chip {
margin: 0 2px;
cursor: pointer;
text-decoration: none;
transition: opacity;
margin-left: 4px;
margin: 0 2px;
cursor: pointer;
text-decoration: none;
transition: opacity;
margin-left: 4px;
}
.ref-chip:hover {
opacity: 0.8;
}
.ref-fallback {
font-size: 0.9em;
opacity: 0.8;
}
</style>

View File

@@ -1,225 +1,262 @@
<template>
<transition name="slide-left">
<div v-if="isOpen" class="refs-sidebar">
<div class="sidebar-header">
<h3 class="sidebar-title">{{ tm('refs.title') }}</h3>
<v-btn icon="mdi-close" size="small" variant="text" @click="close"></v-btn>
</div>
<transition name="slide-left">
<div v-if="isOpen" class="refs-sidebar">
<div class="sidebar-header">
<h3 class="sidebar-title">{{ tm("refs.title") }}</h3>
<v-btn
icon="mdi-close"
size="small"
variant="text"
@click="close"
></v-btn>
</div>
<div class="refs-list">
<div v-for="(ref, index) in refs?.used || []" :key="index" class="ref-item" @click="openLink(ref.url)">
<div class="ref-item-icon">
<img v-if="ref.favicon" :src="ref.favicon" class="ref-item-favicon"
@error="(e) => e.target.style.display = 'none'" />
<div v-else class="ref-item-initial">{{ getRefInitial(ref.title) }}</div>
</div>
<div class="ref-item-content">
<div class="ref-item-title">{{ ref.title }}</div>
<div class="ref-item-url">{{ formatUrl(ref.url) }}</div>
<div v-if="ref.snippet" class="ref-item-snippet">{{ ref.snippet }}</div>
</div>
<v-icon size="small" class="ref-item-arrow">mdi-open-in-new</v-icon>
</div>
<div class="refs-list">
<div
v-for="(ref, index) in normalizedRefs"
:key="ref.index || index"
class="ref-item"
@click="openLink(ref.url)"
>
<div class="ref-item-icon">
<img
v-if="ref.favicon"
:src="ref.favicon"
class="ref-item-favicon"
@error="(e) => (e.target.style.display = 'none')"
/>
<div v-else class="ref-item-initial">
{{ getRefInitial(ref.title) }}
</div>
</div>
<div class="ref-item-content">
<div class="ref-item-title">{{ ref.title }}</div>
<div class="ref-item-url">{{ formatUrl(ref.url) }}</div>
<div v-if="ref.snippet" class="ref-item-snippet">
{{ ref.snippet }}
</div>
</div>
<v-icon size="small" class="ref-item-arrow">mdi-open-in-new</v-icon>
</div>
</transition>
</div>
</div>
</transition>
</template>
<script>
import { useModuleI18n } from '@/i18n/composables';
import { useModuleI18n } from "@/i18n/composables";
export default {
name: 'RefsSidebar',
props: {
modelValue: {
type: Boolean,
default: false
},
refs: {
type: Object,
default: null
}
name: "RefsSidebar",
props: {
modelValue: {
type: Boolean,
default: false,
},
emits: ['update:modelValue'],
setup() {
const { tm } = useModuleI18n('features/chat');
return { tm };
refs: {
type: Object,
default: null,
},
computed: {
isOpen: {
get() {
return this.modelValue;
},
set(value) {
this.$emit('update:modelValue', value);
}
}
},
emits: ["update:modelValue"],
setup() {
const { tm } = useModuleI18n("features/chat");
return { tm };
},
computed: {
isOpen: {
get() {
return this.modelValue;
},
set(value) {
this.$emit("update:modelValue", value);
},
},
methods: {
close() {
this.isOpen = false;
},
getRefInitial(title) {
if (!title) return '?';
return title.charAt(0).toUpperCase();
},
normalizedRefs() {
const used = Array.isArray(this.refs?.used)
? this.refs.used
: Array.isArray(this.refs)
? this.refs
: [];
formatUrl(url) {
if (!url) return '';
try {
const urlObj = new URL(url);
return urlObj.hostname;
} catch {
return url;
}
},
return used
.map((ref) => ({
index: ref?.index,
title: ref?.title || ref?.url || "Reference",
url: ref?.url,
snippet: ref?.snippet,
favicon: ref?.favicon,
}))
.filter((ref) => ref.url);
},
},
methods: {
close() {
this.isOpen = false;
},
openLink(url) {
if (url) {
window.open(url, '_blank');
}
}
}
}
getRefInitial(title) {
if (!title) return "?";
return title.charAt(0).toUpperCase();
},
formatUrl(url) {
if (!url) return "";
try {
const urlObj = new URL(url);
return urlObj.hostname;
} catch {
return url;
}
},
openLink(url) {
if (url) {
window.open(url, "_blank");
}
},
},
};
</script>
<style scoped>
.refs-sidebar {
width: 360px;
height: 100%;
background-color: var(--v-theme-surface);
border-left: 1px solid var(--v-theme-border);
display: flex;
flex-direction: column;
flex-shrink: 0;
width: 360px;
height: 100%;
background-color: rgb(var(--v-theme-surface));
border-left: 1px solid rgba(var(--v-border-color), 0.16);
display: flex;
flex-direction: column;
flex-shrink: 0;
color: rgb(var(--v-theme-on-surface));
}
.slide-left-enter-active,
.slide-left-leave-active {
transition: all 0.3s ease;
transition: all 0.3s ease;
}
.slide-left-enter-from {
transform: translateX(100%);
opacity: 0;
transform: translateX(100%);
opacity: 0;
}
.slide-left-leave-to {
transform: translateX(100%);
opacity: 0;
transform: translateX(100%);
opacity: 0;
}
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
flex-shrink: 0;
}
.sidebar-title {
font-size: 18px;
font-weight: 600;
color: var(--v-theme-primaryText);
font-size: 18px;
font-weight: 600;
color: var(--v-theme-primaryText);
}
.refs-list {
padding: 12px;
padding-top: 0;
overflow-y: auto;
flex: 1;
padding: 12px;
padding-top: 0;
overflow-y: auto;
flex: 1;
}
.ref-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
margin-bottom: 8px;
border-radius: 8px;
border: 1px solid var(--v-theme-border);
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
margin-bottom: 8px;
border-radius: 8px;
border: 1px solid var(--v-theme-border);
cursor: pointer;
transition: all 0.2s ease;
}
.ref-item:hover {
background-color: rgba(103, 58, 183, 0.05);
border-color: rgba(103, 58, 183, 0.3);
background-color: rgba(103, 58, 183, 0.05);
border-color: rgba(103, 58, 183, 0.3);
}
.ref-item-icon {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 50%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 50%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.ref-item-favicon {
width: 100%;
height: 100%;
object-fit: cover;
width: 100%;
height: 100%;
object-fit: cover;
}
.ref-item-initial {
font-size: 14px;
font-weight: 600;
color: white;
font-size: 14px;
font-weight: 600;
color: white;
}
.ref-item-content {
flex: 1;
min-width: 0;
flex: 1;
min-width: 0;
}
.ref-item-title {
font-size: 14px;
font-weight: 500;
color: var(--v-theme-primaryText);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
font-size: 14px;
font-weight: 500;
color: var(--v-theme-primaryText);
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.ref-item-url {
font-size: 12px;
color: var(--v-theme-secondaryText);
opacity: 0.7;
margin-bottom: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: var(--v-theme-secondaryText);
opacity: 0.7;
margin-bottom: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ref-item-snippet {
font-size: 12px;
color: var(--v-theme-secondaryText);
opacity: 0.8;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
font-size: 12px;
color: var(--v-theme-secondaryText);
opacity: 0.8;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.ref-item-arrow {
flex-shrink: 0;
margin-top: 4px;
color: var(--v-theme-secondaryText);
opacity: 0.5;
transition: opacity 0.2s ease;
flex-shrink: 0;
margin-top: 4px;
color: var(--v-theme-secondaryText);
opacity: 0.5;
transition: opacity 0.2s ease;
}
.ref-item:hover .ref-item-arrow {
opacity: 1;
opacity: 1;
}
</style>

View File

@@ -1,290 +1,271 @@
<template>
<div class="tool-call-card" :class="{ 'is-dark': isDark, 'expanded': isExpanded }" :style="isDark ? {
backgroundColor: 'rgba(40, 60, 100, 0.4)',
borderColor: 'rgba(100, 140, 200, 0.4)'
} : {}">
<!-- Header -->
<div class="tool-call-header" :class="{ 'is-dark': isDark }" @click="toggleExpanded">
<v-icon size="small" class="tool-call-expand-icon" :class="{ 'expanded': isExpanded }">
mdi-chevron-right
</v-icon>
<v-icon size="small" class="tool-call-icon">mdi-wrench-outline</v-icon>
<div class="tool-call-info">
<span class="tool-call-name">{{ toolCall.name }}</span>
</div>
<span class="tool-call-status"
:class="{ 'status-running': !toolCall.finished_ts, 'status-finished': toolCall.finished_ts }">
<template v-if="toolCall.finished_ts">
<v-icon size="x-small" class="status-icon">mdi-check-circle</v-icon>
{{ formatDuration(toolCall.finished_ts - toolCall.ts) }}
</template>
<template v-else>
<v-icon size="x-small" class="status-icon spinning">mdi-loading</v-icon>
{{ elapsedTime }}
</template>
</span>
</div>
<div class="tool-call-card" :class="{ expanded: isExpanded }">
<button class="tool-call-header" type="button" @click="toggleExpanded">
<v-icon size="16" class="tool-call-icon">{{ toolCallIcon }}</v-icon>
<span class="tool-call-title">
{{ tm("actions.toolCallUsed", { name: displayToolName }) }}
</span>
<span class="tool-call-duration">{{ toolCallDuration }}</span>
<v-icon
size="22"
class="tool-call-expand-icon"
:class="{ expanded: isExpanded }"
>
mdi-chevron-right
</v-icon>
</button>
<!-- Details -->
<div v-if="isExpanded" class="tool-call-details" :style="isDark ? {
borderTopColor: 'rgba(100, 140, 200, 0.3)',
backgroundColor: 'rgba(30, 45, 70, 0.5)'
} : {}">
<!-- ID -->
<div class="tool-call-detail-row">
<span class="detail-label">ID:</span>
<code class="detail-value" :style="isDark ? { backgroundColor: 'transparent' } : {}">
{{ toolCall.id }}
<div v-if="isExpanded" class="tool-call-details">
<div v-if="toolCall.id" class="tool-call-detail-row">
<span class="detail-label">ID:</span>
<code class="detail-value">
{{ toolCall.id }}
</code>
</div>
</div>
<!-- Args -->
<div class="tool-call-detail-row">
<span class="detail-label">Args:</span>
<pre class="detail-value detail-json" :style="isDark ? { backgroundColor: 'transparent' } : {}">{{
JSON.stringify(toolCall.args, null, 2) }}</pre>
</div>
<div class="tool-call-detail-row">
<span class="detail-label">Args:</span>
<pre class="detail-value detail-json">{{
JSON.stringify(toolCall.args, null, 2)
}}</pre>
</div>
<!-- Result -->
<div v-if="toolCall.result" class="tool-call-detail-row">
<span class="detail-label">Result:</span>
<pre class="detail-value detail-json detail-result"
:style="isDark ? { backgroundColor: 'transparent' } : {}">{{
formattedResult }}</pre>
</div>
</div>
<div v-if="toolCall.result" class="tool-call-detail-row">
<span class="detail-label">Result:</span>
<pre class="detail-value detail-json detail-result">{{
formattedResult
}}</pre>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { computed, onMounted, onUnmounted, ref } from "vue";
import { useModuleI18n } from "@/i18n/composables";
const props = defineProps({
toolCall: {
type: Object,
required: true
},
isDark: {
type: Boolean,
default: false
},
initialExpanded: {
type: Boolean,
default: false
}
toolCall: {
type: Object,
required: true,
},
isDark: {
type: Boolean,
default: false,
},
initialExpanded: {
type: Boolean,
default: false,
},
});
const { tm } = useModuleI18n("features/chat");
const isExpanded = ref(props.initialExpanded);
const currentTime = ref(Date.now() / 1000);
let timer = null;
const elapsedTime = computed(() => {
if (props.toolCall.finished_ts) return '';
const elapsed = currentTime.value - props.toolCall.ts;
return formatDuration(elapsed);
if (props.toolCall.finished_ts) return "";
const startTime = Number(props.toolCall.ts);
if (!Number.isFinite(startTime) || startTime <= 0) return "";
return formatDuration(currentTime.value - startTime);
});
const displayToolName = computed(() => props.toolCall.name || "tool");
const toolCallIcon = computed(() => {
const name = String(props.toolCall.name || "");
if (name === "astrbot_execute_ipython" || name === "astrbot_execute_python") {
return "mdi-code-json";
}
if (name.includes("web_search") || name.includes("tavily")) {
return "mdi-web";
}
if (name === "astrbot_execute_shell") {
return "mdi-console-line";
}
return "mdi-wrench";
});
const toolCallDuration = computed(() => {
const startTime = Number(props.toolCall.ts);
if (!Number.isFinite(startTime) || startTime <= 0) return "";
if (props.toolCall.finished_ts) {
return formatDuration(Number(props.toolCall.finished_ts) - startTime);
}
return elapsedTime.value;
});
const formattedResult = computed(() => {
if (!props.toolCall.result) return '';
try {
const parsed = JSON.parse(props.toolCall.result);
return JSON.stringify(parsed, null, 2);
} catch {
return props.toolCall.result;
}
if (!props.toolCall.result) return "";
try {
const parsed = JSON.parse(props.toolCall.result);
return JSON.stringify(parsed, null, 2);
} catch {
return props.toolCall.result;
}
});
const formatDuration = (seconds) => {
if (seconds < 1) {
return `${Math.round(seconds * 1000)}ms`;
} else if (seconds < 60) {
return `${seconds.toFixed(1)}s`;
} else {
const minutes = Math.floor(seconds / 60);
const secs = Math.round(seconds % 60);
return `${minutes}m ${secs}s`;
}
if (!Number.isFinite(seconds) || seconds < 0) return "";
if (seconds < 1) {
return `${Math.round(seconds * 1000)}ms`;
} else if (seconds < 60) {
return `${seconds.toFixed(1)}s`;
} else {
const minutes = Math.floor(seconds / 60);
const secs = Math.round(seconds % 60);
return `${minutes}m ${secs}s`;
}
};
const toggleExpanded = () => {
isExpanded.value = !isExpanded.value;
isExpanded.value = !isExpanded.value;
};
const updateTime = () => {
currentTime.value = Date.now() / 1000;
currentTime.value = Date.now() / 1000;
};
onMounted(() => {
// Update time periodically if tool call is running
if (!props.toolCall.finished_ts) {
timer = setInterval(updateTime, 100);
}
if (!props.toolCall.finished_ts) {
timer = setInterval(updateTime, 100);
}
});
onUnmounted(() => {
if (timer) {
clearInterval(timer);
}
if (timer) {
clearInterval(timer);
}
});
</script>
<style scoped>
.tool-call-card {
border-radius: 8px;
overflow: hidden;
background-color: #eff3f6;
margin: 8px 0px;
width: fit-content;
min-width: 320px;
max-width: 100%;
transition: all 0.1s ease;
margin: 6px 0;
max-width: 100%;
color: rgba(var(--v-theme-on-surface), 0.7);
font-size: inherit;
line-height: inherit;
}
.tool-call-card.expanded {
width: 100%;
width: 100%;
}
.tool-call-header {
display: flex;
align-items: center;
padding: 10px 12px;
cursor: pointer;
user-select: none;
transition: background-color;
gap: 8px;
max-width: 100%;
border: 0;
padding: 0;
background: transparent;
color: inherit;
cursor: pointer;
user-select: none;
display: inline-flex;
align-items: center;
gap: 8px;
font: inherit;
text-align: left;
}
.tool-call-header:hover {
background-color: rgba(169, 194, 219, 0.15);
}
.tool-call-header.is-dark:hover {
background-color: rgba(100, 150, 200, 0.2);
color: rgba(var(--v-theme-on-surface), 0.88);
}
.tool-call-expand-icon {
color: var(--v-theme-secondary);
transition: transform 0.2s ease;
flex-shrink: 0;
color: currentcolor;
transition: transform 0.2s ease;
flex-shrink: 0;
}
.tool-call-expand-icon.expanded {
transform: rotate(90deg);
transform: rotate(90deg);
}
.tool-call-icon {
color: var(--v-theme-secondary);
flex-shrink: 0;
color: currentcolor;
flex-shrink: 0;
}
.tool-call-info {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
.tool-call-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tool-call-name {
font-size: 13px;
font-weight: 600;
color: var(--v-theme-secondary);
}
.tool-call-status {
margin-left: 8px;
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 500;
flex-shrink: 0;
}
.tool-call-status.status-running {
color: #ff9800;
}
.tool-call-status.status-finished {
color: #4caf50;
}
.tool-call-status .status-icon {
font-size: 14px;
}
.tool-call-status .status-icon.spinning {
animation: spin 1s linear infinite;
.tool-call-duration {
flex-shrink: 0;
color: rgba(var(--v-theme-on-surface), 0.48);
}
.tool-call-details {
padding: 12px;
background-color: rgba(255, 255, 255, 0.5);
animation: fadeIn 0.2s ease-in-out;
margin-top: 8px;
padding-left: 26px;
animation: fadeIn 0.2s ease-in-out;
}
.tool-call-detail-row {
display: flex;
flex-direction: column;
margin-bottom: 8px;
display: flex;
flex-direction: column;
margin-bottom: 8px;
}
.tool-call-detail-row:last-child {
margin-bottom: 0;
margin-bottom: 0;
}
.detail-label {
font-size: 11px;
font-weight: 600;
color: var(--v-theme-secondaryText);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
font-size: 11px;
font-weight: 600;
color: rgba(var(--v-theme-on-surface), 0.55);
text-transform: uppercase;
margin-bottom: 4px;
}
.detail-value {
font-size: 12px;
color: var(--v-theme-primaryText);
background-color: transparent;
padding: 4px 8px;
border-radius: 4px;
word-break: break-all;
font-size: 12px;
color: rgba(var(--v-theme-on-surface), 0.8);
background-color: transparent;
padding: 0;
border-radius: 4px;
word-break: break-all;
}
.detail-json {
font-family: 'Fira Code', 'Consolas', monospace;
white-space: pre-wrap;
max-height: 200px;
overflow-y: auto;
margin: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
white-space: pre-wrap;
max-height: 200px;
overflow-y: auto;
margin: 0;
}
.detail-result {
max-height: 300px;
background-color: transparent;
max-height: 300px;
background-color: transparent;
}
.animate-fade-in {
animation: fadeIn 0.2s ease-in-out;
animation: fadeIn 0.2s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
from {
opacity: 0;
}
to {
opacity: 1;
}
to {
opacity: 1;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -110,6 +110,10 @@
<small style="color: grey">*{{ tm('dialogs.addServer.tips.timeoutConfig') }}</small>
<v-alert type="info" variant="tonal" density="compact" class="mt-3">
{{ tm('dialogs.addServer.tips.transportRecommendation') }}
</v-alert>
<div class="monaco-container" style="margin-top: 16px;">
<VueMonacoEditor v-model:value="serverConfigJson" theme="vs-dark" language="json" :options="{
minimap: {

View File

@@ -860,17 +860,15 @@ export default {
// 过滤出属于该平台的路由,并保持顺序
const routes = [];
for (const [umop, confId] of Object.entries(routingTable)) {
if (this.isUmopMatchPlatform(umop, platformId)) {
const parts = umop.split(':');
if (parts.length === 3) {
routes.push({
umop: umop,
originalUmop: umop, // 保存原始 UMOP 用于更新时查找
messageType: parts[1] === '' || parts[1] === '*' ? '*' : parts[1],
sessionId: parts[2] === '' || parts[2] === '*' ? '*' : parts[2],
configId: confId
});
}
const parsedUmop = this.parseUmop(umop);
if (this.isParsedUmopMatchPlatform(parsedUmop, platformId)) {
routes.push({
umop: umop,
originalUmop: umop, // 保存原始 UMOP 用于更新时查找
messageType: parsedUmop.messageType || '*',
sessionId: parsedUmop.sessionId || '*',
configId: confId
});
}
}
@@ -992,11 +990,29 @@ export default {
},
isUmopMatchPlatform(umop, platformId) {
if (!umop) return false;
const parts = umop.split(':');
if (parts.length !== 3) return false;
const platform = parts[0];
return platform === platformId || platform === '' || platform === '*';
const parsedUmop = this.parseUmop(umop);
return this.isParsedUmopMatchPlatform(parsedUmop, platformId);
},
isParsedUmopMatchPlatform(parsedUmop, platformId) {
if (!parsedUmop) return false;
return parsedUmop.platform === platformId || parsedUmop.platform === '' || parsedUmop.platform === '*';
},
parseUmop(umop) {
if (!umop) return null;
const firstSeparatorIndex = umop.indexOf(':');
if (firstSeparatorIndex === -1) return null;
const secondSeparatorIndex = umop.indexOf(':', firstSeparatorIndex + 1);
if (secondSeparatorIndex === -1) return null;
return {
platform: umop.slice(0, firstSeparatorIndex),
messageType: umop.slice(firstSeparatorIndex + 1, secondSeparatorIndex),
sessionId: umop.slice(secondSeparatorIndex + 1)
};
},
// 获取消息类型标签

View File

@@ -5,7 +5,6 @@ import axios from 'axios';
import { MarkdownRender, enableKatex, enableMermaid } from 'markstream-vue';
import 'markstream-vue/index.css';
import 'katex/dist/katex.min.css';
import 'highlight.js/styles/github.css';
enableKatex();
enableMermaid();

View File

@@ -1,11 +1,16 @@
<script setup>
import { ref, watch, computed, onUnmounted } from "vue";
import { useTheme } from "vuetify";
import MarkdownIt from "markdown-it";
import hljs from "highlight.js";
import axios from "axios";
import DOMPurify from "dompurify";
import "highlight.js/styles/github.css";
import { useI18n } from "@/i18n/composables";
import {
escapeHtml,
ensureShikiLanguages,
normalizeShikiLanguage,
renderShikiCode,
} from "@/utils/shiki";
// 1. 在 setup 作用域创建 MarkdownIt 实例
const md = new MarkdownIt({
@@ -41,6 +46,7 @@ const props = defineProps({
const emit = defineEmits(["update:show"]);
const { t, locale } = useI18n();
const theme = useTheme();
const content = ref(null);
const error = ref(null);
@@ -48,7 +54,103 @@ const loading = ref(false);
const isEmpty = ref(false);
const copyFeedbackTimer = ref(null);
const lastRequestId = ref(0);
const lastRenderId = ref(0);
const scrollContainer = ref(null);
const renderedHtml = ref("");
const isDark = computed(() => theme.global.current.value.dark);
const MARKDOWN_SANITIZE_OPTIONS = {
ALLOWED_TAGS: [
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"p",
"br",
"hr",
"ul",
"ol",
"li",
"blockquote",
"pre",
"code",
"a",
"img",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
"strong",
"em",
"del",
"s",
"details",
"summary",
"div",
"span",
"input",
"button",
"svg",
"rect",
"path",
"polyline",
],
ALLOWED_ATTR: [
"href",
"src",
"alt",
"title",
"class",
"id",
"target",
"rel",
"type",
"checked",
"disabled",
"open",
"align",
"width",
"height",
"viewBox",
"fill",
"stroke",
"stroke-width",
"points",
"d",
"x",
"y",
"rx",
"ry",
"data-code-block-index",
],
};
const CODE_BLOCK_SANITIZE_OPTIONS = {
ALLOWED_TAGS: ["div", "span", "button", "svg", "rect", "path", "polyline", "pre", "code"],
ALLOWED_ATTR: [
"class",
"title",
"type",
"width",
"height",
"viewBox",
"fill",
"stroke",
"stroke-width",
"points",
"d",
"x",
"y",
"rx",
"ry",
"style",
"tabindex",
],
};
function slugifyHeading(text, slugCounts) {
const base = (text || "")
@@ -71,104 +173,62 @@ onUnmounted(() => {
if (copyFeedbackTimer.value) clearTimeout(copyFeedbackTimer.value);
});
// 渲染后的 HTML
const renderedHtml = computed(() => {
// 强制依赖 locale确保语言切换时重新渲染
const _ = locale?.value;
if (!content.value) return "";
function sanitizeHighlightedBlock(html) {
return DOMPurify.sanitize(html, CODE_BLOCK_SANITIZE_OPTIONS);
}
async function updateRenderedHtml() {
const source = content.value;
const renderId = ++lastRenderId.value;
void locale?.value;
if (!source) {
renderedHtml.value = "";
return;
}
let highlighter = null;
const env = {};
const tokens = md.parse(source, env);
try {
const languages = tokens
.filter((token) => token.type === "fence")
.map((token) => normalizeShikiLanguage(token.info));
highlighter = await ensureShikiLanguages(languages);
} catch (err) {
console.error("Failed to initialize Shiki for README dialog:", err);
}
if (renderId !== lastRenderId.value) return;
const highlightedBlocks = [];
// 设置 fence 规则,直接使用当前作用域的 t 函数
md.renderer.rules.fence = (tokens, idx) => {
const token = tokens[idx];
const lang = token.info.trim() || "";
const lang = normalizeShikiLanguage(token.info);
const code = token.content;
const highlighted =
lang && hljs.getLanguage(lang)
? hljs.highlight(code, { language: lang }).value
: md.utils.escapeHtml(code);
return `<div class="code-block-wrapper">
${lang ? `<span class="code-lang-label">${lang}</span>` : ""}
const escapedLangLabel =
lang && lang !== "text" ? escapeHtml(lang) : "";
const highlighted = highlighter
? renderShikiCode(highlighter, code, lang, isDark.value ? "dark" : "light")
: `<pre class="shiki shiki-fallback"><code>${escapeHtml(code)}</code></pre>`;
const html = sanitizeHighlightedBlock(`<div class="code-block-wrapper">
${escapedLangLabel ? `<span class="code-lang-label">${escapedLangLabel}</span>` : ""}
<button class="copy-code-btn" title="${t("core.common.copy")}">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
</button>
<pre class="hljs"><code class="language-${lang}">${highlighted}</code></pre>
</div>`;
${highlighted}
</div>`);
const placeholderIndex = highlightedBlocks.push(html) - 1;
return `<div data-code-block-index="${placeholderIndex}"></div>`;
};
const rawHtml = md.render(content.value);
const rawHtml = md.renderer.render(tokens, md.options, env);
const cleanHtml = DOMPurify.sanitize(rawHtml, {
ALLOWED_TAGS: [
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"p",
"br",
"hr",
"ul",
"ol",
"li",
"blockquote",
"pre",
"code",
"a",
"img",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
"strong",
"em",
"del",
"s",
"details",
"summary",
"div",
"span",
"input",
"button",
"svg",
"rect",
"path",
"polyline",
],
ALLOWED_ATTR: [
"href",
"src",
"alt",
"title",
"class",
"id",
"target",
"rel",
"type",
"checked",
"disabled",
"open",
"align",
"width",
"height",
"viewBox",
"fill",
"stroke",
"stroke-width",
"points",
"d",
"x",
"y",
"rx",
"ry",
],
});
const cleanHtml = DOMPurify.sanitize(rawHtml, MARKDOWN_SANITIZE_OPTIONS);
// 3. 后处理方案:完全隔离,安全性最高
const tempDiv = document.createElement("div");
tempDiv.innerHTML = cleanHtml;
@@ -185,15 +245,21 @@ const renderedHtml = computed(() => {
tempDiv.querySelectorAll("a").forEach((link) => {
const href = link.getAttribute("href");
// 强制所有外部链接使用安全的 _blank 策略
if (href && (href.startsWith("http") || href.startsWith("//"))) {
link.setAttribute("target", "_blank");
link.setAttribute("rel", "noopener noreferrer");
}
});
return tempDiv.innerHTML;
});
tempDiv.querySelectorAll("[data-code-block-index]").forEach((placeholder) => {
const index = Number(placeholder.getAttribute("data-code-block-index"));
placeholder.outerHTML = highlightedBlocks[index] || "";
});
if (renderId === lastRenderId.value) {
renderedHtml.value = tempDiv.innerHTML;
}
}
const modeConfig = computed(() => {
if (props.mode === "changelog") {
@@ -279,6 +345,10 @@ watch(
{ immediate: true },
);
watch([content, locale, isDark], () => {
updateRenderedHtml();
}, { immediate: true });
function handleContainerClick(event) {
const btn = event.target.closest(".copy-code-btn");
if (btn) {
@@ -549,22 +619,32 @@ const showActionArea = computed(() => {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
}
:deep(.markdown-body pre.hljs) {
:deep(.markdown-body pre.shiki) {
padding: 16px;
padding-top: 32px;
overflow: auto;
font-size: 85%;
line-height: 1.45;
background-color: #0d1117;
border-radius: 6px;
margin: 0;
border: 1px solid rgba(128, 128, 128, 0.18);
}
:deep(.markdown-body pre.hljs code) {
:deep(.markdown-body pre.shiki code) {
background-color: transparent;
padding: 0;
border-radius: 0;
color: #c9d1d9;
color: inherit;
}
:deep(.markdown-body pre.shiki .line) {
display: block;
min-height: 1.45em;
}
:deep(.markdown-body pre.shiki.shiki-fallback) {
background-color: #f6f8fa;
color: #24292f;
}
:deep(.markdown-body ul),
:deep(.markdown-body ol) {
@@ -679,13 +759,4 @@ const showActionArea = computed(() => {
margin-top: 12px;
}
:deep(.markdown-body .hljs-keyword),
:deep(.markdown-body .hljs-selector-tag),
:deep(.markdown-body .hljs-title),
:deep(.markdown-body .hljs-section),
:deep(.markdown-body .hljs-doctag),
:deep(.markdown-body .hljs-name),
:deep(.markdown-body .hljs-strong) {
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,40 @@
<template>
<MarkdownCodeBlockNode
:key="themeRenderKey"
v-bind="forwardedBindings"
>
<template
v-for="(_, slotName) in $slots"
#[slotName]="slotProps"
>
<slot :name="slotName" v-bind="slotProps || {}" />
</template>
</MarkdownCodeBlockNode>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { MarkdownCodeBlockNode } from "markstream-vue";
import { useAttrs } from "vue";
defineOptions({
inheritAttrs: false,
});
const props = withDefaults(
defineProps<{
node: Record<string, unknown>;
isDark?: boolean;
}>(),
{
isDark: false,
},
);
const attrs = useAttrs();
const forwardedBindings = computed(() => ({
...attrs,
...props,
}));
const themeRenderKey = computed(() => (props.isDark ? "dark" : "light"));
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -56,6 +56,10 @@
"ipython": {
"output": "Output"
},
"toolStatus": {
"done": "Done",
"running": "Running"
},
"conversation": {
"newConversation": "New Conversation",
"noHistory": "No conversation history",
@@ -158,4 +162,4 @@
"partialFailure": "{failed} of {total} conversations failed to delete",
"requestFailed": "Failed to delete conversations. Please try again."
}
}
}

View File

@@ -95,7 +95,8 @@
"sync": "Sync"
},
"tips": {
"timeoutConfig": "Please configure tool call timeout separately in the configuration page"
"timeoutConfig": "Please configure tool call timeout separately in the configuration page",
"transportRecommendation": "Prefer Streamable HTTP or SSE mode. Stdio mode starts a local process on the AstrBot host and should only be used for trusted MCP servers."
}
},
"serverDetail": {

View File

@@ -1,151 +1,155 @@
{
"title": "Давай пообщаемся!",
"subtitle": "Общение с AI-помощником",
"input": {
"placeholder": "Введите сообщение...",
"send": "Отправить",
"clear": "Очистить",
"upload": "Загрузить файл",
"voice": "Голосовой ввод",
"recordingPrompt": "Запись... говорите",
"chatPrompt": "Давай пообщаемся!",
"dropToUpload": "Отпустите, чтобы загрузить файл",
"stopGenerating": "Остановить генерацию"
},
"message": {
"user": "Вы",
"assistant": "Ассистент",
"system": "Система",
"error": "Ошибка в сообщении",
"loading": "Думаю..."
},
"voice": {
"start": "Начать запись",
"stop": "Стоп",
"recording": "Запись",
"processing": "Обработка...",
"error": "Ошибка записи",
"listening": "Слушаю...",
"speaking": "Говорю",
"startRecording": "Начать голосовой ввод",
"liveMode": "Общение в реальном времени"
},
"welcome": {
"title": "Добро пожаловать в AstrBot",
"subtitle": "Ваш умный помощник",
"quickActions": "Быстрые действия",
"examples": "Примеры вопросов"
},
"actions": {
"copy": "Копировать",
"regenerate": "Перегенерировать",
"like": "Нравится",
"dislike": "Не нравится",
"share": "Поделиться",
"newChat": "Новый чат",
"deleteChat": "Удалить чат",
"editTitle": "Изменить заголовок",
"fullscreen": "На весь экран",
"exitFullscreen": "Выход из полноэкранного режима",
"reply": "Ответить",
"providerConfig": "Настройки AI",
"toolsUsed": "Использованные инструменты",
"toolCallUsed": "Использован инструмент {name}",
"pythonCodeAnalysis": "Использован анализ кода Python"
},
"ipython": {
"output": "Вывод"
},
"conversation": {
"newConversation": "Новый чат",
"noHistory": "История диалогов пуста",
"systemStatus": "Статус системы",
"llmService": "Сервис LLM",
"speechToText": "Преобразование речи",
"editDisplayName": "Изменить имя чата",
"displayName": "Имя чата",
"displayNameUpdated": "Имя чата обновлено",
"displayNameUpdateFailed": "Не удалось обновить имя чата",
"confirmDelete": "Вы уверены, что хотите удалить «{name}»? Это действие необратимо."
},
"modes": {
"darkMode": "Темная тема",
"lightMode": "Светлая тема"
},
"shortcuts": {
"help": "Справка",
"voiceRecord": "Запись голоса",
"pasteImage": "Вставить изображение",
"sendKey": {
"title": "Клавиша отправки",
"enterToSend": "Enter для отправки",
"shiftEnterToSend": "Shift+Enter для отправки"
}
},
"streaming": {
"enabled": "Потоковый ответ включен",
"disabled": "Потоковый ответ выключен",
"on": "Поток",
"off": "Обычный"
},
"transport": {
"title": "Протокол передачи",
"sse": "SSE",
"websocket": "WebSocket"
},
"config": {
"title": "Конфигурация"
},
"reasoning": {
"thinking": "Рассуждение"
},
"reply": {
"replyTo": "В ответ на",
"notFound": "Сообщение не найдено"
},
"project": {
"title": "Проект",
"create": "Создать проект",
"edit": "Изменить проект",
"name": "Имя проекта",
"emoji": "Иконка (Emoji)",
"description": "Описание проекта (опционально)",
"noSessions": "В этом проекте пока нет диалогов",
"confirmDelete": "Вы уверены, что хотите удалить проект «{title}»? Диалоги внутри проекта не будут удалены."
},
"time": {
"today": "Сегодня",
"yesterday": "Вчера"
},
"stats": {
"tokens": "Токены",
"inputTokens": "Входящие",
"outputTokens": "Исходящие",
"cachedTokens": "Кэшированные",
"duration": "Время",
"ttft": "Время до первого токена"
},
"refs": {
"title": "Ссылки",
"sources": "Источники"
},
"connection": {
"title": "Статус подключения",
"message": "Системе необходимо переустановить соединение с чатом.",
"reasons": "Это может быть вызвано следующими причинами:",
"reasonWindowResize": "Изменение размера окна (нормально)",
"reasonMultipleTabs": "Страница чата открыта в другой вкладке",
"reasonNetworkIssue": "Временная проблема с сетью",
"notice": "Примечание: для стабильной работы допускается только одно активное соединение. Если вы используете чат в нескольких вкладках, рекомендуем оставить только одну.",
"understand": "Понятно",
"status": {
"reconnecting": "Переподключение...",
"reconnected": "Соединение восстановлено",
"failed": "Ошибка подключения, обновите страницу"
}
},
"errors": {
"sendMessageFailed": "Ошибка отправки сообщения, попробуйте еще раз",
"createSessionFailed": "Ошибка создания сессии, обновите страницу"
"title": "Давай пообщаемся!",
"subtitle": "Общение с AI-помощником",
"input": {
"placeholder": "Введите сообщение...",
"send": "Отправить",
"clear": "Очистить",
"upload": "Загрузить файл",
"voice": "Голосовой ввод",
"recordingPrompt": "Запись... говорите",
"chatPrompt": "Давай пообщаемся!",
"dropToUpload": "Отпустите, чтобы загрузить файл",
"stopGenerating": "Остановить генерацию"
},
"message": {
"user": "Вы",
"assistant": "Ассистент",
"system": "Система",
"error": "Ошибка в сообщении",
"loading": "Думаю..."
},
"voice": {
"start": "Начать запись",
"stop": "Стоп",
"recording": "Запись",
"processing": "Обработка...",
"error": "Ошибка записи",
"listening": "Слушаю...",
"speaking": "Говорю",
"startRecording": "Начать голосовой ввод",
"liveMode": "Общение в реальном времени"
},
"welcome": {
"title": "Добро пожаловать в AstrBot",
"subtitle": "Ваш умный помощник",
"quickActions": "Быстрые действия",
"examples": "Примеры вопросов"
},
"actions": {
"copy": "Копировать",
"regenerate": "Перегенерировать",
"like": "Нравится",
"dislike": "Не нравится",
"share": "Поделиться",
"newChat": "Новый чат",
"deleteChat": "Удалить чат",
"editTitle": "Изменить заголовок",
"fullscreen": "На весь экран",
"exitFullscreen": "Выход из полноэкранного режима",
"reply": "Ответить",
"providerConfig": "Настройки AI",
"toolsUsed": "Использованные инструменты",
"toolCallUsed": "Использован инструмент {name}",
"pythonCodeAnalysis": "Использован анализ кода Python"
},
"ipython": {
"output": "Вывод"
},
"toolStatus": {
"done": "Готово",
"running": "Выполняется"
},
"conversation": {
"newConversation": "Новый чат",
"noHistory": "История диалогов пуста",
"systemStatus": "Статус системы",
"llmService": "Сервис LLM",
"speechToText": "Преобразование речи",
"editDisplayName": "Изменить имя чата",
"displayName": "Имя чата",
"displayNameUpdated": "Имя чата обновлено",
"displayNameUpdateFailed": "Не удалось обновить имя чата",
"confirmDelete": "Вы уверены, что хотите удалить «{name}»? Это действие необратимо."
},
"modes": {
"darkMode": "Темная тема",
"lightMode": "Светлая тема"
},
"shortcuts": {
"help": "Справка",
"voiceRecord": "Запись голоса",
"pasteImage": "Вставить изображение",
"sendKey": {
"title": "Клавиша отправки",
"enterToSend": "Enter для отправки",
"shiftEnterToSend": "Shift+Enter для отправки"
}
},
"streaming": {
"enabled": "Потоковый ответ включен",
"disabled": "Потоковый ответ выключен",
"on": "Поток",
"off": "Обычный"
},
"transport": {
"title": "Протокол передачи",
"sse": "SSE",
"websocket": "WebSocket"
},
"config": {
"title": "Конфигурация"
},
"reasoning": {
"thinking": "Рассуждение"
},
"reply": {
"replyTo": "В ответ на",
"notFound": "Сообщение не найдено"
},
"project": {
"title": "Проект",
"create": "Создать проект",
"edit": "Изменить проект",
"name": "Имя проекта",
"emoji": "Иконка (Emoji)",
"description": "Описание проекта (опционально)",
"noSessions": "В этом проекте пока нет диалогов",
"confirmDelete": "Вы уверены, что хотите удалить проект «{title}»? Диалоги внутри проекта не будут удалены."
},
"time": {
"today": "Сегодня",
"yesterday": "Вчера"
},
"stats": {
"tokens": "Токены",
"inputTokens": "Входящие",
"outputTokens": "Исходящие",
"cachedTokens": "Кэшированные",
"duration": "Время",
"ttft": "Время до первого токена"
},
"refs": {
"title": "Ссылки",
"sources": "Источники"
},
"connection": {
"title": "Статус подключения",
"message": "Системе необходимо переустановить соединение с чатом.",
"reasons": "Это может быть вызвано следующими причинами:",
"reasonWindowResize": "Изменение размера окна (нормально)",
"reasonMultipleTabs": "Страница чата открыта в другой вкладке",
"reasonNetworkIssue": "Временная проблема с сетью",
"notice": "Примечание: для стабильной работы допускается только одно активное соединение. Если вы используете чат в нескольких вкладках, рекомендуем оставить только одну.",
"understand": "Понятно",
"status": {
"reconnecting": "Переподключение...",
"reconnected": "Соединение восстановлено",
"failed": "Ошибка подключения, обновите страницу"
}
},
"errors": {
"sendMessageFailed": "Ошибка отправки сообщения, попробуйте еще раз",
"createSessionFailed": "Ошибка создания сессии, обновите страницу"
}
}

View File

@@ -95,7 +95,8 @@
"sync": "Синхронизировать"
},
"tips": {
"timeoutConfig": "Тайм-аут вызова инструментов настраивается отдельно на странице конфигурации"
"timeoutConfig": "Тайм-аут вызова инструментов настраивается отдельно на странице конфигурации",
"transportRecommendation": "Рекомендуется сначала использовать режим Streamable HTTP или SSE. Режим stdio запускает локальный процесс на хосте AstrBot и подходит только для доверенных MCP-серверов."
}
},
"serverDetail": {

View File

@@ -56,6 +56,10 @@
"ipython": {
"output": "输出"
},
"toolStatus": {
"done": "已完成",
"running": "运行中"
},
"conversation": {
"newConversation": "新的聊天",
"noHistory": "暂无对话历史",
@@ -158,4 +162,4 @@
"partialFailure": "{total} 个对话中有 {failed} 个删除失败",
"requestFailed": "删除对话失败,请重试。"
}
}
}

View File

@@ -1604,26 +1604,26 @@
},
"deerflow_assistant_id": {
"description": "Assistant ID",
"hint": "LangGraph assistant_id默认为 lead_agent。"
"hint": "DeerFlow 2.0 LangGraph assistant_id默认为 lead_agent。"
},
"deerflow_model_name": {
"description": "模型名称覆盖",
"hint": "可选。覆盖 DeerFlow 默认模型(对应 runtime context 的 model_name。"
"hint": "可选。覆盖 DeerFlow 默认模型(对应运行时 configurable 的 model_name。"
},
"deerflow_thinking_enabled": {
"description": "启用思考模式"
},
"deerflow_plan_mode": {
"description": "启用计划模式",
"hint": "对应 DeerFlow 的 is_plan_mode。"
"hint": "对应 DeerFlow 2.0 运行时 configurable 的 is_plan_mode。"
},
"deerflow_subagent_enabled": {
"description": "启用子智能体",
"hint": "对应 DeerFlow 的 subagent_enabled。"
"hint": "对应 DeerFlow 2.0 运行时 configurable 的 subagent_enabled。"
},
"deerflow_max_concurrent_subagents": {
"description": "子智能体最大并发数",
"hint": "对应 DeerFlow 的 max_concurrent_subagents。仅在启用子智能体时生效默认 3。"
"hint": "对应 DeerFlow 2.0 运行时 configurable 的 max_concurrent_subagents。仅在启用子智能体时生效默认 3。"
},
"deerflow_recursion_limit": {
"description": "递归深度上限",

View File

@@ -95,7 +95,8 @@
"sync": "同步"
},
"tips": {
"timeoutConfig": "工具调用的超时时间请前往配置页面单独配置"
"timeoutConfig": "工具调用的超时时间请前往配置页面单独配置",
"transportRecommendation": "建议优先使用 Streamable HTTP 或 SSE 模式。stdio 模式会在 AstrBot 主机上启动本地进程,仅适合可信 MCP 服务器。"
}
},
"serverDetail": {

View File

@@ -9,7 +9,6 @@ import { useCommonStore } from '@/stores/common';
import { MarkdownRender, enableKatex, enableMermaid } from 'markstream-vue';
import 'markstream-vue/index.css';
import 'katex/dist/katex.min.css';
import 'highlight.js/styles/github.css';
import { useI18n } from '@/i18n/composables';
import { router } from '@/router';
import { useRoute } from 'vue-router';

View File

@@ -0,0 +1,19 @@
.v-theme--PurpleThemeDark {
.shiki.shiki-themes,
.shiki.shiki-themes span {
color: var(--shiki-dark) !important;
}
.shiki.shiki-themes {
background-color: var(--shiki-dark-bg) !important;
}
.markstream-vue {
--border: 217.2 32.6% 17.5%;
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--secondary: 217.2 32.6% 17.5%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
}
}

View File

@@ -1,107 +0,0 @@
// highlight.js dark mode overrides
// Scoped to the dark Vuetify theme so the default github.css light styles work in light mode.
.v-theme--PurpleThemeDark {
.hljs {
background: transparent;
color: #adbac7;
}
.hljs-doctag,
.hljs-keyword,
.hljs-meta .hljs-keyword,
.hljs-template-tag,
.hljs-template-variable,
.hljs-type,
.hljs-variable.language_ {
color: #f47067;
}
.hljs-title,
.hljs-title.class_,
.hljs-title.class_.inherited__,
.hljs-title.function_ {
color: #dcbdfb;
}
.hljs-attr,
.hljs-attribute,
.hljs-literal,
.hljs-meta,
.hljs-number,
.hljs-operator,
.hljs-selector-attr,
.hljs-selector-class,
.hljs-selector-id,
.hljs-variable {
color: #6cb6ff;
}
.hljs-meta .hljs-string,
.hljs-regexp,
.hljs-string {
color: #96d0ff;
}
.hljs-built_in,
.hljs-symbol {
color: #f69d50;
}
.hljs-code,
.hljs-comment,
.hljs-formula {
color: #768390;
}
.hljs-name,
.hljs-quote,
.hljs-selector-pseudo,
.hljs-selector-tag {
color: #8ddb8c;
}
.hljs-subst {
color: #adbac7;
}
.hljs-section {
color: #316dca;
font-weight: bold;
}
.hljs-bullet {
color: #eac55f;
}
.hljs-emphasis {
color: #adbac7;
font-style: italic;
}
.hljs-strong {
color: #adbac7;
font-weight: bold;
}
.hljs-addition {
color: #b4f1b4;
background-color: #1b4721;
}
.hljs-deletion {
color: #ffd8d3;
background-color: #78191b;
}
// markstream-vue dark mode variables override
// markstream-vue expects a `.dark` ancestor to activate its dark palette,
// but Vuetify uses `.v-theme--PurpleThemeDark` instead.
.markstream-vue {
--border: 217.2 32.6% 17.5%;
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--secondary: 217.2 32.6% 17.5%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
}
}

View File

@@ -2,17 +2,12 @@ html {
overflow-y: auto;
}
.v-main {
margin-right: 20px;
margin: 0;
}
.top-header {
border-bottom: 1px solid rgba(var(--v-theme-borderLight), 0.5);
}
@media (max-width: 1279px) {
.v-main {
margin: 0 10px;
}
}
.spacer {
padding: 100px 0;
}

View File

@@ -13,7 +13,7 @@
@import './components/VTextField';
@import './components/VTabs';
@import './components/VScrollbar';
@import './components/HljsDark';
@import './components/CodeBlockDark';
@import './pages/dashboards';

View File

@@ -2,8 +2,7 @@ import { defineStore } from 'pinia';
import { router } from '@/router';
import axios from 'axios';
export const useAuthStore = defineStore({
id: 'auth',
export const useAuthStore = defineStore("auth", {
state: () => ({
// @ts-ignore
username: '',

View File

@@ -1,8 +1,7 @@
import { defineStore } from 'pinia';
import axios from 'axios';
export const useCommonStore = defineStore({
id: 'common',
export const useCommonStore = defineStore("common", {
state: () => ({
// @ts-ignore
eventSource: null,

Some files were not shown because too many files have changed in this diff Show More