Compare commits

..

24 Commits

Author SHA1 Message Date
Soulter c0c967390c chore: remove japanese prompt 2026-01-21 16:03:50 +08:00
Soulter aec5f4e9e6 perf: live mode entry 2026-01-21 15:59:24 +08:00
Soulter 991b85e0c0 Merge branch 'master' into feat/live-mode 2026-01-21 15:49:42 +08:00
Ruochen Pan 473d258b69 feat: implement persona folder for advanced persona management (#4443)
* feat(db): add persona folder management for hierarchical organization

Implement hierarchical folder structure for organizing personas:
- Add PersonaFolder model with recursive parent-child relationships
- Add folder_id and sort_order fields to Persona model
- Implement CRUD operations for persona folders in database layer
- Add migration support for existing databases
- Extend PersonaManager with folder management methods
- Add dashboard API routes for folder operations

* feat(persona): add batch sort order update endpoint for personas and folders

Add new API endpoint POST /persona/reorder to batch update sort_order
for both personas and folders. This enables drag-and-drop reordering
in the dashboard UI.

Changes:
- Add abstract batch_update_sort_order method to BaseDatabase
- Implement batch_update_sort_order in SQLiteDatabase
- Add batch_update_sort_order to PersonaManager with cache refresh
- Add reorder_items route handler with input validation

* feat(persona): add folder_id and sort_order params to persona creation

Extend persona creation flow to support folder placement and ordering:
- Add folder_id and sort_order parameters to insert_persona in db layer
- Update PersonaManager.create_persona to accept and pass folder params
- Add get_folder_detail API endpoint for retrieving folder information
- Include folder_id and sort_order in persona creation response
- Add session flush/refresh to return complete persona object

* feat(dashboard): implement persona folder management UI

- Add folder management system with tree view and breadcrumbs
- Implement create, rename, delete, and move operations for folders
- Add drag-and-drop support for organizing personas and folders
- Create new PersonaManager component and Pinia store for state management
- Refactor PersonaPage to support hierarchical structure
- Update locale files with folder-related translations
- Handle empty parent_id correctly in backend route

* feat(dashboard): centralize folder expansion state in persona store

Move folder expansion logic from local component state to global Pinia
store to persist expansion state.
- Add `expandedFolderIds` state and toggle actions to `personaStore`
- Update `FolderTreeNode` to use store state instead of local data
- Automatically navigate to target folder after moving a persona

* feat(dashboard): add reusable folder management component library

Extract folder management UI into reusable base components and create
persona-specific wrapper components that integrate with personaStore.

- Add base folder components (tree, breadcrumb, card, dialogs) with
  customizable labels for i18n support
- Create useFolderManager composable for folder state management
- Implement drag-and-drop support for moving personas between folders
- Add persona-specific wrapper components connecting to personaStore
- Reorganize PersonaManager into views/persona directory structure
- Include comprehensive README documentation for component usage

* refactor(dashboard): remove legacy persona folder management components

Remove deprecated persona folder management Vue components that have been
superseded by the new reusable folder management component library.

Deleted components:
- CreateFolderDialog.vue
- FolderBreadcrumb.vue
- FolderCard.vue
- FolderTree.vue
- FolderTreeNode.vue
- MoveTargetNode.vue
- MoveToFolderDialog.vue
- PersonaCard.vue
- PersonaManager.vue

These components are replaced by the centralized folder management
implementation introduced in commit 3fbb3db2.

* fix(dashboard): add delayed skeleton loading to prevent UI flicker

Implement a 150ms delay before showing the skeleton loader in
PersonaManager to prevent visual flicker during fast loading operations.

- Add showSkeleton state with timer-based delay mechanism
- Use v-fade-transition for smooth skeleton visibility transitions
- Clean up timer on component unmount to prevent memory leaks
- Only display skeleton when loading exceeds threshold duration

* feat(dashboard): add generic folder item selector component for persona selection

Introduce BaseFolderItemSelector.vue as a reusable component for selecting
items within folder hierarchies. Refactor PersonaSelector to use this new
base component instead of its previous flat list implementation.

Changes:
- Add BaseFolderItemSelector with folder tree navigation and item selection
- Extend folder types with SelectableItem and FolderItemSelectorLabels
- Refactor PersonaSelector to leverage the new base component
- Add i18n translations for rootFolder and emptyFolder labels

* feat(persona): add tree-view display for persona list command

Add hierarchical folder tree output for the persona list command,
showing personas organized by folders with visual tree connectors.

- Add _build_tree_output method for recursive tree structure rendering
- Display folders with 📁 icon and personas with 👤 icon
- Show root-level personas separately from folder contents
- Include total persona count in output

* refactor(persona): simplify tree-view output with shorter indentation lines

Replace complex tree connector logic with simpler depth-based indentation
using "│ " prefix. Remove unnecessary parameters (prefix, is_last) and
computed variables (has_content, total_items, item_idx) in favor of a
cleaner depth-based approach.

* feat(dashboard): add duplicate persona ID validation in create form

Add frontend validation to prevent creating personas with duplicate IDs.
Load existing persona IDs when opening the create form and validate
against them in real-time.

- Add existingPersonaIds array and loadExistingPersonaIds method
- Add validation rule to check for duplicate persona IDs
- Add i18n messages for duplicate ID error (en-US and zh-CN)
- Fix minLength validation to require at least 1 character

* i18n(persona): add createButton translation key for folder dialog

Move create button label to folder-specific translation path
instead of using generic buttons.create key.

* feat(persona): show target folder name in persona creation dialog

Add visual feedback showing which folder a new persona will be created in.

- Add info alert in PersonaForm displaying the target folder name
- Pass currentFolderName prop from PersonaManager and PersonaSelector
- Add recursive findFolderName helper to resolve folder ID to name
- Add i18n translations for createInFolder and rootFolder labels

* style:format code

* fix: remove 'persistent' attribute from dialog components

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-01-21 15:45:35 +08:00
jiangman202506 93cc4cebe6 fix: streaming response for DingTalk (#4590)
closes: #4384

* #4384 钉钉消息回复卡片模板

* chore: ruff format

* chore: ruff format

---------

Co-authored-by: ManJiang <man.jiang@jg-robust.com>
Co-authored-by: Soulter <905617992@qq.com>
2026-01-21 15:45:35 +08:00
Clhikari 4d28de6b4a feat: add file drag upload feature for ChatUI (#4583)
* feat(chat): add drag-drop upload and fix batch file upload

* style(chat): adjust drop overlay to only cover input container
2026-01-21 15:45:35 +08:00
Anima-IGCenter e7540b80ad perf: T2I template editor preview (#4574) 2026-01-21 15:45:09 +08:00
Soulter 97ee36b422 fix: ensure embedding dimensions are returned as integers in providers (#4547)
* fix: ensure embedding dimensions are returned as integers in providers

* chore: ruff format
2026-01-21 15:45:09 +08:00
Soulter 242cf8745b chore: bump version to 4.12.3 2026-01-21 15:45:09 +08:00
Soulter 625401a4d0 refactor: update event types for LLM tool usage and response 2026-01-21 15:45:09 +08:00
Soulter c95bbd11ae docs: update 4.12.2 changelog 2026-01-21 15:45:09 +08:00
Soulter 831907b22a chore: bump version to 4.12.2 2026-01-21 15:45:09 +08:00
Soulter ad2dae3a8c fix: clarify logic for skipping initial system messages in conversation 2026-01-21 15:45:09 +08:00
Soulter 92de1061aa feat: skip saving head system messages in history (#4538)
* feat: skip saving the first system message in history

* fix: rename variable for clarity in system message handling

* fix: update logic to skip all system messages until the first non-system message
2026-01-21 15:45:09 +08:00
Soulter ddff652003 chore: update readme
Added '自动压缩对话' feature and updated features list.
2026-01-21 15:45:09 +08:00
Soulter fa4df28c22 feat: nervous 2026-01-18 17:07:19 +08:00
Soulter 06fa7be63e feat: eyes 2026-01-18 10:53:04 +08:00
Soulter e92b103fd0 feat: add metrics 2026-01-17 21:44:13 +08:00
Soulter dcd699d733 feat: enhance live mode audio processing and text handling 2026-01-17 17:11:31 +08:00
Soulter 2e53d8116e feat: genie tts 2026-01-17 16:27:20 +08:00
Soulter 856d3496fa feat: enhance audio processing and metrics display in live mode 2026-01-17 15:35:02 +08:00
Soulter 19e6253d5d feat: metrics 2026-01-17 15:34:46 +08:00
Soulter 1d426a7458 chore: remove 2026-01-17 14:44:36 +08:00
Soulter c0846bc789 feat: astr live 2026-01-17 14:41:05 +08:00
108 changed files with 631 additions and 4418 deletions
-33
View File
@@ -1,33 +0,0 @@
## Setup commands
### Core
```
uv sync
uv run main.py
```
Exposed an API server on `http://localhost:6185` by default.
### Dashboard(WebUI)
```
cd dashboard
pnpm install # First time only. Use npm install -g pnpm if pnpm is not installed.
pnpm dev
```
Runs on `http://localhost:3000` by default.
## Dev environment tips
1. When modifying the WebUI, be sure to maintain componentization and clean code. Avoid duplicate code.
2. Do not add any report files such as xxx_SUMMARY.md.
3. After finishing, use `ruff format .` and `ruff check .` to format and check the code.
4. When committing, ensure to use conventional commits messages, such as `feat: add new agent for data analysis` or `fix: resolve bug in provider manager`.
5. Use English for all new comments.
## PR instructions
1. Title format: use conventional commit messages
2. Use English to write PR title and descriptions.
+1 -1
View File
@@ -41,7 +41,7 @@ AstrBot 是一个开源的一站式 Agent 聊天机器人平台,可接入主
## 主要功能
1. 💯 免费 & 开源。
1. ✨ AI 大模型对话,多模态,Agent,MCP,Skills知识库,人格设定,自动压缩对话。
1. ✨ AI 大模型对话,多模态,Agent,MCP,知识库,人格设定,自动压缩对话。
2. 🤖 支持接入 Dify、阿里云百炼、Coze 等智能体平台。
2. 🌐 多平台,支持 QQ、企业微信、飞书、钉钉、微信公众号、Telegram、Slack 以及[更多](#支持的消息平台)。
3. 📦 插件扩展,已有近 800 个插件可一键安装。
+19 -28
View File
@@ -1,13 +1,8 @@
![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/ffd99b6b-3272-4682-beaa-6fe74250f7d9)
<div align="center">
</p>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">中文</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_en.md">English</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
<div align="center">
<br>
@@ -19,17 +14,22 @@
<br>
<div>
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
<img src="https://deepwiki.com/badge.svg" href="https://deepwiki.com/AstrBotDevs/AstrBot">
<a href="https://zread.ai/AstrBotDevs/AstrBot" target="_blank"><img src="https://img.shields.io/badge/Ask_Zread-_.svg?style=flat&color=00b0aa&labelColor=000000&logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQuOTYxNTYgMS42MDAxSDIuMjQxNTZDMS44ODgxIDEuNjAwMSAxLjYwMTU2IDEuODg2NjQgMS42MDE1NiAyLjI0MDFWNC45NjAxQzEuNjAxNTYgNS4zMTM1NiAxLjg4ODEgNS42MDAxIDIuMjQxNTYgNS42MDAxSDQuOTYxNTZDNS4zMTUwMiA1LjYwMDEgNS42MDE1NiA1LjMxMzU2IDUuNjAxNTYgNC45NjAxVjIuMjQwMUM1LjYwMTU2IDEuODg2NjQgNS4zMTUwMiAxLjYwMDEgNC45NjE1NiAxLjYwMDFaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00Ljk2MTU2IDEwLjM5OTlIMi4yNDE1NkMxLjg4ODEgMTAuMzk5OSAxLjYwMTU2IDEwLjY4NjQgMS42MDE1NiAxMS4wMzk5VjEzLjc1OTlDMS42MDE1NiAxNC4xMTM0IDEuODg4MSAxNC4zOTk5IDIuMjQxNTYgMTQuMzk5OUg0Ljk2MTU2QzUuMzE1MDIgMTQuMzk5OSA1LjYwMTU2IDE0LjExMzQgNS42MDE1NiAxMy43NTk5VjExLjAzOTlDNS42MDE1NiAxMC42ODY0IDUuMzE1MDIgMTAuMzk5OSA0Ljk2MTU2IDEwLjM5OTlaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik0xMy43NTg0IDEuNjAwMUgxMS4wMzg0QzEwLjY4NSAxLjYwMDEgMTAuMzk4NCAxLjg4NjY0IDEwLjM5ODQgMi4yNDAxVjQuOTYwMUMxMC4zOTg0IDUuMzEzNTYgMTAuNjg1IDUuNjAwMSAxMS4wMzg0IDUuNjAwMUgxMy43NTg0QzE0LjExMTkgNS42MDAxIDE0LjM5ODQgNS4zMTM1NiAxNC4zOTg0IDQuOTYwMVYyLjI0MDFDMTQuMzk4NCAxLjg4NjY0IDE0LjExMTkgMS42MDAxIDEzLjc1ODQgMS42MDAxWiIgZmlsbD0iI2ZmZiIvPgo8cGF0aCBkPSJNNCAxMkwxMiA0TDQgMTJaIiBmaWxsPSIjZmZmIi8%2BCjxwYXRoIGQ9Ik00IDEyTDEyIDQiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIxLjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPgo8L3N2Zz4K&logoColor=ffffff" alt="zread"/></a>
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?color=76bad9"/></a>
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20plugins&label=Marketplace&cacheSeconds=3600">
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
<img src="https://img.shields.io/github/v/release/AstrBotDevs/AstrBot?style=for-the-badge&color=76bad9" href="https://github.com/AstrBotDevs/AstrBot/releases/latest">
<img src="https://img.shields.io/badge/python-3.10+-blue.svg?style=for-the-badge&color=76bad9" alt="python">
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg?style=for-the-badge&color=76bad9"/></a>
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=wtbaNx7EioxeaqS9z7RQWVXPIxg2zYr7&jump_from=webapi&authKey=vlqnv/AV2DbJEvGIcxdlNSpfxVy+8vVqijgreRdnVKOaydpc+YSw4MctmEbr0k5"><img alt="QQ_community" src="https://img.shields.io/badge/QQ群-775869627-purple?style=for-the-badge&color=76bad9"></a>
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
<img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%20plugins&style=for-the-badge&label=Marketplace&cacheSeconds=3600">
</div>
<br>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README.md">中文</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_zh-TW.md">繁體中文</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_fr.md">Français</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
<a href="https://astrbot.app/">Documentation</a>
<a href="https://blog.astrbot.app/">Blog</a>
<a href="https://astrbot.featurebase.app/roadmap">Roadmap</a>
@@ -38,19 +38,17 @@
AstrBot is an open-source all-in-one Agent chatbot platform that integrates with mainstream instant messaging apps. It provides reliable and scalable conversational AI infrastructure for individuals, developers, and teams. Whether you're building a personal AI companion, intelligent customer service, automation assistant, or enterprise knowledge base, AstrBot enables you to quickly build production-ready AI applications within your IM platform workflows.
![070d50ba43ea3c96980787127bbbe552](https://github.com/user-attachments/assets/6fe147c5-68d9-4f47-a8de-252e63fdcbd8)
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
## Key Features
1. 💯 Free & Open Source.
2. ✨ AI LLM Conversations, Multimodal, Agent, MCP, Skills, Knowledge Base, Persona Settings, Auto Context Compression.
3. 🤖 Supports integration with Dify, Alibaba Cloud Bailian, Coze, and other agent platforms.
2. ✨ AI LLM Conversations, Multimodal, Agent, MCP, Knowledge Base, Persona Settings.
3. 🤖 Supports integration with Dify, Alibaba Cloud Bailian, Coze and other agent platforms.
4. 🌐 Multi-Platform: QQ, WeChat Work, Feishu, DingTalk, WeChat Official Accounts, Telegram, Slack, and [more](#supported-messaging-platforms).
5. 📦 Plugin Extensions with nearly 800 plugins available for one-click installation.
6. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) for isolated, safe execution of code, shell calls, and session-level resource reuse.
7. 💻 WebUI Support.
8. 🌈 Web ChatUI Support with built-in agent sandbox and web search.
9. 🌐 Internationalization (i18n) Support.
6. 💻 WebUI Support.
7. 🌐 Internationalization (i18n) Support.
## Quick Start
@@ -210,8 +208,6 @@ pre-commit install
- Group 3: 630166526
- Group 5: 822130018
- Group 6: 753075035
- Group 7: 743746109
- Group 8: 1030353265
- Developer Group: 975206796
### Telegram Group
@@ -247,9 +243,4 @@ Additionally, the birth of this project would not have been possible without the
</details>
<div align="center">
_私は、高性能ですから!_
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
</div>
@@ -10,11 +10,8 @@ from astrbot.api.provider import Provider, ProviderRequest
from astrbot.core.agent.message import TextPart
from astrbot.core.pipeline.process_stage.utils import (
CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT,
LOCAL_EXECUTE_SHELL_TOOL,
LOCAL_PYTHON_TOOL,
)
from astrbot.core.provider.func_tool_manager import ToolSet
from astrbot.core.skills.skill_manager import SkillManager, build_skills_prompt
class ProcessLLMRequest:
@@ -28,22 +25,8 @@ class ProcessLLMRequest:
else:
logger.info(f"Timezone set to: {self.timezone}")
self.skill_manager = SkillManager()
def _apply_local_env_tools(self, req: ProviderRequest) -> None:
"""Add local environment tools to the provider request."""
if req.func_tool is None:
req.func_tool = ToolSet()
req.func_tool.add_tool(LOCAL_EXECUTE_SHELL_TOOL)
req.func_tool.add_tool(LOCAL_PYTHON_TOOL)
async def _ensure_persona(
self,
req: ProviderRequest,
cfg: dict,
umo: str,
platform_type: str,
event: AstrMessageEvent,
self, req: ProviderRequest, cfg: dict, umo: str, platform_type: str
):
"""确保用户人格已加载"""
if not req.conversation:
@@ -83,30 +66,6 @@ class ProcessLLMRequest:
if begin_dialogs := copy.deepcopy(persona["_begin_dialogs_processed"]):
req.contexts[:0] = begin_dialogs
# skills select and prompt
runtime = self.skills_cfg.get("runtime", "local")
skills = self.skill_manager.list_skills(active_only=True, runtime=runtime)
if runtime == "sandbox" and not self.sandbox_cfg.get("enable", False):
logger.warning(
"Skills runtime is set to sandbox, but sandbox mode is disabled, will skip skills prompt injection.",
)
req.system_prompt += "\n[Background: User added some skills, and skills runtime is set to sandbox, but sandbox mode is disabled. So skills will be unavailable.]\n"
elif skills:
# persona.skills == None means all skills are allowed
if persona and persona.get("skills") is not None:
if not persona["skills"]:
return
allowed = set(persona["skills"])
skills = [skill for skill in skills if skill.name in allowed]
if skills:
req.system_prompt += f"\n{build_skills_prompt(skills)}\n"
# if user wants to use skills in non-sandbox mode, apply local env tools
runtime = self.skills_cfg.get("runtime", "local")
sandbox_enabled = self.sandbox_cfg.get("enable", False)
if runtime == "local" and not sandbox_enabled:
self._apply_local_env_tools(req)
# tools select
tmgr = self.ctx.get_llm_tool_manager()
if (persona and persona.get("tools") is None) or not persona:
@@ -122,13 +81,7 @@ class ProcessLLMRequest:
tool = tmgr.get_func(tool_name)
if tool and tool.active:
toolset.add_tool(tool)
if not req.func_tool:
req.func_tool = toolset
else:
req.func_tool.merge(toolset)
event.trace.record(
"sel_persona", persona_id=persona_id, persona_toolset=toolset.names()
)
req.func_tool = toolset
logger.debug(f"Tool set for persona {persona_id}: {toolset.names()}")
async def _ensure_img_caption(
@@ -181,8 +134,6 @@ class ProcessLLMRequest:
cfg: dict = self.ctx.get_config(umo=event.unified_msg_origin)[
"provider_settings"
]
self.skills_cfg = cfg.get("skills", {})
self.sandbox_cfg = cfg.get("sandbox", {})
# prompt prefix
if prefix := cfg.get("prompt_prefix"):
@@ -233,7 +184,7 @@ class ProcessLLMRequest:
# inject persona for this request
platform_type = event.get_platform_name()
await self._ensure_persona(
req, cfg, event.unified_msg_origin, platform_type, event
req, cfg, event.unified_msg_origin, platform_type
)
# image caption
@@ -11,6 +11,7 @@ from .provider import ProviderCommands
from .setunset import SetUnsetCommands
from .sid import SIDCommand
from .t2i import T2ICommand
from .tool import ToolCommands
from .tts import TTSCommand
__all__ = [
@@ -26,4 +27,5 @@ __all__ = [
"SetUnsetCommands",
"T2ICommand",
"TTSCommand",
"ToolCommands",
]
@@ -0,0 +1,31 @@
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
class ToolCommands:
def __init__(self, context: star.Context):
self.context = context
async def tool_ls(self, event: AstrMessageEvent):
"""查看函数工具列表"""
event.set_result(
MessageEventResult().message("tool 指令在 AstrBot v4.0.0 已经被移除。"),
)
async def tool_on(self, event: AstrMessageEvent, tool_name: str = ""):
"""启用一个函数工具"""
event.set_result(
MessageEventResult().message("tool 指令在 AstrBot v4.0.0 已经被移除。"),
)
async def tool_off(self, event: AstrMessageEvent, tool_name: str = ""):
"""停用一个函数工具"""
event.set_result(
MessageEventResult().message("tool 指令在 AstrBot v4.0.0 已经被移除。"),
)
async def tool_all_off(self, event: AstrMessageEvent):
"""停用所有函数工具"""
event.set_result(
MessageEventResult().message("tool 指令在 AstrBot v4.0.0 已经被移除。"),
)
@@ -13,6 +13,7 @@ from .commands import (
SetUnsetCommands,
SIDCommand,
T2ICommand,
ToolCommands,
TTSCommand,
)
@@ -23,6 +24,7 @@ class Main(star.Star):
self.help_c = HelpCommand(self.context)
self.llm_c = LLMCommands(self.context)
self.tool_c = ToolCommands(self.context)
self.plugin_c = PluginCommands(self.context)
self.admin_c = AdminCommands(self.context)
self.conversation_c = ConversationCommands(self.context)
@@ -45,6 +47,30 @@ class Main(star.Star):
"""开启/关闭 LLM"""
await self.llm_c.llm(event)
@filter.command_group("tool")
def tool(self):
"""函数工具管理"""
@tool.command("ls")
async def tool_ls(self, event: AstrMessageEvent):
"""查看函数工具列表"""
await self.tool_c.tool_ls(event)
@tool.command("on")
async def tool_on(self, event: AstrMessageEvent, tool_name: str):
"""启用一个函数工具"""
await self.tool_c.tool_on(event, tool_name)
@tool.command("off")
async def tool_off(self, event: AstrMessageEvent, tool_name: str):
"""停用一个函数工具"""
await self.tool_c.tool_off(event, tool_name)
@tool.command("off_all")
async def tool_all_off(self, event: AstrMessageEvent):
"""停用所有函数工具"""
await self.tool_c.tool_all_off(event)
@filter.command_group("plugin")
def plugin(self):
"""插件管理"""
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.13.0"
__version__ = "4.12.3"
-2
View File
@@ -20,8 +20,6 @@ astrbot_config = AstrBotConfig()
t2i_base_url = astrbot_config.get("t2i_endpoint", "https://t2i.soulter.top/text2img")
html_renderer = HtmlRenderer(t2i_base_url)
logger = LogManager.GetLogger(log_name="astrbot")
LogManager.configure_logger(logger, astrbot_config)
LogManager.configure_trace_logger(astrbot_config)
db_helper = SQLiteDatabase(DB_PATH)
# 简单的偏好设置存储, 这里后续应该存储到数据库中, 一些部分可以存储到配置中
sp = SharedPreferences(db_helper=db_helper)
@@ -1,4 +1,3 @@
import copy
import sys
import time
import traceback
@@ -15,7 +14,6 @@ from mcp.types import (
from astrbot import logger
from astrbot.core.agent.message import TextPart, ThinkPart
from astrbot.core.agent.tool import ToolSet
from astrbot.core.message.components import Json
from astrbot.core.message.message_event_result import (
MessageChain,
@@ -66,7 +64,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
# customize
custom_token_counter: TokenCounter | None = None,
custom_compressor: ContextCompressor | None = None,
tool_schema_mode: str | None = "full",
**kwargs: T.Any,
) -> None:
self.req = request
@@ -102,24 +99,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
self.agent_hooks = agent_hooks
self.run_context = run_context
# These two are used for tool schema mode handling
# We now have two modes:
# - "full": use full tool schema for LLM calls, default.
# - "skills_like": use light tool schema for LLM calls, and re-query with param-only schema when needed.
# Light tool schema does not include tool parameters.
# This can reduce token usage when tools have large descriptions.
# See #4681
self.tool_schema_mode = tool_schema_mode
self._tool_schema_param_set = None
if tool_schema_mode == "skills_like":
tool_set = self.req.func_tool
if not tool_set:
return
light_set = tool_set.get_light_tool_set()
self._tool_schema_param_set = tool_set.get_param_only_tool_set()
# MODIFIE the req.func_tool to use light tool schemas
self.req.func_tool = light_set
messages = []
# append existing messages in the run context
for msg in request.contexts:
@@ -274,9 +253,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
# 如果有工具调用,还需处理工具调用
if llm_resp.tools_call_name:
if self.tool_schema_mode == "skills_like":
llm_resp, _ = await self._resolve_tool_exec(llm_resp)
tool_call_result_blocks = []
async for result in self._handle_function_tools(self.req, llm_resp):
if isinstance(result, list):
@@ -293,7 +269,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
type=ar_type,
data=AgentResponseData(chain=result),
)
# 将结果添加到上下文中
parts = []
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
@@ -379,7 +354,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
try:
if not req.func_tool:
return
func_tool = req.func_tool.get_tool(func_tool_name)
func_tool = req.func_tool.get_func(func_tool_name)
logger.info(f"使用工具:{func_tool_name},参数:{func_tool_args}")
if not func_tool:
@@ -562,71 +537,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
if tool_call_result_blocks:
yield tool_call_result_blocks
def _build_tool_requery_context(
self, tool_names: list[str]
) -> list[dict[str, T.Any]]:
"""Build contexts for re-querying LLM with param-only tool schemas."""
contexts: list[dict[str, T.Any]] = []
for msg in self.run_context.messages:
if hasattr(msg, "model_dump"):
contexts.append(msg.model_dump()) # type: ignore[call-arg]
elif isinstance(msg, dict):
contexts.append(copy.deepcopy(msg))
instruction = (
"You have decided to call tool(s): "
+ ", ".join(tool_names)
+ ". Now call the tool(s) with required arguments using the tool schema, "
"and follow the existing tool-use rules."
)
if contexts and contexts[0].get("role") == "system":
content = contexts[0].get("content") or ""
contexts[0]["content"] = f"{content}\n{instruction}"
else:
contexts.insert(0, {"role": "system", "content": instruction})
return contexts
def _build_tool_subset(self, tool_set: ToolSet, tool_names: list[str]) -> ToolSet:
"""Build a subset of tools from the given tool set based on tool names."""
subset = ToolSet()
for name in tool_names:
tool = tool_set.get_tool(name)
if tool:
subset.add_tool(tool)
return subset
async def _resolve_tool_exec(
self,
llm_resp: LLMResponse,
) -> tuple[LLMResponse, ToolSet | None]:
"""Used in 'skills_like' tool schema mode to re-query LLM with param-only tool schemas."""
tool_names = llm_resp.tools_call_name
if not tool_names:
return llm_resp, self.req.func_tool
full_tool_set = self.req.func_tool
if not isinstance(full_tool_set, ToolSet):
return llm_resp, self.req.func_tool
subset = self._build_tool_subset(full_tool_set, tool_names)
if not subset.tools:
return llm_resp, full_tool_set
if isinstance(self._tool_schema_param_set, ToolSet):
param_subset = self._build_tool_subset(
self._tool_schema_param_set, tool_names
)
if param_subset.tools and tool_names:
contexts = self._build_tool_requery_context(tool_names)
requery_resp = await self.provider.text_chat(
contexts=contexts,
func_tool=param_subset,
model=self.req.model,
session_id=self.req.session_id,
)
if requery_resp:
llm_resp = requery_resp
return llm_resp, subset
def done(self) -> bool:
"""检查 Agent 是否已完成工作"""
return self._state in (AgentState.DONE, AgentState.ERROR)
+20 -61
View File
@@ -1,4 +1,3 @@
import copy
from collections.abc import AsyncGenerator, Awaitable, Callable
from typing import Any, Generic
@@ -103,47 +102,6 @@ class ToolSet:
return tool
return None
def get_light_tool_set(self) -> "ToolSet":
"""Return a light tool set with only name/description."""
light_tools = []
for tool in self.tools:
if hasattr(tool, "active") and not tool.active:
continue
light_params = {
"type": "object",
"properties": {},
}
light_tools.append(
FunctionTool(
name=tool.name,
parameters=light_params,
description=tool.description,
handler=None,
)
)
return ToolSet(light_tools)
def get_param_only_tool_set(self) -> "ToolSet":
"""Return a tool set with name/parameters only (no description)."""
param_tools = []
for tool in self.tools:
if hasattr(tool, "active") and not tool.active:
continue
params = (
copy.deepcopy(tool.parameters)
if tool.parameters
else {"type": "object", "properties": {}}
)
param_tools.append(
FunctionTool(
name=tool.name,
parameters=params,
description="",
handler=None,
)
)
return ToolSet(param_tools)
@deprecated(reason="Use add_tool() instead", version="4.0.0")
def add_func(
self,
@@ -189,15 +147,18 @@ class ToolSet:
"""Convert tools to OpenAI API function calling schema format."""
result = []
for tool in self.tools:
func_def = {"type": "function", "function": {"name": tool.name}}
if tool.description:
func_def["function"]["description"] = tool.description
func_def = {
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
},
}
if tool.parameters is not None:
if (
tool.parameters and tool.parameters.get("properties")
) or not omit_empty_parameter_field:
func_def["function"]["parameters"] = tool.parameters
if (
tool.parameters and tool.parameters.get("properties")
) or not omit_empty_parameter_field:
func_def["function"]["parameters"] = tool.parameters
result.append(func_def)
return result
@@ -210,9 +171,11 @@ class ToolSet:
if tool.parameters:
input_schema["properties"] = tool.parameters.get("properties", {})
input_schema["required"] = tool.parameters.get("required", [])
tool_def = {"name": tool.name, "input_schema": input_schema}
if tool.description:
tool_def["description"] = tool.description
tool_def = {
"name": tool.name,
"description": tool.description,
"input_schema": input_schema,
}
result.append(tool_def)
return result
@@ -282,9 +245,10 @@ class ToolSet:
tools = []
for tool in self.tools:
d: dict[str, Any] = {"name": tool.name}
if tool.description:
d["description"] = tool.description
d: dict[str, Any] = {
"name": tool.name,
"description": tool.description,
}
if tool.parameters:
d["parameters"] = convert_schema(tool.parameters)
tools.append(d)
@@ -310,11 +274,6 @@ class ToolSet:
"""获取所有工具的名称列表"""
return [tool.name for tool in self.tools]
def merge(self, other: "ToolSet"):
"""Merge another ToolSet into this one."""
for tool in other.tools:
self.add_tool(tool)
def __len__(self):
return len(self.tools)
+1 -3
View File
@@ -56,10 +56,8 @@ class MainAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
)
# special handle web_search_tavily
platform_name = run_context.context.event.get_platform_name()
if (
platform_name == "webchat"
and tool.name == "web_search_tavily"
tool.name == "web_search_tavily"
and len(run_context.messages) > 0
and tool_result
and len(tool_result.content)
+2 -2
View File
@@ -256,7 +256,7 @@ async def call_local_llm_tool(
# 这里逐步执行异步生成器, 对于每个 yield 返回的 ret, 执行下面的代码
# 返回值只能是 MessageEventResult 或者 None(无返回值)
_has_yielded = True
if isinstance(ret, MessageEventResult | CommandResult):
if isinstance(ret, (MessageEventResult, CommandResult)):
# 如果返回值是 MessageEventResult, 设置结果并继续
event.set_result(ret)
yield
@@ -273,7 +273,7 @@ async def call_local_llm_tool(
elif inspect.iscoroutine(ready_to_call):
# 如果只是一个协程, 直接执行
ret = await ready_to_call
if isinstance(ret, MessageEventResult | CommandResult):
if isinstance(ret, (MessageEventResult, CommandResult)):
event.set_result(ret)
yield
else:
-234
View File
@@ -1,234 +0,0 @@
from __future__ import annotations
import asyncio
import os
import shutil
import subprocess
import sys
from dataclasses import dataclass
from typing import Any
from astrbot.api import logger
from astrbot.core.utils.astrbot_path import (
get_astrbot_data_path,
get_astrbot_root,
get_astrbot_temp_path,
)
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
from .base import ComputerBooter
_BLOCKED_COMMAND_PATTERNS = [
" rm -rf ",
" rm -fr ",
" rm -r ",
" mkfs",
" dd if=",
" shutdown",
" reboot",
" poweroff",
" halt",
" sudo ",
":(){:|:&};:",
" kill -9 ",
" killall ",
]
def _is_safe_command(command: str) -> bool:
cmd = f" {command.strip().lower()} "
return not any(pat in cmd for pat in _BLOCKED_COMMAND_PATTERNS)
def _ensure_safe_path(path: str) -> str:
abs_path = os.path.abspath(path)
allowed_roots = [
os.path.abspath(get_astrbot_root()),
os.path.abspath(get_astrbot_data_path()),
os.path.abspath(get_astrbot_temp_path()),
]
if not any(abs_path.startswith(root) for root in allowed_roots):
raise PermissionError("Path is outside the allowed computer roots.")
return abs_path
@dataclass
class LocalShellComponent(ShellComponent):
async def exec(
self,
command: str,
cwd: str | None = None,
env: dict[str, str] | None = None,
timeout: int | None = 30,
shell: bool = True,
background: bool = False,
) -> dict[str, Any]:
if not _is_safe_command(command):
raise PermissionError("Blocked unsafe shell command.")
def _run() -> dict[str, Any]:
run_env = os.environ.copy()
if env:
run_env.update({str(k): str(v) for k, v in env.items()})
working_dir = _ensure_safe_path(cwd) if cwd else get_astrbot_root()
if background:
proc = subprocess.Popen(
command,
shell=shell,
cwd=working_dir,
env=run_env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
return {"pid": proc.pid, "stdout": "", "stderr": "", "exit_code": None}
result = subprocess.run(
command,
shell=shell,
cwd=working_dir,
env=run_env,
timeout=timeout,
capture_output=True,
text=True,
)
return {
"stdout": result.stdout,
"stderr": result.stderr,
"exit_code": result.returncode,
}
return await asyncio.to_thread(_run)
@dataclass
class LocalPythonComponent(PythonComponent):
async def exec(
self,
code: str,
kernel_id: str | None = None,
timeout: int = 30,
silent: bool = False,
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
try:
result = subprocess.run(
[os.environ.get("PYTHON", sys.executable), "-c", code],
timeout=timeout,
capture_output=True,
text=True,
)
stdout = "" if silent else result.stdout
stderr = result.stderr if result.returncode != 0 else ""
return {
"data": {
"output": {"text": stdout, "images": []},
"error": stderr,
}
}
except subprocess.TimeoutExpired:
return {
"data": {
"output": {"text": "", "images": []},
"error": "Execution timed out.",
}
}
return await asyncio.to_thread(_run)
@dataclass
class LocalFileSystemComponent(FileSystemComponent):
async def create_file(
self, path: str, content: str = "", mode: int = 0o644
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
abs_path = _ensure_safe_path(path)
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
with open(abs_path, "w", encoding="utf-8") as f:
f.write(content)
os.chmod(abs_path, mode)
return {"success": True, "path": abs_path}
return await asyncio.to_thread(_run)
async def read_file(self, path: str, encoding: str = "utf-8") -> dict[str, Any]:
def _run() -> dict[str, Any]:
abs_path = _ensure_safe_path(path)
with open(abs_path, encoding=encoding) as f:
content = f.read()
return {"success": True, "content": content}
return await asyncio.to_thread(_run)
async def write_file(
self, path: str, content: str, mode: str = "w", encoding: str = "utf-8"
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
abs_path = _ensure_safe_path(path)
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
with open(abs_path, mode, encoding=encoding) as f:
f.write(content)
return {"success": True, "path": abs_path}
return await asyncio.to_thread(_run)
async def delete_file(self, path: str) -> dict[str, Any]:
def _run() -> dict[str, Any]:
abs_path = _ensure_safe_path(path)
if os.path.isdir(abs_path):
shutil.rmtree(abs_path)
else:
os.remove(abs_path)
return {"success": True, "path": abs_path}
return await asyncio.to_thread(_run)
async def list_dir(
self, path: str = ".", show_hidden: bool = False
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
abs_path = _ensure_safe_path(path)
entries = os.listdir(abs_path)
if not show_hidden:
entries = [e for e in entries if not e.startswith(".")]
return {"success": True, "entries": entries}
return await asyncio.to_thread(_run)
class LocalBooter(ComputerBooter):
def __init__(self) -> None:
self._fs = LocalFileSystemComponent()
self._python = LocalPythonComponent()
self._shell = LocalShellComponent()
async def boot(self, session_id: str) -> None:
logger.info(f"Local computer booter initialized for session: {session_id}")
async def shutdown(self) -> None:
logger.info("Local computer booter shutdown complete.")
@property
def fs(self) -> FileSystemComponent:
return self._fs
@property
def python(self) -> PythonComponent:
return self._python
@property
def shell(self) -> ShellComponent:
return self._shell
async def upload_file(self, path: str, file_name: str) -> dict:
raise NotImplementedError(
"LocalBooter does not support upload_file operation. Use shell instead."
)
async def download_file(self, remote_path: str, local_path: str):
raise NotImplementedError(
"LocalBooter does not support download_file operation. Use shell instead."
)
async def available(self) -> bool:
return True
-102
View File
@@ -1,102 +0,0 @@
import os
import shutil
import uuid
from pathlib import Path
from astrbot.api import logger
from astrbot.core.skills.skill_manager import SANDBOX_SKILLS_ROOT
from astrbot.core.star.context import Context
from astrbot.core.utils.astrbot_path import (
get_astrbot_skills_path,
get_astrbot_temp_path,
)
from .booters.base import ComputerBooter
from .booters.local import LocalBooter
session_booter: dict[str, ComputerBooter] = {}
local_booter: ComputerBooter | None = None
async def _sync_skills_to_sandbox(booter: ComputerBooter) -> None:
skills_root = get_astrbot_skills_path()
if not os.path.isdir(skills_root):
return
if not any(Path(skills_root).iterdir()):
return
temp_dir = get_astrbot_temp_path()
os.makedirs(temp_dir, exist_ok=True)
zip_base = os.path.join(temp_dir, "skills_bundle")
zip_path = f"{zip_base}.zip"
try:
if os.path.exists(zip_path):
os.remove(zip_path)
shutil.make_archive(zip_base, "zip", skills_root)
remote_zip = Path(SANDBOX_SKILLS_ROOT) / "skills.zip"
await booter.shell.exec(f"mkdir -p {SANDBOX_SKILLS_ROOT}")
upload_result = await booter.upload_file(zip_path, str(remote_zip))
if not upload_result.get("success", False):
raise RuntimeError("Failed to upload skills bundle to sandbox.")
await booter.shell.exec(
f"unzip -o {remote_zip} -d {SANDBOX_SKILLS_ROOT} && rm -f {remote_zip}"
)
finally:
if os.path.exists(zip_path):
try:
os.remove(zip_path)
except Exception:
logger.warning(f"Failed to remove temp skills zip: {zip_path}")
async def get_booter(
context: Context,
session_id: str,
) -> ComputerBooter:
config = context.get_config(umo=session_id)
sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {})
booter_type = sandbox_cfg.get("booter", "shipyard")
if session_id in session_booter:
booter = session_booter[session_id]
if not await booter.available():
# rebuild
session_booter.pop(session_id, None)
if session_id not in session_booter:
uuid_str = uuid.uuid5(uuid.NAMESPACE_DNS, session_id).hex
if booter_type == "shipyard":
from .booters.shipyard import ShipyardBooter
ep = sandbox_cfg.get("shipyard_endpoint", "")
token = sandbox_cfg.get("shipyard_access_token", "")
ttl = sandbox_cfg.get("shipyard_ttl", 3600)
max_sessions = sandbox_cfg.get("shipyard_max_sessions", 10)
client = ShipyardBooter(
endpoint_url=ep, access_token=token, ttl=ttl, session_num=max_sessions
)
elif booter_type == "boxlite":
from .booters.boxlite import BoxliteBooter
client = BoxliteBooter()
else:
raise ValueError(f"Unknown booter type: {booter_type}")
try:
await client.boot(uuid_str)
await _sync_skills_to_sandbox(client)
except Exception as e:
logger.error(f"Error booting sandbox for session {session_id}: {e}")
raise e
session_booter[session_id] = client
return session_booter[session_id]
def get_local_booter() -> ComputerBooter:
global local_booter
if local_booter is None:
local_booter = LocalBooter()
return local_booter
-94
View File
@@ -1,94 +0,0 @@
from dataclasses import dataclass, field
import mcp
from astrbot.api import FunctionTool
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.computer.computer_client import get_booter, get_local_booter
param_schema = {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "The Python code to execute.",
},
"silent": {
"type": "boolean",
"description": "Whether to suppress the output of the code execution.",
"default": False,
},
},
"required": ["code"],
}
def handle_result(result: dict) -> ToolExecResult:
data = result.get("data", {})
output = data.get("output", {})
error = data.get("error", "")
images: list[dict] = output.get("images", [])
text: str = output.get("text", "")
resp = mcp.types.CallToolResult(content=[])
if error:
resp.content.append(mcp.types.TextContent(type="text", text=f"error: {error}"))
if images:
for img in images:
resp.content.append(
mcp.types.ImageContent(
type="image", data=img["image/png"], mimeType="image/png"
)
)
if text:
resp.content.append(mcp.types.TextContent(type="text", text=text))
if not resp.content:
resp.content.append(mcp.types.TextContent(type="text", text="No output."))
return resp
@dataclass
class PythonTool(FunctionTool):
name: str = "astrbot_execute_ipython"
description: str = "Run codes in an IPython shell."
parameters: dict = field(default_factory=lambda: param_schema)
async def call(
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
) -> ToolExecResult:
sb = await get_booter(
context.context.context,
context.context.event.unified_msg_origin,
)
try:
result = await sb.python.exec(code, silent=silent)
return handle_result(result)
except Exception as e:
return f"Error executing code: {str(e)}"
@dataclass
class LocalPythonTool(FunctionTool):
name: str = "astrbot_execute_python"
description: str = "Execute codes in a Python environment."
parameters: dict = field(default_factory=lambda: param_schema)
async def call(
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
) -> ToolExecResult:
if context.context.event.role != "admin":
return "error: Permission denied. Local Python execution is only allowed for admin users. Set admins in AstrBot WebUI."
sb = get_local_booter()
try:
result = await sb.python.exec(code, silent=silent)
return handle_result(result)
except Exception as e:
return f"Error executing code: {str(e)}"
+10 -124
View File
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.13.0"
VERSION = "4.12.3"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -106,7 +106,6 @@ DEFAULT_CONFIG = {
"reachability_check": False,
"max_agent_step": 30,
"tool_call_timeout": 60,
"tool_schema_mode": "full",
"llm_safety_mode": True,
"safety_mode_strategy": "system_prompt", # TODO: llm judge
"file_extract": {
@@ -122,7 +121,6 @@ DEFAULT_CONFIG = {
"shipyard_ttl": 3600,
"shipyard_max_sessions": 10,
},
"skills": {"runtime": "sandbox"},
},
"provider_stt_settings": {
"enable": False,
@@ -168,7 +166,6 @@ DEFAULT_CONFIG = {
"jwt_secret": "",
"host": "0.0.0.0",
"port": 6185,
"disable_access_log": True,
},
"platform": [],
"platform_specific": {
@@ -182,12 +179,6 @@ DEFAULT_CONFIG = {
},
"wake_prefix": ["/"],
"log_level": "INFO",
"log_file_enable": False,
"log_file_path": "logs/astrbot.log",
"log_file_max_mb": 20,
"trace_log_enable": False,
"trace_log_path": "logs/astrbot.trace.log",
"trace_log_max_mb": 20,
"pip_install_arg": "",
"pypi_index_url": "https://mirrors.aliyun.com/pypi/simple/",
"persona": [], # deprecated
@@ -782,21 +773,27 @@ CONFIG_METADATA_2 = {
"interval_method": {
"type": "string",
"options": ["random", "log"],
"hint": "分段回复的间隔时间计算方法。random 为随机时间,log 为根据消息长度计算,$y=log_<log_base>(x)$x为字数,y的单位为秒。",
},
"interval": {
"type": "string",
"hint": "`random` 方法用。每一段回复的间隔时间,格式为 `最小时间,最大时间`。如 `0.75,2.5`",
},
"log_base": {
"type": "float",
"hint": "`log` 方法用。对数函数的底数。默认为 2.6",
},
"words_count_threshold": {
"type": "int",
"hint": "分段回复的字数上限。只有字数小于此值的消息才会被分段,超过此值的长消息将直接发送(不分段)。默认为 150",
},
"regex": {
"type": "string",
"hint": "用于分隔一段消息。默认情况下会根据句号、问号等标点符号分隔。re.findall(r'<regex>', text)",
},
"content_cleanup_rule": {
"type": "string",
"hint": "移除分段后的内容中的指定的内容。支持正则表达式。如填写 `[。?!]` 将移除所有的句号、问号、感叹号。re.sub(r'<regex>', '', text)",
},
},
},
@@ -1194,11 +1191,7 @@ CONFIG_METADATA_2 = {
"type": "genie_tts",
"provider_type": "text_to_speech",
"enable": False,
"genie_character_name": "mika",
"genie_onnx_model_dir": "CharacterModels/v2ProPlus/mika/tts_models",
"genie_language": "Japanese",
"genie_refer_audio_path": "",
"genie_refer_text": "",
"character_name": "mika",
"timeout": 20,
},
"Edge TTS": {
@@ -1417,16 +1410,6 @@ CONFIG_METADATA_2 = {
},
},
"items": {
"genie_onnx_model_dir": {
"description": "ONNX Model Directory",
"type": "string",
"hint": "The directory path containing the ONNX model files",
},
"genie_language": {
"description": "Language",
"type": "string",
"options": ["Japanese", "English", "Chinese"],
},
"provider_source_id": {
"invisible": True,
"type": "string",
@@ -2190,9 +2173,6 @@ CONFIG_METADATA_2 = {
"tool_call_timeout": {
"type": "int",
},
"tool_schema_mode": {
"type": "string",
},
"file_extract": {
"type": "object",
"items": {
@@ -2207,17 +2187,6 @@ CONFIG_METADATA_2 = {
},
},
},
"skills": {
"type": "object",
"items": {
"enable": {
"type": "bool",
},
"runtime": {
"type": "string",
},
},
},
},
},
"provider_stt_settings": {
@@ -2327,18 +2296,6 @@ CONFIG_METADATA_2 = {
"type": "string",
"options": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
},
"log_file_enable": {"type": "bool"},
"log_file_path": {"type": "string", "condition": {"log_file_enable": True}},
"log_file_max_mb": {"type": "int", "condition": {"log_file_enable": True}},
"trace_log_enable": {"type": "bool"},
"trace_log_path": {
"type": "string",
"condition": {"trace_log_enable": True},
},
"trace_log_max_mb": {
"type": "int",
"condition": {"trace_log_enable": True},
},
"t2i_strategy": {
"type": "string",
"options": ["remote", "local"],
@@ -2607,7 +2564,6 @@ CONFIG_METADATA_3 = {
# },
"sandbox": {
"description": "Agent 沙箱环境",
"hint": "",
"type": "object",
"items": {
"provider_settings.sandbox.enable": {
@@ -2619,7 +2575,6 @@ CONFIG_METADATA_3 = {
"description": "沙箱环境驱动器",
"type": "string",
"options": ["shipyard"],
"labels": ["Shipyard"],
"condition": {
"provider_settings.sandbox.enable": True,
},
@@ -2662,27 +2617,6 @@ CONFIG_METADATA_3 = {
},
},
},
"condition": {
"provider_settings.agent_runner_type": "local",
"provider_settings.enable": True,
},
},
"skills": {
"description": "Skills",
"type": "object",
"items": {
"provider_settings.skills.runtime": {
"description": "Skill Runtime",
"type": "string",
"options": ["local", "sandbox"],
"labels": ["本地", "沙箱"],
"hint": "选择 Skills 运行环境。使用沙箱时需先启用沙箱环境。",
},
},
"condition": {
"provider_settings.agent_runner_type": "local",
"provider_settings.enable": True,
},
},
"truncate_and_compress": {
"description": "上下文管理策略",
@@ -2743,10 +2677,6 @@ CONFIG_METADATA_3 = {
},
},
},
"condition": {
"provider_settings.agent_runner_type": "local",
"provider_settings.enable": True,
},
},
"others": {
"description": "其他配置",
@@ -2834,16 +2764,6 @@ CONFIG_METADATA_3 = {
"provider_settings.agent_runner_type": "local",
},
},
"provider_settings.tool_schema_mode": {
"description": "工具调用模式",
"type": "string",
"options": ["skills_like", "full"],
"labels": ["Skills-like(两阶段)", "Full(完整参数)"],
"hint": "skills-like 先下发工具名称与描述,再下发参数;full 一次性下发完整参数。",
"condition": {
"provider_settings.agent_runner_type": "local",
},
},
"provider_settings.wake_prefix": {
"description": "LLM 聊天额外唤醒前缀 ",
"type": "string",
@@ -3111,8 +3031,7 @@ CONFIG_METADATA_3 = {
"type": "bool",
},
"platform_settings.segmented_reply.interval_method": {
"description": "间隔方法",
"hint": "random 为随机时间,log 为根据消息长度计算,$y=log_<log_base>(x)$x为字数,y的单位为秒。",
"description": "间隔方法",
"type": "string",
"options": ["random", "log"],
},
@@ -3127,14 +3046,13 @@ CONFIG_METADATA_3 = {
"platform_settings.segmented_reply.log_base": {
"description": "对数底数",
"type": "float",
"hint": "对数间隔的底数,默认为 2.6。取值范围为 1.0-10.0。",
"hint": "对数间隔的底数,默认为 2.0。取值范围为 1.0-10.0。",
"condition": {
"platform_settings.segmented_reply.interval_method": "log",
},
},
"platform_settings.segmented_reply.words_count_threshold": {
"description": "分段回复字数阈值",
"hint": "分段回复的字数上限。只有字数小于此值的消息才会被分段,超过此值的长消息将直接发送(不分段)。默认为 150",
"type": "int",
},
"platform_settings.segmented_reply.split_mode": {
@@ -3145,7 +3063,6 @@ CONFIG_METADATA_3 = {
},
"platform_settings.segmented_reply.regex": {
"description": "分段正则表达式",
"hint": "用于分隔一段消息。默认情况下会根据句号、问号等标点符号分隔。如填写 `[。?!]` 将移除所有的句号、问号、感叹号。re.findall(r'<regex>', text)",
"type": "string",
"condition": {
"platform_settings.segmented_reply.split_mode": "regex",
@@ -3271,36 +3188,6 @@ CONFIG_METADATA_3_SYSTEM = {
"hint": "控制台输出日志的级别。",
"options": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
},
"log_file_enable": {
"description": "启用文件日志",
"type": "bool",
"hint": "开启后会将日志写入指定文件。",
},
"log_file_path": {
"description": "日志文件路径",
"type": "string",
"hint": "相对路径以 data 目录为基准,例如 logs/astrbot.log;支持绝对路径。",
},
"log_file_max_mb": {
"description": "日志文件大小上限 (MB)",
"type": "int",
"hint": "超过大小后自动轮转,默认 20MB。",
},
"trace_log_enable": {
"description": "启用 Trace 文件日志",
"type": "bool",
"hint": "将 Trace 事件写入独立文件(不影响控制台输出)。",
},
"trace_log_path": {
"description": "Trace 日志文件路径",
"type": "string",
"hint": "相对路径以 data 目录为基准,例如 logs/astrbot.trace.log;支持绝对路径。",
},
"trace_log_max_mb": {
"description": "Trace 日志大小上限 (MB)",
"type": "int",
"hint": "超过大小后自动轮转,默认 20MB。",
},
"pip_install_arg": {
"description": "pip 安装额外参数",
"type": "string",
@@ -3345,7 +3232,6 @@ DEFAULT_VALUE_MAP = {
"string": "",
"text": "",
"list": [],
"file": [],
"object": {},
"template_list": [],
}
+3 -7
View File
@@ -17,7 +17,7 @@ import traceback
from asyncio import Queue
from astrbot.api import logger, sp
from astrbot.core import LogBroker, LogManager
from astrbot.core import LogBroker
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
from astrbot.core.config.default import VERSION
from astrbot.core.conversation_mgr import ConversationManager
@@ -80,13 +80,9 @@ class AstrBotCoreLifecycle:
# 初始化日志代理
logger.info("AstrBot v" + VERSION)
if os.environ.get("TESTING", ""):
LogManager.configure_logger(
logger, self.astrbot_config, override_level="DEBUG"
)
LogManager.configure_trace_logger(self.astrbot_config)
logger.setLevel("DEBUG") # 测试模式下设置日志级别为 DEBUG
else:
LogManager.configure_logger(logger, self.astrbot_config)
LogManager.configure_trace_logger(self.astrbot_config)
logger.setLevel(self.astrbot_config["log_level"]) # 设置日志级别
await self.db.initialize()
-3
View File
@@ -254,7 +254,6 @@ class BaseDatabase(abc.ABC):
system_prompt: str,
begin_dialogs: list[str] | None = None,
tools: list[str] | None = None,
skills: list[str] | None = None,
folder_id: str | None = None,
sort_order: int = 0,
) -> Persona:
@@ -265,7 +264,6 @@ class BaseDatabase(abc.ABC):
system_prompt: System prompt for the persona
begin_dialogs: Optional list of initial dialog strings
tools: Optional list of tool names (None means all tools, [] means no tools)
skills: Optional list of skill names (None means all skills, [] means no skills)
folder_id: Optional folder ID to place the persona in (None means root)
sort_order: Sort order within the folder (default 0)
"""
@@ -288,7 +286,6 @@ class BaseDatabase(abc.ABC):
system_prompt: str | None = None,
begin_dialogs: list[str] | None = None,
tools: list[str] | None = None,
skills: list[str] | None = None,
) -> Persona | None:
"""Update a persona's system prompt or begin dialogs."""
...
+61 -23
View File
@@ -6,14 +6,6 @@ from typing import TypedDict
from sqlmodel import JSON, Field, SQLModel, Text, UniqueConstraint
class TimestampMixin(SQLModel):
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": lambda: datetime.now(timezone.utc)},
)
class PlatformStat(SQLModel, table=True):
"""This class represents the statistics of bot usage across different platforms.
@@ -38,7 +30,7 @@ class PlatformStat(SQLModel, table=True):
)
class ConversationV2(TimestampMixin, SQLModel, table=True):
class ConversationV2(SQLModel, table=True):
__tablename__: str = "conversations"
inner_conversation_id: int | None = Field(
@@ -55,7 +47,11 @@ class ConversationV2(TimestampMixin, SQLModel, table=True):
platform_id: str = Field(nullable=False)
user_id: str = Field(nullable=False)
content: list | None = Field(default=None, sa_type=JSON)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
title: str | None = Field(default=None, max_length=255)
persona_id: str | None = Field(default=None)
token_usage: int = Field(default=0, nullable=False)
@@ -72,7 +68,7 @@ class ConversationV2(TimestampMixin, SQLModel, table=True):
)
class PersonaFolder(TimestampMixin, SQLModel, table=True):
class PersonaFolder(SQLModel, table=True):
"""Persona 文件夹,支持递归层级结构。
用于组织和管理多个 Persona类似于文件系统的目录结构
@@ -96,6 +92,11 @@ class PersonaFolder(TimestampMixin, SQLModel, table=True):
"""父文件夹IDNULL表示根目录"""
description: str | None = Field(default=None, sa_type=Text)
sort_order: int = Field(default=0)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
__table_args__ = (
UniqueConstraint(
@@ -105,7 +106,7 @@ class PersonaFolder(TimestampMixin, SQLModel, table=True):
)
class Persona(TimestampMixin, SQLModel, table=True):
class Persona(SQLModel, table=True):
"""Persona is a set of instructions for LLMs to follow.
It can be used to customize the behavior of LLMs.
@@ -124,12 +125,15 @@ class Persona(TimestampMixin, SQLModel, table=True):
"""a list of strings, each representing a dialog to start with"""
tools: list | None = Field(default=None, sa_type=JSON)
"""None means use ALL tools for default, empty list means no tools, otherwise a list of tool names."""
skills: list | None = Field(default=None, sa_type=JSON)
"""None means use ALL skills for default, empty list means no skills, otherwise a list of skill names."""
folder_id: str | None = Field(default=None, max_length=36)
"""所属文件夹IDNULL 表示在根目录"""
sort_order: int = Field(default=0)
"""排序顺序"""
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
__table_args__ = (
UniqueConstraint(
@@ -139,7 +143,7 @@ class Persona(TimestampMixin, SQLModel, table=True):
)
class Preference(TimestampMixin, SQLModel, table=True):
class Preference(SQLModel, table=True):
"""This class represents preferences for bots."""
__tablename__: str = "preferences"
@@ -155,6 +159,11 @@ class Preference(TimestampMixin, SQLModel, table=True):
"""ID of the scope, such as 'global', 'umo', 'plugin_name'."""
key: str = Field(nullable=False)
value: dict = Field(sa_type=JSON, nullable=False)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
__table_args__ = (
UniqueConstraint(
@@ -166,7 +175,7 @@ class Preference(TimestampMixin, SQLModel, table=True):
)
class PlatformMessageHistory(TimestampMixin, SQLModel, table=True):
class PlatformMessageHistory(SQLModel, table=True):
"""This class represents the message history for a specific platform.
It is used to store messages that are not LLM-generated, such as user messages
@@ -187,9 +196,14 @@ class PlatformMessageHistory(TimestampMixin, SQLModel, table=True):
default=None,
) # Name of the sender in the platform
content: dict = Field(sa_type=JSON, nullable=False) # a message chain list
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
class PlatformSession(TimestampMixin, SQLModel, table=True):
class PlatformSession(SQLModel, table=True):
"""Platform session table for managing user sessions across different platforms.
A session represents a chat window for a specific user on a specific platform.
@@ -217,6 +231,11 @@ class PlatformSession(TimestampMixin, SQLModel, table=True):
"""Display name for the session"""
is_group: int = Field(default=0, nullable=False)
"""0 for private chat, 1 for group chat (not implemented yet)"""
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
__table_args__ = (
UniqueConstraint(
@@ -226,7 +245,7 @@ class PlatformSession(TimestampMixin, SQLModel, table=True):
)
class Attachment(TimestampMixin, SQLModel, table=True):
class Attachment(SQLModel, table=True):
"""This class represents attachments for messages in AstrBot.
Attachments can be images, files, or other media types.
@@ -248,6 +267,11 @@ class Attachment(TimestampMixin, SQLModel, table=True):
path: str = Field(nullable=False) # Path to the file on disk
type: str = Field(nullable=False) # Type of the file (e.g., 'image', 'file')
mime_type: str = Field(nullable=False) # MIME type of the file
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
__table_args__ = (
UniqueConstraint(
@@ -257,7 +281,7 @@ class Attachment(TimestampMixin, SQLModel, table=True):
)
class ChatUIProject(TimestampMixin, SQLModel, table=True):
class ChatUIProject(SQLModel, table=True):
"""This class represents projects for organizing ChatUI conversations.
Projects allow users to group related conversations together.
@@ -284,6 +308,11 @@ class ChatUIProject(TimestampMixin, SQLModel, table=True):
"""Title of the project"""
description: str | None = Field(default=None, max_length=1000)
"""Description of the project"""
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
__table_args__ = (
UniqueConstraint(
@@ -307,6 +336,7 @@ class SessionProjectRelation(SQLModel, table=True):
"""Session ID from PlatformSession"""
project_id: str = Field(nullable=False, max_length=36)
"""Project ID from ChatUIProject"""
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
__table_args__ = (
UniqueConstraint(
@@ -316,7 +346,7 @@ class SessionProjectRelation(SQLModel, table=True):
)
class CommandConfig(TimestampMixin, SQLModel, table=True):
class CommandConfig(SQLModel, table=True):
"""Per-command configuration overrides for dashboard management."""
__tablename__ = "command_configs" # type: ignore
@@ -336,9 +366,14 @@ class CommandConfig(TimestampMixin, SQLModel, table=True):
note: str | None = Field(default=None, sa_type=Text)
extra_data: dict | None = Field(default=None, sa_type=JSON)
auto_managed: bool = Field(default=False, nullable=False)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
class CommandConflict(TimestampMixin, SQLModel, table=True):
class CommandConflict(SQLModel, table=True):
"""Conflict tracking for duplicated command names."""
__tablename__ = "command_conflicts" # type: ignore
@@ -355,6 +390,11 @@ class CommandConflict(TimestampMixin, SQLModel, table=True):
note: str | None = Field(default=None, sa_type=Text)
extra_data: dict | None = Field(default=None, sa_type=JSON)
auto_generated: bool = Field(default=False, nullable=False)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
__table_args__ = (
UniqueConstraint(
@@ -402,8 +442,6 @@ class Personality(TypedDict):
"""情感模拟对话预设。在 v4.0.0 版本及之后,已被废弃。"""
tools: list[str] | None
"""工具列表。None 表示使用所有工具,空列表表示不使用任何工具"""
skills: list[str] | None
"""Skills 列表。None 表示使用所有 Skills,空列表表示不使用任何 Skills"""
# cache
_begin_dialogs_processed: list[dict]
+1 -19
View File
@@ -52,9 +52,8 @@ class SQLiteDatabase(BaseDatabase):
await conn.execute(text("PRAGMA temp_store=MEMORY"))
await conn.execute(text("PRAGMA mmap_size=134217728"))
await conn.execute(text("PRAGMA optimize"))
# 确保 personas 表有 folder_idsort_order、skills 列(前向兼容)
# 确保 personas 表有 folder_idsort_order 列(前向兼容)
await self._ensure_persona_folder_columns(conn)
await self._ensure_persona_skills_column(conn)
await conn.commit()
async def _ensure_persona_folder_columns(self, conn) -> None:
@@ -77,18 +76,6 @@ class SQLiteDatabase(BaseDatabase):
text("ALTER TABLE personas ADD COLUMN sort_order INTEGER DEFAULT 0")
)
async def _ensure_persona_skills_column(self, conn) -> None:
"""确保 personas 表有 skills 列。
这是为了支持旧版数据库的平滑升级新版数据库通过 SQLModel
metadata.create_all 自动创建这些列
"""
result = await conn.execute(text("PRAGMA table_info(personas)"))
columns = {row[1] for row in result.fetchall()}
if "skills" not in columns:
await conn.execute(text("ALTER TABLE personas ADD COLUMN skills JSON"))
# ====
# Platform Statistics
# ====
@@ -577,7 +564,6 @@ class SQLiteDatabase(BaseDatabase):
system_prompt,
begin_dialogs=None,
tools=None,
skills=None,
folder_id=None,
sort_order=0,
):
@@ -590,7 +576,6 @@ class SQLiteDatabase(BaseDatabase):
system_prompt=system_prompt,
begin_dialogs=begin_dialogs or [],
tools=tools,
skills=skills,
folder_id=folder_id,
sort_order=sort_order,
)
@@ -621,7 +606,6 @@ class SQLiteDatabase(BaseDatabase):
system_prompt=None,
begin_dialogs=None,
tools=NOT_GIVEN,
skills=NOT_GIVEN,
):
"""Update a persona's system prompt or begin dialogs."""
async with self.get_db() as session:
@@ -635,8 +619,6 @@ class SQLiteDatabase(BaseDatabase):
values["begin_dialogs"] = begin_dialogs
if tools is not NOT_GIVEN:
values["tools"] = tools
if skills is not NOT_GIVEN:
values["skills"] = skills
if not values:
return None
query = query.values(**values)
-1
View File
@@ -54,7 +54,6 @@ class EventBus:
event (AstrMessageEvent): 事件对象
"""
event.trace.record("event_dispatch", config_name=conf_name)
# 如果有发送者名称: [平台名] 发送者名称/发送者ID: 消息概要
if event.get_sender_name():
logger.info(
+1 -189
View File
@@ -27,15 +27,13 @@ import sys
import time
from asyncio import Queue
from collections import deque
from logging.handlers import RotatingFileHandler
import colorlog
from astrbot.core.config.default import VERSION
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
# 日志缓存大小
CACHED_SIZE = 500
CACHED_SIZE = 200
# 日志颜色配置
log_color_config = {
"DEBUG": "green",
@@ -165,9 +163,6 @@ class LogManager:
提供了获取默认日志记录器logger和设置队列处理器的方法
"""
_FILE_HANDLER_FLAG = "_astrbot_file_handler"
_TRACE_FILE_HANDLER_FLAG = "_astrbot_trace_file_handler"
@classmethod
def GetLogger(cls, log_name: str = "default"):
"""获取指定名称的日志记录器logger
@@ -271,186 +266,3 @@ class LogManager:
),
)
logger.addHandler(handler)
@classmethod
def _default_log_path(cls) -> str:
return os.path.join(get_astrbot_data_path(), "logs", "astrbot.log")
@classmethod
def _resolve_log_path(cls, configured_path: str | None) -> str:
if not configured_path:
return cls._default_log_path()
if os.path.isabs(configured_path):
return configured_path
return os.path.join(get_astrbot_data_path(), configured_path)
@classmethod
def _get_file_handlers(cls, logger: logging.Logger) -> list[logging.Handler]:
return [
handler
for handler in logger.handlers
if getattr(handler, cls._FILE_HANDLER_FLAG, False)
]
@classmethod
def _get_trace_file_handlers(cls, logger: logging.Logger) -> list[logging.Handler]:
return [
handler
for handler in logger.handlers
if getattr(handler, cls._TRACE_FILE_HANDLER_FLAG, False)
]
@classmethod
def _remove_file_handlers(cls, logger: logging.Logger):
for handler in cls._get_file_handlers(logger):
logger.removeHandler(handler)
try:
handler.close()
except Exception:
pass
@classmethod
def _remove_trace_file_handlers(cls, logger: logging.Logger):
for handler in cls._get_trace_file_handlers(logger):
logger.removeHandler(handler)
try:
handler.close()
except Exception:
pass
@classmethod
def _add_file_handler(
cls,
logger: logging.Logger,
file_path: str,
max_mb: int | None = None,
backup_count: int = 3,
trace: bool = False,
):
os.makedirs(os.path.dirname(file_path) or ".", exist_ok=True)
max_bytes = 0
if max_mb and max_mb > 0:
max_bytes = max_mb * 1024 * 1024
if max_bytes > 0:
file_handler = RotatingFileHandler(
file_path,
maxBytes=max_bytes,
backupCount=backup_count,
encoding="utf-8",
)
else:
file_handler = logging.FileHandler(file_path, encoding="utf-8")
file_handler.setLevel(logger.level)
if trace:
formatter = logging.Formatter(
"[%(asctime)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
else:
formatter = logging.Formatter(
"[%(asctime)s] %(plugin_tag)s [%(short_levelname)s]%(astrbot_version_tag)s [%(filename)s:%(lineno)d]: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
file_handler.setFormatter(formatter)
setattr(
file_handler,
cls._TRACE_FILE_HANDLER_FLAG if trace else cls._FILE_HANDLER_FLAG,
True,
)
logger.addHandler(file_handler)
@classmethod
def configure_logger(
cls,
logger: logging.Logger,
config: dict | None,
override_level: str | None = None,
):
"""根据配置设置日志级别和文件日志。
Args:
logger: 需要配置的 logger
config: 配置字典
override_level: 若提供将覆盖配置中的日志级别
"""
if not config:
return
level = override_level or config.get("log_level")
if level:
try:
logger.setLevel(level)
except Exception:
logger.setLevel(logging.INFO)
# 兼容旧版嵌套配置
if "log_file" in config:
file_conf = config.get("log_file") or {}
enable_file = bool(file_conf.get("enable", False))
file_path = file_conf.get("path")
max_mb = file_conf.get("max_mb")
else:
enable_file = bool(config.get("log_file_enable", False))
file_path = config.get("log_file_path")
max_mb = config.get("log_file_max_mb")
file_path = cls._resolve_log_path(file_path)
existing = cls._get_file_handlers(logger)
if not enable_file:
cls._remove_file_handlers(logger)
return
# 如果已有文件处理器且路径一致,则仅同步级别
if existing:
handler = existing[0]
base = getattr(handler, "baseFilename", "")
if base and os.path.abspath(base) == os.path.abspath(file_path):
handler.setLevel(logger.level)
return
cls._remove_file_handlers(logger)
cls._add_file_handler(logger, file_path, max_mb=max_mb)
@classmethod
def configure_trace_logger(cls, config: dict | None):
"""为 trace 事件配置独立的文件日志,不向控制台输出。"""
if not config:
return
enable = bool(
config.get("trace_log_enable")
or (config.get("log_file", {}) or {}).get("trace_enable", False)
)
path = config.get("trace_log_path")
max_mb = config.get("trace_log_max_mb")
if "log_file" in config:
legacy = config.get("log_file") or {}
path = path or legacy.get("trace_path")
max_mb = max_mb or legacy.get("trace_max_mb")
if not enable:
trace_logger = logging.getLogger("astrbot.trace")
cls._remove_trace_file_handlers(trace_logger)
return
file_path = cls._resolve_log_path(path or "logs/astrbot.trace.log")
trace_logger = logging.getLogger("astrbot.trace")
trace_logger.setLevel(logging.INFO)
trace_logger.propagate = False
existing = cls._get_trace_file_handlers(trace_logger)
if existing:
handler = existing[0]
base = getattr(handler, "baseFilename", "")
if base and os.path.abspath(base) == os.path.abspath(file_path):
handler.setLevel(trace_logger.level)
return
cls._remove_trace_file_handlers(trace_logger)
cls._add_file_handler(
trace_logger,
file_path,
max_mb=max_mb,
trace=True,
)
+2 -2
View File
@@ -567,7 +567,7 @@ class Node(BaseMessageComponent):
async def to_dict(self):
data_content = []
for comp in self.content:
if isinstance(comp, Image | Record):
if isinstance(comp, (Image, Record)):
# For Image and Record segments, we convert them to base64
bs64 = await comp.convert_to_base64()
data_content.append(
@@ -584,7 +584,7 @@ class Node(BaseMessageComponent):
# For File segments, we need to handle the file differently
d = await comp.to_dict()
data_content.append(d)
elif isinstance(comp, Node | Nodes):
elif isinstance(comp, (Node, Nodes)):
# For Node segments, we recursively convert them to dict
d = await comp.to_dict()
data_content.append(d)
-8
View File
@@ -10,7 +10,6 @@ DEFAULT_PERSONALITY = Personality(
begin_dialogs=[],
mood_imitation_dialogs=[],
tools=None,
skills=None,
_begin_dialogs_processed=[],
_mood_imitation_dialogs_processed="",
)
@@ -72,7 +71,6 @@ class PersonaManager:
system_prompt: str | None = None,
begin_dialogs: list[str] | None = None,
tools: list[str] | None = None,
skills: list[str] | None = None,
):
"""更新指定 persona 的信息。tools 参数为 None 时表示使用所有工具,空列表表示不使用任何工具"""
existing_persona = await self.db.get_persona_by_id(persona_id)
@@ -83,7 +81,6 @@ class PersonaManager:
system_prompt,
begin_dialogs,
tools=tools,
skills=skills,
)
if persona:
for i, p in enumerate(self.personas):
@@ -242,7 +239,6 @@ class PersonaManager:
system_prompt: str,
begin_dialogs: list[str] | None = None,
tools: list[str] | None = None,
skills: list[str] | None = None,
folder_id: str | None = None,
sort_order: int = 0,
) -> Persona:
@@ -253,7 +249,6 @@ class PersonaManager:
system_prompt: 系统提示词
begin_dialogs: 预设对话列表
tools: 工具列表None 表示使用所有工具空列表表示不使用任何工具
skills: Skills 列表None 表示使用所有 Skills空列表表示不使用任何 Skills
folder_id: 所属文件夹 IDNone 表示根目录
sort_order: 排序顺序
"""
@@ -264,7 +259,6 @@ class PersonaManager:
system_prompt,
begin_dialogs,
tools=tools,
skills=skills,
folder_id=folder_id,
sort_order=sort_order,
)
@@ -290,7 +284,6 @@ class PersonaManager:
"begin_dialogs": persona.begin_dialogs or [],
"mood_imitation_dialogs": [], # deprecated
"tools": persona.tools,
"skills": persona.skills,
}
for persona in self.personas
]
@@ -346,7 +339,6 @@ class PersonaManager:
system_prompt=selected_default_persona["prompt"],
begin_dialogs=selected_default_persona["begin_dialogs"],
tools=selected_default_persona["tools"] or None,
skills=selected_default_persona["skills"] or None,
)
return v3_persona_config, personas_v3, selected_default_persona
+2 -2
View File
@@ -48,7 +48,7 @@ async def call_handler(
# 这里逐步执行异步生成器, 对于每个 yield 返回的 ret, 执行下面的代码
# 返回值只能是 MessageEventResult 或者 None(无返回值)
_has_yielded = True
if isinstance(ret, MessageEventResult | CommandResult):
if isinstance(ret, (MessageEventResult, CommandResult)):
# 如果返回值是 MessageEventResult, 设置结果并继续
event.set_result(ret)
yield
@@ -65,7 +65,7 @@ async def call_handler(
elif inspect.iscoroutine(ready_to_call):
# 如果只是一个协程, 直接执行
ret = await ready_to_call
if isinstance(ret, MessageEventResult | CommandResult):
if isinstance(ret, (MessageEventResult, CommandResult)):
event.set_result(ret)
yield
else:
@@ -52,7 +52,7 @@ class PreProcessStage(Stage):
message_chain = event.get_messages()
for idx, component in enumerate(message_chain):
if isinstance(component, Record | Image) and component.url:
if isinstance(component, (Record, Image)) and component.url:
for mapping in mappings:
from_, to_ = mapping.split(":")
from_ = from_.removesuffix("/")
@@ -46,7 +46,6 @@ from ...utils import (
PYTHON_TOOL,
SANDBOX_MODE_PROMPT,
TOOL_CALL_PROMPT,
TOOL_CALL_PROMPT_SKILLS_LIKE_MODE,
decoded_blocked,
retrieve_knowledge_base,
)
@@ -63,13 +62,6 @@ class InternalAgentSubStage(Stage):
]
self.max_step: int = settings.get("max_agent_step", 30)
self.tool_call_timeout: int = settings.get("tool_call_timeout", 60)
self.tool_schema_mode: str = settings.get("tool_schema_mode", "full")
if self.tool_schema_mode not in ("skills_like", "full"):
logger.warning(
"Unsupported tool_schema_mode: %s, fallback to skills_like",
self.tool_schema_mode,
)
self.tool_schema_mode = "full"
if isinstance(self.max_step, bool): # workaround: #2622
self.max_step = 30
self.show_tool_use: bool = settings.get("show_tool_use_status", True)
@@ -124,12 +116,8 @@ class InternalAgentSubStage(Stage):
if not provider:
logger.error(f"未找到指定的提供商: {sel_provider}")
return provider
try:
prov = _ctx.get_using_provider(umo=event.unified_msg_origin)
except ValueError as e:
logger.error(f"Error occurred while selecting provider: {e}")
return None
return prov
return _ctx.get_using_provider(umo=event.unified_msg_origin)
async def _get_session_conv(self, event: AstrMessageEvent) -> Conversation:
umo = event.unified_msg_origin
@@ -508,7 +496,6 @@ class InternalAgentSubStage(Stage):
try:
provider = self._select_provider(event)
if provider is None:
logger.info("未找到任何对话模型(提供商),跳过 LLM 请求处理。")
return
if not isinstance(provider, Provider):
logger.error(
@@ -525,7 +512,7 @@ class InternalAgentSubStage(Stage):
has_valid_message = bool(event.message_str and event.message_str.strip())
# 检查是否有图片或其他媒体内容
has_media_content = any(
isinstance(comp, Image | File) for comp in event.message_obj.message
isinstance(comp, (Image, File)) for comp in event.message_obj.message
)
if (
@@ -680,28 +667,12 @@ class InternalAgentSubStage(Stage):
# 注入基本 prompt
if req.func_tool and req.func_tool.tools:
tool_prompt = (
TOOL_CALL_PROMPT
if self.tool_schema_mode == "full"
else TOOL_CALL_PROMPT_SKILLS_LIKE_MODE
)
req.system_prompt += f"\n{tool_prompt}\n"
req.system_prompt += f"\n{TOOL_CALL_PROMPT}\n"
action_type = event.get_extra("action_type")
if action_type == "live":
req.system_prompt += f"\n{LIVE_MODE_SYSTEM_PROMPT}\n"
event.trace.record(
"astr_agent_prepare",
system_prompt=req.system_prompt,
tools=req.func_tool.names() if req.func_tool else [],
stream=streaming_response,
chat_provider={
"id": provider.provider_config.get("id", ""),
"model": provider.get_model(),
},
)
await agent_runner.reset(
provider=provider,
request=req,
@@ -717,7 +688,6 @@ class InternalAgentSubStage(Stage):
llm_compress_provider=self._get_compress_provider(),
truncate_turns=self.dequeue_context_length,
enforce_max_turns=self.max_context_length,
tool_schema_mode=self.tool_schema_mode,
)
# 检测 Live Mode
@@ -806,20 +776,12 @@ class InternalAgentSubStage(Stage):
):
yield
final_resp = agent_runner.get_final_llm_resp()
event.trace.record(
"astr_agent_complete",
stats=agent_runner.stats.to_dict(),
resp=final_resp.completion_text if final_resp else None,
)
# 检查事件是否被停止,如果被停止则不保存历史记录
if not event.is_stopped():
await self._save_to_history(
event,
req,
final_resp,
agent_runner.get_final_llm_resp(),
agent_runner.run_context.messages,
agent_runner.stats,
)
+3 -19
View File
@@ -7,11 +7,10 @@ from astrbot.api import logger, sp
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import FunctionTool, ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.computer.tools import (
from astrbot.core.sandbox.tools import (
ExecuteShellTool,
FileDownloadTool,
FileUploadTool,
LocalPythonTool,
PythonTool,
)
from astrbot.core.star.context import Context
@@ -40,23 +39,10 @@ SANDBOX_MODE_PROMPT = (
TOOL_CALL_PROMPT = (
"You MUST NOT return an empty response, especially after invoking a tool."
" Before calling any tool, provide a brief explanatory message to the user stating the purpose of the tool call."
" Use the provided tool schema to format arguments and do not guess parameters that are not defined."
" After the tool call is completed, you must briefly summarize the results returned by the tool for the user."
" Keep the role-play and style consistent throughout the conversation."
"Before calling any tool, provide a brief explanatory message to the user stating the purpose of the tool call."
"After the tool call is completed, you must briefly summarize the results returned by the tool for the user."
)
TOOL_CALL_PROMPT_SKILLS_LIKE_MODE = (
"You MUST NOT return an empty response, especially after invoking a tool."
" Before calling any tool, provide a brief explanatory message to the user stating the purpose of the tool call."
" Tool schemas are provided in two stages: first only name and description; "
"if you decide to use a tool, the full parameter schema will be provided in "
"a follow-up step. Do not guess arguments before you see the schema."
" After the tool call is completed, you must briefly summarize the results returned by the tool for the user."
" Keep the role-play and style consistent throughout the conversation."
)
CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT = (
"You are a calm, patient friend with a systems-oriented way of thinking.\n"
"When someone expresses strong emotional needs, you begin by offering a concise, grounding response "
@@ -207,9 +193,7 @@ async def retrieve_knowledge_base(
KNOWLEDGE_BASE_QUERY_TOOL = KnowledgeBaseQueryTool()
EXECUTE_SHELL_TOOL = ExecuteShellTool()
LOCAL_EXECUTE_SHELL_TOOL = ExecuteShellTool(is_local=True)
PYTHON_TOOL = PythonTool()
LOCAL_PYTHON_TOOL = LocalPythonTool()
FILE_UPLOAD_TOOL = FileUploadTool()
FILE_DOWNLOAD_TOOL = FileDownloadTool()
+1 -3
View File
@@ -82,9 +82,7 @@ class PipelineScheduler:
await self._process_stages(event)
# 如果没有发送操作, 则发送一个空消息, 以便于后续的处理
if isinstance(event, WebChatMessageEvent | WecomAIBotMessageEvent):
if isinstance(event, (WebChatMessageEvent, WecomAIBotMessageEvent)):
await event.send(None)
event.trace.record("event_end")
logger.debug("pipeline 执行完毕。")
@@ -165,6 +165,7 @@ class WakingCheckStage(Stage):
and handler.handler_module_path
== "astrbot.builtin_stars.builtin_commands.main"
):
logger.debug("skipping builtin command")
continue
# filter 需满足 AND 逻辑关系
@@ -4,7 +4,6 @@ import hashlib
import re
import uuid
from collections.abc import AsyncGenerator
from time import time
from typing import Any
from astrbot import logger
@@ -23,7 +22,6 @@ from astrbot.core.message.message_event_result import MessageChain, MessageEvent
from astrbot.core.platform.message_type import MessageType
from astrbot.core.provider.entities import ProviderRequest
from astrbot.core.utils.metrics import Metric
from astrbot.core.utils.trace import TraceSpan
from .astrbot_message import AstrBotMessage, Group
from .message_session import MessageSesion, MessageSession # noqa
@@ -61,21 +59,6 @@ class AstrMessageEvent(abc.ABC):
self._result: MessageEventResult | None = None
"""消息事件的结果"""
self.created_at = time()
"""事件创建时间(Unix timestamp)"""
self.trace = TraceSpan(
name="AstrMessageEvent",
umo=self.unified_msg_origin,
sender_name=self.get_sender_name(),
message_outline=self.get_message_outline(),
)
"""用于记录事件处理的 TraceSpan 对象"""
self.span = self.trace
"""事件级 TraceSpan(别名: span)"""
self.trace.record("umo", umo=self.unified_msg_origin)
self.trace.record("event_created", created_at=self.created_at)
self._has_send_oper = False
"""在此次事件中是否有过至少一次发送消息的操作"""
self.call_llm = False
@@ -33,7 +33,7 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
@staticmethod
async def _from_segment_to_dict(segment: BaseMessageComponent) -> dict:
"""修复部分字段"""
if isinstance(segment, Image | Record):
if isinstance(segment, (Image, Record)):
# For Image and Record segments, we convert them to base64
bs64 = await segment.convert_to_base64()
return {
@@ -110,7 +110,7 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
"""
# 转发消息、文件消息不能和普通消息混在一起发送
send_one_by_one = any(
isinstance(seg, Node | Nodes | File) for seg in message_chain.chain
isinstance(seg, (Node, Nodes, File)) for seg in message_chain.chain
)
if not send_one_by_one:
ret = await cls._parse_onebot_json(message_chain)
@@ -119,7 +119,7 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
await cls._dispatch_send(bot, event, is_group, session_id, ret)
return
for seg in message_chain.chain:
if isinstance(seg, Node | Nodes):
if isinstance(seg, (Node, Nodes)):
# 合并转发消息
if isinstance(seg, Node):
nodes = Nodes([seg])
@@ -62,44 +62,27 @@ class AiocqhttpAdapter(Platform):
@self.bot.on_request()
async def request(event: Event):
try:
abm = await self.convert_message(event)
if not abm:
return
abm = await self.convert_message(event)
if abm:
await self.handle_msg(abm)
except Exception as e:
logger.exception(f"Handle request message failed: {e}")
return
@self.bot.on_notice()
async def notice(event: Event):
try:
abm = await self.convert_message(event)
if abm:
await self.handle_msg(abm)
except Exception as e:
logger.exception(f"Handle notice message failed: {e}")
return
abm = await self.convert_message(event)
if abm:
await self.handle_msg(abm)
@self.bot.on_message("group")
async def group(event: Event):
try:
abm = await self.convert_message(event)
if abm:
await self.handle_msg(abm)
except Exception as e:
logger.exception(f"Handle group message failed: {e}")
return
abm = await self.convert_message(event)
if abm:
await self.handle_msg(abm)
@self.bot.on_message("private")
async def private(event: Event):
try:
abm = await self.convert_message(event)
if abm:
await self.handle_msg(abm)
except Exception as e:
logger.exception(f"Handle private message failed: {e}")
return
abm = await self.convert_message(event)
if abm:
await self.handle_msg(abm)
@self.bot.on_websocket_connection
def on_websocket_connection(_):
@@ -389,10 +372,9 @@ class AiocqhttpAdapter(Platform):
message_str += "".join(at_parts)
elif t == "markdown":
for m in m_group:
text = m["data"].get("markdown") or m["data"].get("content", "")
abm.message.append(Plain(text=text))
message_str += text
text = m["data"].get("markdown") or m["data"].get("content", "")
abm.message.append(Plain(text=text))
message_str += text
else:
for m in m_group:
try:
@@ -90,10 +90,12 @@ class QQOfficialMessageEvent(AstrMessageEvent):
if not isinstance(
source,
botpy.message.Message
| botpy.message.GroupMessage
| botpy.message.DirectMessage
| botpy.message.C2CMessage,
(
botpy.message.Message,
botpy.message.GroupMessage,
botpy.message.DirectMessage,
botpy.message.C2CMessage,
),
):
logger.warning(f"[QQOfficial] 不支持的消息源类型: {type(source)}")
return None
@@ -118,7 +120,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
"msg_id": self.message_obj.message_id,
}
if not isinstance(source, botpy.message.Message | botpy.message.DirectMessage):
if not isinstance(source, (botpy.message.Message, botpy.message.DirectMessage)):
payload["msg_seq"] = random.randint(1, 10000)
ret = None
-31
View File
@@ -1,6 +1,5 @@
import asyncio
import copy
import os
import traceback
from typing import Protocol, runtime_checkable
@@ -407,40 +406,10 @@ class ProviderManager:
pc = merged_config
return pc
def _resolve_env_key_list(self, provider_config: dict) -> dict:
keys = provider_config.get("key", [])
if not isinstance(keys, list):
return provider_config
resolved_keys = []
for idx, key in enumerate(keys):
if isinstance(key, str) and key.startswith("$"):
env_key = key[1:]
if env_key.startswith("{") and env_key.endswith("}"):
env_key = env_key[1:-1]
if env_key:
env_val = os.getenv(env_key)
if env_val is None:
provider_id = provider_config.get("id")
logger.warning(
f"Provider {provider_id} 配置项 key[{idx}] 使用环境变量 {env_key} 但未设置。",
)
resolved_keys.append("")
else:
resolved_keys.append(env_val)
else:
resolved_keys.append(key)
else:
resolved_keys.append(key)
provider_config["key"] = resolved_keys
return provider_config
async def load_provider(self, provider_config: dict):
# 如果 provider_source_id 存在且不为空,则从 provider_sources 中找到对应的配置并合并
provider_config = self.get_merged_provider_config(provider_config)
if provider_config.get("provider_type", "") == "chat_completion":
provider_config = self._resolve_env_key_list(provider_config)
if not provider_config["enable"]:
logger.info(f"Provider {provider_config['id']} is disabled, skipping")
return
+9 -12
View File
@@ -382,18 +382,15 @@ class ProviderGoogleGenAI(Provider):
append_or_extend(gemini_contents, parts, types.ModelContent)
elif role == "tool" and not native_tool_enabled:
func_name = message.get("name", message["tool_call_id"])
part = types.Part.from_function_response(
name=func_name,
response={
"name": func_name,
"content": message["content"],
},
)
if part.function_response:
part.function_response.id = message["tool_call_id"]
parts = [part]
parts = [
types.Part.from_function_response(
name=message["tool_call_id"],
response={
"name": message["tool_call_id"],
"content": message["content"],
},
),
]
append_or_extend(gemini_contents, parts, types.UserContent)
if gemini_contents and isinstance(gemini_contents[0], types.ModelContent):
+2 -16
View File
@@ -29,24 +29,10 @@ class GenieTTSProvider(TTSProvider):
if not genie:
raise ImportError("Please install genie_tts first.")
self.character_name = provider_config.get("genie_character_name", "mika")
language = provider_config.get("genie_language", "Japanese")
model_dir = provider_config.get("genie_onnx_model_dir", "")
refer_audio_path = provider_config.get("genie_refer_audio_path", "")
refer_text = provider_config.get("genie_refer_text", "")
self.character_name = provider_config.get("character_name", "mika")
try:
genie.load_character(
character_name=self.character_name,
language=language,
onnx_model_dir=model_dir,
)
genie.set_reference_audio(
character_name=self.character_name,
audio_path=refer_audio_path,
audio_text=refer_text,
language=language,
)
genie.load_predefined_character(self.character_name)
except Exception as e:
raise RuntimeError(f"Failed to load character {self.character_name}: {e}")
@@ -1,7 +1,7 @@
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
class ComputerBooter:
class SandboxBooter:
@property
def fs(self) -> FileSystemComponent: ...
@@ -16,16 +16,16 @@ class ComputerBooter:
async def shutdown(self) -> None: ...
async def upload_file(self, path: str, file_name: str) -> dict:
"""Upload file to the computer.
"""Upload file to sandbox.
Should return a dict with `success` (bool) and `file_path` (str) keys.
"""
...
async def download_file(self, remote_path: str, local_path: str):
"""Download file from the computer."""
"""Download file from sandbox."""
...
async def available(self) -> bool:
"""Check if the computer is available."""
"""Check if the sandbox is available."""
...
@@ -11,7 +11,7 @@ from shipyard.shell import ShellComponent as ShipyardShellComponent
from astrbot.api import logger
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
from .base import ComputerBooter
from .base import SandboxBooter
class MockShipyardSandboxClient:
@@ -124,7 +124,7 @@ class MockShipyardSandboxClient:
loop -= 1
class BoxliteBooter(ComputerBooter):
class BoxliteBooter(SandboxBooter):
async def boot(self, session_id: str) -> None:
logger.info(
f"Booting(Boxlite) for session: {session_id}, this may take a while..."
@@ -3,10 +3,10 @@ from shipyard import ShipyardClient, Spec
from astrbot.api import logger
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
from .base import ComputerBooter
from .base import SandboxBooter
class ShipyardBooter(ComputerBooter):
class ShipyardBooter(SandboxBooter):
def __init__(
self,
endpoint_url: str,
+52
View File
@@ -0,0 +1,52 @@
import uuid
from astrbot.api import logger
from astrbot.core.star.context import Context
from .booters.base import SandboxBooter
session_booter: dict[str, SandboxBooter] = {}
async def get_booter(
context: Context,
session_id: str,
) -> SandboxBooter:
config = context.get_config(umo=session_id)
sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {})
booter_type = sandbox_cfg.get("booter", "shipyard")
if session_id in session_booter:
booter = session_booter[session_id]
if not await booter.available():
# rebuild
session_booter.pop(session_id, None)
if session_id not in session_booter:
uuid_str = uuid.uuid5(uuid.NAMESPACE_DNS, session_id).hex
if booter_type == "shipyard":
from .booters.shipyard import ShipyardBooter
ep = sandbox_cfg.get("shipyard_endpoint", "")
token = sandbox_cfg.get("shipyard_access_token", "")
ttl = sandbox_cfg.get("shipyard_ttl", 3600)
max_sessions = sandbox_cfg.get("shipyard_max_sessions", 10)
client = ShipyardBooter(
endpoint_url=ep, access_token=token, ttl=ttl, session_num=max_sessions
)
elif booter_type == "boxlite":
from .booters.boxlite import BoxliteBooter
client = BoxliteBooter()
else:
raise ValueError(f"Unknown booter type: {booter_type}")
try:
await client.boot(uuid_str)
except Exception as e:
logger.error(f"Error booting sandbox for session {session_id}: {e}")
raise e
session_booter[session_id] = client
return session_booter[session_id]
@@ -1,11 +1,10 @@
from .fs import FileDownloadTool, FileUploadTool
from .python import LocalPythonTool, PythonTool
from .python import PythonTool
from .shell import ExecuteShellTool
__all__ = [
"FileUploadTool",
"PythonTool",
"LocalPythonTool",
"ExecuteShellTool",
"FileDownloadTool",
]
@@ -9,7 +9,7 @@ from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.message.components import File
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from ..computer_client import get_booter
from ..sandbox_client import get_booter
# @dataclass
# class CreateFileTool(FunctionTool):
+74
View File
@@ -0,0 +1,74 @@
from dataclasses import dataclass, field
import mcp
from astrbot.api import FunctionTool
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.sandbox.sandbox_client import get_booter
@dataclass
class PythonTool(FunctionTool):
name: str = "astrbot_execute_ipython"
description: str = "Execute a command in an IPython shell."
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "The Python code to execute.",
},
"silent": {
"type": "boolean",
"description": "Whether to suppress the output of the code execution.",
"default": False,
},
},
"required": ["code"],
}
)
async def call(
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
) -> ToolExecResult:
sb = await get_booter(
context.context.context,
context.context.event.unified_msg_origin,
)
try:
result = await sb.python.exec(code, silent=silent)
data = result.get("data", {})
output = data.get("output", {})
error = data.get("error", "")
images: list[dict] = output.get("images", [])
text: str = output.get("text", "")
resp = mcp.types.CallToolResult(content=[])
if error:
resp.content.append(
mcp.types.TextContent(type="text", text=f"error: {error}")
)
if images:
for img in images:
resp.content.append(
mcp.types.ImageContent(
type="image", data=img["image/png"], mimeType="image/png"
)
)
if text:
resp.content.append(mcp.types.TextContent(type="text", text=text))
if not resp.content:
resp.content.append(
mcp.types.TextContent(type="text", text="No output.")
)
return resp
except Exception as e:
return f"Error executing code: {str(e)}"
@@ -6,7 +6,7 @@ from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext
from ..computer_client import get_booter, get_local_booter
from ..sandbox_client import get_booter
@dataclass
@@ -37,8 +37,6 @@ class ExecuteShellTool(FunctionTool):
}
)
is_local: bool = False
async def call(
self,
context: ContextWrapper[AstrAgentContext],
@@ -46,16 +44,10 @@ class ExecuteShellTool(FunctionTool):
background: bool = False,
env: dict = {},
) -> ToolExecResult:
if context.context.event.role != "admin":
return "error: Permission denied. Shell execution is only allowed for admin users. Set admins in AstrBot WebUI."
if self.is_local:
sb = get_local_booter()
else:
sb = await get_booter(
context.context.context,
context.context.event.unified_msg_origin,
)
sb = await get_booter(
context.context.context,
context.context.event.unified_msg_origin,
)
try:
result = await sb.shell.exec(command, background=background, env=env)
return json.dumps(result)
-3
View File
@@ -1,3 +0,0 @@
from .skill_manager import SkillInfo, SkillManager, build_skills_prompt
__all__ = ["SkillInfo", "SkillManager", "build_skills_prompt"]
-237
View File
@@ -1,237 +0,0 @@
from __future__ import annotations
import json
import os
import re
import shutil
import tempfile
import zipfile
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from astrbot.core.utils.astrbot_path import (
get_astrbot_data_path,
get_astrbot_skills_path,
get_astrbot_temp_path,
)
SKILLS_CONFIG_FILENAME = "skills.json"
DEFAULT_SKILLS_CONFIG: dict[str, dict] = {"skills": {}}
SANDBOX_SKILLS_ROOT = "/home/shared/skills"
_SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
@dataclass
class SkillInfo:
name: str
description: str
path: str
active: bool
def _parse_frontmatter_description(text: str) -> str:
if not text.startswith("---"):
return ""
lines = text.splitlines()
if not lines or lines[0].strip() != "---":
return ""
end_idx = None
for i in range(1, len(lines)):
if lines[i].strip() == "---":
end_idx = i
break
if end_idx is None:
return ""
for line in lines[1:end_idx]:
if ":" not in line:
continue
key, value = line.split(":", 1)
if key.strip().lower() == "description":
return value.strip().strip('"').strip("'")
return ""
def build_skills_prompt(skills: list[SkillInfo]) -> str:
skills_lines = []
for skill in skills:
description = skill.description or "No description"
skills_lines.append(f"- {skill.name}: {description} (file: {skill.path})")
skills_block = "\n".join(skills_lines)
# Based on openai/codex
return (
"## Skills\n"
"A skill is a set of local instructions stored in a `SKILL.md` file.\n"
"### Available skills\n"
f"{skills_block}\n"
"### Skill Rules\n"
"\n"
"- Discovery: The list above shows all skills available in this session. Full instructions live in the referenced `SKILL.md`.\n"
"- Trigger rules: Use a skill if the user names it or the task matches its description. Do not carry skills across turns unless re-mentioned\n"
"- Unavailable: If a skill is missing or unreadable, say so and fallback.\n"
"### How to use a skill (progressive disclosure):\n"
" 1) After deciding to use a skill, open its `SKILL.md` and read only what is necessary to follow the workflow.\n"
" 2) Load only directly referenced files, DO NOT bulk-load everything.\n"
" 3) If `scripts/` exist, prefer running or patching them instead of retyping large blocks of code.\n"
" 4) If `assets/` or templates exist, reuse them rather than recreating everything from scratch.\n"
"- Coordination:\n"
" - If multiple skills apply, choose the minimal set that covers the request and state the order in which you will use them.\n"
" - Announce which skill(s) you are using and why (one short line). If you skip an obvious skill, explain why.\n"
" - Prefer to use `astrbot_*` tools to perform skills that need to run scripts.\n"
"- Context hygiene:\n"
" - Keep context small: summarize long sections instead of pasting them, and load extra files only when necessary.\n"
" - Avoid deep reference chasing: unless blocked, open only files that are directly linked from `SKILL.md`.\n"
" - When variants exist (frameworks, providers, domains), select only the relevant reference file(s) and note that choice.\n"
"- Failure handling: If a skill cannot be applied, state the issue and continue with the best alternative."
)
class SkillManager:
def __init__(self, skills_root: str | None = None) -> None:
self.skills_root = skills_root or get_astrbot_skills_path()
self.config_path = os.path.join(get_astrbot_data_path(), SKILLS_CONFIG_FILENAME)
os.makedirs(self.skills_root, exist_ok=True)
os.makedirs(get_astrbot_temp_path(), exist_ok=True)
def _load_config(self) -> dict:
if not os.path.exists(self.config_path):
self._save_config(DEFAULT_SKILLS_CONFIG.copy())
return DEFAULT_SKILLS_CONFIG.copy()
with open(self.config_path, encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict) or "skills" not in data:
return DEFAULT_SKILLS_CONFIG.copy()
return data
def _save_config(self, config: dict) -> None:
with open(self.config_path, "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=4)
def list_skills(
self,
*,
active_only: bool = False,
runtime: str = "local",
show_sandbox_path: bool = True,
) -> list[SkillInfo]:
"""List all skills.
show_sandbox_path: If True and runtime is "sandbox",
return the path as it would appear in the sandbox environment,
otherwise return the local filesystem path.
"""
config = self._load_config()
skill_configs = config.get("skills", {})
modified = False
skills: list[SkillInfo] = []
for entry in sorted(Path(self.skills_root).iterdir()):
if not entry.is_dir():
continue
skill_name = entry.name
skill_md = entry / "SKILL.md"
if not skill_md.exists():
continue
active = skill_configs.get(skill_name, {}).get("active", True)
if skill_name not in skill_configs:
skill_configs[skill_name] = {"active": active}
modified = True
if active_only and not active:
continue
description = ""
try:
content = skill_md.read_text(encoding="utf-8")
description = _parse_frontmatter_description(content)
except Exception:
description = ""
if runtime == "sandbox" and show_sandbox_path:
path_str = f"{SANDBOX_SKILLS_ROOT}/{skill_name}/SKILL.md"
else:
path_str = str(skill_md)
path_str = path_str.replace("\\", "/")
skills.append(
SkillInfo(
name=skill_name,
description=description,
path=path_str,
active=active,
)
)
if modified:
config["skills"] = skill_configs
self._save_config(config)
return skills
def set_skill_active(self, name: str, active: bool) -> None:
config = self._load_config()
config.setdefault("skills", {})
config["skills"][name] = {"active": bool(active)}
self._save_config(config)
def delete_skill(self, name: str) -> None:
skill_dir = Path(self.skills_root) / name
if skill_dir.exists():
shutil.rmtree(skill_dir)
config = self._load_config()
if name in config.get("skills", {}):
config["skills"].pop(name, None)
self._save_config(config)
def install_skill_from_zip(self, zip_path: str, *, overwrite: bool = True) -> str:
zip_path_obj = Path(zip_path)
if not zip_path_obj.exists():
raise FileNotFoundError(f"Zip file not found: {zip_path}")
if not zipfile.is_zipfile(zip_path):
raise ValueError("Uploaded file is not a valid zip archive.")
with zipfile.ZipFile(zip_path) as zf:
names = [name.replace("\\", "/") for name in zf.namelist()]
file_names = [name for name in names if name and not name.endswith("/")]
if not file_names:
raise ValueError("Zip archive is empty.")
top_dirs = {
PurePosixPath(name).parts[0] for name in file_names if name.strip()
}
print(top_dirs)
if len(top_dirs) != 1:
raise ValueError("Zip archive must contain a single top-level folder.")
skill_name = next(iter(top_dirs))
if skill_name in {".", "..", ""} or not _SKILL_NAME_RE.match(skill_name):
raise ValueError("Invalid skill folder name.")
for name in names:
if not name:
continue
if name.startswith("/") or re.match(r"^[A-Za-z]:", name):
raise ValueError("Zip archive contains absolute paths.")
parts = PurePosixPath(name).parts
if ".." in parts:
raise ValueError("Zip archive contains invalid relative paths.")
if parts and parts[0] != skill_name:
raise ValueError(
"Zip archive contains unexpected top-level entries."
)
if (
f"{skill_name}/SKILL.md" not in file_names
and f"{skill_name}/skill.md" not in file_names
):
raise ValueError("SKILL.md not found in the skill folder.")
with tempfile.TemporaryDirectory(dir=get_astrbot_temp_path()) as tmp_dir:
zf.extractall(tmp_dir)
src_dir = Path(tmp_dir) / skill_name
if not src_dir.exists():
raise ValueError("Skill folder not found after extraction.")
dest_dir = Path(self.skills_root) / skill_name
if dest_dir.exists():
if not overwrite:
raise FileExistsError("Skill already exists.")
shutil.rmtree(dest_dir)
shutil.move(str(src_dir), str(dest_dir))
self.set_skill_active(skill_name, True)
return skill_name
+1 -1
View File
@@ -303,7 +303,7 @@ def _locate_primary_filter(
handler: StarHandlerMetadata,
) -> CommandFilter | CommandGroupFilter | None:
for filter_ref in handler.event_filters:
if isinstance(filter_ref, CommandFilter | CommandGroupFilter):
if isinstance(filter_ref, (CommandFilter, CommandGroupFilter)):
return filter_ref
return None
+1 -1
View File
@@ -38,7 +38,7 @@ def put_config(namespace: str, name: str, key: str, value, description: str):
raise ValueError("namespace 不能以 internal_ 开头。")
if not isinstance(key, str):
raise ValueError("key 只支持 str 类型。")
if not isinstance(value, str | int | float | bool | list):
if not isinstance(value, (str, int, float, bool, list)):
raise ValueError("value 只支持 str, int, float, bool, list 类型。")
config_dir = os.path.join(get_astrbot_data_path(), "config")
+8 -9
View File
@@ -328,29 +328,28 @@ class Context:
"""获取所有用于 Embedding 任务的 Provider。"""
return self.provider_manager.embedding_provider_insts
def get_using_provider(self, umo: str | None = None) -> Provider | None:
def get_using_provider(self, umo: str | None = None) -> Provider:
"""获取当前使用的用于文本生成任务的 LLM Provider(Chat_Completion 类型)。
Args:
umo: unified_message_origin 如果传入并且用户启用了提供商会话隔离
则使用该会话偏好的对话模型提供商
则使用该会话偏好的提供商
Returns:
当前使用的对话模型提供商如果未设置则返回 None
当前使用的文本生成提供者
Raises:
ValueError: 该会话来源配置的的对话模型提供商的类型不正确
ValueError: 返回的提供者不是 Provider 类型
Note:
通过 /provider 指令可以切换提供者
"""
prov = self.provider_manager.get_using_provider(
provider_type=ProviderType.CHAT_COMPLETION,
umo=umo,
)
if prov is None:
return None
if not isinstance(prov, Provider):
raise ValueError(
f"该会话来源的对话模型(提供商)的类型不正确: {type(prov)}"
)
raise ValueError("返回的 Provider 不是 Provider 类型")
return prov
def get_using_tts_provider(self, umo: str | None = None) -> TTSProvider | None:
+1 -1
View File
@@ -115,7 +115,7 @@ class CommandFilter(HandlerFilter):
# 没有 GreedyStr 的情况
if i >= len(params):
if (
isinstance(param_type_or_default_val, type | types.UnionType)
isinstance(param_type_or_default_val, (type, types.UnionType))
or typing.get_origin(param_type_or_default_val) is typing.Union
or param_type_or_default_val is inspect.Parameter.empty
):
+2 -2
View File
@@ -37,7 +37,7 @@ class CustomFilter(HandlerFilter, metaclass=CustomFilterMeta):
class CustomFilterOr(CustomFilter):
def __init__(self, filter1: CustomFilter, filter2: CustomFilter):
super().__init__()
if not isinstance(filter1, CustomFilter | CustomFilterAnd | CustomFilterOr):
if not isinstance(filter1, (CustomFilter, CustomFilterAnd, CustomFilterOr)):
raise ValueError(
"CustomFilter lass can only operate with other CustomFilter.",
)
@@ -51,7 +51,7 @@ class CustomFilterOr(CustomFilter):
class CustomFilterAnd(CustomFilter):
def __init__(self, filter1: CustomFilter, filter2: CustomFilter):
super().__init__()
if not isinstance(filter1, CustomFilter | CustomFilterAnd | CustomFilterOr):
if not isinstance(filter1, (CustomFilter, CustomFilterAnd, CustomFilterOr)):
raise ValueError(
"CustomFilter lass can only operate with other CustomFilter.",
)
+1 -1
View File
@@ -150,7 +150,7 @@ def register_custom_filter(custom_type_filter, *args, **kwargs):
if args:
raise_error = args[0]
if not isinstance(custom_filter, CustomFilterAnd | CustomFilterOr):
if not isinstance(custom_filter, (CustomFilterAnd, CustomFilterOr)):
custom_filter = custom_filter(raise_error)
def decorator(awaitable):
-6
View File
@@ -9,7 +9,6 @@
T2I 模板目录路径固定为数据目录下的 t2i_templates 目录
WebChat 数据目录路径固定为数据目录下的 webchat 目录
临时文件目录路径固定为数据目录下的 temp 目录
Skills 目录路径固定为数据目录下的 skills 目录
"""
import os
@@ -64,11 +63,6 @@ def get_astrbot_temp_path() -> str:
return os.path.realpath(os.path.join(get_astrbot_data_path(), "temp"))
def get_astrbot_skills_path() -> str:
"""获取Astrbot Skills 目录路径"""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "skills"))
def get_astrbot_knowledge_base_path() -> str:
"""获取Astrbot知识库根目录路径"""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "knowledge_base"))
-73
View File
@@ -1,73 +0,0 @@
import json
import logging
import time
import uuid
from typing import Any
from astrbot import logger
from astrbot.core import LogManager, astrbot_config
from astrbot.core.log import LogQueueHandler
_cached_log_broker = None
_trace_logger = None
def _get_log_broker():
global _cached_log_broker
if _cached_log_broker is not None:
return _cached_log_broker
for handler in logger.handlers:
if isinstance(handler, LogQueueHandler):
_cached_log_broker = handler.log_broker
return _cached_log_broker
return None
def _get_trace_logger():
global _trace_logger
if _trace_logger is not None:
return _trace_logger
# 按配置初始化 trace 文件日志
LogManager.configure_trace_logger(astrbot_config)
_trace_logger = logging.getLogger("astrbot.trace")
return _trace_logger
class TraceSpan:
def __init__(
self,
name: str,
umo: str | None = None,
sender_name: str | None = None,
message_outline: str | None = None,
) -> None:
self.span_id = str(uuid.uuid4())
self.name = name
self.umo = umo
self.sender_name = sender_name
self.message_outline = message_outline
self.started_at = time.time()
def record(self, action: str, **fields: Any) -> None:
payload = {
"type": "trace",
"level": "TRACE",
"time": time.time(),
"span_id": self.span_id,
"name": self.name,
"umo": self.umo,
"sender_name": self.sender_name,
"message_outline": self.message_outline,
"action": action,
"fields": fields,
}
log_broker = _get_log_broker()
if log_broker:
log_broker.publish(payload)
else:
logger.info(f"[trace] {payload}")
trace_logger = _get_trace_logger()
if trace_logger and trace_logger.handlers:
trace_logger.info(json.dumps(payload, ensure_ascii=False))
-2
View File
@@ -12,7 +12,6 @@ from .persona import PersonaRoute
from .platform import PlatformRoute
from .plugin import PluginRoute
from .session_management import SessionManagementRoute
from .skills import SkillsRoute
from .stat import StatRoute
from .static_file import StaticFileRoute
from .tools import ToolsRoute
@@ -36,6 +35,5 @@ __all__ = [
"StatRoute",
"StaticFileRoute",
"ToolsRoute",
"SkillsRoute",
"UpdateRoute",
]
+2 -236
View File
@@ -2,7 +2,6 @@ import asyncio
import inspect
import os
import traceback
from pathlib import Path
from typing import Any
from quart import request
@@ -21,22 +20,11 @@ from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.platform.register import platform_cls_map, platform_registry
from astrbot.core.provider import Provider
from astrbot.core.provider.register import provider_registry
from astrbot.core.star.star import StarMetadata, star_registry
from astrbot.core.utils.astrbot_path import (
get_astrbot_plugin_data_path,
)
from astrbot.core.star.star import star_registry
from astrbot.core.utils.llm_metadata import LLM_METADATAS
from astrbot.core.utils.webhook_utils import ensure_platform_webhook_config
from .route import Response, Route, RouteContext
from .util import (
config_key_to_folder,
get_schema_item,
normalize_rel_path,
sanitize_filename,
)
MAX_FILE_BYTES = 500 * 1024 * 1024
def try_cast(value: Any, type_: str):
@@ -118,32 +106,6 @@ def validate_config(data, schema: dict, is_core: bool) -> tuple[list[str], dict]
_validate_template_list(value, meta, f"{path}{key}", errors, validate)
continue
if meta["type"] == "file":
if not _expect_type(value, list, f"{path}{key}", errors, "list"):
continue
for idx, item in enumerate(value):
if not isinstance(item, str):
errors.append(
f"Invalid type {path}{key}[{idx}]: expected string, got {type(item).__name__}",
)
continue
normalized = normalize_rel_path(item)
if not normalized or not normalized.startswith("files/"):
errors.append(
f"Invalid file path {path}{key}[{idx}]: {item}",
)
continue
key_path = f"{path}{key}"
expected_folder = config_key_to_folder(key_path)
expected_prefix = f"files/{expected_folder}/"
if not normalized.startswith(expected_prefix):
errors.append(
f"Invalid file path {path}{key}[{idx}]: {item}",
)
continue
value[idx] = normalized
continue
if meta["type"] == "list" and not isinstance(value, list):
errors.append(
f"错误的类型 {path}{key}: 期望是 list, 得到了 {type(value).__name__}",
@@ -256,9 +218,6 @@ class ConfigRoute(Route):
"/config/default": ("GET", self.get_default_config),
"/config/astrbot/update": ("POST", self.post_astrbot_configs),
"/config/plugin/update": ("POST", self.post_plugin_configs),
"/config/file/upload": ("POST", self.upload_config_file),
"/config/file/delete": ("POST", self.delete_config_file),
"/config/file/get": ("GET", self.get_config_file_list),
"/config/platform/new": ("POST", self.post_new_platform),
"/config/platform/update": ("POST", self.post_update_platform),
"/config/platform/delete": ("POST", self.post_delete_platform),
@@ -917,193 +876,6 @@ class ConfigRoute(Route):
except Exception as e:
return Response().error(str(e)).__dict__
def _get_plugin_metadata_by_name(self, plugin_name: str) -> StarMetadata | None:
for plugin_md in star_registry:
if plugin_md.name == plugin_name:
return plugin_md
return None
def _resolve_config_file_scope(
self,
) -> tuple[str, str, str, StarMetadata, AstrBotConfig]:
"""将请求参数解析为一个明确的配置作用域。
当前支持的 scope
- scope=pluginname=<plugin_name>key=<config_key_path>
"""
scope = request.args.get("scope") or "plugin"
name = request.args.get("name")
key_path = request.args.get("key")
if scope != "plugin":
raise ValueError(f"Unsupported scope: {scope}")
if not name or not key_path:
raise ValueError("Missing name or key parameter")
md = self._get_plugin_metadata_by_name(name)
if not md or not md.config:
raise ValueError(f"Plugin {name} not found or has no config")
return scope, name, key_path, md, md.config
async def upload_config_file(self):
"""上传文件到插件数据目录(用于某个 file 类型配置项)。"""
try:
scope, name, key_path, md, config = self._resolve_config_file_scope()
except ValueError as e:
return Response().error(str(e)).__dict__
meta = get_schema_item(getattr(config, "schema", None), key_path)
if not meta or meta.get("type") != "file":
return Response().error("Config item not found or not file type").__dict__
file_types = meta.get("file_types")
allowed_exts: list[str] = []
if isinstance(file_types, list):
allowed_exts = [
str(ext).lstrip(".").lower() for ext in file_types if str(ext).strip()
]
files = await request.files
if not files:
return Response().error("No files uploaded").__dict__
storage_root_path = Path(get_astrbot_plugin_data_path()).resolve(strict=False)
plugin_root_path = (storage_root_path / name).resolve(strict=False)
try:
plugin_root_path.relative_to(storage_root_path)
except ValueError:
return Response().error("Invalid name parameter").__dict__
plugin_root_path.mkdir(parents=True, exist_ok=True)
uploaded: list[str] = []
folder = config_key_to_folder(key_path)
errors: list[str] = []
for file in files.values():
filename = sanitize_filename(file.filename or "")
if not filename:
errors.append("Invalid filename")
continue
file_size = getattr(file, "content_length", None)
if isinstance(file_size, int) and file_size > MAX_FILE_BYTES:
errors.append(f"File too large: {filename}")
continue
ext = os.path.splitext(filename)[1].lstrip(".").lower()
if allowed_exts and ext not in allowed_exts:
errors.append(f"Unsupported file type: {filename}")
continue
rel_path = f"files/{folder}/{filename}"
save_path = (plugin_root_path / rel_path).resolve(strict=False)
try:
save_path.relative_to(plugin_root_path)
except ValueError:
errors.append(f"Invalid path: {filename}")
continue
save_path.parent.mkdir(parents=True, exist_ok=True)
await file.save(str(save_path))
if save_path.is_file() and save_path.stat().st_size > MAX_FILE_BYTES:
save_path.unlink()
errors.append(f"File too large: {filename}")
continue
uploaded.append(rel_path)
if not uploaded:
return (
Response()
.error(
"Upload failed: " + ", ".join(errors)
if errors
else "Upload failed",
)
.__dict__
)
return Response().ok({"uploaded": uploaded, "errors": errors}).__dict__
async def delete_config_file(self):
"""删除插件数据目录中的文件。"""
scope = request.args.get("scope") or "plugin"
name = request.args.get("name")
if not name:
return Response().error("Missing name parameter").__dict__
if scope != "plugin":
return Response().error(f"Unsupported scope: {scope}").__dict__
data = await request.get_json()
rel_path = data.get("path") if isinstance(data, dict) else None
rel_path = normalize_rel_path(rel_path)
if not rel_path or not rel_path.startswith("files/"):
return Response().error("Invalid path parameter").__dict__
md = self._get_plugin_metadata_by_name(name)
if not md:
return Response().error(f"Plugin {name} not found").__dict__
storage_root_path = Path(get_astrbot_plugin_data_path()).resolve(strict=False)
plugin_root_path = (storage_root_path / name).resolve(strict=False)
try:
plugin_root_path.relative_to(storage_root_path)
except ValueError:
return Response().error("Invalid name parameter").__dict__
target_path = (plugin_root_path / rel_path).resolve(strict=False)
try:
target_path.relative_to(plugin_root_path)
except ValueError:
return Response().error("Invalid path parameter").__dict__
if target_path.is_file():
target_path.unlink()
return Response().ok(None, "Deleted").__dict__
async def get_config_file_list(self):
"""获取配置项对应目录下的文件列表。"""
try:
_, name, key_path, _, config = self._resolve_config_file_scope()
except ValueError as e:
return Response().error(str(e)).__dict__
meta = get_schema_item(getattr(config, "schema", None), key_path)
if not meta or meta.get("type") != "file":
return Response().error("Config item not found or not file type").__dict__
storage_root_path = Path(get_astrbot_plugin_data_path()).resolve(strict=False)
plugin_root_path = (storage_root_path / name).resolve(strict=False)
try:
plugin_root_path.relative_to(storage_root_path)
except ValueError:
return Response().error("Invalid name parameter").__dict__
folder = config_key_to_folder(key_path)
target_dir = (plugin_root_path / "files" / folder).resolve(strict=False)
try:
target_dir.relative_to(plugin_root_path)
except ValueError:
return Response().error("Invalid path parameter").__dict__
if not target_dir.exists() or not target_dir.is_dir():
return Response().ok({"files": []}).__dict__
files: list[str] = []
for path in target_dir.rglob("*"):
if not path.is_file():
continue
try:
rel_path = path.relative_to(plugin_root_path).as_posix()
except ValueError:
continue
if rel_path.startswith("files/"):
files.append(rel_path)
return Response().ok({"files": files}).__dict__
async def post_new_platform(self):
new_platform_config = await request.json
@@ -1358,14 +1130,8 @@ class ConfigRoute(Route):
raise ValueError(f"插件 {plugin_name} 不存在")
if not md.config:
raise ValueError(f"插件 {plugin_name} 没有注册配置")
assert md.config is not None
try:
errors, post_configs = validate_config(
post_configs, getattr(md.config, "schema", {}), is_core=False
)
if errors:
raise ValueError(f"格式校验未通过: {errors}")
md.config.save_config(post_configs)
save_config(post_configs, md.config)
except Exception as e:
raise e
-7
View File
@@ -57,7 +57,6 @@ class PersonaRoute(Route):
"system_prompt": persona.system_prompt,
"begin_dialogs": persona.begin_dialogs or [],
"tools": persona.tools,
"skills": persona.skills,
"folder_id": persona.folder_id,
"sort_order": persona.sort_order,
"created_at": persona.created_at.isoformat()
@@ -97,7 +96,6 @@ class PersonaRoute(Route):
"system_prompt": persona.system_prompt,
"begin_dialogs": persona.begin_dialogs or [],
"tools": persona.tools,
"skills": persona.skills,
"folder_id": persona.folder_id,
"sort_order": persona.sort_order,
"created_at": persona.created_at.isoformat()
@@ -122,7 +120,6 @@ class PersonaRoute(Route):
system_prompt = data.get("system_prompt", "").strip()
begin_dialogs = data.get("begin_dialogs", [])
tools = data.get("tools")
skills = data.get("skills")
folder_id = data.get("folder_id") # None 表示根目录
sort_order = data.get("sort_order", 0)
@@ -145,7 +142,6 @@ class PersonaRoute(Route):
system_prompt=system_prompt,
begin_dialogs=begin_dialogs if begin_dialogs else None,
tools=tools if tools else None,
skills=skills if skills else None,
folder_id=folder_id,
sort_order=sort_order,
)
@@ -160,7 +156,6 @@ class PersonaRoute(Route):
"system_prompt": persona.system_prompt,
"begin_dialogs": persona.begin_dialogs or [],
"tools": persona.tools or [],
"skills": persona.skills or [],
"folder_id": persona.folder_id,
"sort_order": persona.sort_order,
"created_at": persona.created_at.isoformat()
@@ -188,7 +183,6 @@ class PersonaRoute(Route):
system_prompt = data.get("system_prompt")
begin_dialogs = data.get("begin_dialogs")
tools = data.get("tools")
skills = data.get("skills")
if not persona_id:
return Response().error("缺少必要参数: persona_id").__dict__
@@ -206,7 +200,6 @@ class PersonaRoute(Route):
system_prompt=system_prompt,
begin_dialogs=begin_dialogs,
tools=tools,
skills=skills,
)
return Response().ok({"message": "人格更新成功"}).__dict__
-148
View File
@@ -1,148 +0,0 @@
import os
import traceback
from quart import request
from astrbot.core import DEMO_MODE, logger
from astrbot.core.computer.computer_client import get_booter
from astrbot.core.skills.skill_manager import SkillManager
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from .route import Response, Route, RouteContext
class SkillsRoute(Route):
def __init__(self, context: RouteContext, core_lifecycle) -> None:
super().__init__(context)
self.core_lifecycle = core_lifecycle
self.routes = {
"/skills": ("GET", self.get_skills),
"/skills/upload": ("POST", self.upload_skill),
"/skills/update": ("POST", self.update_skill),
"/skills/delete": ("POST", self.delete_skill),
}
self.register_routes()
async def get_skills(self):
try:
cfg = self.core_lifecycle.astrbot_config.get("provider_settings", {}).get(
"skills", {}
)
runtime = cfg.get("runtime", "local")
skills = SkillManager().list_skills(
active_only=False, runtime=runtime, show_sandbox_path=False
)
return Response().ok([skill.__dict__ for skill in skills]).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
async def upload_skill(self):
if DEMO_MODE:
return (
Response()
.error("You are not permitted to do this operation in demo mode")
.__dict__
)
temp_path = None
try:
files = await request.files
file = files.get("file")
if not file:
return Response().error("Missing file").__dict__
filename = os.path.basename(file.filename or "skill.zip")
if not filename.lower().endswith(".zip"):
return Response().error("Only .zip files are supported").__dict__
temp_dir = get_astrbot_temp_path()
os.makedirs(temp_dir, exist_ok=True)
temp_path = os.path.join(temp_dir, filename)
await file.save(temp_path)
cfg = self.core_lifecycle.astrbot_config.get("provider_settings", {}).get(
"skills", {}
)
runtime = cfg.get("runtime", "local")
if runtime == "sandbox":
sandbox_enabled = (
self.core_lifecycle.astrbot_config.get("provider_settings", {})
.get("sandbox", {})
.get("enable", False)
)
if not sandbox_enabled:
return (
Response()
.error(
"Sandbox is not enabled. Please enable sandbox before using sandbox runtime."
)
.__dict__
)
skill_mgr = SkillManager()
skill_name = skill_mgr.install_skill_from_zip(temp_path, overwrite=True)
if runtime == "sandbox":
sb = await get_booter(self.core_lifecycle.star_context, "skills-upload")
remote_root = "/home/shared/skills"
remote_zip = f"{remote_root}/{skill_name}.zip"
await sb.shell.exec(f"mkdir -p {remote_root}")
upload_result = await sb.upload_file(temp_path, remote_zip)
if not upload_result.get("success", False):
return (
Response().error("Failed to upload skill to sandbox").__dict__
)
await sb.shell.exec(
f"unzip -o {remote_zip} -d {remote_root} && rm -f {remote_zip}"
)
return (
Response()
.ok({"name": skill_name}, "Skill uploaded successfully.")
.__dict__
)
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
finally:
if temp_path and os.path.exists(temp_path):
try:
os.remove(temp_path)
except Exception:
logger.warning(f"Failed to remove temp skill file: {temp_path}")
async def update_skill(self):
if DEMO_MODE:
return (
Response()
.error("You are not permitted to do this operation in demo mode")
.__dict__
)
try:
data = await request.get_json()
name = data.get("name")
active = data.get("active", True)
if not name:
return Response().error("Missing skill name").__dict__
SkillManager().set_skill_active(name, bool(active))
return Response().ok({"name": name, "active": bool(active)}).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
async def delete_skill(self):
if DEMO_MODE:
return (
Response()
.error("You are not permitted to do this operation in demo mode")
.__dict__
)
try:
data = await request.get_json()
name = data.get("name")
if not name:
return Response().error("Missing skill name").__dict__
SkillManager().delete_skill(name)
return Response().ok({"name": name}).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
-102
View File
@@ -1,102 +0,0 @@
"""Dashboard 路由工具集。
这里放一些 dashboard routes 可复用的小工具函数
目前主要用于配置文件上传file 类型配置项功能
- 清洗/规范化用户可控的文件名与相对路径
- 将配置 key 映射到配置项独立子目录
"""
import os
def get_schema_item(schema: dict | None, key_path: str) -> dict | None:
"""按 dot-path 获取 schema 的节点。
同时支持
- 扁平 schema直接 key 命中
- 嵌套 object schema{type: "object", items: {...}}
"""
if not isinstance(schema, dict) or not key_path:
return None
if key_path in schema:
return schema.get(key_path)
current = schema
parts = key_path.split(".")
for idx, part in enumerate(parts):
if part not in current:
return None
meta = current.get(part)
if idx == len(parts) - 1:
return meta
if not isinstance(meta, dict) or meta.get("type") != "object":
return None
current = meta.get("items", {})
return None
def sanitize_filename(name: str) -> str:
"""清洗上传文件名,避免路径穿越与非法名称。
- 丢弃目录部分仅保留 basename
- 将路径分隔符替换为下划线
- 拒绝空字符串 / "." / ".."
"""
cleaned = os.path.basename(name).strip()
if not cleaned or cleaned in {".", ".."}:
return ""
for sep in (os.sep, os.altsep):
if sep:
cleaned = cleaned.replace(sep, "_")
return cleaned
def sanitize_path_segment(segment: str) -> str:
"""清洗目录片段(URL/path 安全,避免穿越)。
仅保留 [A-Za-z0-9_-]其余替换为 "_"
"""
cleaned = []
for ch in segment:
if (
("a" <= ch <= "z")
or ("A" <= ch <= "Z")
or ch.isdigit()
or ch
in {
"-",
"_",
}
):
cleaned.append(ch)
else:
cleaned.append("_")
result = "".join(cleaned).strip("_")
return result or "_"
def config_key_to_folder(key_path: str) -> str:
"""将 dot-path 的配置 key 转成稳定的文件夹路径。"""
parts = [sanitize_path_segment(p) for p in key_path.split(".") if p]
return "/".join(parts) if parts else "_"
def normalize_rel_path(rel_path: str | None) -> str | None:
"""规范化用户传入的相对路径,并阻止路径穿越。"""
if not isinstance(rel_path, str):
return None
rel = rel_path.replace("\\", "/").lstrip("/")
if not rel:
return None
parts = [p for p in rel.split("/") if p]
if any(part in {".", ".."} for part in parts):
return None
if rel.startswith("../") or "/../" in rel:
return None
return "/".join(parts)
+5 -19
View File
@@ -7,8 +7,6 @@ from typing import cast
import jwt
import psutil
from flask.json.provider import DefaultJSONProvider
from hypercorn.asyncio import serve
from hypercorn.config import Config as HyperConfig
from psutil._common import addr as psutil_addr
from quart import Quart, g, jsonify, request
from quart.logging import default_handler
@@ -79,7 +77,6 @@ class AstrBotDashboard:
self.chat_route = ChatRoute(self.context, db, core_lifecycle)
self.chatui_project_route = ChatUIProjectRoute(self.context, db)
self.tools_root = ToolsRoute(self.context, core_lifecycle)
self.skills_route = SkillsRoute(self.context, core_lifecycle)
self.conversation_route = ConversationRoute(self.context, db, core_lifecycle)
self.file_route = FileRoute(self.context)
self.session_management_route = SessionManagementRoute(
@@ -247,22 +244,11 @@ class AstrBotDashboard:
logger.info(display)
# 配置 Hypercorn
config = HyperConfig()
config.bind = [f"{host}:{port}"]
# 根据配置决定是否禁用访问日志
disable_access_log = self.core_lifecycle.astrbot_config.get(
"dashboard", {}
).get("disable_access_log", True)
if disable_access_log:
config.accesslog = None
else:
# 启用访问日志,使用简洁格式
config.accesslog = "-"
config.access_log_format = "%(h)s %(r)s %(s)s %(b)s %(D)s"
return serve(self.app, config, shutdown_trigger=self.shutdown_trigger)
return self.app.run_task(
host=host,
port=port,
shutdown_trigger=self.shutdown_trigger,
)
async def shutdown_trigger(self):
await self.shutdown_event.wait()
-21
View File
@@ -1,21 +0,0 @@
## 更新内容
### 新功能
- 为 ChatUI 添加文件拖拽上传功能 ([#4583](https://github.com/AstrBotDevs/AstrBot/issues/4583))
- 实现人格文件夹以进行高级人格管理 ([#4443](https://github.com/AstrBotDevs/AstrBot/issues/4443))
- 添加人格文件夹管理以支持层级组织 (db)
- 支持 Genie TTS
### 修复
- 增强提供商选择错误处理和日志记录,避免出现 `Provider 不是 Provider 类型` 的错误 ([#4654](https://github.com/AstrBotDevs/AstrBot/issues/4654))
- aiocqhttp 适配器中的 Markdown KeyError 或 UnboundLocalError 问题 ([#4656](https://github.com/AstrBotDevs/AstrBot/issues/4656))
- 确保 providers 中的 embedding 维度作为整数返回 ([#4547](https://github.com/AstrBotDevs/AstrBot/issues/4547))
- 钉钉流式响应问题 ([#4590](https://github.com/AstrBotDevs/AstrBot/issues/4590))
- 提供商选择按钮被长模型名称遮挡的问题 ([#4631](https://github.com/AstrBotDevs/AstrBot/issues/4631))
- 更新 `web_search_tavily` 处理,避免在非 ChatUI 平台出现信息引用 ([#4633](https://github.com/AstrBotDevs/AstrBot/issues/4633))
### 性能优化
- T2I 模板编辑器预览 ([#4574](https://github.com/AstrBotDevs/AstrBot/issues/4574))
### 杂项
- 移除已弃用的 `tool` 命令
-18
View File
@@ -1,18 +0,0 @@
## 更新内容
### 新功能
- 支持 Anthropic Skills 导入和使用。参见 [Skills](https://docs.astrbot.app/use/skills.html)
- 支持新的 Tool Schema 模式:Skill-like。通过两阶段调用来减少 Tool 过多的情况下,占用过多上下文的问题。
- 支持通过环境变量配置提供商 API Key。([#4696](https://github.com/AstrBotDevs/AstrBot/issues/4696))
- 支持插件的上传文件功能配置项类型 `file` ([#4539](https://github.com/AstrBotDevs/AstrBot/issues/4539))
### 修复
- Gemini API 部分情况下工具无限循环调用 ([#4686](https://github.com/AstrBotDevs/AstrBot/issues/4686))
- 修复 WebUI GitHub 代理选择器问题及卸载插件后出现的错误 ([#4724](https://github.com/AstrBotDevs/AstrBot/issues/4724))
### 优化
- 默认不在终端显示 WebUI API 访问日志 ([#4661](https://github.com/AstrBotDevs/AstrBot/issues/4661))
- 增加插件管理页面“更新所有插件”按钮的确认对话框,防止误点击 ([#4658](https://github.com/AstrBotDevs/AstrBot/issues/4658))
+2 -2
View File
@@ -28,14 +28,14 @@
"katex": "^0.16.27",
"lodash": "4.17.21",
"markdown-it": "^14.1.0",
"markstream-vue": "^0.0.6",
"markstream-vue": "^0.0.6-beta.1",
"mermaid": "^11.12.2",
"pinia": "2.1.6",
"pinyin-pro": "^3.26.0",
"remixicon": "3.5.0",
"shiki": "^3.20.0",
"stream-markdown": "^0.0.13",
"stream-monaco": "^0.0.17",
"stream-monaco": "^0.0.15",
"vee-validate": "4.11.3",
"vite-plugin-vuetify": "1.0.2",
"vue": "3.3.4",
+132 -6
View File
@@ -94,9 +94,80 @@
:reasoning="msg.content.reasoning" :is-dark="isDark"
:initial-expanded="isReasoningExpanded(index)" />
<MessagePartsRenderer :parts="msg.content.message" :is-dark="isDark"
:current-time="currentTime" :downloading-files="downloadingFiles"
@open-image-preview="openImagePreview" @download-file="downloadFile" />
<!-- 遍历 message parts (保持顺序) -->
<template v-for="(part, partIndex) in msg.content.message" :key="partIndex">
<!-- iPython Tool Special Block -->
<template v-if="part.type === 'tool_call' && part.tool_calls && part.tool_calls.length > 0">
<template v-for="(toolCall, tcIndex) in part.tool_calls" :key="toolCall.id">
<IPythonToolBlock v-if="isIPythonTool(toolCall)" :tool-call="toolCall" style="margin: 8px 0;"
:is-dark="isDark"
:initial-expanded="isIPythonToolExpanded(index, partIndex, tcIndex)" />
</template>
</template>
<!-- Regular Tool Calls Block (for non-iPython tools) -->
<div v-if="part.type === 'tool_call' && part.tool_calls && part.tool_calls.some(tc => !isIPythonTool(tc))"
class="flex flex-col gap-2">
<div class="font-medium opacity-70" style="font-size: 13px; margin-bottom: 16px;">{{ tm('actions.toolsUsed') }}</div>
<ToolCallCard v-for="(toolCall, tcIndex) in part.tool_calls.filter(tc => !isIPythonTool(tc))"
:key="toolCall.id" :tool-call="toolCall" :is-dark="isDark"
:initial-expanded="isToolCallExpanded(index, partIndex, tcIndex)" />
</div>
<!-- Text (Markdown) -->
<MarkdownRender v-else-if="part.type === 'plain' && part.text && part.text.trim()"
custom-id="message-list"
:custom-html-tags="['ref']"
:content="part.text" :typewriter="false" class="markdown-content"
:is-dark="isDark" :monacoOptions="{ theme: isDark ? 'vs-dark' : 'vs-light' }" />
<!-- Image -->
<div v-else-if="part.type === 'image' && part.embedded_url" class="embedded-images">
<div class="embedded-image">
<img :src="part.embedded_url" class="bot-embedded-image"
@click="openImagePreview(part.embedded_url)" />
</div>
</div>
<!-- Audio -->
<div v-else-if="part.type === 'record' && part.embedded_url" class="embedded-audio">
<audio controls class="audio-player">
<source :src="part.embedded_url" type="audio/wav">
{{ t('messages.errors.browser.audioNotSupported') }}
</audio>
</div>
<!-- Files -->
<div v-else-if="part.type === 'file' && part.embedded_file" class="embedded-files">
<div class="embedded-file">
<a v-if="part.embedded_file.url" :href="part.embedded_file.url"
:download="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">{{ part.embedded_file.filename }}</span>
</a>
<a v-else @click="downloadFile(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">{{ part.embedded_file.filename }}</span>
<v-icon v-if="downloadingFiles.has(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>
</div>
<div class="message-actions" v-if="!msg.content.isLoading || index === messages.length - 1">
@@ -179,13 +250,14 @@
<script>
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { enableKatex, enableMermaid, setCustomComponents } from 'markstream-vue'
import { MarkdownRender, enableKatex, enableMermaid, setCustomComponents } from 'markstream-vue'
import 'markstream-vue/index.css'
import 'katex/dist/katex.min.css'
import 'highlight.js/styles/github.css';
import axios from 'axios';
import ReasoningBlock from './message_list_comps/ReasoningBlock.vue';
import MessagePartsRenderer from './message_list_comps/MessagePartsRenderer.vue';
import IPythonToolBlock from './message_list_comps/IPythonToolBlock.vue';
import ToolCallCard from './message_list_comps/ToolCallCard.vue';
import RefNode from './message_list_comps/RefNode.vue';
import ActionRef from './message_list_comps/ActionRef.vue';
@@ -198,8 +270,10 @@ setCustomComponents('message-list', { ref: RefNode });
export default {
name: 'MessageList',
components: {
MarkdownRender,
ReasoningBlock,
MessagePartsRenderer,
IPythonToolBlock,
ToolCallCard,
RefNode,
ActionRef
},
@@ -245,6 +319,8 @@ export default {
scrollTimer: null,
expandedReasoning: new Set(), // Track which reasoning blocks are expanded
downloadingFiles: new Set(), // Track which files are being downloaded
expandedToolCalls: new Set(), // Track which tool call cards are expanded
expandedIPythonTools: new Set(), // Track which iPython tools are expanded
elapsedTimeTimer: null, // Timer for updating elapsed time
currentTime: Date.now() / 1000, // Current time for elapsed time calculation
//
@@ -465,6 +541,23 @@ export default {
return this.expandedReasoning.has(messageIndex);
},
// Toggle iPython tool expansion state
toggleIPythonTool(messageIndex, partIndex, toolCallIndex) {
const key = `${messageIndex}-${partIndex}-${toolCallIndex}`;
if (this.expandedIPythonTools.has(key)) {
this.expandedIPythonTools.delete(key);
} else {
this.expandedIPythonTools.add(key);
}
// Force reactivity
this.expandedIPythonTools = new Set(this.expandedIPythonTools);
},
// Check if iPython tool is expanded
isIPythonToolExpanded(messageIndex, partIndex, toolCallIndex) {
return this.expandedIPythonTools.has(`${messageIndex}-${partIndex}-${toolCallIndex}`);
},
//
async downloadFile(file) {
if (!file.attachment_id) return;
@@ -728,6 +821,22 @@ export default {
}
},
// Tool call related methods
toggleToolCall(messageIndex, partIndex, toolCallIndex) {
const key = `${messageIndex}-${partIndex}-${toolCallIndex}`;
if (this.expandedToolCalls.has(key)) {
this.expandedToolCalls.delete(key);
} else {
this.expandedToolCalls.add(key);
}
// Force reactivity
this.expandedToolCalls = new Set(this.expandedToolCalls);
},
isToolCallExpanded(messageIndex, partIndex, toolCallIndex) {
return this.expandedToolCalls.has(`${messageIndex}-${partIndex}-${toolCallIndex}`);
},
// Start timer for updating elapsed time
startElapsedTimeTimer() {
// Update every 12ms for sub-second precision, then every second after 1s
@@ -789,6 +898,18 @@ export default {
}
},
// Format tool result for display
formatToolResult(result) {
if (!result) return '';
// Try to parse as JSON for pretty formatting
try {
const parsed = JSON.parse(result);
return JSON.stringify(parsed, null, 2);
} catch {
return result;
}
},
// Get input tokens (input_other + input_cached)
getInputTokens(tokenUsage) {
if (!tokenUsage) return 0;
@@ -822,6 +943,11 @@ export default {
}, 300);
},
// Check if tool is iPython executor
isIPythonTool(toolCall) {
return toolCall.name === 'astrbot_execute_ipython';
},
// Open refs sidebar
openRefsSidebar(refs) {
this.$emit('openRefs', refs);
@@ -1,6 +1,14 @@
<template>
<div class="ipython-tool-block" :class="{ compact: !showHeader }">
<div v-if="displayExpanded" class="py-3 animate-fade-in">
<div class="mb-3 mt-1.5">
<div class="ipython-header" :class="{ 'expanded': isExpanded }" @click="toggleExpanded">
<span class="ipython-label">
{{ tm('actions.pythonCodeAnalysis') }}
</span>
<v-icon size="small" class="ipython-icon" :class="{ 'rotated': isExpanded }">
mdi-chevron-right
</v-icon>
</div>
<div v-if="isExpanded" class="py-3 animate-fade-in">
<!-- Code Section -->
<div class="code-section">
<div v-if="shikiReady && code" class="code-highlighted"
@@ -38,14 +46,6 @@ const props = defineProps({
initialExpanded: {
type: Boolean,
default: false
},
showHeader: {
type: Boolean,
default: true
},
forceExpanded: {
type: Boolean,
default: null
}
});
@@ -92,12 +92,9 @@ const highlightedCode = computed(() => {
}
});
const displayExpanded = computed(() => {
if (props.forceExpanded === null) {
return isExpanded.value;
}
return props.forceExpanded;
});
const toggleExpanded = () => {
isExpanded.value = !isExpanded.value;
};
onMounted(async () => {
try {
@@ -113,13 +110,40 @@ onMounted(async () => {
</script>
<style scoped>
.ipython-tool-block {
.mb-3 {
margin-bottom: 12px;
}
.mt-1\.5 {
margin-top: 6px;
}
.ipython-tool-block.compact {
margin: 0;
.ipython-header {
display: inline-flex;
align-items: center;
cursor: pointer;
user-select: none;
border-radius: 20px;
opacity: 0.7;
transition: opacity;
}
.ipython-header:hover,
.ipython-header.expanded {
opacity: 1;
}
.ipython-label {
font-size: 16px;
}
.ipython-icon {
margin-left: 6px;
transition: transform 0.2s ease;
}
.ipython-icon.rotated {
transform: rotate(90deg);
}
.py-3 {
@@ -136,7 +160,6 @@ onMounted(async () => {
overflow: hidden;
font-size: 14px;
line-height: 1.5;
overflow-x: auto;
}
.code-fallback {
@@ -185,10 +208,6 @@ onMounted(async () => {
animation: fadeIn 0.2s ease-in-out;
}
:deep(.code-highlighted pre) {
background-color: transparent !important;
}
@keyframes fadeIn {
from {
opacity: 0;
@@ -1,334 +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="renderPart.part.text" :typewriter="false"
class="markdown-content" :is-dark="isDark" :monacoOptions="{ theme: isDark ? 'vs-dark' : 'vs-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 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);
}
</style>
@@ -1,74 +0,0 @@
<template>
<div class="tool-call-item">
<div class="tool-call-line" role="button" tabindex="0"
@click="toggleExpanded"
@keydown.enter="toggleExpanded"
@keydown.space.prevent="toggleExpanded">
<slot name="label" :expanded="isExpanded" />
</div>
<transition name="tool-call-fade">
<div v-if="isExpanded" class="tool-call-inline-details" :class="{ 'is-dark': isDark }">
<slot name="details" />
</div>
</transition>
</div>
</template>
<script setup>
import { ref } from 'vue';
const props = defineProps({
isDark: {
type: Boolean,
default: false
}
});
const isExpanded = ref(false);
const toggleExpanded = () => {
isExpanded.value = !isExpanded.value;
};
</script>
<style scoped>
.tool-call-line {
font-size: 14px;
color: var(--v-theme-secondaryText);
opacity: 0.85;
cursor: pointer;
user-select: none;
transition: color 0.2s ease, opacity 0.2s ease;
display: inline-flex;
align-items: center;
gap: 6px;
}
.tool-call-line:hover {
color: var(--v-theme-secondary);
opacity: 1;
}
.tool-call-inline-details {
margin-top: 6px;
padding: 8px 10px;
border-left: 2px solid var(--v-theme-border);
border-radius: 6px;
background-color: rgba(0, 0, 0, 0.02);
}
.tool-call-inline-details.is-dark {
background-color: rgba(255, 255, 255, 0.04);
border-left-color: rgba(255, 255, 255, 0.15);
}
.tool-call-fade-enter-active,
.tool-call-fade-leave-active {
transition: opacity 0.1s ease;
}
.tool-call-fade-enter-from,
.tool-call-fade-leave-to {
opacity: 0;
}
</style>
@@ -1,213 +0,0 @@
<template>
<div class="skills-page">
<v-container fluid class="pa-0" elevation="0">
<v-row class="d-flex justify-space-between align-center px-4 py-3 pb-8">
<div>
<v-btn color="success" prepend-icon="mdi-upload" class="me-2" variant="tonal"
@click="uploadDialog = true">
{{ tm('skills.upload') }}
</v-btn>
<v-btn color="primary" prepend-icon="mdi-refresh" variant="tonal" @click="fetchSkills">
{{ tm('skills.refresh') }}
</v-btn>
</div>
</v-row>
<v-progress-linear v-if="loading" indeterminate color="primary"></v-progress-linear>
<div v-else-if="skills.length === 0" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">mdi-folder-open</v-icon>
<p class="text-grey mt-4">{{ tm('skills.empty') }}</p>
<small class="text-grey">{{ tm('skills.emptyHint') }}</small>
</div>
<v-row v-else>
<v-col v-for="skill in skills" :key="skill.name" cols="12" md="6" lg="4" xl="3">
<item-card :item="skill" title-field="name" enabled-field="active" :loading="itemLoading[skill.name] || false"
:show-edit-button="false" @toggle-enabled="toggleSkill" @delete="confirmDelete">
<template v-slot:item-details="{ item }">
<div class="text-caption text-medium-emphasis mb-2 skill-description">
<v-icon size="small" class="me-1">mdi-text</v-icon>
{{ item.description || tm('skills.noDescription') }}
</div>
<div class="text-caption text-medium-emphasis">
<v-icon size="small" class="me-1">mdi-file-document</v-icon>
{{ tm('skills.path') }}: {{ item.path }}
</div>
</template>
</item-card>
</v-col>
</v-row>
</v-container>
<v-dialog v-model="uploadDialog" max-width="520px" persistent>
<v-card>
<v-card-title>{{ tm('skills.uploadDialogTitle') }}</v-card-title>
<v-card-text>
<small class="text-grey">{{ tm('skills.uploadHint') }}</small>
<v-file-input v-model="uploadFile" accept=".zip" :label="tm('skills.selectFile')" prepend-icon="mdi-file-zip"
variant="outlined" class="mt-4" :multiple="false" />
</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn variant="text" @click="uploadDialog = false">{{ tm('skills.cancel') }}</v-btn>
<v-btn color="primary" :loading="uploading" :disabled="!uploadFile" @click="uploadSkill">
{{ tm('skills.confirmUpload') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="deleteDialog" max-width="400px">
<v-card>
<v-card-title>{{ tm('skills.deleteTitle') }}</v-card-title>
<v-card-text>{{ tm('skills.deleteMessage') }}</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn variant="text" @click="deleteDialog = false">{{ tm('skills.cancel') }}</v-btn>
<v-btn color="error" :loading="deleting" @click="deleteSkill">
{{ t('core.common.itemCard.delete') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar v-model="snackbar.show" :timeout="3000" :color="snackbar.color" elevation="24">
{{ snackbar.message }}
</v-snackbar>
</div>
</template>
<script>
import axios from "axios";
import { ref, reactive, onMounted } from "vue";
import ItemCard from "@/components/shared/ItemCard.vue";
import { useI18n, useModuleI18n } from "@/i18n/composables";
export default {
name: "SkillsSection",
components: { ItemCard },
setup() {
const { t } = useI18n();
const { tm } = useModuleI18n("features/extension");
const skills = ref([]);
const loading = ref(false);
const uploading = ref(false);
const uploadDialog = ref(false);
const uploadFile = ref(null);
const itemLoading = reactive({});
const deleteDialog = ref(false);
const deleting = ref(false);
const skillToDelete = ref(null);
const snackbar = reactive({ show: false, message: "", color: "success" });
const showMessage = (message, color = "success") => {
snackbar.message = message;
snackbar.color = color;
snackbar.show = true;
};
const fetchSkills = async () => {
loading.value = true;
try {
const res = await axios.get("/api/skills");
skills.value = res.data.data || [];
} catch (err) {
showMessage(tm("skills.loadFailed"), "error");
} finally {
loading.value = false;
}
};
const uploadSkill = async () => {
if (!uploadFile.value) return;
uploading.value = true;
try {
const formData = new FormData();
const file = Array.isArray(uploadFile.value)
? uploadFile.value[0]
: uploadFile.value;
if (!file) {
uploading.value = false;
return;
}
formData.append("file", file);
await axios.post("/api/skills/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
showMessage(tm("skills.uploadSuccess"), "success");
uploadDialog.value = false;
uploadFile.value = null;
await fetchSkills();
} catch (err) {
showMessage(tm("skills.uploadFailed"), "error");
} finally {
uploading.value = false;
}
};
const toggleSkill = async (skill) => {
const nextActive = !skill.active;
itemLoading[skill.name] = true;
try {
await axios.post("/api/skills/update", { name: skill.name, active: nextActive });
skill.active = nextActive;
showMessage(tm("skills.updateSuccess"), "success");
} catch (err) {
showMessage(tm("skills.updateFailed"), "error");
} finally {
itemLoading[skill.name] = false;
}
};
const confirmDelete = (skill) => {
skillToDelete.value = skill;
deleteDialog.value = true;
};
const deleteSkill = async () => {
if (!skillToDelete.value) return;
deleting.value = true;
try {
await axios.post("/api/skills/delete", { name: skillToDelete.value.name });
showMessage(tm("skills.deleteSuccess"), "success");
deleteDialog.value = false;
await fetchSkills();
} catch (err) {
showMessage(tm("skills.deleteFailed"), "error");
} finally {
deleting.value = false;
}
};
onMounted(fetchSkills);
return {
t,
tm,
skills,
loading,
uploadDialog,
uploadFile,
uploading,
itemLoading,
deleteDialog,
deleting,
snackbar,
fetchSkills,
uploadSkill,
toggleSkill,
confirmDelete,
deleteSkill,
};
},
};
</script>
<style scoped>
.skill-description {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
@@ -20,14 +20,6 @@ const props = defineProps({
type: String,
required: true
},
pluginName: {
type: String,
default: ''
},
pathPrefix: {
type: String,
default: ''
},
isEditing: {
type: Boolean,
default: false
@@ -111,10 +103,6 @@ function shouldShowItem(itemMeta, itemKey) {
return true
}
function getItemPath(key) {
return props.pathPrefix ? `${props.pathPrefix}.${key}` : key
}
function hasVisibleItemsAfter(items, currentIndex) {
const itemEntries = Object.entries(items)
@@ -162,13 +150,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
<div v-if="metadata[metadataKey].items[key]?.type === 'object'" class="nested-object">
<div v-if="metadata[metadataKey].items[key] && !metadata[metadataKey].items[key]?.invisible && shouldShowItem(metadata[metadataKey].items[key], key)" class="nested-container">
<v-expand-transition>
<AstrBotConfig
:metadata="metadata[metadataKey].items"
:iterable="iterable[key]"
:metadataKey="key"
:pluginName="pluginName"
:pathPrefix="getItemPath(key)"
>
<AstrBotConfig :metadata="metadata[metadataKey].items" :iterable="iterable[key]" :metadataKey="key">
</AstrBotConfig>
</v-expand-transition>
</div>
@@ -223,8 +205,6 @@ function hasVisibleItemsAfter(items, currentIndex) {
<ConfigItemRenderer
v-model="iterable[key]"
:item-meta="metadata[metadataKey].items[key] || null"
:plugin-name="pluginName"
:config-key="getItemPath(key)"
:loading="loadingEmbeddingDim"
:show-fullscreen-btn="!!metadata[metadataKey].items[key]?.editor_mode"
@get-embedding-dim="getEmbeddingDimensions(iterable)"
@@ -269,8 +249,6 @@ function hasVisibleItemsAfter(items, currentIndex) {
v-else
v-model="iterable[metadataKey]"
:item-meta="metadata[metadataKey]"
:plugin-name="pluginName"
:config-key="getItemPath(metadataKey)"
/>
</v-col>
</v-row>
@@ -178,16 +178,6 @@
hide-details
></v-switch>
<FileConfigItem
v-else-if="itemMeta?.type === 'file'"
:model-value="modelValue"
:item-meta="itemMeta"
:plugin-name="pluginName"
:config-key="configKey"
@update:model-value="emitUpdate"
class="config-field"
/>
<ListConfigItem
v-else-if="itemMeta?.type === 'list'"
:model-value="modelValue"
@@ -218,7 +208,6 @@
<script setup>
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
import ListConfigItem from './ListConfigItem.vue'
import FileConfigItem from './FileConfigItem.vue'
import ObjectEditor from './ObjectEditor.vue'
import ProviderSelector from './ProviderSelector.vue'
import PersonaSelector from './PersonaSelector.vue'
@@ -236,14 +225,6 @@ const props = defineProps({
type: Object,
default: null
},
pluginName: {
type: String,
default: ''
},
configKey: {
type: String,
default: ''
},
loading: {
type: Boolean,
default: false
@@ -1,407 +0,0 @@
<template>
<div class="file-config-item">
<div class="d-flex align-center gap-2">
<v-btn size="small" color="primary" variant="tonal" @click="dialog = true">
{{ tm('fileUpload.button') }}
</v-btn>
<span class="text-caption text-medium-emphasis ml-2">
{{ fileCountText }}
</span>
</div>
<v-dialog v-model="dialog" max-width="700">
<v-card class="file-dialog-card" variant="flat">
<v-card-title class="d-flex align-center">
<span class="text-h3">{{ tm('fileUpload.dialogTitle') }}</span>
<v-spacer />
<v-btn icon="mdi-close" variant="text" @click="dialog = false" />
</v-card-title>
<v-card-text class="file-dialog-body">
<div v-if="mergedFileItems.length === 0" class="empty-text">
{{ tm('fileUpload.empty') }}
</div>
<v-list density="compact" lines="one">
<v-list-item v-for="item in mergedFileItems" :key="item.path">
<template #prepend>
<v-icon size="18">mdi-file</v-icon>
</template>
<v-list-item-title class="file-name">
{{ getDisplayName(item.path) }}
</v-list-item-title>
<template #append>
<div class="d-flex align-center gap-1">
<v-chip v-if="item.status !== 'ok'" size="x-small" :color="getStatusColor(item.status)"
variant="tonal">
{{ getStatusText(item.status) }}
</v-chip>
<v-btn v-if="item.status === 'unconfigured'" icon="mdi-plus" size="x-small" variant="text"
@click="addToConfig(item.path)" />
<v-btn icon="mdi-delete" size="x-small" variant="text"
@click="item.status === 'unconfigured' ? deletePhysicalFile(item.path) : deleteFile(item.path)" />
</div>
</template>
</v-list-item>
<v-divider v-if="mergedFileItems.length > 0" class="my-2" />
<v-list-item class="upload-item" :class="{ dragover: isDragging }" @drop.prevent="handleDrop"
@dragover.prevent="isDragging = true" @dragleave="isDragging = false" @click="openFilePicker">
<template #prepend>
<v-icon size="18" color="primary">mdi-plus</v-icon>
</template>
<v-list-item-title>{{ tm('fileUpload.dropzone') }}</v-list-item-title>
<v-list-item-subtitle v-if="allowedTypesText" class="upload-hint">
{{ tm('fileUpload.allowedTypes', { types: allowedTypesText }) }}
</v-list-item-subtitle>
</v-list-item>
</v-list>
<input ref="fileInput" type="file" multiple hidden :accept="acceptAttr" @change="handleFileSelect" />
</v-card-text>
<v-card-actions class="file-dialog-actions">
<v-spacer />
<v-btn color="primary" variant="elevated" @click="dialog = false">
{{ tm('fileUpload.done') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import axios from 'axios'
import { useToast } from '@/utils/toast'
import { useModuleI18n } from '@/i18n/composables'
const props = defineProps({
modelValue: {
type: Array,
default: () => []
},
itemMeta: {
type: Object,
default: null
},
pluginName: {
type: String,
default: ''
},
configKey: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:modelValue'])
const { tm } = useModuleI18n('features/config')
const toast = useToast()
const dialog = ref(false)
const isDragging = ref(false)
const fileInput = ref(null)
const uploading = ref(false)
const loadingFiles = ref(false)
const MAX_FILE_BYTES = 500 * 1024 * 1024
const MAX_FILE_MB = 500
const directoryFiles = ref([])
const fileList = computed({
get: () => (Array.isArray(props.modelValue) ? props.modelValue : []),
set: (val) => emit('update:modelValue', val)
})
const mergedFileItems = computed(() => {
const configured = new Set(fileList.value)
const existing = new Set(directoryFiles.value)
const items = []
for (const path of fileList.value) {
items.push({
path,
status: existing.has(path) ? 'ok' : 'missing'
})
}
for (const path of directoryFiles.value) {
if (!configured.has(path)) {
items.push({
path,
status: 'unconfigured'
})
}
}
return items
})
const acceptAttr = computed(() => {
const types = props.itemMeta?.file_types
if (!Array.isArray(types) || types.length === 0) {
return undefined
}
return types
.map((ext) => `.${String(ext).replace(/^\\./, '')}`)
.join(',')
})
const allowedTypesText = computed(() => {
const types = props.itemMeta?.file_types
if (!Array.isArray(types) || types.length === 0) {
return ''
}
return types.map((ext) => String(ext).replace(/^\\./, '')).join(', ')
})
const fileCountText = computed(() => {
return tm('fileUpload.fileCount', { count: fileList.value.length })
})
const getStatusText = (status) => {
if (status === 'missing') {
return tm('fileUpload.statusMissing')
}
if (status === 'unconfigured') {
return tm('fileUpload.statusUnconfigured')
}
return ''
}
const getStatusColor = (status) => {
if (status === 'missing') {
return 'error'
}
if (status === 'unconfigured') {
return 'warning'
}
return 'primary'
}
const openFilePicker = () => {
fileInput.value?.click()
}
const loadDirectoryFiles = async () => {
if (!props.pluginName || !props.configKey || loadingFiles.value) {
return
}
loadingFiles.value = true
try {
const response = await axios.get(
`/api/config/file/get?scope=plugin&name=${encodeURIComponent(
props.pluginName
)}&key=${encodeURIComponent(props.configKey)}`
)
if (response.data.status === 'ok') {
const files = response.data.data?.files || []
directoryFiles.value = Array.from(new Set(files))
} else {
toast.warning(response.data.message || tm('fileUpload.loadFailed'))
}
} catch (error) {
console.error('Load file list failed:', error)
toast.warning(tm('fileUpload.loadFailed'))
} finally {
loadingFiles.value = false
}
}
const handleFileSelect = (event) => {
const target = event.target
if (target?.files && target.files.length > 0) {
uploadFiles(Array.from(target.files))
}
if (target) {
target.value = ''
}
}
const handleDrop = (event) => {
isDragging.value = false
if (event.dataTransfer?.files && event.dataTransfer.files.length > 0) {
uploadFiles(Array.from(event.dataTransfer.files))
}
}
const uploadFiles = async (files) => {
if (!props.pluginName || !props.configKey) {
toast.warning('Missing plugin config info')
return
}
if (uploading.value) {
return
}
const oversized = files.filter((file) => file.size > MAX_FILE_BYTES)
if (oversized.length > 0) {
oversized.forEach((file) => {
toast.warning(
tm('fileUpload.fileTooLarge', { name: file.name, max: MAX_FILE_MB })
)
})
}
const validFiles = files.filter((file) => file.size <= MAX_FILE_BYTES)
if (validFiles.length === 0) {
return
}
uploading.value = true
try {
const formData = new FormData()
validFiles.forEach((file, index) => {
formData.append(`file${index}`, file)
})
const response = await axios.post(
`/api/config/file/upload?scope=plugin&name=${encodeURIComponent(
props.pluginName
)}&key=${encodeURIComponent(props.configKey)}`,
formData,
{ headers: { 'Content-Type': 'multipart/form-data' } }
)
if (response.data.status === 'ok') {
const uploaded = response.data.data?.uploaded || []
const errors = response.data.data?.errors || []
if (uploaded.length > 0) {
const merged = [...fileList.value]
for (const path of uploaded) {
if (!merged.includes(path)) {
merged.push(path)
}
}
fileList.value = merged
const updatedDirectory = new Set(directoryFiles.value)
uploaded.forEach((path) => updatedDirectory.add(path))
directoryFiles.value = Array.from(updatedDirectory)
toast.success(tm('fileUpload.uploadSuccess', { count: uploaded.length }))
}
if (errors.length > 0) {
toast.warning(errors.join('\\n'))
}
} else {
toast.error(response.data.message || tm('fileUpload.uploadFailed'))
}
} catch (error) {
console.error('File upload failed:', error)
toast.error(tm('fileUpload.uploadFailed'))
} finally {
uploading.value = false
}
}
const addToConfig = (filePath) => {
if (!fileList.value.includes(filePath)) {
fileList.value = [...fileList.value, filePath]
toast.success(tm('fileUpload.addToConfig'))
}
}
const deleteFile = (filePath) => {
fileList.value = fileList.value.filter((item) => item !== filePath)
directoryFiles.value = directoryFiles.value.filter((item) => item !== filePath)
if (props.pluginName) {
axios
.post(
`/api/config/file/delete?scope=plugin&name=${encodeURIComponent(
props.pluginName
)}`,
{ path: filePath }
)
.catch((error) => {
console.warn('Staged file delete failed:', error)
toast.warning(tm('fileUpload.deleteFailed'))
})
}
toast.success(tm('fileUpload.deleteSuccess'))
}
const deletePhysicalFile = (filePath) => {
directoryFiles.value = directoryFiles.value.filter((item) => item !== filePath)
if (props.pluginName) {
axios
.post(
`/api/config/file/delete?scope=plugin&name=${encodeURIComponent(
props.pluginName
)}`,
{ path: filePath }
)
.catch((error) => {
console.warn('File delete failed:', error)
toast.warning(tm('fileUpload.deleteFailed'))
})
}
toast.success(tm('fileUpload.deleteSuccess'))
}
const getDisplayName = (path) => {
if (!path) return ''
const parts = String(path).split('/')
return parts[parts.length - 1] || path
}
watch(
() => dialog.value,
(value) => {
if (value) {
loadDirectoryFiles()
}
}
)
</script>
<style scoped>
.file-config-item {
width: 100%;
}
.file-dialog-card {
height: 70vh;
box-shadow: none;
}
.file-dialog-body {
overflow-y: auto;
max-height: calc(70vh - 120px);
}
.file-dialog-actions {
padding: 16px 24px 20px;
}
.upload-hint {
font-size: 12px;
color: rgba(var(--v-theme-on-surface), 0.5);
}
.empty-text {
font-size: 12px;
color: rgba(var(--v-theme-on-surface), 0.5);
}
.file-name {
font-weight: 600;
word-break: break-word;
}
.upload-item {
cursor: pointer;
transition: background 0.2s ease;
}
.upload-item:hover,
.upload-item.dragover {
background: rgba(var(--v-theme-on-surface), 0.04);
}
</style>
+21 -26
View File
@@ -23,28 +23,27 @@
<slot name="item-details" :item="item"></slot>
</v-card-text>
<v-card-actions style="margin: 8px;">
<v-btn
variant="outlined"
color="error"
size="small"
rounded="xl"
:disabled="loading"
@click="$emit('delete', item)"
>
{{ t('core.common.itemCard.delete') }}
</v-btn>
<v-btn
v-if="showEditButton"
variant="tonal"
color="primary"
size="small"
rounded="xl"
:disabled="loading"
@click="$emit('edit', item)"
>
{{ t('core.common.itemCard.edit') }}
</v-btn>
<v-card-actions style="margin: 8px;">
<v-btn
variant="outlined"
color="error"
size="small"
rounded="xl"
:disabled="loading"
@click="$emit('delete', item)"
>
{{ t('core.common.itemCard.delete') }}
</v-btn>
<v-btn
variant="tonal"
color="primary"
size="small"
rounded="xl"
:disabled="loading"
@click="$emit('edit', item)"
>
{{ t('core.common.itemCard.edit') }}
</v-btn>
<v-btn
v-if="showCopyButton"
variant="tonal"
@@ -104,10 +103,6 @@ export default {
showCopyButton: {
type: Boolean,
default: false
},
showEditButton: {
type: Boolean,
default: true
}
},
emits: ['toggle-enabled', 'delete', 'edit', 'copy'],
+2 -186
View File
@@ -155,100 +155,6 @@
</v-expansion-panel-text>
</v-expansion-panel>
<!-- Skills 选择面板 -->
<v-expansion-panel value="skills">
<v-expansion-panel-title>
<v-icon class="mr-2">mdi-lightning-bolt</v-icon>
{{ tm('form.skills') }}
<v-chip v-if="Array.isArray(personaForm.skills) && personaForm.skills.length > 0"
size="small" color="primary" variant="tonal" class="ml-2">
{{ personaForm.skills.length }}
</v-chip>
</v-expansion-panel-title>
<v-expansion-panel-text>
<div class="mb-3">
<p class="text-body-2 text-medium-emphasis">
{{ tm('form.skillsHelp') }}
</p>
</div>
<v-radio-group class="mt-2" v-model="skillSelectValue" hide-details="true">
<v-radio :label="tm('form.skillsAllAvailable')" value="0"></v-radio>
<v-radio :label="tm('form.skillsSelectSpecific')" value="1"></v-radio>
</v-radio-group>
<div v-if="skillSelectValue === '1'" class="mt-3 ml-8">
<v-text-field v-model="skillSearch" :label="tm('form.searchSkills')"
prepend-inner-icon="mdi-magnify" variant="outlined" density="compact"
hide-details clearable class="mb-3" />
<div v-if="filteredSkills.length > 0" class="skills-selection">
<v-virtual-scroll :items="filteredSkills" height="240" item-height="48">
<template v-slot:default="{ item }">
<v-list-item :key="item.name" density="comfortable"
@click="toggleSkill(item.name)">
<template v-slot:prepend>
<v-checkbox-btn :model-value="isSkillSelected(item.name)"
@click.stop="toggleSkill(item.name)" />
</template>
<v-list-item-title>
{{ item.name }}
</v-list-item-title>
<v-list-item-subtitle v-if="item.description">
{{ truncateText(item.description, 100) }}
</v-list-item-subtitle>
</v-list-item>
</template>
</v-virtual-scroll>
</div>
<div v-else-if="!loadingSkills && availableSkills.length === 0"
class="text-center pa-4">
<v-icon size="48" color="grey-lighten-2" class="mb-2">mdi-lightning-bolt</v-icon>
<p class="text-body-2 text-medium-emphasis">{{ tm('form.noSkillsAvailable') }}
</p>
</div>
<div v-else-if="!loadingSkills && filteredSkills.length === 0"
class="text-center pa-4">
<v-icon size="48" color="grey-lighten-2" class="mb-2">mdi-magnify</v-icon>
<p class="text-body-2 text-medium-emphasis">{{ tm('form.noSkillsFound') }}
</p>
</div>
<div v-if="loadingSkills" class="text-center pa-4">
<v-progress-circular indeterminate color="primary" />
<p class="text-body-2 text-medium-emphasis mt-2">{{ tm('form.loadingSkills') }}
</p>
</div>
<div class="mt-4">
<h4 class="text-subtitle-2 mb-2">
{{ tm('form.selectedSkills') }}
<span v-if="personaForm.skills === null" class="text-success">
({{ tm('form.allSelected') }})
</span>
<span v-else-if="Array.isArray(personaForm.skills)">
({{ personaForm.skills.length }})
</span>
</h4>
<div v-if="Array.isArray(personaForm.skills) && personaForm.skills.length > 0"
class="d-flex flex-wrap ga-1" style="max-height: 100px; overflow-y: auto;">
<v-chip v-for="skillName in personaForm.skills" :key="skillName"
size="small" color="primary" variant="tonal" closable
@click:close="removeSkill(skillName)">
{{ skillName }}
</v-chip>
</div>
<div v-else class="text-body-2 text-medium-emphasis">
{{ tm('form.noSkillsSelected') }}
</div>
</div>
</div>
</v-expansion-panel-text>
</v-expansion-panel>
<!-- 预设对话面板 -->
<v-expansion-panel value="dialogs">
<v-expansion-panel-title>
@@ -339,15 +245,12 @@ export default {
mcpServers: [],
availableTools: [],
loadingTools: false,
availableSkills: [],
loadingSkills: false,
existingPersonaIds: [], // ID
personaForm: {
persona_id: '',
system_prompt: '',
begin_dialogs: [],
tools: [],
skills: [],
folder_id: null
},
personaIdRules: [
@@ -359,9 +262,7 @@ export default {
v => !!v || this.tm('validation.required'),
v => (v && v.length >= 10) || this.tm('validation.minLength', { min: 10 })
],
toolSearch: '',
skillSearch: '',
skillSelectValue: '0'
toolSearch: ''
}
},
@@ -385,16 +286,6 @@ export default {
(tool.mcp_server_name && tool.mcp_server_name.toLowerCase().includes(search))
);
},
filteredSkills() {
if (!this.skillSearch) {
return this.availableSkills;
}
const search = this.skillSearch.toLowerCase();
return this.availableSkills.filter(skill =>
skill.name.toLowerCase().includes(search) ||
(skill.description && skill.description.toLowerCase().includes(search))
);
},
folderDisplayName() {
// 使
if (this.currentFolderName) {
@@ -422,7 +313,6 @@ export default {
}
this.loadMcpServers();
this.loadTools();
this.loadSkills();
}
},
editingPersona: {
@@ -448,15 +338,6 @@ export default {
this.personaForm.tools = [];
}
}
},
skillSelectValue(newValue) {
if (newValue === '0') {
this.personaForm.skills = null;
} else if (newValue === '1') {
if (this.personaForm.skills === null) {
this.personaForm.skills = [];
}
}
}
},
@@ -467,11 +348,9 @@ export default {
system_prompt: '',
begin_dialogs: [],
tools: [],
skills: [],
folder_id: this.currentFolderId
};
this.toolSelectValue = '0';
this.skillSelectValue = '0';
this.expandedPanels = [];
},
@@ -481,12 +360,10 @@ export default {
system_prompt: persona.system_prompt,
begin_dialogs: [...(persona.begin_dialogs || [])],
tools: persona.tools === null ? null : [...(persona.tools || [])],
skills: persona.skills === null ? null : [...(persona.skills || [])],
folder_id: persona.folder_id
};
// tools toolSelectValue
this.toolSelectValue = persona.tools === null ? '0' : '1';
this.skillSelectValue = persona.skills === null ? '0' : '1';
this.expandedPanels = [];
},
@@ -525,24 +402,6 @@ export default {
}
},
async loadSkills() {
this.loadingSkills = true;
try {
const response = await axios.get('/api/skills');
if (response.data.status === 'ok') {
const skills = response.data.data || [];
this.availableSkills = skills.filter(skill => skill.active !== false);
} else {
this.$emit('error', response.data.message || 'Failed to load skills');
}
} catch (error) {
this.$emit('error', error.response?.data?.message || 'Failed to load skills');
this.availableSkills = [];
} finally {
this.loadingSkills = false;
}
},
async loadExistingPersonaIds() {
try {
const response = await axios.get('/api/persona/list');
@@ -679,37 +538,6 @@ export default {
}
},
toggleSkill(skillName) {
if (this.personaForm.skills === null) {
this.personaForm.skills = this.availableSkills.map(skill => skill.name)
.filter(name => name !== skillName);
this.skillSelectValue = '1';
} else if (Array.isArray(this.personaForm.skills)) {
const index = this.personaForm.skills.indexOf(skillName);
if (index !== -1) {
this.personaForm.skills.splice(index, 1);
} else {
this.personaForm.skills.push(skillName);
}
} else {
this.personaForm.skills = [skillName];
this.skillSelectValue = '1';
}
},
removeSkill(skillName) {
if (this.personaForm.skills === null) {
this.personaForm.skills = this.availableSkills.map(skill => skill.name)
.filter(name => name !== skillName);
this.skillSelectValue = '1';
} else if (Array.isArray(this.personaForm.skills)) {
const index = this.personaForm.skills.indexOf(skillName);
if (index !== -1) {
this.personaForm.skills.splice(index, 1);
}
}
},
truncateText(text, maxLength) {
if (!text) return '';
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
@@ -731,13 +559,6 @@ export default {
return Array.isArray(this.personaForm.tools) && this.personaForm.tools.includes(toolName);
},
isSkillSelected(skillName) {
if (this.personaForm.skills === null) {
return true;
}
return Array.isArray(this.personaForm.skills) && this.personaForm.skills.includes(skillName);
},
isServerSelected(server) {
if (!server.tools || server.tools.length === 0) return false;
@@ -760,12 +581,7 @@ export default {
overflow-y: auto;
}
.skills-selection {
max-height: 300px;
overflow-y: auto;
}
.v-virtual-scroll {
padding-bottom: 16px;
}
</style>
</style>
@@ -3,7 +3,7 @@
<span v-if="!modelValue" style="color: rgb(var(--v-theme-primaryText));">
{{ tm('providerSelector.notSelected') }}
</span>
<span v-else class="provider-name-text">
<span v-else>
{{ modelValue }}
</span>
<v-btn size="small" color="primary" variant="tonal" @click="openDialog">
@@ -228,14 +228,6 @@ function closeProviderDrawer() {
</script>
<style scoped>
.provider-name-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: calc(100% - 80px);
display: inline-block;
}
.v-list-item {
transition: all 0.2s ease;
}
@@ -121,13 +121,6 @@ export default {
this.selectedGitHubProxy = localStorage.getItem('selectedGitHubProxy') || "";
this.radioValue = localStorage.getItem('githubProxyRadioValue') || "0";
this.githubProxyRadioControl = localStorage.getItem('githubProxyRadioControl') || "0";
if (this.radioValue === "1") {
if (this.githubProxyRadioControl !== "-1") {
this.selectedGitHubProxy = this.githubProxies[this.githubProxyRadioControl] || "";
}
} else {
this.selectedGitHubProxy = "";
}
},
watch: {
selectedGitHubProxy: function (newVal, oldVal) {
@@ -140,16 +133,10 @@ export default {
localStorage.setItem('githubProxyRadioValue', newVal);
if (newVal === "0") {
this.selectedGitHubProxy = "";
} else if (this.githubProxyRadioControl !== "-1") {
this.selectedGitHubProxy = this.githubProxies[this.githubProxyRadioControl] || "";
}
},
githubProxyRadioControl: function (newVal) {
localStorage.setItem('githubProxyRadioControl', newVal);
if (this.radioValue !== "1") {
this.selectedGitHubProxy = "";
return;
}
if (newVal !== "-1") {
this.selectedGitHubProxy = this.githubProxies[newVal] || "";
} else {
@@ -164,4 +151,4 @@ export default {
.v-label {
font-size: 0.875rem;
}
</style>
</style>
@@ -1,487 +0,0 @@
<script setup>
import axios from 'axios';
import { EventSourcePolyfill } from 'event-source-polyfill';
</script>
<template>
<div class="trace-wrapper">
<div class="trace-table" ref="scrollEl" :style="{ height: tableHeight }">
<div class="trace-row trace-header">
<div class="trace-cell time">Time</div>
<div class="trace-cell span">Event ID</div>
<div class="trace-cell umo">UMO</div>
<!-- <div class="trace-cell count">Records</div> -->
<!-- <div class="trace-cell last">Last</div> -->
<div class="trace-cell sender">Sender</div>
<div class="trace-cell outline">Outline</div>
<div class="trace-cell fields"></div>
</div>
<div class="trace-group" :class="{ highlight: highlightMap[event.span_id] }" v-for="event in events"
:key="event.span_id">
<div class="trace-row trace-event">
<div class="trace-cell time">{{ formatTime(event.first_time) }}</div>
<div class="trace-cell span" :title="event.span_id">
<div class="event-title">
{{ shortSpan(event.span_id) }}
</div>
</div>
<div class="trace-cell umo">{{ event.umo }}</div>
<!-- <div class="trace-cell count">
<div class="event-meta">{{ event.records.length }}</div>
</div> -->
<!-- <div class="trace-cell last">
<div class="event-meta">{{ formatTime(event.last_time) }}</div>
</div> -->
<div class="trace-cell sender">
<div class="event-sub" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">{{
event.sender_name || '-' }}</div>
</div>
<div class="trace-cell outline">
<div class="event-sub outline">{{ event.message_outline || '-' }}</div>
</div>
<div class="trace-cell fields event-controls">
<v-btn size="x-small" variant="text" color="primary" @click="toggleEvent(event.span_id)">
{{ event.collapsed ? 'Expand' : 'Collapse' }}
<span v-if="event.hasAgentPrepare" class="agent-dot" />
</v-btn>
</div>
</div>
<div class="trace-records" v-if="!event.collapsed">
<div class="trace-record" v-for="record in getVisibleRecords(event)" :key="record.key">
<div class="trace-record-time">{{ record.timeLabel }}</div>
<div class="trace-record-action">{{ record.action }}</div>
<pre class="trace-record-fields">{{ record.fieldsText }}</pre>
</div>
<div class="event-more" v-if="event.visibleCount < event.records.length">
<v-btn size="x-small" variant="tonal" color="primary" @click="showMore(event.span_id)">
Show more
</v-btn>
</div>
</div>
</div>
<div v-if="events.length === 0" class="trace-empty">No trace data yet.</div>
</div>
</div>
</template>
<script>
export default {
name: 'TraceDisplayer',
props: {
autoScroll: {
type: Boolean,
default: true
},
maxItems: {
type: Number,
default: 300
}
},
data() {
return {
events: [],
eventIndex: {},
highlightMap: {},
highlightTimers: {},
eventSource: null,
retryTimer: null,
retryAttempts: 0,
maxRetryAttempts: 10,
baseRetryDelay: 1000,
lastEventId: null,
tableHeight: 'auto'
};
},
async mounted() {
await this.fetchTraceHistory();
this.connectSSE();
this.updateTableHeight();
window.addEventListener('resize', this.updateTableHeight);
},
beforeUnmount() {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
if (this.retryTimer) {
clearTimeout(this.retryTimer);
this.retryTimer = null;
}
this.retryAttempts = 0;
window.removeEventListener('resize', this.updateTableHeight);
},
methods: {
updateTableHeight() {
this.$nextTick(() => {
const el = this.$refs.scrollEl;
if (!el || typeof window === 'undefined') return;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
const offsetTop = el.getBoundingClientRect().top;
const height = Math.max(viewportHeight - offsetTop, 0);
this.tableHeight = `${height}px`;
});
},
async fetchTraceHistory() {
try {
const res = await axios.get('/api/log-history');
const logs = res.data?.data?.logs || [];
const traces = logs.filter((item) => item.type === 'trace');
this.processNewTraces(traces);
} catch (err) {
console.error('Failed to fetch trace history:', err);
}
},
connectSSE() {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
const token = localStorage.getItem('token');
this.eventSource = new EventSourcePolyfill('/api/live-log', {
headers: {
Authorization: token ? `Bearer ${token}` : ''
},
heartbeatTimeout: 300000,
withCredentials: true
});
this.eventSource.onopen = () => {
this.retryAttempts = 0;
if (!this.lastEventId) {
this.fetchTraceHistory();
}
};
this.eventSource.onmessage = (event) => {
try {
if (event.lastEventId) {
this.lastEventId = event.lastEventId;
}
const payload = JSON.parse(event.data);
if (payload?.type !== 'trace') {
return;
}
this.processNewTraces([payload]);
} catch (e) {
console.error('Failed to parse trace payload:', e);
}
};
this.eventSource.onerror = (err) => {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
if (this.retryAttempts >= this.maxRetryAttempts) {
console.error('Trace stream reached max retry attempts.');
return;
}
const delay = Math.min(
this.baseRetryDelay * Math.pow(2, this.retryAttempts),
30000
);
if (this.retryTimer) {
clearTimeout(this.retryTimer);
this.retryTimer = null;
}
this.retryTimer = setTimeout(async () => {
this.retryAttempts++;
if (!this.lastEventId) {
await this.fetchTraceHistory();
}
this.connectSSE();
}, delay);
};
},
processNewTraces(newTraces) {
if (!newTraces || newTraces.length === 0) return;
let hasUpdate = false;
const touched = new Set();
newTraces.forEach((trace) => {
if (!trace.span_id) return;
const recordKey = `${trace.time}-${trace.span_id}-${trace.action}`;
let event = this.eventIndex[trace.span_id];
if (!event) {
event = {
span_id: trace.span_id,
name: trace.name,
umo: trace.umo,
sender_name: trace.sender_name,
message_outline: trace.message_outline,
first_time: trace.time,
last_time: trace.time,
collapsed: true,
visibleCount: 20,
records: [],
hasAgentPrepare: trace.action === 'astr_agent_prepare'
};
this.eventIndex[trace.span_id] = event;
this.events.push(event);
hasUpdate = true;
}
const exists = event.records.some((item) => item.key === recordKey);
if (exists) return;
event.records.push({
time: trace.time,
action: trace.action,
fieldsText: this.formatFields(trace.fields),
timeLabel: this.formatTime(trace.time),
key: recordKey
});
if (trace.action === 'astr_agent_prepare') {
event.hasAgentPrepare = true;
}
if (!event.first_time || trace.time < event.first_time) {
event.first_time = trace.time;
}
if (!event.last_time || trace.time > event.last_time) {
event.last_time = trace.time;
}
if (!event.sender_name && trace.sender_name) {
event.sender_name = trace.sender_name;
}
if (!event.message_outline && trace.message_outline) {
event.message_outline = trace.message_outline;
}
touched.add(trace.span_id);
hasUpdate = true;
});
if (hasUpdate) {
this.events.forEach((event) => {
event.records.sort((a, b) => b.time - a.time);
});
this.events.sort((a, b) => b.first_time - a.first_time);
if (this.events.length > this.maxItems) {
const overflow = this.events.length - this.maxItems;
const removed = this.events.splice(this.maxItems, overflow);
removed.forEach((event) => {
delete this.eventIndex[event.span_id];
});
}
touched.forEach((spanId) => {
this.pulseEvent(spanId);
});
}
},
scrollToBottom() {
const el = this.$refs.scrollEl;
if (!el) return;
el.scrollTop = el.scrollHeight;
},
toggleEvent(spanId) {
const event = this.eventIndex[spanId];
if (!event) return;
event.collapsed = !event.collapsed;
},
showMore(spanId) {
const event = this.eventIndex[spanId];
if (!event) return;
event.visibleCount = Math.min(event.records.length, event.visibleCount + 20);
},
pulseEvent(spanId) {
if (!spanId) return;
if (this.highlightTimers[spanId]) {
clearTimeout(this.highlightTimers[spanId]);
}
this.highlightMap = { ...this.highlightMap, [spanId]: true };
const remove = setTimeout(() => {
const next = { ...this.highlightMap };
delete next[spanId];
this.highlightMap = next;
const timers = { ...this.highlightTimers };
delete timers[spanId];
this.highlightTimers = timers;
}, 1200);
this.highlightTimers = { ...this.highlightTimers, [spanId]: remove };
},
getVisibleRecords(event) {
if (!event.records.length) return [];
return event.records.slice(0, event.visibleCount);
},
formatTime(ts) {
if (!ts) return '';
const date = new Date(ts * 1000);
const base = date.toLocaleString();
const ms = String(date.getMilliseconds()).padStart(3, '0');
return `${base}.${ms}`;
},
shortSpan(spanId) {
if (!spanId) return '';
return spanId.slice(0, 8);
},
formatFields(fields) {
if (!fields) return '';
try {
const text = JSON.stringify(fields, null, 2);
if (text.length > 2000) {
return `${text}`;
}
return text;
} catch (e) {
return String(fields);
}
}
}
};
</script>
<style scoped>
.trace-wrapper {
height: 100%;
}
.trace-table {
background: transparent;
border-radius: 0;
padding: 0;
height: 100%;
overflow-y: auto;
color: #2b3340;
font-family: 'Fira Code', monospace;
}
.trace-row {
display: grid;
grid-template-columns: 200px 100px 300px 90px 180px 140px 200px 1fr;
gap: 12px;
}
.trace-group {
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
background: transparent;
padding: 8px 0;
}
.trace-group.highlight {
background: rgba(59, 130, 246, 0.08);
transition: background 0.6s ease;
}
.trace-event {
align-items: start;
}
.trace-header {
font-weight: 600;
color: #6b7280;
border-bottom: 1px solid rgba(15, 23, 42, 0.12);
padding-bottom: 10px;
}
.trace-cell {
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
}
.event-title {
font-weight: 600;
color: #1f2937;
}
.event-meta {
font-size: 12px;
color: #6b7280;
margin-top: 4px;
}
.event-sub {
font-size: 12px;
color: #4b5563;
margin-top: 2px;
word-break: break-word;
}
.event-sub.outline {
color: #6b7280;
}
.event-controls {
display: flex;
justify-content: flex-end;
}
.agent-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #22c55e;
margin-left: 6px;
vertical-align: middle;
}
.trace-cell.fields pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
color: #4b5563;
}
.trace-empty {
padding: 24px;
text-align: center;
color: #6b7280;
}
@media (max-width: 1200px) {
.trace-row {
grid-template-columns: 140px 160px 300px 70px 140px 180px 1fr;
}
.trace-cell.fields {
grid-column: 1 / -1;
}
}
.trace-record {
display: grid;
grid-template-columns: 200px 120px 1fr;
gap: 8px;
padding: 2px 0;
}
.trace-record:last-child {
border-bottom: none;
}
.trace-record-time {
color: #6b7280;
font-size: 11px;
}
.trace-record-action {
color: #1f2937;
font-weight: 600;
font-size: 11px;
}
.trace-record-fields {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
color: #4b5563;
font-size: 10px;
}
.event-more {
display: flex;
justify-content: center;
padding: 6px 0 2px;
}
.trace-records {
padding: 4px 0 2px 0;
}
</style>
+1 -2
View File
@@ -46,7 +46,6 @@ export class I18nLoader {
{ name: 'features/config', path: 'features/config.json' },
{ name: 'features/config-metadata', path: 'features/config-metadata.json' },
{ name: 'features/console', path: 'features/console.json' },
{ name: 'features/trace', path: 'features/trace.json' },
{ name: 'features/about', path: 'features/about.json' },
{ name: 'features/settings', path: 'features/settings.json' },
{ name: 'features/auth', path: 'features/auth.json' },
@@ -296,4 +295,4 @@ export class I18nLoader {
}
}
}
@@ -11,7 +11,6 @@
"conversation": "Conversations",
"sessionManagement": "Custom Rules",
"console": "Console",
"trace": "Trace",
"alkaid": "Alkaid Lab",
"knowledgeBase": "Knowledge Base",
"about": "About",
@@ -49,7 +49,6 @@
"reply": "Reply",
"providerConfig": "AI Configuration",
"toolsUsed": "Tool Used",
"toolCallUsed": "Used {name} tool",
"pythonCodeAnalysis": "Python Code Analysis Used"
},
"ipython": {
@@ -134,4 +133,4 @@
"sendMessageFailed": "Failed to send message, please try again",
"createSessionFailed": "Failed to create session, please refresh the page"
}
}
}
@@ -135,7 +135,6 @@
},
"sandbox": {
"description": "Agent Sandbox Env(Beta)",
"hint": "https://docs.astrbot.app/en/use/astrbot-agent-sandbox.html",
"provider_settings": {
"sandbox": {
"enable": {
@@ -164,17 +163,6 @@
}
}
},
"skills": {
"description": "Skills",
"provider_settings": {
"skills": {
"runtime": {
"description": "Skill Runtime",
"hint": "Select the runtime for Skills. Sandbox runtime requires sandbox to be enabled first. In local mode, the Agent CAN FULLY ACCESS the runtime environment through Shell and Python tools, but non-admin users will be automatically prohibited from using it to ensure security."
}
}
}
},
"truncate_and_compress": {
"description": "Context Management Strategy",
"provider_settings": {
@@ -247,14 +235,6 @@
"tool_call_timeout": {
"description": "Tool Call Timeout (seconds)"
},
"tool_schema_mode": {
"description": "Tool Schema Mode",
"hint": "Skills-like sends name/description first and re-queries for parameters; Full sends the complete schema in one step.",
"labels": [
"Skills-like (two-stage)",
"Full schema"
]
},
"streaming_response": {
"description": "Streaming Output"
},
@@ -467,8 +447,7 @@
"description": "Segment Only LLM Results"
},
"interval_method": {
"description": "Interval Method",
"hint": "random 为随机时间,log 为根据消息长度计算,$y=log_<log_base>(x)$x为字数,y的单位为秒。"
"description": "Interval Method"
},
"interval": {
"description": "Random Interval Time",
@@ -476,15 +455,13 @@
},
"log_base": {
"description": "Logarithm Base",
"hint": "Base for logarithmic intervals, defaults to 2.6. Value range: 1.0-10.0."
"hint": "Base for logarithmic intervals, defaults to 2.0. Value range: 1.0-10.0."
},
"words_count_threshold": {
"description": "Segmented Reply Word Count Threshold",
"hint": "Segmented reply word count threshold. Only messages with less than this number of words will be segmented, and messages with more than this number of words will be sent directly (not segmented)."
"description": "Segmented Reply Word Count Threshold"
},
"split_mode": {
"description": "Split Mode",
"hint": "Used to segment a message. By default, it will be separated by punctuation marks like period, question mark, etc. For example, filling `[。?!]` will remove all periods, question marks, and exclamation marks. re.findall(r'<regex>', text)",
"labels": [
"Regex",
"Words List"
@@ -564,30 +541,6 @@
"description": "Console Log Level",
"hint": "Log level for console output."
},
"log_file_enable": {
"description": "Enable File Logging",
"hint": "Write logs to a file in addition to the console."
},
"log_file_path": {
"description": "Log File Path",
"hint": "Relative paths are resolved under the data directory, e.g. logs/astrbot.log; absolute paths are supported."
},
"log_file_max_mb": {
"description": "Log File Max Size (MB)",
"hint": "Rotate when exceeding this size; default 20MB."
},
"trace_log_enable": {
"description": "Enable Trace File Logging",
"hint": "Write trace events to a separate file (does not change console output)."
},
"trace_log_path": {
"description": "Trace Log File Path",
"hint": "Relative paths are resolved under the data directory, e.g. logs/astrbot.trace.log; absolute paths are supported."
},
"trace_log_max_mb": {
"description": "Trace Log Max Size (MB)",
"hint": "Rotate when exceeding this size; default 20MB."
},
"pip_install_arg": {
"description": "Additional pip Installation Arguments",
"hint": "When installing plugin dependencies, Python's pip tool will be used. Additional arguments can be provided here, such as `--break-system-package`."
@@ -89,23 +89,6 @@
},
"codeEditor": {
"title": "Edit Configuration File"
},
"fileUpload": {
"button": "Manage Files",
"dialogTitle": "Uploaded Files",
"dropzone": "Upload new file",
"allowedTypes": "Allowed types: {types}",
"empty": "No files uploaded",
"statusMissing": "Missing file",
"statusUnconfigured": "Not in config",
"uploadSuccess": "Uploaded {count} files",
"uploadFailed": "Upload failed",
"loadFailed": "Failed to load file list",
"fileTooLarge": "File too large (max {max} MB): {name}",
"deleteSuccess": "Deleted file",
"deleteFailed": "Delete failed",
"addToConfig": "Added to config",
"fileCount": "Files: {count}",
"done": "Done"
}
}
@@ -4,7 +4,6 @@
"tabs": {
"installedPlugins": "Installed Plugins",
"installedMcpServers": "Installed MCP Servers",
"skills": "Skills",
"handlersOperation": "Manage Handlers",
"market": "Extension Market"
},
@@ -152,11 +151,6 @@
"title": "No New Version Detected",
"message": "No new version detected for this plugin. Do you want to force reinstall? This will pull the latest code from the remote repository.",
"confirm": "Force Update"
},
"updateAllConfirm": {
"title": "Confirm Update All Plugins",
"message": "Are you sure you want to update all {count} plugins? This operation may take some time.",
"confirm": "Confirm Update"
}
},
"messages": {
@@ -190,28 +184,6 @@
"selectFile": "Select File",
"enterUrl": "Enter extension repository URL"
},
"skills": {
"upload": "Upload Skills",
"refresh": "Refresh",
"empty": "No Skills found",
"emptyHint": "Upload a Skills zip to get started",
"uploadDialogTitle": "Upload Skills",
"uploadHint": "Upload a zip file that contains skill_name/ and a SKILL.md inside.",
"selectFile": "Select file",
"confirmUpload": "Upload",
"cancel": "Cancel",
"noDescription": "No description",
"path": "Path",
"uploadSuccess": "Upload succeeded",
"uploadFailed": "Upload failed",
"loadFailed": "Failed to load Skills",
"updateSuccess": "Updated successfully",
"updateFailed": "Update failed",
"deleteTitle": "Delete confirmation",
"deleteMessage": "Are you sure you want to delete this Skill?",
"deleteSuccess": "Deleted successfully",
"deleteFailed": "Delete failed"
},
"card": {
"actions": {
"pluginConfig": "Extension Config",
@@ -38,17 +38,6 @@
"loadingTools": "Loading tools...",
"allToolsAvailable": "Use all available tools",
"noToolsSelected": "No tools selected",
"skills": "Skills Selection",
"skillsHelp": "Select available Skills for this persona. Skills provide reusable workflows and guidance.",
"skillsAllAvailable": "Use all available Skills",
"skillsSelectSpecific": "Select specific Skills",
"searchSkills": "Search Skills",
"selectedSkills": "Selected Skills",
"noSkillsAvailable": "No skills available",
"noSkillsFound": "No matching skills found",
"loadingSkills": "Loading skills...",
"allSkillsAvailable": "Use all available Skills",
"noSkillsSelected": "No skills selected",
"createInFolder": "Will be created in \"{folder}\"",
"rootFolder": "All Personas"
},
@@ -84,7 +73,6 @@
"persona": {
"personasTitle": "Personas",
"toolsCount": "tools",
"skillsCount": "skills",
"contextMenu": {
"moveTo": "Move to..."
},
@@ -1,7 +0,0 @@
{
"title": "Trace",
"autoScroll": {
"enabled": "Auto-scroll: On",
"disabled": "Auto-scroll: Off"
}
}
@@ -11,7 +11,6 @@
"conversation": "对话数据",
"sessionManagement": "自定义规则",
"console": "平台日志",
"trace": "追踪",
"alkaid": "Alkaid",
"knowledgeBase": "知识库",
"about": "关于",
@@ -31,4 +30,4 @@
"selectVersion": "选择版本",
"current": "当前"
}
}
}
@@ -49,7 +49,6 @@
"reply": "引用回复",
"providerConfig": "AI 配置",
"toolsUsed": "已使用工具",
"toolCallUsed": "已使用 {name} 工具",
"pythonCodeAnalysis": "已使用 Python 代码分析"
},
"ipython": {
@@ -136,4 +135,4 @@
"sendMessageFailed": "发送消息失败,请重试",
"createSessionFailed": "创建会话失败,请刷新页面重试"
}
}
}
@@ -135,7 +135,6 @@
},
"sandbox": {
"description": "Agent 沙箱环境(Beta)",
"hint": "https://docs.astrbot.app/use/astrbot-agent-sandbox.html",
"provider_settings": {
"sandbox": {
"enable": {
@@ -164,17 +163,6 @@
}
}
},
"skills": {
"description": "Skills",
"provider_settings": {
"skills": {
"runtime": {
"description": "Skill Runtime",
"hint": "选择 Skills 运行环境。使用 sandbox 前需启用沙箱;local 模式下 Agent 可通过 Shell 和 Python 功能完全访问运行环境,非管理员将被自动禁止使用以保证安全。"
}
}
}
},
"truncate_and_compress": {
"description": "上下文管理策略",
"provider_settings": {
@@ -244,14 +232,6 @@
"tool_call_timeout": {
"description": "工具调用超时时间(秒)"
},
"tool_schema_mode": {
"description": "工具调用模式",
"hint": "skills-like 先下发工具名称与描述,再下发参数;full 一次性下发完整参数。",
"labels": [
"Skills-like(两阶段)",
"Full(完整参数)"
]
},
"streaming_response": {
"description": "流式输出"
},
@@ -465,8 +445,7 @@
"description": "仅对 LLM 结果分段"
},
"interval_method": {
"description": "间隔方法",
"hint": "random 为随机时间,log 为根据消息长度计算,$y=log_<log_base>(x)$x为字数,y的单位为秒。"
"description": "间隔方法"
},
"interval": {
"description": "随机间隔时间",
@@ -474,15 +453,13 @@
},
"log_base": {
"description": "对数底数",
"hint": "对数间隔的底数,默认为 2.6。取值范围为 1.0-10.0。"
"hint": "对数间隔的底数,默认为 2.0。取值范围为 1.0-10.0。"
},
"words_count_threshold": {
"description": "分段回复字数阈值",
"hint": "分段回复的字数上限。只有字数小于此值的消息才会被分段,超过此值的长消息将直接发送(不分段),默认为 150。"
"description": "分段回复字数阈值"
},
"split_mode": {
"description": "分段模式",
"hint": "用于分隔一段消息。默认情况下会根据句号、问号等标点符号分隔。如填写 `[。?!]` 将移除所有的句号、问号、感叹号。re.findall(r'<regex>', text)",
"labels": [
"正则表达式",
"分段词列表"
@@ -562,30 +539,6 @@
"description": "控制台日志级别",
"hint": "控制台输出日志的级别。"
},
"log_file_enable": {
"description": "启用文件日志",
"hint": "在控制台输出的同时,将日志写入文件。"
},
"log_file_path": {
"description": "日志文件路径",
"hint": "相对路径以 data 目录为基准,例如 logs/astrbot.log;支持绝对路径。"
},
"log_file_max_mb": {
"description": "日志文件大小上限 (MB)",
"hint": "超过大小后自动轮转,默认 20MB。"
},
"trace_log_enable": {
"description": "启用 Trace 文件日志",
"hint": "将 Trace 事件写入独立文件(不影响控制台输出)。"
},
"trace_log_path": {
"description": "Trace 日志文件路径",
"hint": "相对路径以 data 目录为基准,例如 logs/astrbot.trace.log;支持绝对路径。"
},
"trace_log_max_mb": {
"description": "Trace 日志大小上限 (MB)",
"hint": "超过大小后自动轮转,默认 20MB。"
},
"pip_install_arg": {
"description": "pip 安装额外参数",
"hint": "安装插件依赖时,会使用 Python 的 pip 工具。这里可以填写额外的参数,如 `--break-system-package` 等。"
@@ -89,23 +89,5 @@
},
"codeEditor": {
"title": "编辑配置文件"
},
"fileUpload": {
"button": "管理文件",
"dialogTitle": "已上传文件",
"dropzone": "上传新文件",
"allowedTypes": "允许类型:{types}",
"empty": "暂无已上传文件",
"statusMissing": "文件缺失",
"statusUnconfigured": "未加入配置",
"uploadSuccess": "已上传 {count} 个文件",
"uploadFailed": "上传失败",
"loadFailed": "获取文件列表失败",
"fileTooLarge": "文件过大(上限 {max} MB):{name}",
"deleteSuccess": "已删除文件",
"deleteFailed": "删除失败",
"addToConfig": "已加入配置",
"fileCount": "文件:{count}",
"done": "完成"
}
}
}
@@ -4,7 +4,6 @@
"tabs": {
"installedPlugins": "已安装的插件",
"installedMcpServers": "已安装的 MCP 服务器",
"skills": "Skills",
"handlersOperation": "管理行为",
"market": "插件市场"
},
@@ -152,11 +151,6 @@
"title": "未检测到新版本",
"message": "当前插件未检测到新版本,是否强制重新安装?这将从远程仓库拉取最新代码。",
"confirm": "强制更新"
},
"updateAllConfirm": {
"title": "确认更新全部插件",
"message": "确定要更新全部 {count} 个插件吗?此操作可能需要一些时间。",
"confirm": "确认更新"
}
},
"messages": {
@@ -190,28 +184,6 @@
"selectFile": "选择文件",
"enterUrl": "输入插件仓库链接"
},
"skills": {
"upload": "上传 Skills",
"refresh": "刷新",
"empty": "暂无 Skills",
"emptyHint": "请上传 Skills 压缩包",
"uploadDialogTitle": "上传 Skills",
"uploadHint": "请上传 zip 压缩包,解压后为 skill_name/ 目录,且包含 SKILL.md",
"selectFile": "选择文件",
"confirmUpload": "上传",
"cancel": "取消",
"noDescription": "无描述",
"path": "路径",
"uploadSuccess": "上传成功",
"uploadFailed": "上传失败",
"loadFailed": "加载 Skills 失败",
"updateSuccess": "更新成功",
"updateFailed": "更新失败",
"deleteTitle": "删除确认",
"deleteMessage": "确定要删除该 Skill 吗?",
"deleteSuccess": "删除成功",
"deleteFailed": "删除失败"
},
"card": {
"actions": {
"pluginConfig": "插件配置",
@@ -38,17 +38,6 @@
"loadingTools": "正在加载工具...",
"allToolsAvailable": "使用所有可用工具",
"noToolsSelected": "未选择任何工具",
"skills": "Skills 选择",
"skillsHelp": "为这个人格选择可用的 Skills。Skills 会给 AI 提供可复用的流程与规范。",
"skillsAllAvailable": "默认使用全部 Skills",
"skillsSelectSpecific": "选择指定 Skills",
"searchSkills": "搜索 Skills",
"selectedSkills": "已选择的 Skills",
"noSkillsAvailable": "暂无可用 Skills",
"noSkillsFound": "未找到匹配的 Skills",
"loadingSkills": "正在加载 Skills...",
"allSkillsAvailable": "使用所有可用 Skills",
"noSkillsSelected": "未选择任何 Skills",
"createInFolder": "将在「{folder}」中创建",
"rootFolder": "全部人格"
},
@@ -84,7 +73,6 @@
"persona": {
"personasTitle": "人格",
"toolsCount": "个工具",
"skillsCount": "个 Skills",
"contextMenu": {
"moveTo": "移动到..."
},
@@ -1,7 +0,0 @@
{
"title": "追踪",
"autoScroll": {
"enabled": "自动滚动:开",
"disabled": "自动滚动:关"
}
}
+1 -5
View File
@@ -19,7 +19,6 @@ import zhCNPlatform from './locales/zh-CN/features/platform.json';
import zhCNConfig from './locales/zh-CN/features/config.json';
import zhCNConfigMetadata from './locales/zh-CN/features/config-metadata.json';
import zhCNConsole from './locales/zh-CN/features/console.json';
import zhCNTrace from './locales/zh-CN/features/trace.json';
import zhCNAbout from './locales/zh-CN/features/about.json';
import zhCNSettings from './locales/zh-CN/features/settings.json';
import zhCNAuth from './locales/zh-CN/features/auth.json';
@@ -57,7 +56,6 @@ import enUSPlatform from './locales/en-US/features/platform.json';
import enUSConfig from './locales/en-US/features/config.json';
import enUSConfigMetadata from './locales/en-US/features/config-metadata.json';
import enUSConsole from './locales/en-US/features/console.json';
import enUSTrace from './locales/en-US/features/trace.json';
import enUSAbout from './locales/en-US/features/about.json';
import enUSSettings from './locales/en-US/features/settings.json';
import enUSAuth from './locales/en-US/features/auth.json';
@@ -99,7 +97,6 @@ export const translations = {
config: zhCNConfig,
'config-metadata': zhCNConfigMetadata,
console: zhCNConsole,
trace: zhCNTrace,
about: zhCNAbout,
settings: zhCNSettings,
auth: zhCNAuth,
@@ -145,7 +142,6 @@ export const translations = {
config: enUSConfig,
'config-metadata': enUSConfigMetadata,
console: enUSConsole,
trace: enUSTrace,
about: enUSAbout,
settings: enUSSettings,
auth: enUSAuth,
@@ -173,4 +169,4 @@ export const translations = {
}
};
export type TranslationData = typeof translations;
export type TranslationData = typeof translations;
@@ -46,13 +46,6 @@ let releases = ref([]);
let updatingDashboardLoading = ref(false);
let installLoading = ref(false);
const getSelectedGitHubProxy = () => {
if (typeof window === "undefined" || !window.localStorage) return "";
return localStorage.getItem("githubProxyRadioValue") === "1"
? localStorage.getItem("selectedGitHubProxy") || ""
: "";
};
// Release Notes Modal
let releaseNotesDialog = ref(false);
let selectedReleaseNotes = ref('');
@@ -211,7 +204,7 @@ function switchVersion(version: string) {
installLoading.value = true;
axios.post('/api/update/do', {
version: version,
proxy: getSelectedGitHubProxy()
proxy: localStorage.getItem('selectedGitHubProxy') || ''
})
.then((res) => {
updateStatus.value = res.data.message;
@@ -803,4 +796,4 @@ const changeLanguage = async (langCode: string) => {
font-size: 16px;
}
}
</style>
</style>
@@ -72,11 +72,6 @@ const sidebarItem: menu[] = [
icon: 'mdi-console',
to: '/console'
},
{
title: 'core.navigation.trace',
icon: 'mdi-timeline-text-outline',
to: '/trace'
},
]
}
// {

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