Compare commits

..

402 Commits

Author SHA1 Message Date
Soulter eafb339281 feat(logging): add file and trace logging configuration options 2026-01-30 11:54:08 +08:00
Soulter f03dd87502 fix(log): increase log cache size from 200 to 500 2026-01-30 10:53:15 +08:00
Soulter 6e475074a4 feat: trace 2026-01-29 20:56:45 +08:00
Helian Nuits 2aa0986295 fix(db): using lambda expression to ensure updated_at field (#4730)
* fix(db): 使用 lambda 表达式确保 updated_at 字段正确更新

updated_at 字段原先在 sa_column_kwargs["onupdate"] 中直接使用了 datetime.now(),导致时间戳仅在模块导入时被计算一次,之后不再变化,结果所有记录的更新时间都被设成了程序启动时间。

本次修改将时间戳生成逻辑封装进 lambda 表达式,使 SQLAlchemy 在每次更新记录时才惰性求值,从而保证时间戳实时更新。

* refactor(db): 根据建议引入 TimestampMixin 统一时间戳定义,提取 `created_at`/`updated_at` 至 [TimestampMixin]
2026-01-29 19:07:21 +08:00
Soulter 34c6ceb67c fix(docs): update feature description to include 'Skills' in README files 2026-01-29 17:22:48 +08:00
Soulter 906877cbe6 feat(i18n): add localized message for tool usage in chat 2026-01-29 16:29:44 +08:00
Soulter 609180022e feat(chat): refactor message rendering and introduce ToolCallItem component 2026-01-29 16:07:57 +08:00
Soulter 49c087a141 docs: replace demo banner in readme
Updated the image in the README file.
2026-01-29 12:17:11 +08:00
Soulter 70f12cd686 docs(readme): update language links and enhance feature descriptions 2026-01-29 12:09:38 +08:00
Soulter ea82e00359 fix(changelog): clarify support for Anthropic Skills with usage reference 2026-01-29 00:54:39 +08:00
Soulter 928c557a25 fix: update markstream-vue and stream-monaco dependencies 2026-01-29 00:48:51 +08:00
Soulter 0500ee8e2b chore: bump version to 4.13.0 2026-01-29 00:21:58 +08:00
vmoranv f92f0a3e5d feat(core): supports anthropic-skills-like tool call mode (#4681)
* feat(core): change llmtool to claude skills like func call

* feat: refactor tool execution logic in ToolLoopAgentRunner for improved clarity and efficiency

* feat(core): 添加工具调用模式配置选项

新增 tool_schema_mode 配置项,支持两种工具调用模式:
- skills_like:先发送工具名称和描述,再查询参数(两阶段)
- full:一次性发送完整工具模式

更新了默认配置、配置元数据定义以及代理子阶段处理逻辑,
添加了完整的工具调用提示语句,并在仪表板中提供了国际化支持。

* feat: 优化工具集获取逻辑,添加轻量和参数工具集返回方法

* refactor(runner): 重构工具模式处理逻辑到ToolLoopAgentRunner

- 将工具集激活逻辑提取到新的_build_active_tool_set方法中
- 实现工具模式配置功能,支持full和light模式的动态切换
- 移除InternalAgentSubStage中的工具模式应用逻辑,统一在runner中处理
- 添加_tool_schema_full_set和_tool_schema_param_set实例变量来管理工具集状态
- 修改工具查询逻辑以使用新的工具集管理方式

* fix: update default tool_schema_mode to 'full' in InternalAgentSubStage

* refactor: rename TOOL_CALL_PROMPT_FULL to TOOL_CALL_PROMPT_SKILLS_LIKE_MODE and update prompt logic

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-01-28 22:49:34 +08:00
Soulter c1b764da04 fix: webui github proxy selector and bugs after uninstalling plugins (#4724)
fixes: #4709
2026-01-28 21:04:13 +08:00
Soulter 22bd8d6824 feat: support anthropic skills (#4715)
* feat: support anthropic skills

closes: #4687

* chore: ruff

* feat: implement skills management and selection in persona configuration

* feat: enhance skills management with local environment tools and permissions
2026-01-28 01:48:57 +08:00
xunxiing a4fc92e803 feat: add file upload to plugin config (#4539)
Co-authored-by: Soulter <905617992@qq.com>
2026-01-27 14:56:19 +08:00
Soulter a41391f9f2 feat: resolve provider api keys from env (#4696) 2026-01-26 22:37:30 +08:00
Soulter b04dad1fd2 docs: add AGENTS.md 2026-01-26 21:21:26 +08:00
xunxiing 3765dd46f7 fix: gemini toolcall repetition call (#4686)
* 修复gemini toolcall 的名称导致的循环调用

* Apply suggestions from code review

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

* Refactor function response creation for tool role

Refactor function response handling for tool role to ensure proper ID injection.

---------

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2026-01-26 17:14:58 +08:00
Soulter 17d642efc9 fix: update configuration metadata hints for segmented reply settings 2026-01-25 14:28:07 +08:00
時壹 4839cc6119 feat: add configurable Dashboard API access log toggle (#4661)
* feat: add configurable Dashboard API access log toggle

* chore: remove Dashboard API access log configuration
2026-01-24 16:31:23 +08:00
搁浅 127e8c31c2 feat: add confirmation dialog for update all plugins button to prevent accidental clicks #4300 (#4658) 2026-01-24 16:08:47 +08:00
Soulter 1cf673154c chore: bump version to 4.12.4 2026-01-24 14:55:32 +08:00
Soulter f7c228ede2 fix: markdown keyerror or unbound error in aiocqhttp adapter (#4656)
* fix: markdown keyerror or unbound error in aiocqhttp adapter

* fix: improve exception handling and logging in aiocqhttp adapter
2026-01-24 14:43:49 +08:00
Soulter 78617ec7ce fix: enhance provider selection error handling and logging (#4654) 2026-01-24 14:25:41 +08:00
Soulter e5048bddeb chore: remove deprecated tool commands
closes: #4599
2026-01-23 20:00:59 +08:00
Soulter eebe31f69d fix: update web_search_tavily handling for webchat platform (#4633)
* fix: update web_search_tavily handling for webchat platform

* chore: style consistent
2026-01-23 19:52:31 +08:00
Copilot 90b57eb5cb fix: provider selector button hidden by long model names (#4631)
* Initial plan

* Fix long model name overflow in ProviderSelector

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2026-01-23 19:42:12 +08:00
Soulter 2b2edf4852 fix: genie tts config 2026-01-23 19:00:57 +08:00
Soulter a920e45f96 feat: AstrBot Live Chat Mode on ChatUI (#4534)
* feat: astr live

* chore: remove

* feat: metrics

* feat: enhance audio processing and metrics display in live mode

* feat: genie tts

* feat: enhance live mode audio processing and text handling

* feat: add metrics

* feat: eyes

* feat: nervous

* chore: update readme

Added '自动压缩对话' feature and updated features list.

* 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

* fix: clarify logic for skipping initial system messages in conversation

* chore: bump version to 4.12.2

* docs: update 4.12.2 changelog

* refactor: update event types for LLM tool usage and response

* chore: bump version to 4.12.3

* fix: ensure embedding dimensions are returned as integers in providers (#4547)

* fix: ensure embedding dimensions are returned as integers in providers

* chore: ruff format

* perf: T2I template editor preview (#4574)

* 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

* 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>

* 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>

* perf: live mode entry

* chore: remove japanese prompt

---------

Co-authored-by: Anima-IGCenter <cacheigcrystal2@gmail.com>
Co-authored-by: Clhikari <Clhikari@qq.com>
Co-authored-by: jiangman202506 <jiangman202506@163.com>
Co-authored-by: ManJiang <man.jiang@jg-robust.com>
Co-authored-by: Ruochen Pan <67079377+RC-CHN@users.noreply.github.com>
2026-01-22 16:24:40 +08:00
Ruochen Pan 8910ab3a47 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 13:05:33 +08:00
jiangman202506 c09bbfb8ac 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 12:48:45 +08:00
Clhikari 02909c62ab 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 12:37:18 +08:00
Anima-IGCenter 978d9cbb6a perf: T2I template editor preview (#4574) 2026-01-20 10:23:37 +08:00
Soulter cb3825bb00 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-18 17:09:25 +08:00
Soulter 5f54becbe2 chore: bump version to 4.12.3 2026-01-17 19:11:05 +08:00
Soulter 317b6fa475 refactor: update event types for LLM tool usage and response 2026-01-17 19:09:49 +08:00
Soulter 8199c83072 docs: update 4.12.2 changelog 2026-01-17 18:12:08 +08:00
Soulter 776c9ebfdd chore: bump version to 4.12.2 2026-01-17 18:07:54 +08:00
Soulter 73fca5d1a2 fix: clarify logic for skipping initial system messages in conversation 2026-01-17 18:02:31 +08:00
Soulter 844773a735 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-17 17:57:11 +08:00
Soulter 1a7e8456ab chore: update readme
Added '自动压缩对话' feature and updated features list.
2026-01-16 17:57:49 +08:00
Soulter f6a189f118 feat: add event hooks for tool usage and response handling (#4516)
* feat: add event hooks for tool usage and response handling

* fix: update decorator for LLM tool response handling
2026-01-16 16:51:35 +08:00
Soulter 82e2e0d02f feat: add web search references feature with sidebar and extraction logic (#4515)
* feat: add web search references feature with sidebar and extraction logic

* fix: reorder import statements for consistency

* chore: remove log
2026-01-16 16:49:48 +08:00
Soulter 8771317a1e perf: chatui default persona (#4502) 2026-01-16 16:46:39 +08:00
Soulter ebae70c514 chore: bump version to 4.12.1 2026-01-15 22:20:52 +08:00
Soulter dbdb4f5185 fix: unique session not working (#4490)
* fix: unique session not working

* fix: correct session initialization and update unified_msg_origin setter

* fix: update session ID assignment in WakingCheckStage
2026-01-15 22:16:21 +08:00
Soulter af2b3b3bfc fix: update stream-monaco dependency to version 0.0.15 2026-01-15 19:53:58 +08:00
Soulter 6497d9a46f fix: update stream-markdown dependency to version 0.0.13 2026-01-15 19:52:23 +08:00
Soulter 8f4a62a2cb chore: bump version to 4.12.0 2026-01-15 19:47:53 +08:00
Soulter acbe83a2e2 chore: bump version to 4.12.0 2026-01-15 19:47:25 +08:00
Gao Jinzhe e0f3fb3c3d Merge pull request #4194 from Luna-channel/feat/session-management
feat: add batch operation functionality for session management
2026-01-15 19:38:24 +08:00
Soulter fef789e4d3 feat: add Docker Compose configuration for AstrBot and Shipyard services 2026-01-15 18:53:24 +08:00
Soulter 680b900c76 feat: implement iPython tool and reasoning blocks with enhanced UI components 2026-01-15 18:15:42 +08:00
Soulter f797f132cf perf: refine tool call related prompts 2026-01-15 17:22:50 +08:00
Soulter 941ab6db84 chore: add requirement 2026-01-15 16:19:26 +08:00
Soulter 5eea508296 feat: astrbot agent sandbox env(improved code interpreter) (#4449)
* stage

* fix: update tool call logging to include tool call IDs and enhance sandbox ship creation parameters

* feat: file upload

* fix

* update

* fix: remove 'boxlite' option from booter and handle error in PythonTool execution

* feat: implement singleton pattern for ShipyardSandboxClient and add FileUploadTool for file uploads

* feat: sandbox

* fix

* beta

* uv lock

* remove

* chore: makes world better

* feat: implement localStorage persistence for showReservedPlugins state

* docs: refine EULA

* fix

* feat: add availability check for sandbox in Shipyard and base booters

* feat: add shipyard session configuration options and update related tools

* feat: add file download functionality and update shipyard SDK version

* fix: sending OpenAI-style image_url causes Anthropic 400 invalid tag error (#4444)

* feat: chatui project (#4477)

* feat: chatui-project

* fix: remove console log from getProjects function

* fix: title saving logic and update project sessions on changes

* docs: standardize Context class documentation formatting (#4436)

* docs: standardize Context class documentation formatting

- Unified all method docstrings to standard format
- Fixed mixed language and formatting issues
- Added complete parameter and return descriptions
- Enhanced developer experience for plugin creators
- Fixes #4429

* docs: fix Context class documentation issues per review

- Restored Sphinx directives for versionadded notes
- Fixed MessageSesion typo to MessageSession throughout file
- Added clarification for kwargs propagation in tool_loop_agent
- Unified deprecation marker format
- Fixes #4429

* Convert developer API comments to English

* chore: revise comments

---------

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>

* fix: handle empty output case in PythonTool execution

* fix: update description for command parameter in ExecuteShellTool

* refactor: remove unused file tools and update PythonTool output handling

* project list

* fix: ensure message stream order (#4487)

* feat: enhance iPython tool rendering with Shiki syntax highlighting

* bugfixes

* feat: add sandbox mode prompt for enhanced user guidance in executing commands

* chore: remove skills prompt

---------

Co-authored-by: 時壹 <137363396+KBVsent@users.noreply.github.com>
Co-authored-by: Li-shi-ling <114913764+Li-shi-ling@users.noreply.github.com>
2026-01-15 16:17:56 +08:00
時壹 9782d1bff8 fix:exclude disabled commands from platform command registration (#4485) 2026-01-15 14:04:15 +08:00
Soulter 0e3d224c12 fix: ensure message stream order (#4487) 2026-01-15 13:11:27 +08:00
Soulter 8aeb2229ce fix: chatui title (#4486) 2026-01-15 12:47:55 +08:00
Li-shi-ling 179f3e6426 docs: standardize Context class documentation formatting (#4436)
* docs: standardize Context class documentation formatting

- Unified all method docstrings to standard format
- Fixed mixed language and formatting issues
- Added complete parameter and return descriptions
- Enhanced developer experience for plugin creators
- Fixes #4429

* docs: fix Context class documentation issues per review

- Restored Sphinx directives for versionadded notes
- Fixed MessageSesion typo to MessageSession throughout file
- Added clarification for kwargs propagation in tool_loop_agent
- Unified deprecation marker format
- Fixes #4429

* Convert developer API comments to English

* chore: revise comments

---------

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2026-01-14 23:46:27 +08:00
Soulter 561741d43d fix: title saving logic and update project sessions on changes 2026-01-14 20:51:54 +08:00
Soulter 63e8d0634f feat: chatui project (#4477)
* feat: chatui-project

* fix: remove console log from getProjects function
2026-01-14 19:15:48 +08:00
時壹 350667b60f fix: sending OpenAI-style image_url causes Anthropic 400 invalid tag error (#4444) 2026-01-14 10:50:56 +08:00
Soulter 6a86dae76e docs: refine EULA 2026-01-13 12:19:05 +08:00
Soulter a7eca40fe7 feat: implement localStorage persistence for showReservedPlugins state 2026-01-13 02:23:31 +08:00
Soulter ef28dc5001 chore: makes world better 2026-01-13 02:20:24 +08:00
Soulter d29ac4023a fix: typo 2026-01-12 21:24:11 +08:00
Soulter c2af2c6d5e chore: bump version to 4.11.4 2026-01-12 20:42:49 +08:00
Soulter d9fb29d314 docs: add initial EULA for user agreement and compliance 2026-01-12 20:32:28 +08:00
Soulter 981421ded6 docs: update readme 2026-01-12 20:31:17 +08:00
Soulter 49ad22ca82 fix(i18n): refine default source label in English and Chinese locales 2026-01-12 20:05:21 +08:00
Soulter 858e245108 chore: remove default provider source displayed in webui 2026-01-12 19:51:18 +08:00
Soulter 6ac37ecd60 chore: bump version to 4.11.3 2026-01-12 19:35:41 +08:00
Soulter 2bbe010747 Sanitize invalid platform IDs on load (#4432) 2026-01-12 19:04:44 +08:00
Soulter 52bba9026a feat(safety): LLM healthy mode (#4431)
* feat(safety): implement LLM safety mode

* chore: ruff format
2026-01-12 18:33:34 +08:00
clown145 3416e8990c fix(webui): optimize markdown rendering and remove redundant code (#4415)
* fix(dashboard): optimize markdown rendering and remove redundant code

* style: format code and refactor ReadmeDialog for i18n/isolation

* fix: robust clipboard fallback for http context

* refactor: optimize markdown rendering and fix table styles in ReadmeDialog
2026-01-12 17:39:53 +08:00
時壹 eedb62a5a3 fix: detect image MIME type from binary data for Anthropic API (#4426) 2026-01-12 17:35:23 +08:00
Oscar Shaw e8bd821e72 feat(log): append version number tag to WARN and ERROR level logs (#4388)
* feat(log): 在 WARN 和 ERROR 级别日志中追加版本号标签

* refactor(core): 简化日志版本标签过滤逻辑以包含 WARNING 及以上级别
2026-01-12 17:30:38 +08:00
NayukiMeko 131950b909 fix (#4297): fix list config being saved as [""] instead of [] after deletion (#4401)
* fix: 修复列表配置项删除后保存为['']而非[]的问题 (#4297)

* fix: 添加类型检查以处理非字符串列表项

* refactor: 移除 ExtensionPage 中重复的 cleanEmptyListItems

过滤逻辑已在 ListConfigItem.vue 源头处理,保存配置时无需再次过滤。
2026-01-11 18:53:15 +08:00
Futureppo 2e172804e3 feat(context): sannitize llm context by modalities (#4367)
* feat(context): 添加按模型能力清理历史上下文

* fix(config): 更新历史上下文清理提示信息

* chore: ruff format

* fix: simplify modality checks and sanitize context handling

* fix(config): disable context sanitization by modalities

* fix(agent): skip messages with empty roles in InternalAgentSubStage

* fix(agent): refine tool call handling in InternalAgentSubStage

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-01-11 15:39:23 +08:00
Gao Jinzhe 2f3a3f354f fix: add image placeholder for non-vision models to fix no response in private chat (#4411)
* fix: 修复私聊中单独发送图片无响应的问题,为非视觉模型添加图片占位符

* ruffcheck

* 修复占位符被重复添加的问题

* 简化逻辑
2026-01-11 15:11:35 +08:00
letr 86e9b41dde fix(core): correct duplicate word in agent logger warning (#4390)
- Fix duplicate '没有' in logger warning message
- Improve punctuation and readability of tool response comments
2026-01-11 15:00:35 +08:00
Gao Jinzhe 8dfe43f22f fix(webui): add null check for plugin list in config to fix empty list issue (#4392) 2026-01-11 14:39:54 +08:00
stevessr 6c2f738940 fix: when session_id including ":" (#4380) 2026-01-11 14:33:44 +08:00
letr c1102f2f5c fix(webui): fix unexpected expansion of all rows in tool table (#4366)
Corrected the property from `item-key` to `item-value` to align with
Vuetify 3 API. This ensures each row has a unique identifier for
the expansion state.
2026-01-11 14:27:07 +08:00
Li-shi-ling 9a91f2fb11 fix: ensure atomic creation of knowledge base with proper cleanup on failure (#4406)
* fix: ensure atomic creation of knowledge base with proper cleanup on failure

- Added pre-validation for embedding_provider_id parameter
- Added check for existing knowledge base with same name
- Implemented proper rollback mechanism when KBHelper initialization fails
- Uses same session for cleanup to ensure data consistency
- Fixes #4403

* fix: ensure atomic KB creation with session.flush() to remove race condition risks

* fix: ensure change the annotation back
2026-01-11 14:24:26 +08:00
Soulter 81309bc908 perf: enhance reply functionality to support selected text quoting (#4387)
* feat(chat): enhance reply functionality to support selected text quoting

* perf: improve ui

* feat(chat): add label for tools used in tool calls and update translations

* feat(chat): simplify reply handling by removing text truncation logic
2026-01-09 18:04:43 +08:00
Soulter f003b83443 docs: update demo banner 2026-01-09 16:49:03 +08:00
Soulter 34921e91f0 chore: bump version to 4.11.2 2026-01-08 15:32:37 +08:00
Soulter 6c15592cbb feat(metrics): add metrics tracking for plugin installation events 2026-01-08 15:29:32 +08:00
letr 8c7a4b87d0 fix(webui): maintain international consistency of the 'repo' button (#4358) 2026-01-08 13:34:13 +08:00
Gao Jinzhe 8ff12e3972 fix: on_waiting_llm_request hook did not check message validity (#4349)
* fix:修复waitingllmrequest没有检查消息有效性的问题

* 进行ruff修复
2026-01-07 12:51:00 +08:00
Gao Jinzhe eefa3f2f00 fix: conversation was still saved to the context after stop_event (#4345) 2026-01-07 12:48:13 +08:00
Oscar Shaw 479284a8dd feat(extension): plugin marketplace search supports matching display names. (#4332) 2026-01-06 12:54:11 +08:00
clown145 9322218880 feat: supports to display plugin CHANGELOG.md (#4337)
* feat: optimize plugin update changelog feature, refactor to reuse ReadmeDialog and support independent view entry

* fix: distinguish error state from empty state in ReadmeDialog
2026-01-06 12:53:14 +08:00
Soulter 399062f14d chore: remove wechatpadpro 2026-01-06 11:14:54 +08:00
Soulter de82df3c33 chore: bump version to 4.11.1 2026-01-05 20:22:18 +08:00
Soulter 9896aebfb5 feat: enhance provider source configuration with custom hints and tooltips 2026-01-05 20:20:09 +08:00
Soulter df7653eb99 fix: 部分情况下选择提供商出现”暂无可用提供商的问题“,即使实际上有 2026-01-05 20:01:54 +08:00
Soulter 8e7b44185d chore: bump version to 4.11.0 2026-01-05 18:05:12 +08:00
RC-CHN ef1c66a92e feat(webui): enable Range request support for backup downloads (#4329) 2026-01-05 17:27:03 +08:00
Soulter 241f1c26d3 feat: context compress (#4322)
* feat: context compressor

Co-authored-by: kawayiYokami <289104862@qq.com>

* Add comprehensive tests for ContextManager and ContextTruncator

- Implemented a full test suite for ContextManager covering initialization, message processing, token-based compression, and error handling.
- Added tests for ContextTruncator focusing on message fixing, truncation by turns, dropping oldest turns, and halving.
- Ensured that both test suites validate edge cases and maintain expected behavior with various message types, including system and tool messages.

* feat: add MockProvider for LLM compression tests

* chore: remove lock

* ruff fix

* fix

* perf

* feat: enhance context compression with token tracking and logging

* feat: update logging for context compression trigger

* feat: implement context compression logic with dynamic threshold and token tracking

* fix: reorder import statements for consistency

* feat: add token_usage tracking to conversations and update related processing logic

---------

Co-authored-by: kawayiYokami <289104862@qq.com>
2026-01-05 17:26:10 +08:00
Soulter 3615b7dde2 fix: token usage is always 0 in anthropic source (#4328) 2026-01-05 17:06:12 +08:00
RC-CHN 9bcf9bf2a0 fix(dashboard): complete i18n support for shared components (#4327)
* fix(dashboard): complete i18n support for shared components

- Replace hardcoded Chinese strings with i18n translations in:
  - PluginSetSelector.vue
  - ProviderSelector.vue
  - PersonaSelector.vue
  - KnowledgeBaseSelector.vue
  - T2ITemplateEditor.vue
  - AstrBotConfigV4.vue
  - ConfigItemRenderer.vue
  - ProxySelector.vue
  - ListConfigItem.vue

- Add missing translations to locale files:
  - core/shared.json: personaSelector, t2iTemplateEditor
  - core/common.json: autoDetect
  - features/settings.json: network.proxySelector

- Change prop defaults from hardcoded Chinese to empty strings,
  allowing components to use i18n fallback translations

* fix(i18n): 修正插件选择器标签的翻译格式,添加冒号

* fix(deployment): 添加持久化 machine-id PVC 和初始化容器,优化资源限制
2026-01-05 09:45:28 +08:00
Gao Jinzhe 7f5cc7cf1a feat: add on_waiting_llm_request event hook (#4319)
* 加入on_waiting_llm_request钩子

* ruff check
2026-01-04 16:11:12 +08:00
Oscar Shaw f26867c77d ci(stale): 增加 stale action 每次运行的操作限制 (#4256) 2026-01-04 11:20:03 +08:00
Soulter a14d588b44 docs: add Matrix adapter to community maintained section in multiple languages 2026-01-04 10:15:16 +08:00
Soulter e236402d92 chore: update platform adapter name for clarity 2026-01-04 10:12:25 +08:00
Soulter 454841de10 fix: database is locked error when invoking tts command (#4313)
* fix: database is locked error when invoking /tts command

fixes: #4311

* chore: rm pnpm lockfile

* perf: 减少操作数据库的次数
2026-01-03 19:12:39 +08:00
clown145 442b5403df feat(webui): supports force update plugins (#4293) 2026-01-03 15:30:50 +08:00
Soulter 9db7bf59b8 docs: add new community group contact 2026-01-03 00:48:55 +08:00
雪語 3622504021 fix: retry failed due to a mismatch in the msg.id data type of a WeChat Official Account (#4292)
问题描述:
- 控制台显示正常发送消息,但公众号未收到
- 处理时间 > 5秒的消息几乎总是失败(如 AI 图片生成)
- 短消息(<5秒)正常工作

根本原因:
msg.id 是整数类型,但字典 key 使用字符串类型,导致类型不匹配。
检查时整数无法匹配字符串 key,导致每次都创建新的 future,
微信重试时无法重用,最终导致响应失败。

修复内容:
将 msg.id 转换为字符串后再检查字典
  if str(msg.id) in self.wexin_event_workers:

影响范围:
- 修复了微信重试时无法正确重用 future 的问题
- AI 图片生成、长文本生成等耗时操作现在可以正常工作
- 仅影响微信公众号适配器,其他平台不受影响

Fixes #1679
2026-01-02 22:16:04 +08:00
Soulter fc42db40ce chore: bump version to 4.10.6 2026-01-02 12:14:59 +08:00
Soulter e413a002c1 perf: list view mode toggle with localStorage support in ExtensionPage (#4288)
closes: #4253
2026-01-02 11:59:41 +08:00
tjc66666666 6437d759a3 fix: reasoning content inject for openai api (#4284) 2026-01-02 01:09:28 +08:00
Soulter c758b2d888 feat: use shell globbing to match umop config router (#4270)
* feat: use shell globbing to match umop config router

* rf

* fix: use fnmatchcase for case-sensitive matching in UmopConfigRouter
2025-12-31 23:10:12 +08:00
Soulter 510290fe0e chore: bump version to 4.10.5 2025-12-31 17:58:28 +08:00
Soulter c61d62edb6 fix: handle null item-meta in ConfigItemRenderer (#4269)
fixes: #4268
2025-12-31 17:55:49 +08:00
Soulter 45bce6fe76 chore: bump version to 4.10.4 2025-12-31 12:50:37 +08:00
Soulter f156adddf8 feat: enhance configuration editor with template schema support and UI improvements (#4267)
- Added support for template schemas in the configuration editor, allowing users to define and manage additional parameters like temperature, top_p, and max_tokens.
- Improved UI components in ProviderModelsPanel and ObjectEditor for better user interaction, including new configuration buttons and enhanced input handling.
- Updated localization files to include new configuration options.
2025-12-31 12:19:29 +08:00
Soulter b5a4b80c36 perf: Add list item add button (#4259)
fixes: #4254
2025-12-30 15:27:17 +08:00
Soulter 792fb69d6d perf: allow zero chunk overlap in recursive chunker (#4258)
* Allow zero chunk overlap

* Validate recursive chunking bounds
2025-12-30 15:23:05 +08:00
Oscar Shaw 300a73ace0 fix(#4188): terminate the same plugin when install the plugin via file (#4250)
* fix(#4188): 从文件安装插件时先终止并解绑已存在的同名插件

* feat(star): 优化从文件安装插件的处理同名冲突逻辑,增加边缘检查
2025-12-30 13:43:44 +08:00
Oscar Shaw a5b9de3695 Update stale.yml 2025-12-30 11:10:21 +08:00
fluidcat 90142bcafe fix: ensure close aiodocker.Docker() (#4251)
* fix: ensure close aiodocker.Docker()

* fix: code formatted
2025-12-30 00:24:29 +08:00
Misaka Mikoto 79d0487c03 feat: add template_list config type to support multiple repeated core/plugin config sets (#4208)
* feat: 添加模板列表配置支持,包含验证和编辑功能

* refactor(dashboard): extract ConfigItemRenderer to eliminate code duplication

- Create ConfigItemRenderer.vue to centralize rendering logic for various config types (string, int, bool, selectors, etc.)
- Refactor TemplateListEditor.vue to use the new renderer for entry fields
- Refactor AstrBotConfig.vue and AstrBotConfigV4.vue to simplify metadata-driven rendering
- Resolve circular dependency by decoupling TemplateListEditor from the base renderer

* ruff format

* refactor: improve config validation and fix unidirection data flow

- Frontend: Fix one-way data flow in TemplateListEditor.vue by cloning entries before applying defaults and emitting updates instead of in-place modification.
- Frontend: Remove unused TemplateListEditor import in ConfigItemRenderer.vue.
- Backend: Refactor validate_config in config.py by extracting _expect_type and _validate_template_list helpers to reduce nesting and complexity.
2025-12-30 00:16:24 +08:00
akuuma 4f15102e79 perf(satori): increase websocket max message size to 10MB (#4238)
* perf(satori): increase websocket max message size to 10MB

Add max_size parameter to websocket connection to handle larger messages
and prevent connection drops when receiving large payloads from Satori platform.

* chore: ruff format

---------

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2025-12-29 23:59:55 +08:00
Oscar Shaw ef1feb639c fix(utils): optimize pip install output decoding for cross-platform encoding compatibility (#4249) 2025-12-29 23:54:11 +08:00
Oscar Shaw 1039a4f864 chore: update stale issue workflow to target only 'bug' labeled issues and adjust inactivity handling (#4252) 2025-12-29 23:49:40 +08:00
Soulter 66e2f49c11 perf: support extended thinking for Anthropic, DeepSeek reasoning mode, and Gemini text part thought signatures to improve multi-turn reasoning performance. (#4240)
* perf: support extended thinking for Anthropic, DeepSeek reasoning mode, and Gemini text part thought signatures to improve multi-turn reasoning performance.

* chore: remove verbose

* perf

* refactor: remove special tools handling for deepseek-reasoner model in openai source

* fix: improve error handling and logging in InternalAgentSubStage processing

* refactor: remove unused reasoning content from Gemini source processing

* refactor: enhance modality determination logic in useProviderSources

Co-authored-by: kawayiYokami <289104862@qq.com>
2025-12-29 14:22:30 +08:00
fluidcat c5773fe63e feat: add JSON value for custom_extra_body (#4246)
* feat: add JSON value for custom_extra_body

* feat: add invalid format tip
2025-12-29 12:52:10 +08:00
NieiR 4e9ef48af2 fix: handle None values in _extract_usage to prevent TypeError (#4244)
* fix: handle None values in _extract_usage to prevent TypeError

Some LLM providers (especially API proxies) may return None for
prompt_tokens and completion_tokens in the usage response. This
causes a TypeError when attempting arithmetic operations.

Added null checks with fallback to 0 for both prompt_tokens and
completion_tokens before performing calculations.

* refactor: use explicit None check and reuse cached variable

- Use `is None` instead of `or 0` to avoid masking unexpected falsy values
- Reuse `cached` variable for `input_cached` to avoid redundant calculation

* ruff format

---------

Co-authored-by: Soulter <905617992@qq.com>
2025-12-29 12:49:25 +08:00
RC-CHN 9eafd7b44a feat: add features for chunked upload and backup file management to the backup section (#4237)
* feat: 添加分片上传备份文件功能

* feat: 为上传备份文件添加异步并发以提升速度

* feat: 使用浏览器原生下载方式以显示进度条

* feat: 添加从已上传备份列表恢复的功能

* feat: 允许重命名备份文件

* feat: 在后端校验可用备份文件后在前端部分显示备份版本号,添加手动上传提示

* style: format code

* fix: 更新备份部分测试

* fix: 修复浏览器原生下载鉴权问题,通过url传参的方式完成认证

* feat(backup): 改进备份系统的分片上传和下载鉴权

- 修复浏览器原生下载鉴权问题,支持 URL 参数传递 token
- 修复上传会话过期判断,使用 last_activity 避免活跃上传被清理
- 延迟启动后台清理任务,避免 asyncio 事件循环问题
- 统一由后端计算 chunk_size 和 total_chunks,避免前后端不一致
- 更新 generate_unique_filename 文档注释与实际行为一致
- 更新测试用例以验证 origin 字段

修复问题:
- 浏览器下载时显示"需要授权"
- 大文件上传可能因会话过期失败
- __init__ 中 asyncio.create_task 可能失败

* style: format code
2025-12-29 12:30:59 +08:00
Soulter fc61f7ad32 fix: unique session config cannot be applied in non-default astrbot config (#4232)
* fix: unique session config cannot be applied in non-default astrbot config

fixes: #4195

* perf: sesison id
2025-12-28 15:01:43 +08:00
simplify123 f51810997a fix: Xinference STT failed: INVALID (#4231)
* Update xinference_stt_provider.py

* chore: ruff format

---------

Co-authored-by: Soulter <905617992@qq.com>
2025-12-28 14:42:06 +08:00
Soulter fb4baf676f perf: add auto voice emotion for minimax tts (#4228)
* perf: add `auto` voice emotion for minimax tts

* ruff format
2025-12-28 00:34:44 +08:00
Oscar Shaw 71ad974c3c feat: two dashboard persistence optimizations (#4221)
* feat: persist console visibility state in local storage on PlatformPage

* feat: add persistence for sidebar opened items in local storage
2025-12-27 14:06:01 +08:00
Soulter f0fff68947 fix: at sender users not working in dingtalk (#4219)
fixes: #4218
2025-12-27 11:26:39 +08:00
Soulter 3e3599835e chore: bump version to 4.10.3 2025-12-26 22:39:59 +08:00
Soulter 5255388e2d refactor: move builtin stars to astrbot package (#4209)
* refactor: move builtin stars to astrbot package

fixes: #4202

* chore: ruff format

* chore: remove print
2025-12-26 22:31:22 +08:00
Yokami fbdd60b64c feat: add extra user content block support (#4189)
* feat: 多文本块功能

* FIX

* 传递链

* 重命名

* refactor: unify extra_user_content_parts type to ContentPart across providers and update related handling

* claude额外块支持图片模态

* 已经处理过了不用再处理

* feat: enhance image handling in extra content blocks for multiple providers

---------

Co-authored-by: Soulter <905617992@qq.com>
2025-12-26 22:08:20 +08:00
Soulter bd1b0a2836 perf: drop unnecessary none-value fields in tool call loop (#4213) 2025-12-26 21:12:34 +08:00
Soulter 19541d9d07 fix: ensure max_tokens is set and validate tool_calls type in ProviderAnthropic (#4212) 2025-12-26 21:01:05 +08:00
大饼鸡蛋 2a5d574394 fix: failed to initialize FishAudio TTS instance (#4200)
fixes: #4172

* fix: 修复 FishAudio 源的配置加载问题并增强请求鲁棒性

- Fix `KeyError: 'model'``: 适配新版配置结构。
- Add `timeout` support: 防止长文本生成时超时。
- Improve response handling: 使用更标准的 Header 检查方式。

* feat: 使用更安全的类型转换并优化错误信息打印
2025-12-26 20:50:45 +08:00
Soulter f2924fbd1b chore: update readme 2025-12-26 18:04:56 +08:00
Gao Jinzhe 703e208947 fix: handle index out of range error when selecting provider (#4206) 2025-12-26 18:02:43 +08:00
NoctuUFO 9a5cc977c2 fix: fix log loss on SSE reconnect using Last-Event-ID (#4205)
* feat: implement last-event-id handing in log route

* perf: better log handling

* chore: ruff format

* perf: log

* Update ConsoleDisplayer.vue

* Update package.json

* Update ConsoleDisplayer.vue

* Update common.js

* chore: ruff format

* fix: ensure last_event_id is required for log replay

---------

Co-authored-by: Soulter <905617992@qq.com>
2025-12-26 18:01:58 +08:00
RC-CHN aa38fe776a feat: supports data backup (#4105)
* feat: 添加数据迁移功能

* test: 添加迁移相关测试

* feat: 备份插件及相关持久化目录

* fix: 修复版本号比较逻辑,添加相关测试

* fix: 清洗文件名,添加相关测试

* fix: 修复安全文件名测试用例断言

* refactor: 优化代码,为备份模块提取公用常量

* feat: 修改备份版本校验逻辑,允许强制小版本间导入

* fix: 修复备份创建时间读取,修复备份相关i18n

* refactor(backup): 使用 astrbot_path 统一管理备份目录路径

* fix(backup): 清理备份模块中未使用的导入

* refactor(backup): 统一备份路径与参数并移除未用附件目录

- 通过 astrbot_path 动态获取备份/知识库/数据相关路径
- 移除 exporter/importer 未使用的 attachments_dir/data_root 传参
- 更新备份路由与测试用例的构造参数

* fix(dashboard): alias mermaid to dist entry for Vite prebundle

* fix(backup): 放行start-time接口到白名单以处理备份导入后jwt token变化导致无法自动刷新webui的问题

* chore(backup): 统一配置路径以使用动态数据目录

* refactor(backup): 使用 VersionComparator 替代重复的版本比较函数

* style(backup test): format code

---------

Co-authored-by: Soulter <905617992@qq.com>
2025-12-26 15:47:50 +08:00
Luna-channel 61dfb0f207 chore: remove unnecessary files and revert auto_release.yml 2025-12-25 15:04:47 +08:00
Luna-channel 6f9cb770be 修复格式问题 2025-12-25 14:40:33 +08:00
Luna-channel f4e05e1352 2.0 2025-12-25 02:25:38 +08:00
Luna-channel 8af46ab804 稳定版 2025-12-25 01:44:24 +08:00
Luna-channel 9d32c4e720 自定义规则界面修改 2025-12-25 00:37:10 +08:00
Soulter 701399c00c docs: update readme xmas 2025-12-24 21:58:04 +08:00
Soulter eaee98d4b8 chore: bump version to 4.10.2 2025-12-24 21:55:05 +08:00
Soulter 76c66000a7 chore: restrict psutil version <7.2.0 to avoid compatibility issues
fixes: #4176
2025-12-24 15:48:58 +08:00
Oscar Shaw 4b365143c0 feat: support for managing command aliases (#4170)
* feat(command): persist aliases on rename and apply to runtime filter

* feat(dashboard-api): support aliases in rename command endpoint

* feat(dashboard-ui): add alias editor to rename command dialog

* feat(dashboard-ui): enhance alias editor UI in rename dialog
2025-12-24 15:37:10 +08:00
Soulter 6e4e5011e2 chore: bump version to 4.10.1 2025-12-23 21:35:40 +08:00
Venus Yan d853bfde84 perf: handle unsupported message types with logging in OneBot adapter (#4164)
* Handle unsupported message types with logging

解决else 分支中对未知消息类型毫无防御,直接索引ComponentTypes[t],导致新类型markdown类信息报错并炸掉事件管道,且对应群聊单群永久不响应插件;尝试支持markdown类型进行支持但未经过测试

* chore: ruff format

* chore: ruff format

---------

Co-authored-by: Soulter <905617992@qq.com>
2025-12-23 21:31:32 +08:00
Soulter a0e856f80f fix: provider source id contains slash will lead to 405 (#4162) 2025-12-22 20:28:20 +08:00
Oscar Shaw 8c94a0010c fix(core): improve error handling of command parser and sync (#4161) 2025-12-22 19:54:26 +08:00
Soulter a44fdaaec0 chore: bump version to 4.10.0 2025-12-22 18:10:30 +08:00
Soulter 60105c76f5 feat: implement router loading progress indicator 2025-12-22 13:20:39 +08:00
Soulter bcf87d3ce4 fix: update provider subtitle for clarity in English and Chinese locales
- Revised the subtitle in the provider feature localization files to provide a more detailed description of functionalities, including chat model configuration and third-party service integrations.
2025-12-22 13:13:42 +08:00
Soulter 4d7c8c8453 style: add active background color for provider source list item in dark theme 2025-12-22 12:59:55 +08:00
Soulter a064a9115f fix: omit thinking params for gemini image generation models (#4151)
- Expanded model name checks to include specific Gemini 2.5 and 3 variants, ensuring correct configuration for thinking parameters based on the model used.
2025-12-22 00:09:30 +08:00
Soulter 6ef99e1553 feat: enhance ChatInput and ConversationSidebar dark theme 2025-12-21 21:19:54 +08:00
Soulter c0dbe5cf65 chore: bump version to 4.10.0-alpha.2 2025-12-21 13:11:32 +08:00
Soulter 3598c51eff fix: enhance provider model menu and sidebar session selection handling (#4144)
- Updated `ProviderModelMenu.vue` to manage menu state and load provider configurations dynamically upon opening.
- Filtered provider configurations to exclude those with `enable` set to false.
- Improved session selection logic in `useSessions.ts` to ensure the currently selected session is highlighted and properly managed during navigation.
2025-12-21 13:05:15 +08:00
Soulter b5cdb8f650 fix: improve error handling in tool execution to prevent infinite tool call loops (#4143)
* fix: improve error handling in tool execution to prevent infinite tool call loops

- Enhanced error handling in `call_local_llm_tool` to provide more informative exceptions for ValueError and TypeError, including detailed parameter information.
- Updated `ToolLoopAgentRunner` to yield appropriate messages for cases with no response or unsupported types, ensuring clearer communication to users.
- Improved logging and messaging consistency across tool execution processes.

* refactor: clean up unused router parameter in message retrieval functions

- Removed the unused `router` parameter from `getSessionMessages` and related function calls in `Chat.vue` and `useMessages.ts`.
- Commented out the `tool_calls` dictionary in `chat.py` for clarity, indicating it is not currently in use.

* fix: enhance exception handling in tool execution for clearer error reporting

- Improved exception handling in `call_local_llm_tool` by chaining exceptions for ValueError and TypeError, providing more context in error messages.
- Ensured that traceback information is preserved in raised exceptions for better debugging.
2025-12-21 12:57:54 +08:00
Yokami fc5b520f9b perf(agent): add max step limit to prevent infinite tool call loops (#4110)
* perf(agent): add max step limit to prevent infinite tool call loops

* feat: implement max step limit handling in main agent runner

- Enhanced the agent runner to enforce a maximum step limit, logging a warning and forcing a final response when the limit is reached.
- Updated message handling to append a user prompt when the tool call limit is exceeded.
- Refactored tool response handling to yield appropriate messages based on the response type, including handling cases with no response or unsupported types.
- Improved conversation message formatting to ensure consistent output in the assistant's responses.

* chore: ruff format

---------

Co-authored-by: Soulter <905617992@qq.com>
2025-12-21 12:30:43 +08:00
Soulter 904f56b32f fix: webui conversation traj data display error (#4142)
fixes: #4141
2025-12-20 23:29:40 +08:00
Soulter 2f15fd019c chore: bump version to v4.10.0-alpha.1 2025-12-20 16:35:54 +08:00
Soulter 82330b8d10 feat: add changelog functionality and dialog component (#4135)
* feat: add changelog functionality and dialog component

- Implemented new routes for fetching changelogs and available versions in StatRoute.
- Created ChangelogDialog.vue for displaying changelog content and version selection.
- Updated VerticalSidebar.vue to include a button for opening the changelog dialog.
- Enhanced localization files for English and Chinese to support new changelog features.
- Adjusted styles in VerticalHeader.vue for improved layout consistency.

* chore: ruff format
2025-12-20 16:33:12 +08:00
Soulter 3ee6af7027 feat: add route watcher for viewMode changes in VerticalHeader.vue
- Introduced a watcher to monitor changes in customizer.viewMode, automatically redirecting to the homepage when switching from 'chat' to 'bot' mode.
- Updated imports to include useRoute from vue-router for routing functionality.
- Adjusted button styles for improved layout consistency in bot mode.
2025-12-20 15:38:01 +08:00
Soulter 6e20ebe901 feat: add KaTeX and Mermaid and computation-friendly renderer support (#4118)
* feat: add KaTeX and Mermaid support for enhanced markdown rendering in MessageList.vue

closes: #3747
- Integrated @mdit/plugin-katex and katex for LaTeX rendering.
- Added markstream-vue for improved markdown rendering capabilities.
- Updated MessageList.vue to utilize MarkdownRender component for rendering markdown content.
- Enhanced UI for dark mode compatibility across various components.
- Introduced new styles for file links, reasoning blocks, and tool call cards to improve visual consistency.

* refactor: replace markdown-it with markstream-vue for improved markdown rendering

- Removed markdown-it and related configurations from ReadmeDialog.vue, VerticalHeader.vue, and ConversationPage.vue.
- Integrated markstream-vue for enhanced markdown rendering capabilities, including support for KaTeX and Mermaid.
- Updated components to utilize MarkdownRender for rendering markdown content, improving consistency and performance.

* chore: remove deprecated markdown-it and marked dependencies from pnpm-lock.yaml

- Cleaned up pnpm-lock.yaml by removing markdown-it and marked entries, streamlining the dependency list.
- This change follows the recent integration of markstream-vue for improved markdown rendering capabilities.

* chore: remove d3 dependency and update MessageList.vue for dark mode support

- Removed d3 from package.json and commented out its import in LongTermMemory.vue to clean up unused dependencies.
- Updated MessageList.vue to ensure consistent dark mode styling by passing the isDark prop to MarkdownRender components.

* feat: add loading indicator for message retrieval in Chat and MessageList components

- Introduced a loading overlay in Chat.vue and MessageList.vue to indicate when messages are being loaded.
- Added a new `isLoadingMessages` prop to manage loading state and enhance user experience during message retrieval.
- Updated styles to ensure the loading indicator is visually integrated with the existing UI.

* feat: add provider configuration dialog to chat sidebar

- Introduced a new `ProviderConfigDialog` component for managing provider settings.
- Added a menu item in the `ConversationSidebar` to open the provider configuration dialog.
- Updated English and Chinese localization files to include translations for the new provider configuration feature.

* feat: update dashboard components and styles for improved chat experience

- Replaced font in index.html to use 'Outfit' for a fresh look.
- Changed icon in ConversationSidebar.vue to 'mdi-creation' for better representation.
- Refactored MessageList.vue to streamline loading indicators and enhance styling consistency.
- Updated localization files to change 'Provider Configuration' to 'AI Configuration' for clarity.
- Introduced new styles for loading indicators and chat mode adjustments in FullLayout.vue.
- Added functionality for toggling between bot and chat modes in the header.
- Removed deprecated sidebar item for chat navigation.

* feat: xmas easter egg

* chore: remove pnpm lock file
2025-12-20 15:22:48 +08:00
Yokami 4d6150fd6d fix: handle quoted messages correctly to prevent breaking cache (#4112)
* fix: Handle quoted messages correctly as user context

This change ensures quoted messages, including text and image captions, are appended to the conversation history as a user message rather than being injected into the system prompt.

Fixes #3886

* 注入到req.prompt里
2025-12-20 11:03:27 +08:00
Soulter 544e52191b Merge pull request #4065 from AstrBotDevs/refactor/provider-source
refactor: SUPER AMAZING model provider refactor
2025-12-20 00:09:36 +08:00
Soulter f2c2a6da4a chore: ruff format 2025-12-20 00:07:42 +08:00
Soulter dd3df425ee feat: add warnings for missing provider IDs in manager and context
- Introduced logging warnings in ProviderManager and Context classes when a provider ID is not found, indicating potential issues due to ID modifications.
- Updated the ProviderPage.vue to advise against modifying provider IDs, highlighting possible configuration impacts.
2025-12-20 00:06:42 +08:00
Soulter 40b4a27a3d Merge remote-tracking branch 'origin/master' into refactor/provider-source 2025-12-19 15:48:42 +08:00
Soulter 9d991c7468 perf: enhance chat components with theme and fullscreen toggles (#4116)
* perf: enhance chat components with theme and fullscreen toggles

- Added theme and fullscreen toggle functionality to Chat.vue and ConversationSidebar.vue.
- Introduced a new StyledMenu component for improved dropdown menus.
- Updated MessageList.vue and ChatInput.vue for better mobile responsiveness and UI consistency.
- Enhanced language switcher integration in ConversationSidebar.vue.
- Added new settings translations in English and Chinese locales.

* fix: streamline conversation selection handling in Chat.vue

- Updated handleSelectConversation function to immediately set the current session ID and selected sessions, reducing the need for multiple clicks.
- Adjusted padding in ConversationSidebar.vue for improved layout consistency.
2025-12-19 11:18:01 +08:00
Soulter ad6a8b5c94 Merge remote-tracking branch 'origin/master' into refactor/provider-source 2025-12-18 17:39:27 +08:00
Soulter 1b4bfcbd72 chore: ruff format 2025-12-18 17:37:12 +08:00
Soulter 9d3cc593a1 feat: supports thinking level of google gemini (#4104)
* feat: supports thinking level of google gemini

- Updated google-genai version to >=1.56.0 in pyproject.toml and requirements.txt.
- Changed model configuration from "gemini-1.5-flash" to "gemini-3-flash-preview" in default.py.
- Enhanced thinking configuration handling in gemini_source.py to support new parameters for Gemini 3 models.

* fix: standardize thinking level configuration in default.py and gemini_source.py

- Updated the thinking level values in default.py to uppercase for consistency.
- Enhanced gemini_source.py to validate the thinking level and default to "HIGH" if an invalid value is provided.
2025-12-18 17:37:11 +08:00
Soulter f0dee35ba9 feat: enhance tool call handling and agent stats tracking and UI integration for tool calls render (#4101)
* feat: enhance tool call handling and UI integration for tool calls render

- Added support for tool call messages in the agent runner and webchat event handling.
- Implemented JSON message component for structured tool call data.
- Updated chat route to save tool call information in message history.
- Enhanced frontend to display tool call details in a collapsible format, including status and results.
- Introduced elapsed time tracking for ongoing tool calls in the chat interface.

* fix: improve message handling in agent run utility and tool loop runner

- Refactored message sending logic in `astr_agent_run_util.py` to use `msg_chain` directly for better clarity.
- Added a check in `tool_loop_agent_runner.py` to ensure `tool_call_result_blocks` is not empty before yielding the last tool call result, preventing potential errors.

* refactor: enhance message structure and UI for chat components

- Updated message handling in `MessageList.vue` to support structured message parts, including plain text, images, audio, and files.
- Improved the `Chat.vue` component styles for better visual consistency.
- Refactored message parsing logic in `useMessages.ts` to accommodate new message formats and ensure proper rendering of embedded content.
- Removed deprecated tool call handling from the message structure, streamlining the message display process.

* chore: ruff format

* feat: implement agent statistics tracking and display in chat

- Added `AgentStats` and `TokenUsage` data classes to track agent performance metrics.
- Enhanced `ToolLoopAgentRunner` to collect and update agent statistics during execution.
- Integrated agent statistics sending to webchat for real-time updates.
- Updated chat route to save and display agent statistics in message history.
- Improved frontend components to visualize agent statistics, including token usage and duration metrics.

* fix: improve message handling in Telegram event and agent run utility

- Updated message sending logic in `astr_agent_run_util.py` to send the correct message chain for tool calls.
- Enhanced `tg_event.py` to edit messages during streaming breaks, improving message management and user experience.
- Added error handling for message editing failures to ensure robustness.

* chore: ruff format
2025-12-18 17:36:45 +08:00
Soulter 4135bd84d5 refactor: update OneBot configuration and add platform logo (#4106)
- Renamed "QQ 个人号(OneBot v11)" to "OneBot v11" in the configuration.
- Added a new logo for OneBot in the dashboard assets.
- Updated platform icon retrieval logic to include the new OneBot logo.
2025-12-18 17:34:59 +08:00
Soulter f6da614e5d fix: validation error for ToolCall.extra_content in specific upstream model providers (#4102)
* fix: validation error for ToolCall.extra_content in specific upstream model providers

* fix: handle missing extra_content gracefully in ToolCall serialization
2025-12-18 17:34:59 +08:00
Soulter 5f531c9be5 chore: ruff format 2025-12-18 17:17:17 +08:00
Soulter 94591d965b feat: supports thinking level of google gemini (#4104)
* feat: supports thinking level of google gemini

- Updated google-genai version to >=1.56.0 in pyproject.toml and requirements.txt.
- Changed model configuration from "gemini-1.5-flash" to "gemini-3-flash-preview" in default.py.
- Enhanced thinking configuration handling in gemini_source.py to support new parameters for Gemini 3 models.

* fix: standardize thinking level configuration in default.py and gemini_source.py

- Updated the thinking level values in default.py to uppercase for consistency.
- Enhanced gemini_source.py to validate the thinking level and default to "HIGH" if an invalid value is provided.
2025-12-18 17:15:01 +08:00
Soulter 8a0f865af1 feat: enhance tool call handling and agent stats tracking and UI integration for tool calls render (#4101)
* feat: enhance tool call handling and UI integration for tool calls render

- Added support for tool call messages in the agent runner and webchat event handling.
- Implemented JSON message component for structured tool call data.
- Updated chat route to save tool call information in message history.
- Enhanced frontend to display tool call details in a collapsible format, including status and results.
- Introduced elapsed time tracking for ongoing tool calls in the chat interface.

* fix: improve message handling in agent run utility and tool loop runner

- Refactored message sending logic in `astr_agent_run_util.py` to use `msg_chain` directly for better clarity.
- Added a check in `tool_loop_agent_runner.py` to ensure `tool_call_result_blocks` is not empty before yielding the last tool call result, preventing potential errors.

* refactor: enhance message structure and UI for chat components

- Updated message handling in `MessageList.vue` to support structured message parts, including plain text, images, audio, and files.
- Improved the `Chat.vue` component styles for better visual consistency.
- Refactored message parsing logic in `useMessages.ts` to accommodate new message formats and ensure proper rendering of embedded content.
- Removed deprecated tool call handling from the message structure, streamlining the message display process.

* chore: ruff format

* feat: implement agent statistics tracking and display in chat

- Added `AgentStats` and `TokenUsage` data classes to track agent performance metrics.
- Enhanced `ToolLoopAgentRunner` to collect and update agent statistics during execution.
- Integrated agent statistics sending to webchat for real-time updates.
- Updated chat route to save and display agent statistics in message history.
- Improved frontend components to visualize agent statistics, including token usage and duration metrics.

* fix: improve message handling in Telegram event and agent run utility

- Updated message sending logic in `astr_agent_run_util.py` to send the correct message chain for tool calls.
- Enhanced `tg_event.py` to edit messages during streaming breaks, improving message management and user experience.
- Added error handling for message editing failures to ensure robustness.

* chore: ruff format
2025-12-18 17:11:09 +08:00
Soulter 4aced976a8 refactor: update OneBot configuration and add platform logo (#4106)
- Renamed "QQ 个人号(OneBot v11)" to "OneBot v11" in the configuration.
- Added a new logo for OneBot in the dashboard assets.
- Updated platform icon retrieval logic to include the new OneBot logo.
2025-12-18 15:19:15 +08:00
Soulter 0299aa6e4c fix: validation error for ToolCall.extra_content in specific upstream model providers (#4102)
* fix: validation error for ToolCall.extra_content in specific upstream model providers

* fix: handle missing extra_content gracefully in ToolCall serialization
2025-12-18 11:55:49 +08:00
Soulter e8b54a019e refactor: replace ProviderModelSelector with ProviderModelMenu for improved UI and functionality 2025-12-17 22:57:32 +08:00
Soulter 98ce796275 chore: remove copilot instruction 2025-12-17 17:21:33 +08:00
Soulter b87dcf2275 refactor: improve provider source ID validation to prevent duplicates during configuration updates 2025-12-17 17:19:35 +08:00
Soulter 591a228431 refactor: enhance provider management with resource locking and CRUD operations 2025-12-17 17:08:52 +08:00
Soulter f52f375154 refactor: update provider handling to use new config structure and improve template retrieval 2025-12-17 16:55:12 +08:00
Soulter 975c685a17 chore: ruff format 2025-12-17 16:32:38 +08:00
Soulter 6db80d36a8 fix: prevent platform ID modification during updates and ensure correct routing table handling 2025-12-17 16:16:50 +08:00
Soulter 4651bd2807 feat: implement provider deletion functionality and ensure unique provider IDs 2025-12-17 15:00:22 +08:00
Soulter 94ada3793e Merge remote-tracking branch 'origin/master' into refactor/provider-source 2025-12-17 13:33:23 +08:00
Soulter fd05b0bf09 docs: update contributing guidelines to include code style and formatting instructions 2025-12-17 13:26:22 +08:00
Soulter 4d046f8490 delete: remove backup of ProviderPage.vue 2025-12-17 11:34:12 +08:00
Copilot 58e32b7b70 fix: inverted logic in segmented reply LLM-only filter (#4071)
* Initial plan

* Fix: Correct inverted logic in is_seg_reply_required for only_llm_result option

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2025-12-17 11:12:05 +08:00
Soulter 903dd0f9f7 feat: add manual model addition functionality and search capability in ProviderPage 2025-12-17 10:56:45 +08:00
Soulter 1acac0cac2 feat: enhance provider selection with a new drawer interface and localization updates 2025-12-17 10:39:16 +08:00
Oscar Shaw 80b89fd2ea feat: implements command management and improve webui feature structure (#3904)
move mcp management to plugin managemanet page

* feat: 新增命令配置数据库模型

* feat: 实现核心命令管理系统

* feat: 将命令管理集成到 Star 框架

* feat: 新增命令管理后台 API

* feat: 新增命令管理界面页面

* feat: 新增命令管理国际化支持

* test: 新增命令管理相关测试

* refactor(command): 移除指令重命名时的别名功能

* fix(command): 修正指令冲突检测逻辑

* fix(command): 排除已禁用指令的冲突检测

- 只有 `effective_command` 存在且 `enabled` 为 `True` 的指令才会被纳入冲突检测范围。

* feat(command): 优化指令冲突显示与提示

- 【功能】新增指令冲突警告提示,当检测到冲突时显示详细信息及解决方案。
- 【优化】调整指令列表排序逻辑,将冲突指令优先显示并分组。
- 【样式】为冲突指令行添加专属高亮样式,提升视觉识别度。
- 【国际化】更新英文和中文多语言文件,增加指令冲突警告相关的翻译文本。

* chore(command-page): 禁用命令表格部分列的排序功能

* style(command-page): 调整命令页面表格样式和图标大小

* refactor(command): 优化指令页面布局并更新冲突警告

- 【布局优化】重新组织指令管理页面布局,将筛选器移至顶部独立行
- 【信息展示】将搜索栏与总指令数、已禁用指令数合并显示,提升页面空间利用率
- 【视觉更新】更新指令冲突警告样式

* style: UI 细节

* refactor(command): 调整指令管理中的成员权限显示与筛选

  - 更新指令筛选逻辑,当选择“所有人”权限筛选时,将同时包含 `everyone` 和 `member` 权限的指令。

* feat(command-management): 新增指令层级管理与UI展示

- 【后端】
  - `CommandDescriptor` 新增 `parent_group_handler` 和 `sub_commands` 字段,支持指令层级结构定义。
  - `list_commands` 函数重构,实现指令的层级收集与构建,将子指令正确挂载到其父指令组下。
  - 新增 `_collect_all_descriptors` 和 `_find_parent_group_handler` 辅助函数,用于全面收集指令并定位父指令组。
  - `_build_descriptor` 优化指令类型判断逻辑,明确区分普通指令、指令组和子指令。
  - `_descriptor_to_dict` 递归处理子指令,确保 API 返回完整的指令层级数据。
- 【前端】
  - 指令管理页面 (`CommandPage.vue`) 增加指令类型筛选器,并支持指令组的展开/折叠功能。
  - 表格展示优化,为指令组和子指令添加不同的样式和缩进,提升层级结构的视觉可读性。
  - 指令详情对话框新增指令类型、所属指令组和子指令列表的展示。
  - 更新 `CommandItem` 接口,以适配后端提供的层级数据结构。
- 【i18n】
  - 新增指令类型(指令、指令组、子指令)的国际化文本。
  - 更新指令管理相关 UI 文本,包括表格头部、详情对话框字段和筛选器选项。

* style(command): 优化指令组子指令数量显示UI

* refactor(command): 修改指令列表排序逻辑

* style(command-page): 优化命令列表UI

* feat(command): 添加系统插件指令过滤与冲突处理

* refactor(command): 更新指令数展示逻辑

* style(command): 更新空状态描述

* feat(extension): 添加插件指令冲突检测与提示

- 在插件安装或启用后,自动检测并提示指令冲突。
- 当检测到指令冲突时,显示警告对话框,告知用户冲突数量及可能的影响。

* refactor(command): 移除指令表格内部加载指示器

* style(extension): 文案修改

* refactor(command): 模块化指令管理面板前端代码

* refactor(commandPanel): 重命名指令模块目录为 commandPanel

* style(commandPanel): 微调指令面板UI

* fix(command): 确保新命令配置的事务提交

* fix(sidebar): 补全新增侧边栏项后的侧边栏位追加逻辑

* refactor(commands): 重构/help指令以动态显示实际命令并补充部分命令描述

* style(builtin_commands): 补充命令描述

* refactor(commandPanel): 移除未使用的 filterState 常量

* perf(dashboard): 删除多余的CommandPage.vue文件(已被模块化引用)

* perf(command): 优化命令冲突计数逻辑

* perf(command): 优化指令管理辅助函数和配置绑定逻辑

* perf(db): 优化重构command相关数据库操作

* refactor(sidebar): 提取侧边栏项目解析逻辑到工具函数复用

* refactor: move mcp and command page to extension page

* refactor: remove unused imports in component panel

* fix: update terminology for handler management in extension localization

---------

Co-authored-by: Soulter <905617992@qq.com>
2025-12-16 20:24:57 +08:00
Soulter 26f863ba81 Revert "fix: omit empty content field for the LLM request after tool calls ar…" (#4068)
This reverts commit f78a90218e.
2025-12-16 20:22:13 +08:00
sctop f78a90218e fix: omit empty content field for the LLM request after tool calls are completed (#4008)
* fix: omit content field for the LLM request after tool calls are completed and content is empy string or none

* chore: ruff format

---------

Co-authored-by: Soulter <905617992@qq.com>
2025-12-16 20:11:11 +08:00
Soulter a3ecebd2aa fix: correct text accumulation logic in webchat (#4066) 2025-12-16 19:35:41 +08:00
Soulter 67c33b842d feat: add new provider icons and improve provider source handling
- Added icons for 'modelstack', 'tokenpony', and 'compshare' in providerUtils.js.
- Updated ProviderPage.vue to display the correct count of displayed provider sources.
- Enhanced the logic for displaying provider sources to include placeholders for unselected templates.
- Improved the display name for provider sources to show template keys for placeholders.
- Adjusted styles for better layout and overflow handling in provider source list and cards.
- Refactored source selection logic to handle placeholder sources correctly.
- Updated error handling in provider testing to provide clearer messages.
2025-12-16 16:11:56 +08:00
Soulter 5431c9f46e refactor: remove unused tab from AddNewProvider and disable button based on provider status in ProviderPage 2025-12-16 12:26:26 +08:00
Soulter 764b91a5f7 chore: ruff check 2025-12-16 12:21:14 +08:00
Soulter c20c1b84bf feat: implement LLM metadata fetching and integrate into provider model selection 2025-12-16 12:19:40 +08:00
Soulter fd66a0ac00 perf: better UI 2025-12-16 11:24:07 +08:00
Soulter aaee283367 fix: type checking of AstrAgentContext 2025-12-16 10:09:57 +08:00
Soulter 4a5b7d1976 fix: type checking of contextwrapper 2025-12-16 09:59:56 +08:00
Sukafon 08244548ab fix: incorrect type assignment when the agent send an image (#4050) 2025-12-16 08:28:10 +08:00
dependabot[bot] b486de6a98 chore(deps): bump actions/upload-artifact in the github-actions group (#4061)
Bumps the github-actions group with 1 update: [actions/upload-artifact](https://github.com/actions/upload-artifact).


Updates `actions/upload-artifact` from 5 to 6
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-16 08:24:03 +08:00
Soulter e2f928a7e5 chore: bump version to 4.9.2 2025-12-15 16:58:32 +08:00
Soulter b8e4068c75 feat: support key-value storage for plugins (#4048)
* feat: support key-value storage for plugins

* fix: remove unnecessary initialization method from Main class
2025-12-15 16:50:44 +08:00
Soulter 0916177a57 chore: bump version to 4.9.1 2025-12-15 16:07:10 +08:00
Soulter 02cd5e396b feat: add trigger probability setting for TTS and support to render slider in schema (#4047)
* feat: add trigger probability setting for TTS and support to render slider in schema

* chore: ruff format
2025-12-15 16:04:27 +08:00
Soulter 56673ad78f fix: prevent duplicate result content type after streaming finishes in RespondStage 2025-12-15 15:33:40 +08:00
Soulter 9a4d05e2b6 fix: remove unnecessary persistent attribute from ReadmeDialog and adjust dialog structure in ExtensionPage 2025-12-15 15:27:42 +08:00
Soulter b2e9dab233 refactor: enhance layout and improve provider source management in ProviderPage 2025-12-15 15:15:17 +08:00
Soulter 45110200ea feat: update provider and provider source configuration handling 2025-12-15 12:31:29 +08:00
Soulter c3f45449e8 docs: readme
wa ta shi wa ko sei no de su ka ra!
2025-12-15 11:47:21 +08:00
Copilot 65da469deb feat: add conversation export feature to JSONL for AI training (#4037)
* Initial plan

* Add conversation export functionality (backend and frontend)

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>

* Address code review feedback: move imports, simplify logic, improve i18n

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>

* Simplify frontend download logic: remove redundant Blob wrapper and complex filename parsing

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>

* fix: update conversation export filename format for consistency

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
Co-authored-by: Soulter <905617992@qq.com>
2025-12-14 21:44:12 +08:00
Soulter 16df64c405 fix: lark domain and log_level of Lark API client (#4038)
fixes: #4035
2025-12-14 21:31:17 +08:00
i0cLiceao 6b73b19e54 fix: support using GitHub Raw content as plugin source (#3975)
* Update plugin.py

* Update plugin.py

* Update plugin.py

* Update plugin.py
2025-12-14 18:23:29 +08:00
Soulter a70088b799 Merge remote-tracking branch 'origin/master' into refactor/provider-source 2025-12-13 23:37:23 +08:00
Soulter e7e97730af chore: bump version to 4.9.0 2025-12-13 18:49:07 +08:00
Soulter 467ca1eb5c fix: webui log output incompletely (#4029)
* fix: webui log output incompletely

* fix: improve SSE log parsing to handle partial data chunks

* fix: enhance log handling by implementing local cache and fetching history

* fix: log time handling to use epoch time
2025-12-13 18:46:16 +08:00
Soulter bb45d9cb54 stage 2025-12-13 17:16:07 +08:00
RC-CHN 46528391c2 feat: add pre-chunk import strategy for knowledge base (#3973)
* feat: 添加文档导入功能及相关测试

* feat: 优化文档上传功能,支持从文件名推断文件类型,并增强文档切片验证

* feat: 添加文档导入功能的无效输入测试,验证 chunks 类型和内容的错误处理

* refactor: 重构文档上传和导入任务的状态管理,添加任务初始化、结果设置和进度更新方法
2025-12-12 23:15:11 +08:00
Soulter 8a0b7717cc feat: supports webhook mode for Lark platform (#4016)
* feat: add Lark platform support with unified webhook configuration

* fix: update token verification logic in LarkWebhookServer

* feat: implement event deduplication and cleanup for Lark webhook events
2025-12-12 22:12:13 +08:00
Copilot 3b81fb4985 fix: mobile dialog close button visibility (#4010)
* Initial plan

* Fix mobile dialog close button visibility by adding max-height and scrollable content

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2025-12-12 16:02:24 +08:00
Soulter c09d57a820 refactor: improve UI layout and interaction for list item management (#4002)
* refactor: improve UI layout and interaction for list item management

* feat: enhance list configuration UI with batch import functionality

* feat: add internationalization support for list configuration UI
2025-12-11 18:55:56 +08:00
Soulter ec408a2aff fix: lark message timestamp 2025-12-11 18:20:50 +08:00
Soulter 417179a6b9 ci: add smoke test 2025-12-11 10:44:15 +08:00
Soulter fcd29445c7 refactor: remove unused current provider initialization in StarRequestSubStage 2025-12-11 10:36:33 +08:00
BiDuang 5f535001db fix: incorrect modalities enum of gemini api provider (#3993) 2025-12-10 20:27:51 +08:00
PaloMiku 750d245b16 docs: Update README with new Zread link and badges (#3992)
ZRead 是由智谱 AI 推出的 DeepWiki 类似平替品。
2025-12-10 20:22:56 +08:00
Dt8333 f624971613 chore: fix bunches of type checking errors (#3213)
* chore(core.utils): 🚨 修正错误Lint

* chore(core.provider): 🚨 修复基类错误Lint

* chore(core.utils): 补全session_get()的重载

* chore(core.provider): 🚨 修正实现错误Lint

* chore(core.platform): 🚨 修正platform基类和webchat的错误Lint

* chore(core.platform): 修正错误实现Lint

* fix(core.provider): 修复循环调用和错误assert

* chore(core.platform): 修复部分实现Lint

* chore(core.provider): 补充Dify.text_chat_stream的参数类型

* chore(core.pipeline): 🚨 修复错误Lint

* fix(core.slack): 补充遗漏导入

* chore(core.utils): 修复错误的session_get声明

* chore(core.platform): 移除Lark adapter import中的wildcard

* chore(core.db): 修复声明和部分逻辑

* chore(core.db): 添加typings,使faiss参数能被正确识别。

* chore(core): 修复声明

* chore(core): 修改声明

* chore: 补充faiss声明

* chore(dashboard): 修改实现,减少报错

* chore(package): 修改部分声明与实现,减少报错

* chore(core): 添加Handler的overload,以去除部分assert同时通过类型检查

* chore(core.pipeline): 修改Pipeline Scheduler的execute,将判断属性改为判断类型,通过静态类型检查

* chore(core.config): 添加类型标注,通过类型检查

* chore(core.message): 为File._download_file添加检查,通过类型检查

* fix: 将断言改为条件判断以实现优雅关闭的容错性

* refactor: 移除 discord 客户端中的 assert,改用 if None 判断并抛出异常

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* fix: DiscordPlatformAdapter 对 self.client.user 为 None 做日志并返回,移除断言

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* fix: 增强 Lark 相关空值/异常检查并完善日志输出

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* refactor: 将断言替换为条件检查并加入日志与错误处理

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* chore: 移除LLM生成的无用注释

* refactor: 使用 File.get_file 替换下载逻辑并移除 assert,提供默认 filename

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* fix: Slack Socket 未初始化抛出运行时异常,图片 URL 判空改为非空判断

* refactor: 将 WeChatPadProAdapter 的断言改为空值判断并添加日志

* refactor: 使用 isinstance 替代断言实现类型判断,便于静态检查

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* fix: 去除cast,直接使用字段与字典访问,修正端口解析

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* refactor: 使用 match-case 重构 ProviderManager 加载并通过类型检查抛出 TypeError

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* fix: group_name_display 时若 group 对象为空则记录错误并返回

* fix: 将 _get_current_persona_id 的 assert 替换成 if guard 并返回 None

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* fix: 优化插件目录存在性检查及图片URL非空验证,更新JSON排序配置

* fix: 将 datetime_str 的 assert 替换为显式检查并抛出异常

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* refactor: 移除 cast,改为运行时检查并在找不到调度器时跳过

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* refactor: 移除 cast,改用 isinstance 检查 FaissVecDB 并警告

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* fix: 删除 typing.cast 导入,并在获取文件绝对路径前校验 file_

* refactor: 移除 typing.cast,简化内容安全检查调用

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* refactor: 将 PlatformMetadata.id 设为必填并在注册时传入 id,移除 cast

* refactor: 移除 cast,改用 HasInitialize 与 isinstance 进行初始化

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* fix: 为 ProviderManager.initialize 增加ID类型判断,避免 None 导致 get 失败

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* refactor: 为 OTTSProvider 与 AzureNativeProvider 引入 _client 与 client 属性改进上下文管理

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* fix: 为 Whisper 自托管源添加模型未初始化校验并直接调用 transcribe

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* refactor: 移除未使用的 cast 导入并简化 platform_name 赋值

* refactor: 引入 cast 并对 id 使用 cast(str, ...) 提升类型安全

* fix: 将 _id_to_sid 返回改为 str,空值返回空串;对 id 与 message_id 使用 cast

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* refactor: 重构 Discord 处理逻辑:强制 类型转换、优先斜杠指令并优化提及判断

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* fix: 统一对 id 获取执行 cast,并在微信消息解析失败时抛错

* Revert "fix: 去除cast,直接使用字段与字典访问,修正端口解析"

This reverts commit 1cbfdf9d1b.

* fix: 百炼 Rerank 会话关闭时返回空结果;初始化 request.prompt 避免空值拼接

* fix: 统一处理搜索结果链接为字符串,新增 _get_url 助手并适配 Bing/Sogo

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>

* refactor: 调整 call_handler 泛型、Discord 通道注解及 FishAudioTTS API 请求类型

* refactor: 使用 col(...) 替代列引用并对结果进行 CursorResult 强转

* chore: ruff format

---------

Co-authored-by: aider (openai/gemini-3-pro-high) <aider@aider.chat>
Co-authored-by: Soulter <905617992@qq.com>
2025-12-09 14:13:47 +08:00
Soulter aa6d07afcc refactor: move all internal commands from astrbot plugin to default_command plugin (#3960)
* refactor: move all internal commands from astrbot plugin to default_command plugin

* ruff check

* feat: add config

* ruff check
2025-12-08 22:17:32 +08:00
Soulter 2c36649874 feat: add Agent Runner test prompt dialog in ProviderPage (#3968) 2025-12-08 21:46:47 +08:00
Soulter c95735dcc0 docs: update readme 2025-12-08 12:05:57 +08:00
Soulter 03bb278f50 chore: ruff check 2025-12-08 11:00:43 +08:00
Soulter a5e0974da3 chore: ruff format 2025-12-08 00:36:56 +08:00
vmoranv f0fb447fbc feat: custom plugin api source manager (#3956)
* feat: custom plugin api source manager

* fix: rename plugin source file in a safer way

* chore: turned the way of saving plugin source to backend and refacted some components

* style: clean up whitespace and improve logging message formatting

---------

Co-authored-by: Soulter <905617992@qq.com>
2025-12-08 00:32:50 +08:00
Soulter 37566182b0 feat: segment reply supports segmentation words (#3959)
* feat: segment reply supports segmentation words

* chore: ruff format

* feat: enhance segmented reply processing by refining word extraction logic

* ruff format
2025-12-08 00:27:17 +08:00
Soulter e460b411da chore: remove dev version from webui (#3951)
* chore: remove dev version

* chore: remove development version references from header localization files
2025-12-07 15:23:30 +08:00
Soulter e14ed804da chore: bump version to 4.8.0 2025-12-05 19:09:56 +08:00
Oscar Shaw 8e4e49df20 fix: not invoke on_llm_response hook when LLM request has error (#3871)
* fix: handle on_agent_done in error responses

- Introduced an LLMResponse for error messages to be processed by agent hooks, ensuring better error reporting and handling.

* fix: improve error logging in on_agent_done hook

---------

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2025-12-05 16:13:46 +08:00
Oscar Shaw 5d856900ef perf: some UI/UX fixes, change Console to Platform Logs (#3873)
* refactor: 统一‘平台日志’文案

* perf: 优化自动滚动开关键操作逻辑

* perf: add tooltips to save and code editor buttons
2025-12-05 16:02:20 +08:00
Soulter 380a68b96c chore: add CONTRIBUTING.md 2025-12-05 15:59:18 +08:00
易推倒白毛 8879bd7e9d fix: add supports for Whisper with QQ amr audio file
* fix: Whisper API对QQ语音amr文件的支持

* Update whisper_api_source.py

* fix: cleanup temporary files in Whisper API

---------

Co-authored-by: Soulter <905617992@qq.com>
2025-12-05 15:41:37 +08:00
RC-CHN 2cce09400f feat: add Kubernetes manifests for astrbot and napcat deployment with services and persistent storage (#3901)
* feat: add Kubernetes manifests for astrbot and napcat deployment with services and persistent storage

* chore: remove 11451 port

---------

Co-authored-by: Soulter <905617992@qq.com>
2025-12-04 20:36:35 +08:00
Oscar Shaw 54d26dcd38 perf: integrate Pinia store for log cache management (#3852)
* perf: integrate Pinia store for log cache management

* perf: remove unused code
2025-12-04 14:26:05 +08:00
Soulter 205024f27a fix: correct SQL query syntax in SQLiteDatabase class 2025-12-04 12:51:22 +08:00
Soulter efde994907 chore: revise badges and language links
Updated badge links and language options in README.
2025-12-03 17:21:09 +08:00
Soulter 8ca4f9cb74 feat: update README files for multilingual support and enhanced descriptions
- Added French, Russian, and Traditional Chinese README files to support a wider audience.
- Updated English and Japanese README files with improved descriptions of AstrBot's capabilities and features.
- Enhanced community section in all README files to include QQ, Telegram, and Discord group information.
- Adjusted plugin marketplace badge and key features list for clarity and consistency across languages.
2025-12-03 17:01:56 +08:00
Soulter 54e49b997b feat: enhance platform management with status tracking and error handling
- Introduced PlatformStatus enum to manage platform states (pending, running, error, stopped).
- Added error recording and retrieval functionality in the Platform class.
- Implemented a new method in PlatformManager to gather statistics for all platforms.
- Updated the dashboard to display platform statuses and error details, including a dialog for error insights.
- Enhanced localization for runtime statuses and error dialogs in both English and Chinese.
2025-12-03 16:48:57 +08:00
Soulter 5714944eef feat: unified platform webhook url (#3889)
* feat: unified platform webhook url

* chore: ruff format

* fix: 修复 Telegram 语音使用 Whisper API 报错 (#3884)

* Update whisper_api_source.py

* chore: ruff format

---------

Co-authored-by: Soulter <905617992@qq.com>

* Update astrbot/dashboard/routes/platform.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* chore: ruff format

* fix: update webhook dialog descriptions for clarity in English and Chinese locales

* fix: update webhook URL paths to include '/api' prefix for consistency across the application

---------

Co-authored-by: 易推倒白毛 <zhaixingbi@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-03 15:44:52 +08:00
Soulter defc46b6c9 fix: remove unnecessary blocks in Slack reply message (#3897) 2025-12-03 13:59:41 +08:00
Soulter 4d819546b0 fix: handle message sending in QQOfficialMessageEvent class (#3894)
- Added a fallback to the `_post_send` method without parameters when the stream payload is not set, ensuring proper message handling in all scenarios.

fixes: #3893
2025-12-03 13:15:12 +08:00
易推倒白毛 8006981976 fix: 修复 Telegram 语音使用 Whisper API 报错 (#3884)
* Update whisper_api_source.py

* chore: ruff format

---------

Co-authored-by: Soulter <905617992@qq.com>
2025-12-03 02:50:50 +08:00
Soulter f7a716af43 refactor: message storage format of webchat, support reply and file message segment (#3845)
* refactor: message storage format of webchat

* refactor: update image and record handling in webchat event processing

* fix: thinking placeholder in webchat

* feat: supports file upload in webchat

* feat: supports to delete attachments when webchat session is deleted

* perf: improve performance of file downloading

* refactor: remove unused import in chat route

* feat: add message timestamp formatting and localization support in chat

* fix: handle missing filename in file upload for chat route

* feat: enhance file handling in chat and webchat, supporting video uploads and improved attachment management

* fix: update property name for embedded files in message handling

* fix: compute variable errors after uninstalling plugins

* feat: supported for reply message and standarlize the message param

* fix: ensure message actions are displayed for the last message in the list
2025-12-02 17:11:08 +08:00
Copilot a708901e7f fix: fix dark mode white background in conversation preview dialog (#3881)
* Initial plan

* Fix dark mode background issue in conversation data preview

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>

* style: update conversation messages container background color and add debug log for dark mode detection

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
Co-authored-by: Soulter <905617992@qq.com>
2025-12-02 17:03:59 +08:00
Soulter e9be8cf69f chore: bump version to 4.7.4 2025-12-01 18:42:07 +08:00
Soulter 31d53edb9d refactor: standardize provider test method implementation
- Updated the `test` method in all provider classes to remove return values and raise exceptions for failure cases, enhancing clarity and consistency.
- Adjusted related logic in the dashboard and command routes to align with the new `test` method behavior, simplifying error handling.
2025-12-01 18:37:08 +08:00
Soulter 2ba0460f19 feat: introduce file extract capability (#3870)
* feat: introduce file extract capability

powered by MoonshotAI

* fix: correct indentation in default configuration file

* fix: add error handling for file extract application in InternalAgentSubStage

* fix: update file name handling in InternalAgentSubStage to correctly associate file names with extracted content

* feat: add condition settings for local agent runner in default configuration

* fix: enhance file naming logic in File component and update prompt handling in InternalAgentSubStage
2025-12-01 18:12:39 +08:00
雪語 0e034f0fbd fix: aiocqhttp 适配器 NapCat 文件名获取为空 (#3853)
* aiocqhttp 适配器 NapCat 文件名获取为空

修复使用 NapCat 时,文件消息的 File.name 为空的问题。原代码硬编码 name="",导致下游插件无法获取文件名和扩展名

* Enhance file name retrieval from message data

Updated file name extraction logic to check multiple fields for better accuracy.
2025-12-01 13:36:19 +08:00
Soulter 2a7d03f9e1 fix: fit language and log AI responses more clearly (#3864)
* fix: fit language and log AI responses more clearly

* chore: ruff format
2025-12-01 13:24:52 +08:00
Soulter 72fac4b9f1 feat: implement unified provider availability testing across components (#3865)
- Added a `test` method to each provider class to standardize availability checks.
- Updated the dashboard and command routes to utilize the new `test` method for provider reachability verification, simplifying the logic and improving maintainability.
- Removed redundant reachability check logic from the command handler.
2025-12-01 13:17:20 +08:00
Soulter 38281ba2cf refactor: restore reachability check configuration in default settings and localization files 2025-12-01 00:38:30 +08:00
Soulter 21aa3174f4 fix: disable reachability check in default configuration 2025-12-01 00:16:11 +08:00
邹永赫 dcda871fc0 feat: provider availability reachability improvements (#3708) 2025-12-01 01:06:10 +09:00
Soulter c13c51f499 fix: assistant message validation error when tool_call exists but content not exists (#3862)
* fix: assistant message validation error when tool_call exists but content not exists

* fix: enhance content validation in Message model to allow None for assistant role with tool_calls
2025-11-30 23:42:37 +08:00
Dt8333 a130db5cf4 fix: 将 Graceful shutdown 的异常改为 KeyboardInterrupt (#3855) 2025-11-30 20:31:17 +08:00
邹永赫 7faeb5cea8 Merge pull request #3850 from zouyonghe/feature/plugin-upgrade-all
增加升级所有插件按钮
2025-11-30 15:12:36 +09:00
ZouYonghe 8d3ff61e0d Format plugin route with ruff 2025-11-30 11:56:24 +08:00
ZouYonghe 4c03e82570 Fix plugin update JSON parsing and concurrency handling 2025-11-30 11:50:46 +08:00
ZouYonghe e7e8664ab4 chore: tweak update all label 2025-11-30 11:18:30 +08:00
ZouYonghe 1dd1623e7d feat: batch update plugins via new api 2025-11-30 11:11:36 +08:00
ZouYonghe 80d8161d58 feat: add update all plugins action 2025-11-30 10:40:46 +08:00
Soulter fc80d7d681 chore: bump version to 4.7.3 2025-11-30 00:42:49 +08:00
Soulter c2f036b27c chore: bump vertion to 4.7.2 2025-11-30 00:33:07 +08:00
Soulter 4087bbb512 perf: set content attribute optional to AssistantMessageSegment for enhanced message handling
fixes: #3843
2025-11-30 00:32:00 +08:00
Soulter e1c728582d chore: bump version to 4.7.2 2025-11-30 00:18:23 +08:00
Oscar Shaw 93c69a639a feat: 新增群聊模式下的专用图片转述模型配置 (#3822)
* feat: add image caption provider configuration for group chat

- Introduced `image_caption_provider_id` to allow separate configuration for group chat image understanding.
- Updated metadata and hints in English and Chinese for clarity on new settings.
- Adjusted logic in long term memory to utilize the new provider ID for image captioning.

* fix: format

* Fix logic for image caption and active reply settings

* Fix indentation and formatting in long_term_memory.py

* chore: ruff format

---------

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
Co-authored-by: Soulter <905617992@qq.com>
2025-11-29 23:53:32 +08:00
Soulter a7fdc98b29 fix: third party agent runner cannot run properly when using non-default config file
fix: #3815
2025-11-29 23:45:12 +08:00
Soulter 85b7f104df fix: remove unnecessary provider check (#3846)
fixes: #3815
2025-11-29 23:15:19 +08:00
Oscar Shaw d76d1bd7fe perf: adjust padding for PlatformPage and ProviderPage log sections (#3825)
- Added bottom margin to log card for better spacing.
2025-11-29 19:15:35 +08:00
Soulter df4412aa80 style: adjust bot-embedded-image max-width and remove hover effect for improved layout 2025-11-29 01:31:25 +08:00
Soulter ab2c94e19a chore: comment out error logging in provider sources to reduce verbosity 2025-11-28 19:59:33 +08:00
Oscar Shaw 37cc4e2121 perf: console tag UI improve (#3816)
- Added yarn.lock to .gitignore to prevent tracking of Yarn lock files.
- Updated ConsoleDisplayer.vue to improve chip styling
2025-11-28 17:17:11 +08:00
Soulter 60dfdd0a66 chore: update astrbot cli version 2025-11-28 16:53:20 +08:00
Soulter bb8b2cb194 chore: bump version to 4.7.1 2025-11-28 15:13:35 +08:00
Soulter 4e29684aa3 fix: add plugin set and knowledge bases selection in custom rules page (#3813)
fixes: #3806
2025-11-28 13:29:50 +08:00
Soulter 0e17e3553d chore: bump version to 4.7.0 2025-11-27 23:50:05 +08:00
Soulter 0a55060e89 fix: session controller in webchat 2025-11-27 22:32:35 +08:00
Soulter 77859c7daa feat: enhance provider status display in ProviderPage
- Added a tooltip to show detailed provider status, including availability and error messages.
- Refactored item details template to include status chips for better visual representation.
- Removed unused status section to streamline the UI.
2025-11-27 16:39:51 +08:00
Soulter ba39c393a0 perf: enhance provider management with reload locking and logging (#3793)
- Introduced a reload lock to prevent concurrent reloads of providers.
- Added logging to indicate when a provider is disabled and when providers are being synchronized with the configuration.
- Refactored the reload method to improve clarity and maintainability.


Co-authored-by: anka <1350989414@qq.com>
2025-11-27 16:25:31 +08:00
Soulter 6a50d316d9 fix: mcp server cannot reload successfully after updating mcp server config (#3797)
fixes: #3780
2025-11-27 16:22:26 +08:00
Soulter 88c1d77f0b perf: add at message to group chat history (#3796)
* feat: enhance long-term memory message formatting

- Added support for 'At' message components in long-term memory, allowing for better representation of mentions in messages.

* chore: ruff check
2025-11-27 15:59:07 +08:00
Dt8333 758ce40cc1 chore: fix test (#3787) 2025-11-27 14:02:42 +08:00
Soulter 3e7bb80492 chore: ruff format 2025-11-27 14:01:25 +08:00
Soulter 75e95aa9ca fix: update session management icon in sidebar
- Changed the icon for the session management sidebar item from 'mdi-account-group' to 'mdi-pencil-ruler' for better representation.
2025-11-27 14:00:05 +08:00
Soulter a389842e25 feat: update session management UI with information button and layout adjustments
- Added an information button linking to custom rules documentation.
- Adjusted layout for improved spacing and readability in the session management page.
- Minor refactoring of the data table component for better alignment.
2025-11-27 13:58:37 +08:00
Soulter 0f6a3c3f5a refactor: session management custom rules (#3792)
* refactor: umo custom rules

* feat(i18n): update session management translations and improve provider configuration handling

- Updated English and Chinese translations for session management, including "Unified Message Origin" and "Follow Config".
- Enhanced provider configuration options to include "Follow Config" as a selectable item.
- Removed unused clear buttons and refactored provider configuration saving logic to handle updates and deletions more efficiently.
2025-11-27 13:30:43 +08:00
Soulter 133f27422d feat: implement i18n of astrbot config (#3772)
* feat: implement i18n of astrbot config

* feat(config): update configuration metadata with i18n details and future deprecation notes
2025-11-26 16:40:58 +08:00
RC-CHN abc6deb244 feat: add plugin logo placeholder (#3784) 2025-11-26 16:22:11 +08:00
teapot1de 06869b4597 docs: clarify segmented_reply words_count_threshold hint (#3779)
Update the configuration hint for `words_count_threshold` to explicitly state that it acts as a maximum limit for segmentation, preventing user confusion about it being a minimum trigger.
2025-11-26 16:15:09 +08:00
dependabot[bot] d32cea9870 chore(deps): bump actions/checkout in the github-actions group (#3775)
Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 5 to 6
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-26 16:13:42 +08:00
Soulter 4b68100f16 feat(chat): add standalone chat component and integrate with config page for testing configurations (#3767)
* feat(chat): add standalone chat component and integrate with config page for testing configurations

* feat(chat): add error handling for message sending and session creation
2025-11-24 22:06:02 +08:00
Soulter 5c5515d462 fix: segmented reply regex error handling (#3771)
* fix: segmented reply regex error handling

closes: #3761

* fix: improve regex handling for segmented replies to support multiline input

* fix: update regex handling in ResultDecorateStage to use findall for segmented replies

* fix: update error logging message for segmented reply regex handling
2025-11-24 22:00:59 +08:00
Soulter 3932b8f982 Merge pull request #3760 from AstrBotDevs/feat/agent-runner
refactor: transfer dify, coze and alibaba dashscope from chat provider to agent runner
2025-11-24 15:33:20 +08:00
Soulter 82488ca900 feat(api): enhance file upload method to support mime type and file name 2025-11-24 15:30:49 +08:00
Soulter 29d9b9b2d6 feat(config): add condition for display_reasoning_text based on agent_runner_type 2025-11-24 15:10:17 +08:00
Soulter 02215e9b7b feat(config): update hint for agent_runner execution method to clarify third-party integration 2025-11-24 15:07:33 +08:00
Soulter 7160b7a18b fix: dify workflow streaming mode 2025-11-24 15:04:15 +08:00
Soulter ea8dac837a feat(config): enhance hint for agent_runner execution method in configuration 2025-11-24 14:42:36 +08:00
Soulter e2a7a028bd feat(migration): enhance migration process with error handling and agent runner config updates 2025-11-24 14:37:25 +08:00
Soulter 70db8d264b fix(config): disable auto_save_history option in configuration 2025-11-24 14:25:14 +08:00
Soulter 0518e6d487 feat(config): add hint for agent_runner execution method in configuration 2025-11-24 14:23:53 +08:00
Soulter 39eb367866 perf: improve file structure
- Implemented CozeAPIClient for file upload, image download, chat messaging, and context management.
- Developed DashscopeAgentRunner for handling requests to the Dashscope API with streaming support.
- Created DifyAgentRunner to manage interactions with the Dify API, including file uploads and workflow execution.
- Introduced DifyAPIClient for making asynchronous requests to the Dify API.
- Updated third-party agent imports to reflect new module structure.
2025-11-24 14:00:16 +08:00
Soulter f1d51a22ad feat(dashscope_agent_runner): refactor request payload construction and enhance streaming response handling 2025-11-24 13:21:34 +08:00
Soulter 77fb554e8f feat(dashscope_agent_runner): implement streaming response handling and request payload construction 2025-11-24 13:09:57 +08:00
Soulter 91f8a0ae09 fix(provider_manager): use get method for provider_type check in load_provider 2025-11-24 10:57:13 +08:00
Soulter 370cda7cf0 feat(dify_api_client): add docstring for file_upload method 2025-11-24 10:53:50 +08:00
Soulter 66b3eed273 fix: correct typo in agent state transition log message 2025-11-24 00:03:22 +08:00
Soulter 99b061a143 fix: make session properties required in Session interface 2025-11-23 23:25:29 +08:00
Soulter 5f3c7ed673 feat(conversation): update agent runner type configuration path to provider_settings 2025-11-23 23:05:36 +08:00
Soulter a6dc458212 feat(third-party-agent): implement streaming response handling and enhance agent execution flow 2025-11-23 23:03:56 +08:00
Soulter 520f521887 feat(provider): enhance agent runner provider selection with subtype filtering 2025-11-23 22:23:23 +08:00
Soulter 01427d9969 feat(config): add hint for non-built-in agent execution model configuration 2025-11-23 22:13:52 +08:00
Soulter 34c03ce983 Merge remote-tracking branch 'origin/master' into feat/agent-runner 2025-11-23 22:06:52 +08:00
Soulter 95e9da42d6 fix(webchat): webchat session cannot be deleted (#3759) 2025-11-23 22:03:07 +08:00
Soulter 1338cab61b feat: add configuration selector for session management and enhance session handling in chat components 2025-11-23 21:53:56 +08:00
Soulter 7ba98c1e91 feat: enhance provider display with grouped categorization and improved filtering 2025-11-23 21:06:16 +08:00
Soulter 9a5f507cbe feat: enable agent runner providers in configuration 2025-11-23 20:58:18 +08:00
Soulter d560671d1f feat: agent runner config migration 2025-11-23 20:54:19 +08:00
Soulter 82c9cf4db6 chore: remove legacy coze and dashscope provider 2025-11-23 20:18:51 +08:00
Soulter 910ec6c695 feat: implement third party agent sub stage and refactor provider management
- Added `ThirdPartyAgentSubStage` to handle interactions with third-party agent runners (Dify, Coze, Dashscope).
- Refactored `star_request.py` to ensure consistent return types in the `process` method.
- Updated `stage.py` to initialize and utilize the new `AgentRequestSubStage`.
- Modified `ProviderManager` to skip loading agent runner providers.
- Removed `Dify` source implementation as it is now handled by the new agent runner structure.
- Enhanced `DifyAPIClient` to support file uploads via both file path and file data.
- Cleaned up shared preferences handling to simplify session preference retrieval.
- Updated dashboard configuration to reflect changes in agent runner provider selection.
- Refactored conversation commands to accommodate the new agent runner structure and remove direct dependencies on Dify.
- Adjusted main application logic to ensure compatibility with the new conversation management approach.
2025-11-23 20:18:51 +08:00
Soulter 766d6f2bec fix(conversation): update session configuration retrieval to use unified message origin 2025-11-23 20:18:51 +08:00
Soulter 9f39140987 fix(conversation): update session configuration retrieval to use unified message origin 2025-11-23 19:59:21 +08:00
Soulter 89716ef4da Merge remote-tracking branch 'origin/master' into feat/agent-runner 2025-11-23 14:48:08 +08:00
Soulter 3c4ea5a339 chore: bump version to 4.6.1 2025-11-23 13:58:53 +08:00
Soulter 601846a8c1 docs: refine readme 2025-11-22 18:57:08 +08:00
Soulter 85d66c1056 fix(migration): update migration_done key for webchat session tracking (#3746) 2025-11-22 18:51:00 +08:00
Dt8333 b89d3f663c fix(core.db): 修复升级后webchat未正确迁移的问题 (#3745)
不是所有人都叫Astrbot

#3722
2025-11-22 18:37:39 +08:00
Soulter 0260d430d1 Merge pull request #3706 from piexian/master 2025-11-22 01:11:35 +08:00
piexian 2e608cdc09 refactor(bailian_rerank): 修复误删除并优化top_n参数处理
- 移除不合理的知识库配置读取逻辑
- 添加os模块导入(用于读取环境变量)
- 抽取辅助函数:_build_payload()、_parse_results()、_log_usage()
- 添加自定义异常类:BailianRerankError、BailianAPIError、BailianNetworkError
- 使用.get()安全访问API响应字段,避免KeyError
- 使用raise ... from e保持异常链
2025-11-21 05:34:18 +08:00
piexian 234ce93dc1 refactor(bailian_rerank): 优化代码质量和错误处理
- 移除未使用的 os 导入
- 简化 API Key 验证逻辑
- 优化 top_n 参数处理,优先使用传入值
- 改进错误处理,使用 RuntimeError 替代通用 Exception
- 添加异常链保持原始错误上下文
2025-11-21 04:07:45 +08:00
Soulter 4e2154feb7 fix(ci): repository name must be lowercase 2025-11-20 23:46:34 +08:00
Soulter 604958898c chore: bump version to 4.6.0 2025-11-20 23:41:20 +08:00
Soulter a093f5ad0a fix(dependencies): specify upper version limit for google-genai 2025-11-20 23:32:05 +08:00
Soulter a7e9a7f30c fix(gemini): ensure extra_content is not empty before processing 2025-11-20 23:30:19 +08:00
Soulter 5d1e9de096 Merge pull request #3678 from AstrBotDevs/refactor/webchat-session
refactor: Implement WebChat session management and migration
2025-11-20 17:23:10 +08:00
Soulter 89da4eb747 Merge branch 'master' into refactor/webchat-session 2025-11-20 17:21:48 +08:00
Soulter 8899a1dee1 feat(chat): refactor chat component structure and add new features (#3701)
- Introduced `ConversationSidebar.vue` for improved conversation management and sidebar functionality.
- Enhanced `MessageList.vue` to handle loading states and improved message rendering.
- Created new composables: `useConversations`, `useMessages`, `useMediaHandling`, `useRecording` for better code organization and reusability.
- Added loading indicators and improved user experience during message processing.
- Ensured backward compatibility and maintained existing functionalities.
2025-11-20 17:19:45 +08:00
Soulter 384a687ec3 delete: remove useConversations composable 2025-11-20 17:15:47 +08:00
Soulter 70cfdd2f8b feat(chat): refactor chat component structure and add new features (#3701)
- Introduced `ConversationSidebar.vue` for improved conversation management and sidebar functionality.
- Enhanced `MessageList.vue` to handle loading states and improved message rendering.
- Created new composables: `useConversations`, `useMessages`, `useMediaHandling`, `useRecording` for better code organization and reusability.
- Added loading indicators and improved user experience during message processing.
- Ensured backward compatibility and maintained existing functionalities.
2025-11-20 17:15:04 +08:00
Soulter bdbd2f009a delete: useConversations 2025-11-20 17:11:01 +08:00
Soulter 164e0d26e0 feat(chat): refactor chat component structure and add new features (#3701)
- Introduced `ConversationSidebar.vue` for improved conversation management and sidebar functionality.
- Enhanced `MessageList.vue` to handle loading states and improved message rendering.
- Created new composables: `useConversations`, `useMessages`, `useMediaHandling`, `useRecording` for better code organization and reusability.
- Added loading indicators and improved user experience during message processing.
- Ensured backward compatibility and maintained existing functionalities.
2025-11-20 17:10:36 +08:00
Soulter cb087b5ff9 refactor: update timestamp handling in session management and chat components 2025-11-20 17:02:01 +08:00
Soulter 1d3928d145 refactor(sqlite): remove auto-generation of session_id in insert method 2025-11-20 16:33:57 +08:00
Soulter 6dc3d161e7 feat(chat): refactor chat component structure and add new features (#3701)
- Introduced `ConversationSidebar.vue` for improved conversation management and sidebar functionality.
- Enhanced `MessageList.vue` to handle loading states and improved message rendering.
- Created new composables: `useConversations`, `useMessages`, `useMediaHandling`, `useRecording` for better code organization and reusability.
- Added loading indicators and improved user experience during message processing.
- Ensured backward compatibility and maintained existing functionalities.
2025-11-20 16:30:05 +08:00
Soulter e9805ba205 fix: anyio.ClosedResourceError when calling mcp tools (#3700)
* fix: anyio.ClosedResourceError when calling mcp tools

added reconnect mechanism

fixes: 3676

* fix(mcp_client): implement thread-safe reconnection using asyncio.Lock
2025-11-20 16:24:02 +08:00
Dt8333 d5280dcd88 fix(core.platform): 修复启用多个企业微信智能机器人适配器时消息混乱的问题 (#3693)
* fix(core.platform): 修复启用多个企业微信智能机器人适配器时消息混乱的问题

移除了全局的消息队列,改为每个适配器处理自己的队列。修改相关方法适应该更改。

#3673

* chore: apply suggestions from code review

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

---------

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2025-11-20 16:24:02 +08:00
Dt8333 67a9663eff fix(dashboard.i18n): complete the missing i18n keys(#3699)
#3679
2025-11-20 16:24:02 +08:00
Soulter 77dd89b8eb feat: add supports for gemini-3 series thought signature (#3698)
* feat: add supports for gemini-3 series thought signature

* feat: refactor tools_call_extra_content to use a dictionary for better structure
2025-11-20 16:24:02 +08:00
Soulter 8e511bf14b fix: build docker ci failed 2025-11-20 16:24:02 +08:00
Soulter 164a4226ea feat(chat): refactor chat component structure and add new features (#3701)
- Introduced `ConversationSidebar.vue` for improved conversation management and sidebar functionality.
- Enhanced `MessageList.vue` to handle loading states and improved message rendering.
- Created new composables: `useConversations`, `useMessages`, `useMediaHandling`, `useRecording` for better code organization and reusability.
- Added loading indicators and improved user experience during message processing.
- Ensured backward compatibility and maintained existing functionalities.
2025-11-20 16:07:09 +08:00
Soulter 6d6fefc435 fix: anyio.ClosedResourceError when calling mcp tools (#3700)
* fix: anyio.ClosedResourceError when calling mcp tools

added reconnect mechanism

fixes: 3676

* fix(mcp_client): implement thread-safe reconnection using asyncio.Lock
2025-11-20 16:01:22 +08:00
Soulter aa59532287 refactor: implement migration for WebChat sessions by creating PlatformSession records from platform_message_history 2025-11-20 15:58:27 +08:00
piexian 2ada1deb9a 修复文档返回读取问题 2025-11-20 08:31:50 +08:00
piexian 788ceb9721 添加阿里百炼重排序模型 2025-11-20 08:05:42 +08:00
Dt8333 8488c9aeab fix(core.platform): 修复启用多个企业微信智能机器人适配器时消息混乱的问题 (#3693)
* fix(core.platform): 修复启用多个企业微信智能机器人适配器时消息混乱的问题

移除了全局的消息队列,改为每个适配器处理自己的队列。修改相关方法适应该更改。

#3673

* chore: apply suggestions from code review

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

---------

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2025-11-19 21:44:38 +08:00
Dt8333 676f9fd4ff fix(dashboard.i18n): complete the missing i18n keys(#3699)
#3679
2025-11-19 21:36:34 +08:00
Soulter 1935ce4700 refactor: update session handling by replacing conversation_id with session_id in chat routes and components 2025-11-19 19:54:29 +08:00
Soulter e760956353 refactor: enhance PlatformSession migration by adding display_name from Conversations and improve session item styling 2025-11-19 19:41:57 +08:00
Soulter be3e5f3f8b refactor: update message history deletion logic to remove newer records based on offset 2025-11-19 19:41:25 +08:00
Soulter cdf617feac refactor: optimize WebChat session migration by batch inserting records 2025-11-19 19:16:15 +08:00
Soulter afb56cf707 feat: add supports for gemini-3 series thought signature (#3698)
* feat: add supports for gemini-3 series thought signature

* feat: refactor tools_call_extra_content to use a dictionary for better structure
2025-11-19 18:54:56 +08:00
Soulter cd2556ab94 fix: build docker ci failed 2025-11-19 15:40:41 +08:00
Soulter cf4a5d9ea4 refactor: change to platform session 2025-11-18 22:37:55 +08:00
Soulter 0747099cac fix: restore migration check for version 4.7 2025-11-18 22:07:43 +08:00
Soulter 323ec29b02 refactor: Implement WebChat session management and migration from version 4.6 to 4.7
- Added WebChatSession model for managing user sessions.
- Introduced methods for creating, retrieving, updating, and deleting WebChat sessions in the database.
- Updated core lifecycle to include migration from version 4.6 to 4.7, creating WebChat sessions from existing platform message history.
- Refactored chat routes to support new session-based architecture, replacing conversation-related endpoints with session endpoints.
- Updated frontend components to handle sessions instead of conversations, including session creation and management.
2025-11-18 22:04:26 +08:00
magisk317 ae81d70685 ci(docker-build): build nightly image everyday (#3120)
* ci: build test image on master pushes

* ci: split workflows for master test and release builds

* test ci

* test ci

* Update docker-image.yml

* test ci

Updated README to enhance deployment instructions.

* Make GHCR publishing optional in Docker workflow

* chore: Update DockerHub password secret in workflow

* Update .github/workflows/docker-image.yml

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* chore: rename job to build nightly image in workflow

* feat: schedule the nightly build at 0:00 am everyday, if have new commits

* fix: update build-nightly-image job to trigger only on schedule events

* Update fetch-depth and enable fetch-tag in workflows

---------

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
Co-authored-by: LIghtJUNction <lightjunction.me@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Soulter <905617992@qq.com>
2025-11-18 10:47:58 +08:00
RC-CHN 270c89c12f feat: Add URL document parser for knowledge base (#3622)
* feat: 添加从 URL 上传文档的功能,支持进度回调和错误处理

* feat: 添加从 URL 上传文档的前端

* chore: 添加 URL 上传功能的警告提示,确保用户配置正确

* feat: 添加内容清洗功能,支持从 URL 上传文档时的清洗设置和服务提供商选择

* feat: 更新内容清洗系统提示,增强信息提取规则;添加 URL 上传功能的测试版标识

* style: format code

* perf: 优化上传设置,增强 URL 上传时的禁用逻辑和清洗提供商验证

* refactor:使用自带chunking模块

* refactor: 提取prompt到单独文件

* feat: 添加 Tavily API Key 配置对话框,增强网页搜索功能的配置体验

* fix: update URL hint and warning messages for clarity in knowledge base upload settings

* fix: 修复设置tavily_key的热重载问题

---------

Co-authored-by: Soulter <905617992@qq.com>
2025-11-17 19:05:14 +08:00
Soulter c7a58252fe feat: supports knowledge base agentic search (#3667)
* feat: supports knowledge base agentic search

* fix: correct formatting of system prompt in knowledge base results
2025-11-17 17:29:18 +08:00
Soulter 47ad8c86e5 docs: update translations of README 2025-11-17 12:50:01 +08:00
Soulter 937e879e5e chore: revise the issue template
Updated the bug report template to include English translations for all fields and improved clarity.
2025-11-17 11:35:24 +08:00
Soulter 1ecf26eead chore: revice pr template
Removed unnecessary comments and streamlined the pull request template.
2025-11-17 11:27:48 +08:00
Soulter 61a68477d0 stage 2025-10-21 14:19:38 +08:00
Soulter e74f626383 stage 2025-10-21 09:55:14 +08:00
Soulter ef99f64291 feat(config): 添加 agent 运行器类型及相关配置支持 2025-10-21 00:47:04 +08:00
489 changed files with 56788 additions and 14547 deletions
+21 -23
View File
@@ -1,46 +1,44 @@
name: '🐛 报告 Bug'
name: '🐛 Report Bug / 报告 Bug'
title: '[Bug]'
description: 提交报告帮助我们改进。
description: Submit bug report to help us improve. / 提交报告帮助我们改进。
labels: [ 'bug' ]
body:
- type: markdown
attributes:
value: |
感谢您抽出时间报告问题!请准确解释您的问题。如果可能,请提供一个可复现的片段(这有助于更快地解决问题)。请注意,不详细 / 没有日志的 issue 会被直接关闭,谢谢理解。
Thank you for taking the time to report this issue! Please describe your problem accurately. If possible, please provide a reproducible snippet (this will help resolve the issue more quickly). Please note that issues that are not detailed or have no logs will be closed immediately. Thank you for your understanding. / 感谢您抽出时间报告问题!请准确解释您的问题。如果可能,请提供一个可复现的片段(这有助于更快地解决问题)。请注意,不详细 / 没有日志的 issue 会被直接关闭,谢谢理解。
- type: textarea
attributes:
label: 发生了什么
description: 描述你遇到的异常
label: What happened / 发生了什么
description: Description
placeholder: >
一个清晰且具体的描述这个异常是什么。请注意,不详细 / 没有日志的 issue 会被直接关闭,谢谢理解。
Please provide a clear and specific description of what this exception is. Please note that issues that are not detailed or have no logs will be closed immediately. Thank you for your understanding. / 一个清晰且具体的描述这个异常是什么。请注意,不详细 / 没有日志的 issue 会被直接关闭,谢谢理解。
validations:
required: true
- type: textarea
attributes:
label: 如何复现?
label: Reproduce / 如何复现?
description: >
复现该问题的步骤
The steps to reproduce the issue. / 复现该问题的步骤
placeholder: >
: 1. 打开 '...'
Example: 1. Open '...'
validations:
required: true
- type: textarea
attributes:
label: AstrBot 版本、部署方式(如 Windows Docker Desktop 部署)、使用的提供商、使用的消息平台适配器
description: >
请提供您的 AstrBot 版本和部署方式。
label: AstrBot version, deployment method (e.g., Windows Docker Desktop deployment), provider used, and messaging platform used. / AstrBot 版本、部署方式(如 Windows Docker Desktop 部署)、使用的提供商、使用的消息平台适配器
placeholder: >
如: 3.1.8 Docker, 3.1.7 Windows启动器
Example: 4.5.7 Docker, 3.1.7 Windows Launcher
validations:
required: true
- type: dropdown
attributes:
label: 操作系统
label: OS
description: |
你在哪个操作系统上遇到了这个问题?
On which operating system did you encounter this problem? / 你在哪个操作系统上遇到了这个问题?
multiple: false
options:
- 'Windows'
@@ -53,30 +51,30 @@ body:
- type: textarea
attributes:
label: 报错日志
label: Logs / 报错日志
description: >
如报错日志、截图等。请提供完整的 Debug 级别的日志,不要介意它很长!请注意,不详细 / 没有日志的 issue 会被直接关闭,谢谢理解。
Please provide complete Debug-level logs, such as error logs and screenshots. Don't worry if they're long! Please note that issues with insufficient details or no logs will be closed immediately. Thank you for your understanding. / 如报错日志、截图等。请提供完整的 Debug 级别的日志,不要介意它很长!请注意,不详细 / 没有日志的 issue 会被直接关闭,谢谢理解。
placeholder: >
请提供完整的报错日志或截图。
Please provide a complete error log or screenshot. / 请提供完整的报错日志或截图。
validations:
required: true
- type: checkboxes
attributes:
label: 你愿意提交 PR 吗?
label: Are you willing to submit a PR? / 你愿意提交 PR 吗?
description: >
这不是必需的,但我们很乐意在贡献过程中为您提供指导特别是如果你已经很好地理解了如何实现修复。
This is not required, but we would be happy to provide guidance during the contribution process, especially if you already have a good understanding of how to implement the fix. / 这不是必需的,但我们很乐意在贡献过程中为您提供指导特别是如果你已经很好地理解了如何实现修复。
options:
- label: 是的,我愿意提交 PR!
- label: Yes!
- type: checkboxes
attributes:
label: Code of Conduct
options:
- label: >
我已阅读并同意遵守该项目的 [行为准则](https://docs.github.com/zh/site-policy/github-terms/github-community-code-of-conduct)。
I have read and agree to abide by the project's [Code of Conduct](https://docs.github.com/zh/site-policy/github-terms/github-community-code-of-conduct)。
required: true
- type: markdown
attributes:
value: "感谢您填写我们的表单!"
value: "Thank you for filling out our form! / 感谢您填写我们的表单!"
+6 -25
View File
@@ -1,44 +1,25 @@
<!-- 如果有的话,请指定此 PR 旨在解决的 ISSUE 编号。 -->
<!-- If applicable, please specify the ISSUE number this PR aims to resolve. -->
fixes #XYZ
---
### Motivation / 动机
<!--请描述此项更改的动机:它解决了什么问题?(例如:修复了 XX 错误,添加了 YY 功能)-->
<!--Please describe the motivation for this change: What problem does it solve? (e.g., Fixes XX bug, adds YY feature)-->
<!--Please describe the motivation for this change: What problem does it solve? (e.g., Fixes XX issue, adds YY feature)-->
<!--请描述此项更改的动机:它解决了什么问题?(例如:修复了 XX issue,添加了 YY 功能)-->
### Modifications / 改动点
<!--请总结你的改动:哪些核心文件被修改了?实现了什么功能?-->
<!--Please summarize your changes: What core files were modified? What functionality was implemented?-->
### Verification Steps / 验证步骤
<!--请为审查者 (Reviewer) 提供清晰、可复现的验证步骤(例如:1. 导航到... 2. 点击...)。-->
<!--Please provide clear and reproducible verification steps for the Reviewer (e.g., 1. Navigate to... 2. Click...).-->
- [x] This is NOT a breaking change. / 这不是一个破坏性变更。
<!-- If your changes is a breaking change, please uncheck the checkbox above -->
### Screenshots or Test Results / 运行截图或测试结果
<!--请粘贴截图、GIF 或测试日志,作为执行“验证步骤”的证据,证明此改动有效。-->
<!--Please paste screenshots, GIFs, or test logs here as evidence of executing the "Verification Steps" to prove this change is effective.-->
### Compatibility & Breaking Changes / 兼容性与破坏性变更
<!--请说明此变更的兼容性:哪些是破坏性变更?哪些地方做了向后兼容处理?是否提供了数据迁移方法?-->
<!--Please explain the compatibility of this change: What are the breaking changes? What backward-compatible measures were taken? Are data migration paths provided?-->
- [ ] 这是一个破坏性变更 (Breaking Change)。/ This is a breaking change.
- [ ] 这不是一个破坏性变更。/ This is NOT a breaking change.
<!--请粘贴截图、GIF 或测试日志,作为执行“验证步骤”的证据,证明此改动有效。-->
---
### Checklist / 检查清单
<!--如果分支被合并,您的代码将服务于数万名用户!在提交前,请核查一下几点内容。-->
<!--If merged, your code will serve tens of thousands of users! Please double-check the following items before submitting.-->
<!--如果分支被合并,您的代码将服务于数万名用户!在提交前,请核查一下几点内容。-->
- [ ] 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。/ If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
- [ ] 👀 我的更改经过了良好的测试,**并已在上方提供了“验证步骤”和“运行截图”**。/ My changes have been well-tested, **and "Verification Steps" and "Screenshots" have been provided above**.
+1 -2
View File
@@ -15,7 +15,6 @@ Always reference these instructions first and fallback to search or bash command
### Running the Application
- Run main application: `uv run main.py` -- starts in ~3 seconds
- Application creates WebUI on http://localhost:6185 (default credentials: `astrbot`/`astrbot`)
- Application loads plugins automatically from `packages/` and `data/plugins/` directories
### Dashboard Build (Vue.js/Node.js)
- **Prerequisites**: Node.js 20+ and npm 10+ required
@@ -35,7 +34,7 @@ Always reference these instructions first and fallback to search or bash command
- **ALWAYS** run `uv run ruff check .` and `uv run ruff format .` before committing changes
### Plugin Development
- Plugins load from `packages/` (built-in) and `data/plugins/` (user-installed)
- Plugins load from `astrbot/builtin_stars/` (built-in) and `data/plugins/` (user-installed)
- Plugin system supports function tools and message handlers
- Key plugins: python_interpreter, web_searcher, astrbot, reminder, session_controller
+2 -2
View File
@@ -13,7 +13,7 @@ jobs:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Dashboard Build
run: |
@@ -70,7 +70,7 @@ jobs:
needs: build-and-publish-to-github-release
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
+1 -1
View File
@@ -56,7 +56,7 @@ jobs:
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0
+2 -2
View File
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
@@ -36,7 +36,7 @@ jobs:
zip -r dist.zip dist
- name: Archive production artifacts
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: dist-without-markdown
path: |
+127 -18
View File
@@ -3,18 +3,125 @@ name: Docker Image CI/CD
on:
push:
tags:
- 'v*'
- "v*"
schedule:
# Run at 00:00 UTC every day
- cron: "0 0 * * *"
workflow_dispatch:
jobs:
publish-docker:
build-nightly-image:
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
env:
DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }}
GHCR_OWNER: soulter
HAS_GHCR_TOKEN: ${{ secrets.GHCR_GITHUB_TOKEN != '' }}
steps:
- name: Pull The Codes
uses: actions/checkout@v5
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0 # Must be 0 so we can fetch tags
fetch-depth: 1
fetch-tag: true
- name: Check for new commits today
if: github.event_name == 'schedule'
id: check-commits
run: |
# Get commits from the last 24 hours
commits=$(git log --since="24 hours ago" --oneline)
if [ -z "$commits" ]; then
echo "No commits in the last 24 hours, skipping build"
echo "has_commits=false" >> $GITHUB_OUTPUT
else
echo "Found commits in the last 24 hours:"
echo "$commits"
echo "has_commits=true" >> $GITHUB_OUTPUT
fi
- name: Exit if no commits
if: github.event_name == 'schedule' && steps.check-commits.outputs.has_commits == 'false'
run: exit 0
- name: Build Dashboard
run: |
cd dashboard
npm install
npm run build
mkdir -p dist/assets
echo $(git rev-parse HEAD) > dist/assets/version
cd ..
mkdir -p data
cp -r dashboard/dist data/
- name: Determine test image tags
id: test-meta
run: |
short_sha=$(echo "${GITHUB_SHA}" | cut -c1-12)
build_date=$(date +%Y%m%d)
echo "short_sha=$short_sha" >> $GITHUB_OUTPUT
echo "build_date=$build_date" >> $GITHUB_OUTPUT
- name: Set QEMU
uses: docker/setup-qemu-action@v3
- name: Set Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Login to GitHub Container Registry
if: env.HAS_GHCR_TOKEN == 'true'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ env.GHCR_OWNER }}
password: ${{ secrets.GHCR_GITHUB_TOKEN }}
- name: Build nightly image tags list
id: test-tags
run: |
TAGS="${{ env.DOCKER_HUB_USERNAME }}/astrbot:nightly-latest
${{ env.DOCKER_HUB_USERNAME }}/astrbot:nightly-${{ steps.test-meta.outputs.build_date }}-${{ steps.test-meta.outputs.short_sha }}"
if [ "${{ env.HAS_GHCR_TOKEN }}" = "true" ]; then
TAGS="$TAGS
ghcr.io/${{ env.GHCR_OWNER }}/astrbot:nightly-latest
ghcr.io/${{ env.GHCR_OWNER }}/astrbot:nightly-${{ steps.test-meta.outputs.build_date }}-${{ steps.test-meta.outputs.short_sha }}"
fi
echo "tags<<EOF" >> $GITHUB_OUTPUT
echo "$TAGS" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Build and Push Nightly Image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.test-tags.outputs.tags }}
- name: Post build notifications
run: echo "Test Docker image has been built and pushed successfully"
build-release-image:
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v'))
runs-on: ubuntu-latest
env:
DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }}
GHCR_OWNER: soulter
HAS_GHCR_TOKEN: ${{ secrets.GHCR_GITHUB_TOKEN != '' }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
fetch-tag: true
- name: Get latest tag (only on manual trigger)
id: get-latest-tag
@@ -27,21 +134,22 @@ jobs:
if: github.event_name == 'workflow_dispatch'
run: git checkout ${{ steps.get-latest-tag.outputs.latest_tag }}
- name: Check if version is pre-release
id: check-prerelease
- name: Compute release metadata
id: release-meta
run: |
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
version="${{ steps.get-latest-tag.outputs.latest_tag }}"
else
version="${{ github.ref_name }}"
version="${GITHUB_REF#refs/tags/}"
fi
if [[ "$version" == *"beta"* ]] || [[ "$version" == *"alpha"* ]]; then
echo "is_prerelease=true" >> $GITHUB_OUTPUT
echo "Version $version is a pre-release, will not push latest tag"
echo "Version $version marked as pre-release"
else
echo "is_prerelease=false" >> $GITHUB_OUTPUT
echo "Version $version is a stable release, will push latest tag"
echo "Version $version marked as stable"
fi
echo "version=$version" >> $GITHUB_OUTPUT
- name: Build Dashboard
run: |
@@ -67,23 +175,24 @@ jobs:
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Login to GitHub Container Registry
if: env.HAS_GHCR_TOKEN == 'true'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: Soulter
username: ${{ env.GHCR_OWNER }}
password: ${{ secrets.GHCR_GITHUB_TOKEN }}
- name: Build and Push Docker to DockerHub and Github GHCR
- name: Build and Push Release Image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ steps.check-prerelease.outputs.is_prerelease == 'false' && format('{0}/astrbot:latest', secrets.DOCKER_HUB_USERNAME) || '' }}
${{ secrets.DOCKER_HUB_USERNAME }}/astrbot:${{ github.event_name == 'workflow_dispatch' && steps.get-latest-tag.outputs.latest_tag || github.ref_name }}
${{ steps.check-prerelease.outputs.is_prerelease == 'false' && 'ghcr.io/soulter/astrbot:latest' || '' }}
ghcr.io/soulter/astrbot:${{ github.event_name == 'workflow_dispatch' && steps.get-latest-tag.outputs.latest_tag || github.ref_name }}
${{ steps.release-meta.outputs.is_prerelease == 'false' && format('{0}/astrbot:latest', env.DOCKER_HUB_USERNAME) || '' }}
${{ steps.release-meta.outputs.is_prerelease == 'false' && env.HAS_GHCR_TOKEN == 'true' && format('ghcr.io/{0}/astrbot:latest', env.GHCR_OWNER) || '' }}
${{ format('{0}/astrbot:{1}', env.DOCKER_HUB_USERNAME, steps.release-meta.outputs.version) }}
${{ env.HAS_GHCR_TOKEN == 'true' && format('ghcr.io/{0}/astrbot:{1}', env.GHCR_OWNER, steps.release-meta.outputs.version) || '' }}
- name: Post build notifications
run: echo "Docker image has been built and pushed successfully"
run: echo "Release Docker image has been built and pushed successfully"
+58
View File
@@ -0,0 +1,58 @@
name: Smoke Test
on:
push:
branches:
- master
paths-ignore:
- 'README*.md'
- 'changelogs/**'
- 'dashboard/**'
pull_request:
workflow_dispatch:
jobs:
smoke-test:
name: Run smoke tests
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Install UV package manager
run: |
pip install uv
- name: Install dependencies
run: |
uv sync
timeout-minutes: 15
- name: Run smoke tests
run: |
uv run main.py &
APP_PID=$!
echo "Waiting for application to start..."
for i in {1..60}; do
if curl -f http://localhost:6185 > /dev/null 2>&1; then
echo "Application started successfully!"
kill $APP_PID
exit 0
fi
sleep 1
done
echo "Application failed to start within 30 seconds"
kill $APP_PID 2>/dev/null || true
exit 1
timeout-minutes: 2
+52 -15
View File
@@ -1,27 +1,64 @@
# This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time.
# 本工作流用于标记并关闭长期不活跃的 Issue。
# 目前仅针对带 `bug` 标签的 Issue 生效,不会处理 PR。
#
# You can adjust the behavior by modifying this file.
# For more information, see:
# https://github.com/actions/stale
name: Mark stale issues and pull requests
# 文档: https://github.com/actions/stale
name: Mark stale bug issues
on:
schedule:
- cron: '21 23 * * *'
# 每天 UTC 08:30 执行 (北京时间 16:30)
- cron: '30 8 * * *'
workflow_dispatch:
inputs:
dry-run:
description: '仅预览, 不实际执行 (Dry run mode)'
required: false
default: true
type: boolean
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v10
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: 'Stale issue message'
stale-pr-message: 'Stale pull request message'
stale-issue-label: 'no-issue-activity'
stale-pr-label: 'no-pr-activity'
- uses: actions/stale@v10
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
operations-per-run: 200
# 只处理带 bug 标签的 Issue
any-of-labels: 'bug'
# 不处理 PR
days-before-pr-stale: -1
days-before-pr-close: -1
# 不活跃判定与关闭策略: 先标记 stale, 再延迟关闭
days-before-issue-stale: 60
days-before-issue-close: 30
stale-issue-label: 'stale'
stale-issue-message: |
This issue has been automatically marked as **stale** because it has not had any activity.
It will be closed in a certain period of time if no further activity occurs.
If this issue is still relevant, please leave a comment.
---
该 Issue 已较长时间无活动, 已被标记为 `stale`。
如无后续活动, 将在一段时间后自动关闭。
如仍需跟进, 请回复评论。
close-issue-message: |
This issue has been automatically closed due to inactivity.
If the problem still exists, feel free to reopen or create a new issue with updated information.
---
该 Issue 因长期无活动已自动关闭。
如问题仍存在, 欢迎补充复现信息并重新打开或新建 Issue。
remove-stale-when-updated: true
debug-only: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run }}
+9 -2
View File
@@ -24,9 +24,9 @@ configs/session
configs/config.yaml
cmd_config.json
# Plugins and packages
# Plugins
addons/plugins
packages/python_interpreter/workplace
astrbot/builtin_stars/python_interpreter/workplace
tests/astrbot_plugin_openai
# Dashboard
@@ -34,6 +34,7 @@ dashboard/node_modules/
dashboard/dist/
package-lock.json
package.json
yarn.lock
# Operating System
**/.DS_Store
@@ -47,3 +48,9 @@ astrbot.lock
chroma
venv/*
pytest.ini
AGENTS.md
IFLOW.md
# genie_tts data
CharacterModels/
GenieData/
+33
View File
@@ -0,0 +1,33 @@
## 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.
+90
View File
@@ -0,0 +1,90 @@
# CONTRIBUTING
## 贡献指南
首先,感谢您花时间做出贡献!❤️
所有类型的贡献都受到鼓励和重视。有关不同的帮助方式和处理方式的详细信息,请参阅[目录](#目录)。在做出贡献之前,请确保阅读相关部分。这将使我们维护人员的工作变得更加容易,并为所有参与者带来顺畅的体验。社区期待您的贡献。🎉
### 目录
- [报告问题](#报告问题)
- [提交代码更改](#提交代码更改)
### 报告问题
如果您在使用 AstrBot 时遇到任何问题,请按照以下步骤报告:
1. **检查现有问题**:在提交新问题之前,请先检查 [Issues](https://github.com/AstrBotDevs/AstrBot/issues) 中是否已经存在类似的问题。
2. **创建新问题**:如果没有类似的问题,请创建一个新问题。请确保提供以下信息:
- 问题的简要描述
- 重现问题的步骤
- 预期结果和实际结果
- 相关日志或错误消息
### 提交代码更改
#### 分支命名
我们使用 `fix/` 前缀来修复错误,使用 `feat/` 前缀来添加新功能。对于 `fix/` 分支,请使用简短的描述,或者直接使用 Issue 编号。例如:`fix/1234` 或者 `fix/1234-login-typo`。对于 `feat/` 分支,请使用简短的描述,例如:`feat/add-user-profile`
#### PR 描述
- 请使用英文描述您的 PR。
- 标题请使用 `fix: `, `feat: `, `docs: `, `style: `, `refactor: `, `test: `, `chore: ` 等语义化前缀,并简要描述更改内容。如:`fix: correct login page typo`
#### 代码规范
##### Core
我们使用 Ruff 作为代码格式化和静态分析工具。在提交代码之前,请运行以下命令以确保代码符合规范:
```bash
ruff format .
ruff check .
```
如果您使用 VSCode,可以安装 `Ruff` 插件。
## Contributing Guide
First off, thanks for taking the time to contribute! ❤️
All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉
### Table of Contents
- [Reporting Issues](#reporting-issues)
- [Pull Requests](#pull-requests)
### Reporting Issues
If you encounter any issues while using AstrBot, please follow these steps to report them:
1. **Check Existing Issues**: Before submitting a new issue, please check if a similar issue already exists in the [Issues](https://github.com/AstrBotDevs/AstrBot/issues) section of the repository.
2. **Create a New Issue**: If no similar issue exists, please create a new issue. Make sure to provide the following information:
- A brief description of the issue
- Steps to reproduce the issue
- Expected and actual results
- Relevant logs or error messages
### Pull Requests
#### Branch Naming
We use the `fix/` prefix for bug fixes and the `feat/` prefix for new features. For `fix/` branches, please use a short description or directly use the Issue number, e.g., `fix/1234` or `fix/1234-login-typo`. For `feat/` branches, please use a short description, e.g., `feat/add-user-profile`.
#### PR Description
- Please use English to describe your PR.
- Use semantic prefixes like `fix: `, `feat: `, `docs: `, `style: `, `refactor: `, `test: `, `chore: ` in the title, followed by a brief description of the changes, e.g., `fix: correct login page typo`.
#### Code Style
##### Core
We use Ruff as our code formatter and static analysis tool. Before submitting your code, please run the following commands to ensure your code adheres to the style guidelines:
```bash
ruff format .
ruff check .
```
+244
View File
@@ -0,0 +1,244 @@
# 最终用户许可协议(EULA
> 我们热爱开源软件,并始终致力于为所有用户提供健康、安全、可靠的使用体验。 ❤️
For English edition, please refer to the section below the Chinese version.
**最后更新:** 2026-01-12
感谢您使用 **AstrBot**
在使用本项目之前,请仔细阅读以下声明内容。
**您一旦安装、运行或使用本项目,即表示您已阅读、理解并同意本声明中的全部内容。**
## 1. 项目性质
AstrBot 是一个遵循 **GNU Affero General Public License v3AGPLv3** 协议发布的**免费开源软件项目**。
* 截至目前,AstrBot 项目未开展任何形式的商业化服务,AstrBot 团队也未通过本项目向用户提供任何收费服务。若您因使用 AstrBot 被要求付费,请务必提高警惕,谨防诈骗行为。
* AstrBot 的代码实现未对任何第三方系统进行逆向工程、破解、反编译或绕过安全机制等行为。AstrBot 仅使用并支持各即时通讯(IM)平台官方公开提供的机器人接入接口、开放平台能力或相关通信协议进行集成与通信。
## 2. 无担保声明
AstrBot 按“**现状(as is)**”提供,不附带任何形式的明示或暗示担保。
AstrBot 团队不对以下内容作出任何保证:
* 系统本身的安全性、可靠性或稳定性;
* 任何第三方插件的安全性、正确性或可信度;
* 任何第三方 AI 模型或外部服务 API 的可用性、质量、准确性或安全性;
* 本软件对任何特定用途的适用性。
**您使用本软件所产生的一切风险均由您自行承担。**
## 3. 第三方插件与服务
* AstrBot 支持第三方插件及外部 AI 服务接入;
* AstrBot 团队**不对任何第三方插件、扩展或服务进行审计、控制、背书或担保**;
* 因使用第三方插件或服务所产生的任何风险、损失、数据泄露或法律后果,均由用户自行承担。
* 第三方插件指代的是非 AstrBot 自带的插件,AstrBot 自带的插件指代的是插件实现代码已经包含在 AstrBotDevs/AstrBot 代码库中的插件。插件市场中的插件都是第三方插件。
## 4. 使用与内容限制
您同意不会将 AstrBot 用于以下行为:
* 输入、生成、传播或处理任何违法、极端、暴力、色情、仇恨、辱骂或其他有害内容;
* 从事违反您所在国家或地区法律法规,或任何适用国际法律的行为;
* 试图绕过、关闭、削弱或破坏本系统内置的安全机制或内容限制。
* 任何侵犯他人合法权益、损害他人和自己身心健康、涉及个人隐私、个人信息等敏感内容的内容。
## 5. 项目用途说明
AstrBot 是一个**工具型对话与 Agent 系统**,在**安全、健康、友善**的前提下提供有限的人性化交互能力。
项目的主要目标是:
* 提供 Agent 能力与自动化辅助;
* 帮助用户提升工作、学习和信息处理效率;
* 在合理范围内提供友好的人机交互体验。
* 辅助用户成长,提供有益于用户身心健康的内容。
## 6. 安全措施说明
AstrBot 团队**已尽合理努力在技术和策略层面设置安全与内容约束机制**,以引导系统输出健康、友善、安全的内容。
但请理解:
* 世界上任何的系统均无法保证完全无误、绝对安全或无法被滥用;
* 用户仍有责任自行合理配置、监督并正确使用本系统。
如果您要关闭 AstrBot 默认启用的“健康模式”,请在 cmd_config.json 中将 `provider_settings.llm_safety_mode` 设置为 `False`。但请注意,关闭健康模式不是推荐的使用方式,可能导致系统输出不安全或不适当的内容。关闭该功能所产生的任何风险与后果,均由用户自行承担,AstrBot 团队不对此承担任何责任。
## 7. 心理健康提示
如果您在使用本项目过程中因系统输出内容而感到心理不适、情绪困扰,
或您本身正处于心理压力较大、情绪不稳定、焦虑、抑郁等状态并因此使用本项目,
请优先考虑寻求来自专业人士的帮助,例如心理咨询师、心理医生或当地心理援助机构。
如遇紧急情况(例如存在自伤或他伤风险),请立即联系当地的紧急救助电话或专业机构。
## 8. 统计信息与隐私说明
AstrBot 可能会收集有限的匿名统计信息,用于了解系统使用情况、发现问题以及持续改进项目。
所收集的统计信息仅包括与系统运行和功能使用相关的基础技术指标,例如功能使用频率、错误信息等。
AstrBot **不会收集、上传或存储您的对话内容、消息正文、输入文本,或任何能够识别您个人身份的敏感信息**
您可以手动关闭此项功能,通过在系统环境变量中设置 `ASTRBOT_DISABLE_METRICS=1` 来禁用匿名统计信息收集。
## 9. 责任限制
在法律允许的最大范围内,AstrBot 团队不对因以下原因导致的任何直接或间接损失承担责任,包括但不限于:
* 使用或无法使用本软件;
* 使用第三方插件或服务;
* 系统生成的内容或输出;
* 数据丢失、服务中断或安全事件。
## 10. 条款的接受
您一旦安装、运行、修改或使用 AstrBot,即确认:
* 您已阅读并理解本声明内容;
* 您同意并接受上述所有条款;
* 您对自身使用行为承担全部责任。
如您不同意本声明的任何内容,请勿使用本项目。
## 11. 许可与版权
AstrBot 的源代码、文档及相关内容受版权法及相关法律保护。
在遵守本声明及 AGPLv3 协议的前提下,AstrBot 授予您一项非独占、不可转让、不可再许可的许可,用于下载、安装、运行、修改和分发本软件。
除非法律另有规定或本声明另有明确说明,AstrBot 团队保留本项目的所有未明确授予的权利。
## 12. 适用法律
本声明的解释与适用应遵循您所在地或项目发布地适用的法律法规。
如本声明的任何条款被认定为无效或不可执行,其余条款仍然有效。
---
# EULA
> We love open-source software and are always committed to providing all users with a healthy, safe, and reliable experience. ❤️
**Last updated:** January 12, 2026
Thank you for using **AstrBot**.
Please read the following notice carefully before using this project.
**By installing, running, or using this project, you acknowledge that you have read, understood, and agreed to all the terms stated below.**
## 1. Nature of the Project
AstrBot is a **free and open-source software project** released under the **GNU Affero General Public License v3 (AGPLv3)**.
* AstrBot does not constitute any form of commercial service;
* The AstrBot Team does not provide any paid services through this project;
* AstrBots implementation does not involve reverse engineering, cracking, decompilation, or circumvention of security mechanisms of any third-party systems. AstrBot only uses and supports officially published bot integration interfaces, open platform capabilities, or related communication protocols provided by instant messaging (IM) platforms for integration and communication.
## 2. No Warranty
AstrBot is provided **“as is”**, without any express or implied warranties.
The AstrBot Team makes no guarantees regarding:
* The security, reliability, or stability of the system;
* The security, correctness, or trustworthiness of any third-party plugins;
* The availability, quality, accuracy, or safety of any third-party AI model APIs or external services;
* The fitness of the software for any particular purpose.
**All risks arising from the use of this software are borne solely by the user.**
## 3. Third-Party Plugins and Services
* AstrBot supports third-party plugins and external AI services;
* The AstrBot Team does **not audit, control, endorse, or guarantee** any third-party plugins, extensions, or services;
* Any risks, losses, data leaks, or legal consequences arising from the use of third-party plugins or services are solely the responsibility of the user;
* “Third-party plugins” refer to plugins that are not built into AstrBot. Built-in plugins are those whose implementation code is included in the AstrBotDevs/AstrBot repository. All plugins available in the plugin marketplace are third-party plugins.
## 4. Usage and Content Restrictions
You agree not to use AstrBot for any of the following activities:
* Inputting, generating, distributing, or processing any illegal, extremist, violent, pornographic, hateful, abusive, or otherwise harmful content;
* Engaging in activities that violate the laws or regulations of your country or region, or any applicable international laws;
* Attempting to bypass, disable, weaken, or undermine the built-in safety mechanisms or content restrictions of the system;
* Any activities that infringe upon the legitimate rights and interests of others, harm the physical or mental well-being of yourself or others, or involve personal privacy or sensitive personal information.
## 5. Intended Use
AstrBot is a **tool-oriented conversational and agent system** that provides limited human-like interaction capabilities under the principles of **safety, health, and friendliness**.
The primary goals of the project are to:
* Provide agent capabilities and automation assistance;
* Help users improve efficiency in work, study, and information processing;
* Offer a friendly humancomputer interaction experience within reasonable boundaries;
* Support user growth and provide content beneficial to users physical and mental well-being.
## 6. Safety Measures
The AstrBot Team has made **reasonable efforts** at both technical and policy levels to implement safety and content restriction mechanisms, guiding the system to produce healthy, friendly, and safe outputs.
However, please understand that:
* No system in the world can be guaranteed to be completely error-free, absolutely secure, or immune to misuse;
* Users remain responsible for properly configuring, supervising, and using the system.
If you wish to disable AstrBots default “Safety Mode,” please set `provider_settings.llm_safety_mode` to `False` in `cmd_config.json`. However, please note that disabling Safety Mode is not recommended and may lead to unsafe or inappropriate outputs. Any risks or consequences arising from disabling this feature are solely borne by the user, and the AstrBot Team assumes no responsibility.
## 7. Mental Health Notice
If you experience psychological discomfort or emotional distress due to system outputs during use,
or if you are experiencing significant psychological stress, emotional instability, anxiety, or depression and are using this project for such reasons,
please prioritize seeking help from qualified professionals, such as psychologists, psychiatrists, or local mental health support services.
In case of emergency (for example, if there is a risk of self-harm or harm to others), please immediately contact your local emergency number or professional crisis support services.
## 8. Metrics and Privacy
AstrBot may collect a limited amount of anonymous usage statistics to understand system usage, identify issues, and continuously improve the project.
Collected metrics are limited to basic technical indicators related to system operation and feature usage, such as feature usage frequency and error information.
AstrBot **does not collect, upload, or store your conversation content, message bodies, input text, or any personally identifiable or sensitive information**.
You may manually disable this feature by setting the environment variable `ASTRBOT_DISABLE_METRICS=1` to turn off anonymous metrics collection.
## 9. Limitation of Liability
To the maximum extent permitted by law, the AstrBot Team shall not be liable for any direct or indirect losses arising from, including but not limited to:
* The use or inability to use this software;
* The use of third-party plugins or services;
* Generated content or system outputs;
* Data loss, service interruptions, or security incidents.
## 10. Acceptance of Terms
By installing, running, modifying, or using AstrBot, you confirm that:
* You have read and understood this Notice;
* You agree to and accept all the terms stated above;
* You assume full responsibility for your use of the software.
If you do not agree with any part of this Notice, please do not use this project.
## 11. License and Copyright
The source code, documentation, and related materials of AstrBot are protected by copyright laws and applicable regulations.
Subject to compliance with this Notice and the AGPLv3 license, AstrBot grants you a non-exclusive, non-transferable, non-sublicensable license to download, install, run, modify, and distribute this software.
Unless otherwise required by law or expressly stated in this Notice, the AstrBot Team reserves all rights not expressly granted.
## 12. Governing Law
The interpretation and application of this Notice shall be governed by the laws and regulations applicable in your jurisdiction or the jurisdiction where the project is released.
If any provision of this Notice is held to be invalid or unenforceable, the remaining provisions shall remain in full force and effect.
+59 -38
View File
@@ -1,10 +1,13 @@
![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/ffd99b6b-3272-4682-beaa-6fe74250f7d9)
</p>
<div align="center">
<br>
<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>
<a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
@@ -14,35 +17,40 @@
<br>
<div>
<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=%E4%B8%AA&style=for-the-badge&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%9C%BA&cacheSeconds=3600">
<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=%E4%B8%AA&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%9C%BA&cacheSeconds=3600">
<img src="https://gitcode.com/Soulter/AstrBot/star/badge.svg" href="https://gitcode.com/Soulter/AstrBot">
</div>
<br>
<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://astrbot.app/">文档</a>
<a href="https://blog.astrbot.app/">Blog</a>
<a href="https://astrbot.featurebase.app/roadmap">路线图</a>
<a href="https://github.com/AstrBotDevs/AstrBot/issues">问题提交</a>
</div>
AstrBot 是一个开源的一站式 Agent 聊天机器人平台及开发框架
AstrBot 是一个开源的一站式 Agent 聊天机器人平台,可接入主流即时通讯软件,为个人、开发者和团队打造可靠、可扩展的对话式智能基础设施。无论是个人 AI 伙伴、智能客服、自动化助手,还是企业知识库,AstrBot 都能在你的即时通讯软件平台的工作流中快速构建生产可用的 AI 应用
![521771166-00782c4c-4437-4d97-aabc-605e3738da5c (1)](https://github.com/user-attachments/assets/61e7b505-f7db-41aa-a75f-4ef8f079b8ba)
## 主要功能
1. **大模型对话**。支持接入多种大模型服务。支持多模态、工具调用、MCP、原生知识库、人设等功能
2. **多消息平台支持**。支持接入 QQ、企业微信、微信公众号、飞书、Telegram、钉钉、Discord、KOOK 等平台。支持速率限制、白名单、百度内容审核
3. **Agent**。完善适配的 Agentic 能力。支持多轮工具调用、内置沙盒代码执行器、网页搜索等功能
4. **插件扩展**。深度优化的插件机制,支持[开发插件](https://astrbot.app/dev/plugin.html)扩展功能,社区插件生态丰富
5. **WebUI**。可视化配置和管理机器人,功能齐全
1. 💯 免费 & 开源
1. ✨ AI 大模型对话,多模态,Agent,MCP,Skills,知识库,人格设定,自动压缩对话
2. 🤖 支持接入 Dify、阿里云百炼、Coze 等智能体平台
2. 🌐 多平台,支持 QQ、企业微信、飞书、钉钉、微信公众号、Telegram、Slack 以及[更多](#支持的消息平台)
3. 📦 插件扩展,已有近 800 个插件可一键安装
5. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) 隔离化环境,安全地执行任何代码、调用 Shell、会话级资源复用。
6. 💻 WebUI 支持。
7. 🌈 Web ChatUI 支持,ChatUI 内置代理沙盒、网页搜索等。
8. 🌐 国际化(i18n)支持。
## 部署方式
## 快速开始
#### Docker 部署(推荐 🥳)
@@ -50,6 +58,12 @@ AstrBot 是一个开源的一站式 Agent 聊天机器人平台及开发框架
请参阅官方文档 [使用 Docker 部署 AstrBot](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot) 。
#### uv 部署
```bash
uvx astrbot
```
#### 宝塔面板部署
AstrBot 与宝塔面板合作,已上架至宝塔面板。
@@ -101,24 +115,6 @@ uv run main.py
或者请参阅官方文档 [通过源码部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html) 。
## 🌍 社区
### QQ 群组
- 1 群:322154837
- 3 群:630166526
- 5 群:822130018
- 6 群:753075035
- 开发者群:975206796
### Telegram 群组
<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>
### Discord 群组
<a href="https://discord.gg/hAVk6tgV36"><img alt="Discord_community" src="https://img.shields.io/badge/Discord-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
## 支持的消息平台
**官方维护**
@@ -138,10 +134,9 @@ uv run main.py
**社区维护**
- [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter)
- [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter)
- [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat)
- [Bilibili 私信](https://github.com/Hina-Chat/astrbot_plugin_bilibili_adapter)
- [wxauto](https://github.com/luosheng520qaq/wxauto-repost-onebotv11)
## 支持的模型服务
@@ -205,6 +200,26 @@ pip install pre-commit
pre-commit install
```
## 🌍 社区
### QQ 群组
- 1 群:322154837
- 3 群:630166526
- 5 群:822130018
- 6 群:753075035
- 7 群:743746109
- 8 群:1030353265
- 开发者群:975206796
### Telegram 群组
<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>
### Discord 群组
<a href="https://discord.gg/hAVk6tgV36"><img alt="Discord_community" src="https://img.shields.io/badge/Discord-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
## ❤️ Special Thanks
特别感谢所有 Contributors 和插件开发者对 AstrBot 的贡献 ❤️
@@ -230,4 +245,10 @@ pre-commit install
</details>
<div align="center">
_私は、高性能ですから!_
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
</div
+191 -118
View File
@@ -1,182 +1,255 @@
<p align="center">
![6e1279651f16d7fdf4727558b72bbaf1](https://github.com/user-attachments/assets/ead4c551-fc3c-48f7-a6f7-afbfdb820512)
</p>
![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/ffd99b6b-3272-4682-beaa-6fe74250f7d9)
<div align="center">
_✨ Easy-to-use Multi-platform LLM Chatbot & Development Framework ✨_
<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>
<br>
<div>
<a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/AstrBotDevs/AstrBot)](https://github.com/AstrBotDevs/AstrBot/releases/latest)
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" alt="python">
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot"/></a>
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=wtbaNx7EioxeaqS9z7RQWVXPIxg2zYr7&jump_from=webapi&authKey=vlqnv/AV2DbJEvGIcxdlNSpfxVy+8vVqijgreRdnVKOaydpc+YSw4MctmEbr0k5"><img alt="Static Badge" src="https://img.shields.io/badge/QQ群-630166526-purple"></a>
[![wakatime](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e.svg)](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e)
![Dynamic JSON Badge](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fstats&query=v&label=7%E6%97%A5%E6%B6%88%E6%81%AF%E4%B8%8A%E8%A1%8C%E9%87%8F&cacheSeconds=3600)
[![codecov](https://codecov.io/gh/AstrBotDevs/AstrBot/graph/badge.svg?token=FF3P5967B8)](https://codecov.io/gh/AstrBotDevs/AstrBot)
<a href="https://astrbot.app/">Documentation</a>
<a href="https://github.com/AstrBotDevs/AstrBot/issues">Issue Tracking</a>
<a href="https://hellogithub.com/repository/AstrBotDevs/AstrBot" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d127d50cd5e54c5382328acc3bb25483&claim_uid=ZO9by7qCXgSd6Lp&t=2" alt="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</div>
AstrBot is a loosely coupled, asynchronous chatbot and development framework that supports multi-platform deployment, featuring an easy-to-use plugin system and comprehensive Large Language Model (LLM) integration capabilities.
<br>
## ✨ Key Features
<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">
</div>
1. **LLM Conversations** - Supports various LLMs including OpenAI API, Google Gemini, Llama, Deepseek, ChatGLM, etc. Enables local model deployment via Ollama/LLMTuner. Features multi-turn dialogues, personality contexts, multimodal capabilities (image understanding), and speech-to-text (Whisper).
2. **Multi-platform Integration** - Supports QQ (OneBot), QQ Channels, WeChat (Gewechat), Feishu, and Telegram. Planned support for DingTalk, Discord, WhatsApp, and Xiaomi Smart Speakers. Includes rate limiting, whitelisting, keyword filtering, and Baidu content moderation.
3. **Agent Capabilities** - Native support for code execution, natural language TODO lists, web search. Integrates with [Dify Platform](https://dify.ai/) for easy access to Dify assistants/knowledge bases/workflows.
4. **Plugin System** - Optimized plugin mechanism with minimal development effort. Supports multiple installed plugins.
5. **Web Dashboard** - Visual configuration management, plugin controls, logging, and WebChat interface for direct LLM interaction.
6. **High Stability & Modularity** - Event bus and pipeline architecture ensures high modularization and loose coupling.
<br>
> [!TIP]
> Dashboard Demo: [https://demo.astrbot.app/](https://demo.astrbot.app/)
> Username: `astrbot`, Password: `astrbot` (LLM not configured for chat page)
<a href="https://astrbot.app/">Documentation</a>
<a href="https://blog.astrbot.app/">Blog</a>
<a href="https://astrbot.featurebase.app/roadmap">Roadmap</a>
<a href="https://github.com/AstrBotDevs/AstrBot/issues">Issue Tracker</a>
</div>
## ✨ Deployment
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.
#### Docker Deployment
![070d50ba43ea3c96980787127bbbe552](https://github.com/user-attachments/assets/6fe147c5-68d9-4f47-a8de-252e63fdcbd8)
See docs: [Deploy with Docker](https://astrbot.app/deploy/astrbot/docker.html#docker-deployment)
## Key Features
#### Windows Installer
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.
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.
Requires Python (>3.10). See docs: [Windows Installer Guide](https://astrbot.app/deploy/astrbot/windows.html)
## Quick Start
#### Replit Deployment
#### Docker Deployment (Recommended 🥳)
We recommend deploying AstrBot using Docker or Docker Compose.
Please refer to the official documentation: [Deploy AstrBot with Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
#### uv Deployment
```bash
uvx astrbot
```
#### BT-Panel Deployment
AstrBot has partnered with BT-Panel and is now available in their marketplace.
Please refer to the official documentation: [BT-Panel Deployment](https://astrbot.app/deploy/astrbot/btpanel.html).
#### 1Panel Deployment
AstrBot has been officially listed on the 1Panel marketplace.
Please refer to the official documentation: [1Panel Deployment](https://astrbot.app/deploy/astrbot/1panel.html).
#### Deploy on RainYun
AstrBot has been officially listed on RainYun's cloud application platform with one-click deployment.
[![Deploy on RainYun](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-en.svg)](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
#### Deploy on Replit
Community-contributed deployment method.
[![Run on Repl.it](https://repl.it/badge/github/AstrBotDevs/AstrBot)](https://repl.it/github/AstrBotDevs/AstrBot)
#### Windows One-Click Installer
Please refer to the official documentation: [Deploy AstrBot with Windows One-Click Installer](https://astrbot.app/deploy/astrbot/windows.html).
#### CasaOS Deployment
Community-contributed method.
See docs: [CasaOS Deployment](https://astrbot.app/deploy/astrbot/casaos.html)
Community-contributed deployment method.
Please refer to the official documentation: [CasaOS Deployment](https://astrbot.app/deploy/astrbot/casaos.html).
#### Manual Deployment
See docs: [Source Code Deployment](https://astrbot.app/deploy/astrbot/cli.html)
First, install uv:
## ⚡ Platform Support
```bash
pip install uv
```
| Platform | Status | Details | Message Types |
| -------------------------------------------------------------- | ------ | ------------------- | ------------------- |
| QQ (Official Bot) | ✔ | Private/Group chats | Text, Images |
| QQ (OneBot) | ✔ | Private/Group chats | Text, Images, Voice |
| WeChat (Personal) | ✔ | Private/Group chats | Text, Images, Voice |
| [Telegram](https://github.com/AstrBotDevs/AstrBot_plugin_telegram) | ✔ | Private/Group chats | Text, Images |
| [WeChat Work](https://github.com/AstrBotDevs/AstrBot_plugin_wecom) | ✔ | Private chats | Text, Images, Voice |
| Feishu | ✔ | Group chats | Text, Images |
| WeChat Open Platform | 🚧 | Planned | - |
| Discord | 🚧 | Planned | - |
| WhatsApp | 🚧 | Planned | - |
| Xiaomi Speakers | 🚧 | Planned | - |
Install AstrBot via Git Clone:
## Provider Support Status
```bash
git clone https://github.com/AstrBotDevs/AstrBot && cd AstrBot
uv run main.py
```
| Name | Support | Type | Notes |
|---------------------------|---------|------------------------|-----------------------------------------------------------------------|
| OpenAI API | ✔ | Text Generation | Supports all OpenAI API-compatible services including DeepSeek, Google Gemini, GLM, Moonshot, Alibaba Cloud Bailian, Silicon Flow, xAI, etc. |
| Claude API | ✔ | Text Generation | |
| Google Gemini API | ✔ | Text Generation | |
| Dify | ✔ | LLMOps | |
| DashScope (Alibaba Cloud) | ✔ | LLMOps | |
| Ollama | ✔ | Model Loader | Local deployment for open-source LLMs (DeepSeek, Llama, etc.) |
| LM Studio | ✔ | Model Loader | Local deployment for open-source LLMs (DeepSeek, Llama, etc.) |
| LLMTuner | ✔ | Model Loader | Local loading of fine-tuned models (e.g. LoRA) |
| OneAPI | ✔ | LLM Distribution | |
| Whisper | ✔ | Speech-to-Text | Supports API and local deployment |
| SenseVoice | ✔ | Speech-to-Text | Local deployment |
| OpenAI TTS API | ✔ | Text-to-Speech | |
| Fishaudio | ✔ | Text-to-Speech | Project involving GPT-Sovits author |
Or refer to the official documentation: [Deploy AstrBot from Source](https://astrbot.app/deploy/astrbot/cli.html).
# 🦌 Roadmap
## Supported Messaging Platforms
> [!TIP]
> Suggestions welcome via Issues <3
**Officially Maintained**
- [ ] Ensure feature parity across all platform adapters
- [ ] Optimize plugin APIs
- [ ] Add default TTS services (e.g., GPT-Sovits)
- [ ] Enhance chat features with persistent memory
- [ ] i18n Planning
- QQ (Official Platform & OneBot)
- Telegram
- WeChat Work Application & WeChat Work Intelligent Bot
- WeChat Customer Service & WeChat Official Accounts
- Feishu (Lark)
- DingTalk
- Slack
- Discord
- Satori
- Misskey
- WhatsApp (Coming Soon)
- LINE (Coming Soon)
## ❤️ Contributions
**Community Maintained**
All Issues/PRs welcome! Simply submit your changes to this project :)
- [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter)
- [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter)
- [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat)
For major features, please discuss via Issues first.
## Supported Model Services
## 🌟 Support
**LLM Services**
- Star this project!
- Support via [Afdian](https://afdian.com/a/soulter)
- WeChat support: [QR Code](https://drive.soulter.top/f/pYfA/d903f4fa49a496fda3f16d2be9e023b5.png)
- OpenAI and Compatible Services
- Anthropic
- Google Gemini
- Moonshot AI
- Zhipu AI
- DeepSeek
- Ollama (Self-hosted)
- LM Studio (Self-hosted)
- [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
- [302.AI](https://share.302.ai/rr1M3l)
- [TokenPony](https://www.tokenpony.cn/3YPyf)
- [SiliconFlow](https://docs.siliconflow.cn/cn/usecases/use-siliconcloud-in-astrbot)
- [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE)
- ModelScope
- OneAPI
## ✨ Demos
**LLMOps Platforms**
> [!NOTE]
> Code executor file I/O currently tested with Napcat(QQ)/Lagrange(QQ)
- Dify
- Alibaba Cloud Bailian Applications
- Coze
<div align='center'>
**Speech-to-Text Services**
<img src="https://github.com/user-attachments/assets/4ee688d9-467d-45c8-99d6-368f9a8a92d8" width="600">
- OpenAI Whisper
- SenseVoice
_✨ Docker-based Sandboxed Code Executor (Beta) ✨_
**Text-to-Speech Services**
<img src="https://github.com/user-attachments/assets/0378f407-6079-4f64-ae4c-e97ab20611d2" height=500>
- OpenAI TTS
- Gemini TTS
- GPT-Sovits-Inference
- GPT-Sovits
- FishAudio
- Edge TTS
- Alibaba Cloud Bailian TTS
- Azure TTS
- Minimax TTS
- Volcano Engine TTS
_✨ Multimodal Input, Web Search, Text-to-Image ✨_
## ❤️ Contributing
<img src="https://github.com/user-attachments/assets/8ec12797-e70f-460a-959e-48eca39ca2bb" height=100>
Issues and Pull Requests are always welcome! Feel free to submit your changes to this project :)
_✨ Natural Language TODO Lists ✨_
### How to Contribute
<img src="https://github.com/user-attachments/assets/e137a9e1-340a-4bf2-bb2b-771132780735" height=150>
<img src="https://github.com/user-attachments/assets/480f5e82-cf6a-4955-a869-0d73137aa6e1" height=150>
You can contribute by reviewing issues or helping with pull request reviews. Any issues or PRs are welcome to encourage community participation. Of course, these are just suggestions—you can contribute in any way you like. For adding new features, please discuss through an Issue first.
_✨ Plugin System Showcase ✨_
### Development Environment
<img src="https://github.com/user-attachments/assets/592a8630-14c7-4e06-b496-9c0386e4f36c" width=600>
AstrBot uses `ruff` for code formatting and linting.
_✨ Web Dashboard ✨_
```bash
git clone https://github.com/AstrBotDevs/AstrBot
pip install pre-commit
pre-commit install
```
![webchat](https://drive.soulter.top/f/vlsA/ezgif-5-fb044b2542.gif)
## 🌍 Community
_✨ Built-in Web Chat Interface ✨_
### QQ Groups
</div>
- Group 1: 322154837
- Group 3: 630166526
- Group 5: 822130018
- Group 6: 753075035
- Group 7: 743746109
- Group 8: 1030353265
- Developer Group: 975206796
### Telegram Group
<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>
### Discord Server
<a href="https://discord.gg/hAVk6tgV36"><img alt="Discord_community" src="https://img.shields.io/badge/Discord-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
## ❤️ Special Thanks
Special thanks to all Contributors and plugin developers for their contributions to AstrBot ❤️
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
</a>
Additionally, the birth of this project would not have been possible without the help of the following open-source projects:
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - The amazing cat framework
## ⭐ Star History
> [!TIP]
> If this project helps you, please give it a star <3
> [!TIP]
> If this project has helped you in your life or work, or if you're interested in its future development, please give the project a Star. It's the driving force behind maintaining this open-source project <3
<div align="center">
[![Star History Chart](https://api.star-history.com/svg?repos=AstrBotDevs/AstrBot&type=Date)](https://star-history.com/#AstrBotDevs/AstrBot&Date)
[![Star History Chart](https://api.star-history.com/svg?repos=astrbotdevs/astrbot&type=Date)](https://star-history.com/#astrbotdevs/astrbot&Date)
</div>
## Disclaimer
1. Licensed under `AGPL-v3`.
2. WeChat integration uses [Gewechat](https://github.com/Devo919/Gewechat). Use at your own risk with non-critical accounts.
3. Users must comply with local laws and regulations.
<!-- ## ✨ ATRI [Beta]
Available as plugin: [astrbot_plugin_atri](https://github.com/AstrBotDevs/AstrBot_plugin_atri)
1. Qwen1.5-7B-Chat Lora model fine-tuned with ATRI character data
2. Long-term memory
3. Meme understanding & responses
4. TTS integration
-->
</details>
<div align="center">
_私は、高性能ですから!_
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
</div>
+247
View File
@@ -0,0 +1,247 @@
![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/ffd99b6b-3272-4682-beaa-6fe74250f7d9)
</p>
<div align="center">
<br>
<div>
<a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://hellogithub.com/repository/AstrBotDevs/AstrBot" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d127d50cd5e54c5382328acc3bb25483&claim_uid=ZO9by7qCXgSd6Lp&t=2" alt="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</div>
<br>
<div>
<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_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_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">Feuille de route</a>
<a href="https://github.com/AstrBotDevs/AstrBot/issues">Signaler un problème</a>
</div>
AstrBot est une plateforme de chatbot Agent tout-en-un open source qui s'intègre aux principales applications de messagerie instantanée. Elle fournit une infrastructure d'IA conversationnelle fiable et évolutive pour les particuliers, les développeurs et les équipes. Que vous construisiez un compagnon IA personnel, un service client intelligent, un assistant d'automatisation ou une base de connaissances d'entreprise, AstrBot vous permet de créer rapidement des applications d'IA prêtes pour la production dans les flux de travail de votre plateforme de messagerie.
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
## Fonctionnalités principales
1. 💯 Gratuit & Open Source.
2. ✨ Conversations avec LLM IA, Multimodal, Agent, MCP, Base de connaissances, Paramètres de personnalité.
3. 🤖 Prise en charge de l'intégration avec Dify, Alibaba Cloud Bailian, Coze et autres plateformes d'agents.
4. 🌐 Multi-plateforme : QQ, WeChat Work, Feishu, DingTalk, Comptes officiels WeChat, Telegram, Slack, et [plus encore](#plateformes-de-messagerie-prises-en-charge).
5. 📦 Extensions de plugins avec près de 800 plugins disponibles pour une installation en un clic.
6. 💻 Support WebUI.
7. 🌐 Support de l'internationalisation (i18n).
## Démarrage rapide
#### Déploiement Docker (Recommandé 🥳)
Nous recommandons de déployer AstrBot en utilisant Docker ou Docker Compose.
Veuillez consulter la documentation officielle : [Déployer AstrBot avec Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
#### Déploiement uv
```bash
uvx astrbot
```
#### Déploiement BT-Panel
AstrBot s'est associé à BT-Panel et est maintenant disponible sur leur marketplace.
Veuillez consulter la documentation officielle : [Déploiement BT-Panel](https://astrbot.app/deploy/astrbot/btpanel.html).
#### Déploiement 1Panel
AstrBot a été officiellement listé sur le marketplace 1Panel.
Veuillez consulter la documentation officielle : [Déploiement 1Panel](https://astrbot.app/deploy/astrbot/1panel.html).
#### Déployer sur RainYun
AstrBot a été officiellement listé sur la plateforme d'applications cloud de RainYun avec un déploiement en un clic.
[![Deploy on RainYun](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-en.svg)](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
#### Déployer sur Replit
Méthode de déploiement contribuée par la communauté.
[![Run on Repl.it](https://repl.it/badge/github/AstrBotDevs/AstrBot)](https://repl.it/github/AstrBotDevs/AstrBot)
#### Installateur Windows en un clic
Veuillez consulter la documentation officielle : [Déployer AstrBot avec l'installateur Windows en un clic](https://astrbot.app/deploy/astrbot/windows.html).
#### Déploiement CasaOS
Méthode de déploiement contribuée par la communauté.
Veuillez consulter la documentation officielle : [Déploiement CasaOS](https://astrbot.app/deploy/astrbot/casaos.html).
#### Déploiement manuel
Tout d'abord, installez uv :
```bash
pip install uv
```
Installez AstrBot via Git Clone :
```bash
git clone https://github.com/AstrBotDevs/AstrBot && cd AstrBot
uv run main.py
```
Ou consultez la documentation officielle : [Déployer AstrBot depuis les sources](https://astrbot.app/deploy/astrbot/cli.html).
## Plateformes de messagerie prises en charge
**Maintenues officiellement**
- QQ (Plateforme officielle & OneBot)
- Telegram
- Application WeChat Work & Bot intelligent WeChat Work
- Service client WeChat & Comptes officiels WeChat
- Feishu (Lark)
- DingTalk
- Slack
- Discord
- Satori
- Misskey
- WhatsApp (Bientôt disponible)
- LINE (Bientôt disponible)
**Maintenues par la communauté**
- [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter)
- [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter)
- [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat)
## Services de modèles pris en charge
**Services LLM**
- OpenAI et services compatibles
- Anthropic
- Google Gemini
- Moonshot AI
- Zhipu AI
- DeepSeek
- Ollama (Auto-hébergé)
- LM Studio (Auto-hébergé)
- [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
- [302.AI](https://share.302.ai/rr1M3l)
- [TokenPony](https://www.tokenpony.cn/3YPyf)
- [SiliconFlow](https://docs.siliconflow.cn/cn/usecases/use-siliconcloud-in-astrbot)
- [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE)
- ModelScope
- OneAPI
**Plateformes LLMOps**
- Dify
- Applications Alibaba Cloud Bailian
- Coze
**Services de reconnaissance vocale**
- OpenAI Whisper
- SenseVoice
**Services de synthèse vocale**
- OpenAI TTS
- Gemini TTS
- GPT-Sovits-Inference
- GPT-Sovits
- FishAudio
- Edge TTS
- Alibaba Cloud Bailian TTS
- Azure TTS
- Minimax TTS
- Volcano Engine TTS
## ❤️ Contribuer
Les Issues et Pull Requests sont toujours les bienvenues ! N'hésitez pas à soumettre vos modifications à ce projet :)
### Comment contribuer
Vous pouvez contribuer en examinant les issues ou en aidant à la revue des pull requests. Toutes les issues ou PRs sont les bienvenues pour encourager la participation de la communauté. Bien sûr, ce ne sont que des suggestions - vous pouvez contribuer de la manière que vous souhaitez. Pour l'ajout de nouvelles fonctionnalités, veuillez d'abord en discuter via une Issue.
### Environnement de développement
AstrBot utilise `ruff` pour le formatage et le linting du code.
```bash
git clone https://github.com/AstrBotDevs/AstrBot
pip install pre-commit
pre-commit install
```
## 🌍 Communauté
### Groupes QQ
- Groupe 1 : 322154837
- Groupe 3 : 630166526
- Groupe 5 : 822130018
- Groupe 6 : 753075035
- Groupe développeurs : 975206796
### Groupe Telegram
<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>
### Serveur Discord
<a href="https://discord.gg/hAVk6tgV36"><img alt="Discord_community" src="https://img.shields.io/badge/Discord-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
## ❤️ Remerciements spéciaux
Un grand merci à tous les contributeurs et développeurs de plugins pour leurs contributions à AstrBot ❤️
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
</a>
De plus, la naissance de ce projet n'aurait pas été possible sans l'aide des projets open source suivants :
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - L'incroyable framework chat
## ⭐ Historique des étoiles
> [!TIP]
> Si ce projet vous a aidé dans votre vie ou votre travail, ou si vous êtes intéressé par son développement futur, veuillez donner une étoile au projet. C'est la force motrice derrière la maintenance de ce projet open source <3
<div align="center">
[![Star History Chart](https://api.star-history.com/svg?repos=astrbotdevs/astrbot&type=Date)](https://star-history.com/#astrbotdevs/astrbot&Date)
</div>
</details>
_私は、高性能ですから!_
+182 -102
View File
@@ -1,167 +1,247 @@
<p align="center">
![6e1279651f16d7fdf4727558b72bbaf1](https://github.com/user-attachments/assets/ead4c551-fc3c-48f7-a6f7-afbfdb820512)
![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/ffd99b6b-3272-4682-beaa-6fe74250f7d9)
</p>
<div align="center">
_✨ 簡単に使えるマルチプラットフォーム LLM チャットボットおよび開発フレームワーク ✨_
<br>
<div>
<a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/AstrBotDevs/AstrBot)](https://github.com/AstrBotDevs/AstrBot/releases/latest)
<img src="https://img.shields.io/badge/python-3.10+-blue.svg" 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"/></a>
<img alt="Static Badge" src="https://img.shields.io/badge/QQ群-630166526-purple">
[![wakatime](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e.svg)](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e)
![Dynamic JSON Badge](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fstats&query=v&label=7%E6%97%A5%E6%B6%88%E6%81%AF%E4%B8%8A%E8%A1%8C%E9%87%8F&cacheSeconds=3600)
[![codecov](https://codecov.io/gh/AstrBotDevs/AstrBot/graph/badge.svg?token=FF3P5967B8)](https://codecov.io/gh/AstrBotDevs/AstrBot)
<a href="https://astrbot.app/">ドキュメントを見る</a>
<a href="https://github.com/AstrBotDevs/AstrBot/issues">問題を報告する</a>
<a href="https://hellogithub.com/repository/AstrBotDevs/AstrBot" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d127d50cd5e54c5382328acc3bb25483&claim_uid=ZO9by7qCXgSd6Lp&t=2" alt="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</div>
AstrBot は、疎結合、非同期、複数のメッセージプラットフォームに対応したデプロイ、使いやすいプラグインシステム、および包括的な大規模言語モデル(LLM)接続機能を備えたチャットボットおよび開発フレームワークです。
<br>
## ✨ 主な機能
<div>
<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=%E5%80%8B&style=for-the-badge&label=%E3%83%97%E3%83%A9%E3%82%B0%E3%82%A4%E3%83%B3&cacheSeconds=3600">
</div>
1. **大規模言語モデルの対話**。OpenAI API、Google Gemini、Llama、Deepseek、ChatGLM など、さまざまな大規模言語モデルをサポートし、Ollama、LLMTuner を介してローカルにデプロイされた大規模モデルをサポートします。多輪対話、人格シナリオ、多モーダル機能を備え、画像理解、音声からテキストへの変換(Whisper)をサポートします。
2. **複数のメッセージプラットフォームの接続**。QQOneBot)、QQ チャンネル、Feishu、Telegram への接続をサポートします。今後、DingTalk、Discord、WhatsApp、Xiaoai 音響をサポートする予定です。レート制限、ホワイトリスト、キーワードフィルタリング、Baidu コンテンツ監査をサポートします。
3. **エージェント**。一部のエージェント機能をネイティブにサポートし、コードエグゼキューター、自然言語タスク、ウェブ検索などを提供します。[Dify プラットフォーム](https://dify.ai/)と連携し、Dify スマートアシスタント、ナレッジベース、Dify ワークフローを簡単に接続できます。
4. **プラグインの拡張**。深く最適化されたプラグインメカニズムを備え、[プラグインの開発](https://astrbot.app/dev/plugin.html)をサポートし、機能を拡張できます。複数のプラグインのインストールをサポートします。
5. **ビジュアル管理パネル**。設定の視覚的な変更、プラグイン管理、ログの表示などをサポートし、設定の難易度を低減します。WebChat を統合し、パネル上で大規模モデルと対話できます。
6. **高い安定性と高いモジュール性**。イベントバスとパイプラインに基づくアーキテクチャ設計により、高度にモジュール化され、低結合です。
<br>
> [!TIP]
> 管理パネルのオンラインデモを体験する: [https://demo.astrbot.app/](https://demo.astrbot.app/)
>
> ユーザー名: `astrbot`, パスワード: `astrbot`。LLM が設定されていないため、チャットページで大規模モデルを使用することはできません。(デモのログインパスワードを変更しないでください 😭)
<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_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/">ドキュメント</a>
<a href="https://blog.astrbot.app/">Blog</a>
<a href="https://astrbot.featurebase.app/roadmap">ロードマップ</a>
<a href="https://github.com/AstrBotDevs/AstrBot/issues">Issue</a>
</div>
#### Docker デプロイ
AstrBot は、主要なインスタントメッセージングアプリと統合できるオープンソースのオールインワン Agent チャットボットプラットフォームです。個人、開発者、チームに信頼性が高くスケーラブルな会話型 AI インフラストラクチャを提供します。パーソナル AI コンパニオン、インテリジェントカスタマーサービス、オートメーションアシスタント、エンタープライズナレッジベースなど、AstrBot を使用すると、IM プラットフォームのワークフロー内で本番環境対応の AI アプリケーションを迅速に構築できます。
公式ドキュメント [Docker を使用して AstrBot をデプロイする](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot) を参照してください。
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
#### Windows ワンクリックインストーラーのデプロイ
## 主な機能
コンピュータに Python(>3.10)がインストールされている必要があります。公式ドキュメント [Windows ワンクリックインストーラーを使用して AstrBot をデプロイする](https://astrbot.app/deploy/astrbot/windows.html) を参照してください
1. 💯 無料 & オープンソース
2. ✨ AI 大規模言語モデル対話、マルチモーダル、Agent、MCP、ナレッジベース、ペルソナ設定。
3. 🤖 Dify、Alibaba Cloud 百炼、Coze などの Agent プラットフォームとの統合をサポート。
4. 🌐 マルチプラットフォーム:QQ、WeChat Work、Feishu、DingTalk、WeChat 公式アカウント、Telegram、Slack、[その他](#サポートされているメッセージプラットフォーム)。
5. 📦 約800個のプラグインをワンクリックでインストール可能なプラグイン拡張機能。
6. 💻 WebUI サポート。
7. 🌐 国際化(i18n)サポート。
#### Replit デプロイ
## クイックスタート
#### Docker デプロイ(推奨 🥳)
Docker / Docker Compose を使用した AstrBot のデプロイを推奨します。
公式ドキュメント [Docker を使用した AstrBot のデプロイ](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot) をご参照ください。
#### uv デプロイ
```bash
uvx astrbot
```
#### 宝塔パネルデプロイ
AstrBot は宝塔パネルと提携し、宝塔パネルに公開されています。
公式ドキュメント [宝塔パネルデプロイ](https://astrbot.app/deploy/astrbot/btpanel.html) をご参照ください。
#### 1Panel デプロイ
AstrBot は 1Panel 公式により 1Panel パネルに公開されています。
公式ドキュメント [1Panel デプロイ](https://astrbot.app/deploy/astrbot/1panel.html) をご参照ください。
#### 雨云でのデプロイ
AstrBot は雨云公式によりクラウドアプリケーションプラットフォームに公開され、ワンクリックでデプロイ可能です。
[![Deploy on RainYun](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-en.svg)](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
#### Replit でのデプロイ
コミュニティ貢献によるデプロイ方法。
[![Run on Repl.it](https://repl.it/badge/github/AstrBotDevs/AstrBot)](https://repl.it/github/AstrBotDevs/AstrBot)
#### Windows ワンクリックインストーラーデプロイ
公式ドキュメント [Windows ワンクリックインストーラーを使用した AstrBot のデプロイ](https://astrbot.app/deploy/astrbot/windows.html) をご参照ください。
#### CasaOS デプロイ
コミュニティが提供するデプロイ方法です
コミュニティ貢献によるデプロイ方法。
公式ドキュメント [ソースコードを使用して AstrBot をデプロイする](https://astrbot.app/deploy/astrbot/casaos.html) を参照してください。
公式ドキュメント [CasaOS デプロイ](https://astrbot.app/deploy/astrbot/casaos.html) を参照ください。
#### 手動デプロイ
公式ドキュメント [ソースコードを使用して AstrBot をデプロイする](https://astrbot.app/deploy/astrbot/cli.html) を参照してください。
まず uv をインストールします:
## ⚡ メッセージプラットフォームのサポート状況
```bash
pip install uv
```
| プラットフォーム | サポート状況 | 詳細 | メッセージタイプ |
| -------- | ------- | ------- | ------ |
| QQ(公式ロボットインターフェース) | ✔ | プライベートチャット、グループチャット、QQ チャンネルプライベートチャット、グループチャット | テキスト、画像 |
| QQ(OneBot) | ✔ | プライベートチャット、グループチャット | テキスト、画像、音声 |
| WeChat(個人アカウント) | ✔ | WeChat 個人アカウントのプライベートチャット、グループチャット | テキスト、画像、音声 |
| [Telegram](https://github.com/Soulter/astrbot_plugin_telegram) | ✔ | プライベートチャット、グループチャット | テキスト、画像 |
| [WeChat(企業 WeChat)](https://github.com/Soulter/astrbot_plugin_wecom) | ✔ | プライベートチャット | テキスト、画像、音声 |
| Feishu | ✔ | グループチャット | テキスト、画像 |
| WeChat 対話オープンプラットフォーム | 🚧 | 計画中 | - |
| Discord | 🚧 | 計画中 | - |
| WhatsApp | 🚧 | 計画中 | - |
| Xiaoai 音響 | 🚧 | 計画中 | - |
Git Clone で AstrBot をインストール:
# 🦌 今後のロードマップ
```bash
git clone https://github.com/AstrBotDevs/AstrBot && cd AstrBot
uv run main.py
```
> [!TIP]
> Issue でさらに多くの提案を歓迎します <3
または、公式ドキュメント [ソースコードから AstrBot をデプロイ](https://astrbot.app/deploy/astrbot/cli.html) をご参照ください。
- [ ] 現在のすべてのプラットフォームアダプターの機能の一貫性を確保し、改善する
- [ ] プラグインインターフェースの最適化
- [ ] GPT-Sovits などの TTS サービスをデフォルトでサポート
- [ ] "チャット強化" 部分を完成させ、永続的な記憶をサポート
- [ ] i18n の計画
## サポートされているメッセージプラットフォーム
## ❤️ 貢献
**公式メンテナンス**
Issue や Pull Request を歓迎します!このプロジェクトに変更を加えるだけです :)
- QQ (公式プラットフォーム & OneBot)
- Telegram
- WeChat Work アプリケーション & WeChat Work インテリジェントボット
- WeChat カスタマーサービス & WeChat 公式アカウント
- Feishu (Lark)
- DingTalk
- Slack
- Discord
- Satori
- Misskey
- WhatsApp (近日対応予定)
- LINE (近日対応予定)
新機能の追加については、まず Issue で議論してください。
**コミュニティメンテナンス**
## 🌟 サポート
- [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter)
- [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter)
- [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat)
- このプロジェクトに Star を付けてください!
- [愛発電](https://afdian.com/a/soulter)で私をサポートしてください!
- [WeChat](https://drive.soulter.top/f/pYfA/d903f4fa49a496fda3f16d2be9e023b5.png)で私をサポートしてください~
## ✨ デモ
## サポートされているモデルサービス
> [!NOTE]
> コードエグゼキューターのファイル入力/出力は現在 Napcat(QQ)、Lagrange(QQ) でのみテストされています
**大規模言語モデルサービス**
<div align='center'>
- OpenAI および互換サービス
- Anthropic
- Google Gemini
- Moonshot AI
- 智谱 AI
- DeepSeek
- Ollama (セルフホスト)
- LM Studio (セルフホスト)
- [優云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
- [302.AI](https://share.302.ai/rr1M3l)
- [小馬算力](https://www.tokenpony.cn/3YPyf)
- [硅基流動](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot)
- [PPIO 派欧云](https://ppio.com/user/register?invited_by=AIOONE)
- ModelScope
- OneAPI
<img src="https://github.com/user-attachments/assets/4ee688d9-467d-45c8-99d6-368f9a8a92d8" width="600">
**LLMOps プラットフォーム**
_✨ Docker ベースのサンドボックス化されたコードエグゼキューター(ベータテスト中)✨_
- Dify
- Alibaba Cloud 百炼アプリケーション
- Coze
<img src="https://github.com/user-attachments/assets/0378f407-6079-4f64-ae4c-e97ab20611d2" height=500>
**音声認識サービス**
_✨ 多モーダル、ウェブ検索、長文の画像変換(設定可能)✨_
- OpenAI Whisper
- SenseVoice
<img src="https://github.com/user-attachments/assets/8ec12797-e70f-460a-959e-48eca39ca2bb" height=100>
**音声合成サービス**
_✨ 自然言語タスク ✨_
- OpenAI TTS
- Gemini TTS
- GPT-Sovits-Inference
- GPT-Sovits
- FishAudio
- Edge TTS
- Alibaba Cloud 百炼 TTS
- Azure TTS
- Minimax TTS
- Volcano Engine TTS
<img src="https://github.com/user-attachments/assets/e137a9e1-340a-4bf2-bb2b-771132780735" height=150>
<img src="https://github.com/user-attachments/assets/480f5e82-cf6a-4955-a869-0d73137aa6e1" height=150>
## ❤️ コントリビューション
_✨ プラグインシステム - 一部のプラグインの展示 ✨_
Issue や Pull Request は大歓迎です!このプロジェクトに変更を送信してください :)
<img src="https://github.com/user-attachments/assets/592a8630-14c7-4e06-b496-9c0386e4f36c" width="600">
### コントリビュート方法
_✨ 管理パネル ✨_
Issue を確認したり、PR(プルリクエスト)のレビューを手伝うことで貢献できます。どんな Issue や PR への参加も歓迎され、コミュニティ貢献を促進します。もちろん、これらは提案に過ぎず、どんな方法でも貢献できます。新機能の追加については、まず Issue で議論してください。
![webchat](https://drive.soulter.top/f/vlsA/ezgif-5-fb044b2542.gif)
### 開発環境
_✨ 内蔵 Web Chat、オンラインでボットと対話 ✨_
AstrBot はコードのフォーマットとチェックに `ruff` を使用しています。
</div>
```bash
git clone https://github.com/AstrBotDevs/AstrBot
pip install pre-commit
pre-commit install
```
## 🌍 コミュニティ
### QQ グループ
- 1群: 322154837
- 3群: 630166526
- 5群: 822130018
- 6群: 753075035
- 開発者群: 975206796
### Telegram グループ
<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>
### Discord サーバー
<a href="https://discord.gg/hAVk6tgV36"><img alt="Discord_community" src="https://img.shields.io/badge/Discord-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
## ❤️ Special Thanks
AstrBot への貢献をしていただいたすべてのコントリビューターとプラグイン開発者に特別な感謝を ❤️
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
</a>
また、このプロジェクトの誕生は以下のオープンソースプロジェクトの助けなしには実現できませんでした:
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - 素晴らしい猫猫フレームワーク
## ⭐ Star History
> [!TIP]
> このプロジェクトがあなたの生活や仕事に役立った場合、またはこのプロジェクトの将来の発展に関心がある場合は、プロジェクトに Star を付けてください。これこのオープンソースプロジェクトを維持するためのモチベーションです <3
> このプロジェクトがあなたの生活や仕事に役立ったり、このプロジェクトの今後の発展に関心がある場合は、プロジェクトに Star をください。これこのオープンソースプロジェクトを維持する原動力です <3
<div align="center">
[![Star History Chart](https://api.star-history.com/svg?repos=soulter/astrbot&type=Date)](https://star-history.com/#soulter/astrbot&Date)
[![Star History Chart](https://api.star-history.com/svg?repos=astrbotdevs/astrbot&type=Date)](https://star-history.com/#astrbotdevs/astrbot&Date)
</div>
## スポンサー
[<img src="https://api.gitsponsors.com/api/badge/img?id=575865240" height="20">](https://api.gitsponsors.com/api/badge/link?p=XEpbdGxlitw/RbcwiTX93UMzNK/jgDYC8NiSzamIPMoKvG2lBFmyXhSS/b0hFoWlBBMX2L5X5CxTDsUdyvcIEHTOfnkXz47UNOZvMwyt5CzbYpq0SEzsSV1OJF1cCo90qC/ZyYKYOWedal3MhZ3ikw==)
## 免責事項
1. このプロジェクトは `AGPL-v3` オープンソースライセンスの下で保護されています。
2. このプロジェクトを使用する際は、現地の法律および規制を遵守してください。
<!-- ## ✨ ATRI [ベータテスト]
この機能はプラグインとしてロードされます。プラグインリポジトリのアドレス:[astrbot_plugin_atri](https://github.com/Soulter/astrbot_plugin_atri)
1. 《ATRI ~ My Dear Moments》の主人公 ATRI のキャラクターセリフを微調整データセットとして使用した `Qwen1.5-7B-Chat Lora` 微調整モデル。
2. 長期記憶
3. ミームの理解と返信
4. TTS
-->
</details>
_私は、高性能ですから!_
+247
View File
@@ -0,0 +1,247 @@
![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/ffd99b6b-3272-4682-beaa-6fe74250f7d9)
</p>
<div align="center">
<br>
<div>
<a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://hellogithub.com/repository/AstrBotDevs/AstrBot" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d127d50cd5e54c5382328acc3bb25483&claim_uid=ZO9by7qCXgSd6Lp&t=2" alt="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</div>
<br>
<div>
<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=%20%D0%BF%D0%BB%D0%B0%D0%B3%D0%B8%D0%BD%D0%BE%D0%B2&style=for-the-badge&label=%D0%9C%D0%B0%D0%B3%D0%B0%D0%B7%D0%B8%D0%BD&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_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://astrbot.app/">Документация</a>
<a href="https://blog.astrbot.app/">Блог</a>
<a href="https://astrbot.featurebase.app/roadmap">Дорожная карта</a>
<a href="https://github.com/AstrBotDevs/AstrBot/issues">Сообщить о проблеме</a>
</div>
AstrBot — это универсальная платформа Agent-чатботов с открытым исходным кодом, которая интегрируется с основными приложениями для обмена мгновенными сообщениями. Она предоставляет надёжную и масштабируемую инфраструктуру разговорного ИИ для частных лиц, разработчиков и команд. Будь то персональный ИИ-компаньон, интеллектуальная служба поддержки, автоматизированный помощник или корпоративная база знаний — AstrBot позволяет быстро создавать готовые к использованию ИИ-приложения в рабочих процессах вашей платформы обмена сообщениями.
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
## Основные возможности
1. 💯 Бесплатно и с открытым исходным кодом.
2. ✨ ИИ-диалоги с LLM, мультимодальность, Agent, MCP, база знаний, настройки личности.
3. 🤖 Поддержка интеграции с Dify, Alibaba Cloud Bailian, Coze и другими платформами агентов.
4. 🌐 Мультиплатформенность: QQ, WeChat Work, Feishu, DingTalk, официальные аккаунты WeChat, Telegram, Slack и [другие](#поддерживаемые-платформы-обмена-сообщениями).
5. 📦 Расширения плагинов с почти 800 плагинами, доступными для установки в один клик.
6. 💻 Поддержка WebUI.
7. 🌐 Поддержка интернационализации (i18n).
## Быстрый старт
#### Развёртывание Docker (Рекомендуется 🥳)
Мы рекомендуем развёртывать AstrBot с помощью Docker или Docker Compose.
См. официальную документацию: [Развёртывание AstrBot с Docker](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot).
#### Развёртывание uv
```bash
uvx astrbot
```
#### Развёртывание BT-Panel
AstrBot в партнёрстве с BT-Panel теперь доступен на их маркетплейсе.
См. официальную документацию: [Развёртывание BT-Panel](https://astrbot.app/deploy/astrbot/btpanel.html).
#### Развёртывание 1Panel
AstrBot официально размещён на маркетплейсе 1Panel.
См. официальную документацию: [Развёртывание 1Panel](https://astrbot.app/deploy/astrbot/1panel.html).
#### Развёртывание на RainYun
AstrBot официально размещён на облачной платформе приложений RainYun с развёртыванием в один клик.
[![Deploy on RainYun](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-en.svg)](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
#### Развёртывание на Replit
Метод развёртывания от сообщества.
[![Run on Repl.it](https://repl.it/badge/github/AstrBotDevs/AstrBot)](https://repl.it/github/AstrBotDevs/AstrBot)
#### Установщик Windows в один клик
См. официальную документацию: [Развёртывание AstrBot с установщиком Windows в один клик](https://astrbot.app/deploy/astrbot/windows.html).
#### Развёртывание CasaOS
Метод развёртывания от сообщества.
См. официальную документацию: [Развёртывание CasaOS](https://astrbot.app/deploy/astrbot/casaos.html).
#### Ручное развёртывание
Сначала установите uv:
```bash
pip install uv
```
Установите AstrBot через Git Clone:
```bash
git clone https://github.com/AstrBotDevs/AstrBot && cd AstrBot
uv run main.py
```
Или см. официальную документацию: [Развёртывание AstrBot из исходного кода](https://astrbot.app/deploy/astrbot/cli.html).
## Поддерживаемые платформы обмена сообщениями
**Официально поддерживаемые**
- QQ (Официальная платформа и OneBot)
- Telegram
- Приложение WeChat Work и интеллектуальный бот WeChat Work
- Служба поддержки WeChat и официальные аккаунты WeChat
- Feishu (Lark)
- DingTalk
- Slack
- Discord
- Satori
- Misskey
- WhatsApp (Скоро)
- LINE (Скоро)
**Поддерживаемые сообществом**
- [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter)
- [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter)
- [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat)
## Поддерживаемые сервисы моделей
**Сервисы LLM**
- OpenAI и совместимые сервисы
- Anthropic
- Google Gemini
- Moonshot AI
- Zhipu AI
- DeepSeek
- Ollama (Самостоятельное размещение)
- LM Studio (Самостоятельное размещение)
- [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
- [302.AI](https://share.302.ai/rr1M3l)
- [TokenPony](https://www.tokenpony.cn/3YPyf)
- [SiliconFlow](https://docs.siliconflow.cn/cn/usecases/use-siliconcloud-in-astrbot)
- [PPIO Cloud](https://ppio.com/user/register?invited_by=AIOONE)
- ModelScope
- OneAPI
**Платформы LLMOps**
- Dify
- Приложения Alibaba Cloud Bailian
- Coze
**Сервисы распознавания речи**
- OpenAI Whisper
- SenseVoice
**Сервисы синтеза речи**
- OpenAI TTS
- Gemini TTS
- GPT-Sovits-Inference
- GPT-Sovits
- FishAudio
- Edge TTS
- Alibaba Cloud Bailian TTS
- Azure TTS
- Minimax TTS
- Volcano Engine TTS
## ❤️ Вклад в проект
Issues и Pull Request всегда приветствуются! Не стесняйтесь отправлять свои изменения в этот проект :)
### Как внести вклад
Вы можете внести вклад, просматривая issues или помогая с ревью pull request. Любые issues или PR приветствуются для поощрения участия сообщества. Конечно, это лишь предложения — вы можете вносить вклад любым удобным для вас способом. Для добавления новых функций сначала обсудите это через Issue.
### Среда разработки
AstrBot использует `ruff` для форматирования и линтинга кода.
```bash
git clone https://github.com/AstrBotDevs/AstrBot
pip install pre-commit
pre-commit install
```
## 🌍 Сообщество
### Группы QQ
- Группа 1: 322154837
- Группа 3: 630166526
- Группа 5: 822130018
- Группа 6: 753075035
- Группа разработчиков: 975206796
### Группа Telegram
<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>
### Сервер Discord
<a href="https://discord.gg/hAVk6tgV36"><img alt="Discord_community" src="https://img.shields.io/badge/Discord-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
## ❤️ Особая благодарность
Особая благодарность всем контрибьюторам и разработчикам плагинов за их вклад в AstrBot ❤️
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
</a>
Кроме того, рождение этого проекта было бы невозможно без помощи следующих проектов с открытым исходным кодом:
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - Замечательный кошачий фреймворк
## ⭐ История звёзд
> [!TIP]
> Если этот проект помог вам в жизни или работе, или если вас интересует его будущее развитие, пожалуйста, поставьте проекту звезду. Это движущая сила поддержки этого проекта с открытым исходным кодом <3
<div align="center">
[![Star History Chart](https://api.star-history.com/svg?repos=astrbotdevs/astrbot&type=Date)](https://star-history.com/#astrbotdevs/astrbot&Date)
</div>
</details>
_私は、高性能ですから!_
+247
View File
@@ -0,0 +1,247 @@
![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/ffd99b6b-3272-4682-beaa-6fe74250f7d9)
</p>
<div align="center">
<br>
<div>
<a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
<a href="https://hellogithub.com/repository/AstrBotDevs/AstrBot" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=d127d50cd5e54c5382328acc3bb25483&claim_uid=ZO9by7qCXgSd6Lp&t=2" alt="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
</div>
<br>
<div>
<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=%E5%80%8B&style=for-the-badge&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%A0%B4&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_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_fr.md">Français</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ru.md">Русский</a>
<a href="https://astrbot.app/">文件</a>
<a href="https://blog.astrbot.app/">Blog</a>
<a href="https://astrbot.featurebase.app/roadmap">路線圖</a>
<a href="https://github.com/AstrBotDevs/AstrBot/issues">問題回報</a>
</div>
AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主流即時通訊軟體,為個人、開發者和團隊打造可靠、可擴展的對話式智慧基礎設施。無論是個人 AI 夥伴、智慧客服、自動化助手,還是企業知識庫,AstrBot 都能在您的即時通訊軟體平台的工作流程中快速構建生產可用的 AI 應用程式。
<img width="1776" height="1080" alt="image" src="https://github.com/user-attachments/assets/00782c4c-4437-4d97-aabc-605e3738da5c" />
## 主要功能
1. 💯 免費 & 開源。
2. ✨ AI 大型模型對話,多模態,Agent,MCP,知識庫,人格設定。
3. 🤖 支援接入 Dify、阿里雲百煉、Coze 等智慧體平台。
4. 🌐 多平台:QQ、企業微信、飛書、釘釘、微信公眾號、Telegram、Slack 以及[更多](#支援的訊息平台)。
5. 📦 外掛擴充,已有近 800 個外掛可一鍵安裝。
6. 💻 WebUI 支援。
7. 🌐 國際化(i18n)支援。
## 快速開始
#### Docker 部署(推薦 🥳)
推薦使用 Docker / Docker Compose 方式部署 AstrBot。
請參閱官方文件 [使用 Docker 部署 AstrBot](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot)。
#### uv 部署
```bash
uvx astrbot
```
#### 寶塔面板部署
AstrBot 與寶塔面板合作,已上架至寶塔面板。
請參閱官方文件 [寶塔面板部署](https://astrbot.app/deploy/astrbot/btpanel.html)。
#### 1Panel 部署
AstrBot 已由 1Panel 官方上架至 1Panel 面板。
請參閱官方文件 [1Panel 部署](https://astrbot.app/deploy/astrbot/1panel.html)。
#### 在雨雲上部署
AstrBot 已由雨雲官方上架至雲端應用程式平台,可一鍵部署。
[![Deploy on RainYun](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-en.svg)](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
#### 在 Replit 上部署
社群貢獻的部署方式。
[![Run on Repl.it](https://repl.it/badge/github/AstrBotDevs/AstrBot)](https://repl.it/github/AstrBotDevs/AstrBot)
#### Windows 一鍵安裝器部署
請參閱官方文件 [使用 Windows 一鍵安裝器部署 AstrBot](https://astrbot.app/deploy/astrbot/windows.html)。
#### CasaOS 部署
社群貢獻的部署方式。
請參閱官方文件 [CasaOS 部署](https://astrbot.app/deploy/astrbot/casaos.html)。
#### 手動部署
首先安裝 uv
```bash
pip install uv
```
透過 Git Clone 安裝 AstrBot
```bash
git clone https://github.com/AstrBotDevs/AstrBot && cd AstrBot
uv run main.py
```
或者請參閱官方文件 [透過原始碼部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html)。
## 支援的訊息平台
**官方維護**
- QQ(官方平台 & OneBot
- Telegram
- 企微應用 & 企微智慧機器人
- 微信客服 & 微信公眾號
- 飛書
- 釘釘
- Slack
- Discord
- Satori
- Misskey
- Whatsapp(即將支援)
- LINE(即將支援)
**社群維護**
- [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter)
- [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter)
- [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat)
## 支援的模型服務
**大型模型服務**
- OpenAI 及相容服務
- Anthropic
- Google Gemini
- Moonshot AI
- 智譜 AI
- DeepSeek
- Ollama(本機部署)
- LM Studio(本機部署)
- [優雲智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74)
- [302.AI](https://share.302.ai/rr1M3l)
- [小馬算力](https://www.tokenpony.cn/3YPyf)
- [矽基流動](https://docs.siliconflow.cn/cn/usercases/use-siliconcloud-in-astrbot)
- [PPIO 派歐雲](https://ppio.com/user/register?invited_by=AIOONE)
- ModelScope
- OneAPI
**LLMOps 平台**
- Dify
- 阿里雲百煉應用
- Coze
**語音轉文字服務**
- OpenAI Whisper
- SenseVoice
**文字轉語音服務**
- OpenAI TTS
- Gemini TTS
- GPT-Sovits-Inference
- GPT-Sovits
- FishAudio
- Edge TTS
- 阿里雲百煉 TTS
- Azure TTS
- Minimax TTS
- 火山引擎 TTS
## ❤️ 貢獻
歡迎任何 Issues/Pull Requests!只需要將您的變更提交到此專案 :)
### 如何貢獻
您可以透過檢視問題或協助審核 PR(拉取請求)來貢獻。任何問題或 PR 都歡迎參與,以促進社群貢獻。當然,這些只是建議,您可以以任何方式進行貢獻。對於新功能的新增,請先透過 Issue 討論。
### 開發環境
AstrBot 使用 `ruff` 進行程式碼格式化和檢查。
```bash
git clone https://github.com/AstrBotDevs/AstrBot
pip install pre-commit
pre-commit install
```
## 🌍 社群
### QQ 群組
- 1 群:322154837
- 3 群:630166526
- 5 群:822130018
- 6 群:753075035
- 開發者群:975206796
### Telegram 群組
<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>
### Discord 群組
<a href="https://discord.gg/hAVk6tgV36"><img alt="Discord_community" src="https://img.shields.io/badge/Discord-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
## ❤️ Special Thanks
特別感謝所有 Contributors 和外掛開發者對 AstrBot 的貢獻 ❤️
<a href="https://github.com/AstrBotDevs/AstrBot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
</a>
此外,本專案的誕生離不開以下開源專案的幫助:
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - 偉大的貓貓框架
## ⭐ Star History
> [!TIP]
> 如果本專案對您的生活 / 工作產生了幫助,或者您關注本專案的未來發展,請給專案 Star,這是我們維護這個開源專案的動力 <3
<div align="center">
[![Star History Chart](https://api.star-history.com/svg?repos=astrbotdevs/astrbot&type=Date)](https://star-history.com/#astrbotdevs/astrbot&Date)
</div>
</details>
_私は、高性能ですから!_
+10
View File
@@ -20,7 +20,14 @@ from astrbot.core.star.register import (
)
from astrbot.core.star.register import register_on_llm_request as on_llm_request
from astrbot.core.star.register import register_on_llm_response as on_llm_response
from astrbot.core.star.register import (
register_on_llm_tool_respond as on_llm_tool_respond,
)
from astrbot.core.star.register import register_on_platform_loaded as on_platform_loaded
from astrbot.core.star.register import register_on_using_llm_tool as on_using_llm_tool
from astrbot.core.star.register import (
register_on_waiting_llm_request as on_waiting_llm_request,
)
from astrbot.core.star.register import register_permission_type as permission_type
from astrbot.core.star.register import (
register_platform_adapter_type as platform_adapter_type,
@@ -46,7 +53,10 @@ __all__ = [
"on_llm_request",
"on_llm_response",
"on_platform_loaded",
"on_waiting_llm_request",
"permission_type",
"platform_adapter_type",
"regex",
"on_using_llm_tool",
"on_llm_tool_respond",
]
@@ -6,9 +6,9 @@ from collections import defaultdict
from astrbot import logger
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent
from astrbot.api.message_components import Image, Plain
from astrbot.api.message_components import At, Image, Plain
from astrbot.api.platform import MessageType
from astrbot.api.provider import Provider, ProviderRequest
from astrbot.api.provider import LLMResponse, Provider, ProviderRequest
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
"""
@@ -30,16 +30,13 @@ class LongTermMemory:
except BaseException as e:
logger.error(e)
max_cnt = 300
image_caption = (
True
if cfg["provider_settings"]["default_image_caption_provider_id"]
and cfg["provider_ltm_settings"]["image_caption"]
else False
)
image_caption_prompt = cfg["provider_settings"]["image_caption_prompt"]
image_caption_provider_id = cfg["provider_settings"][
"default_image_caption_provider_id"
]
image_caption_provider_id = cfg["provider_ltm_settings"].get(
"image_caption_provider_id"
)
image_caption = cfg["provider_ltm_settings"]["image_caption"] and bool(
image_caption_provider_id
)
active_reply = cfg["provider_ltm_settings"]["active_reply"]
enable_active_reply = active_reply.get("enable", False)
ar_method = active_reply["method"]
@@ -142,6 +139,8 @@ class LongTermMemory:
logger.error(f"获取图片描述失败: {e}")
else:
parts.append(" [Image]")
elif isinstance(comp, At):
parts.append(f" [At: {comp.name}]")
final_message = "".join(parts)
logger.debug(f"ltm | {event.unified_msg_origin} | {final_message}")
@@ -159,8 +158,12 @@ class LongTermMemory:
cfg = self.cfg(event)
if cfg["enable_active_reply"]:
prompt = req.prompt
req.prompt = f"You are now in a chatroom. The chat history is as follows:\n{chats_str}"
req.prompt += f"\nNow, a new message is coming: `{prompt}`. Please react to it. Only output your response and do not output any other information."
req.prompt = (
f"You are now in a chatroom. The chat history is as follows:\n{chats_str}"
f"\nNow, a new message is coming: `{prompt}`. "
"Please react to it. Only output your response and do not output any other information. "
"You MUST use the SAME language as the chatroom is using."
)
req.contexts = [] # 清空上下文,当使用了主动回复,所有聊天记录都在一个prompt中。
else:
req.system_prompt += (
@@ -168,13 +171,15 @@ class LongTermMemory:
)
req.system_prompt += chats_str
async def after_req_llm(self, event: AstrMessageEvent):
async def after_req_llm(self, event: AstrMessageEvent, llm_resp: LLMResponse):
if event.unified_msg_origin not in self.session_chats:
return
if event.get_result() and event.get_result().is_llm_result():
final_message = f"[You/{datetime.datetime.now().strftime('%H:%M:%S')}]: {event.get_result().get_plain_text()}"
logger.debug(f"ltm | {event.unified_msg_origin} | {final_message}")
if llm_resp.completion_text:
final_message = f"[You/{datetime.datetime.now().strftime('%H:%M:%S')}]: {llm_resp.completion_text}"
logger.debug(
f"Recorded AI response: {event.unified_msg_origin} | {final_message}"
)
self.session_chats[event.unified_msg_origin].append(final_message)
cfg = self.cfg(event)
if len(self.session_chats[event.unified_msg_origin]) > cfg["max_cnt"]:
+120
View File
@@ -0,0 +1,120 @@
import traceback
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, filter
from astrbot.api.message_components import Image, Plain
from astrbot.api.provider import LLMResponse, ProviderRequest
from astrbot.core import logger
from .long_term_memory import LongTermMemory
from .process_llm_request import ProcessLLMRequest
class Main(star.Star):
def __init__(self, context: star.Context) -> None:
self.context = context
self.ltm = None
try:
self.ltm = LongTermMemory(self.context.astrbot_config_mgr, self.context)
except BaseException as e:
logger.error(f"聊天增强 err: {e}")
self.proc_llm_req = ProcessLLMRequest(self.context)
def ltm_enabled(self, event: AstrMessageEvent):
ltmse = self.context.get_config(umo=event.unified_msg_origin)[
"provider_ltm_settings"
]
return ltmse["group_icl_enable"] or ltmse["active_reply"]["enable"]
@filter.platform_adapter_type(filter.PlatformAdapterType.ALL)
async def on_message(self, event: AstrMessageEvent):
"""群聊记忆增强"""
has_image_or_plain = False
for comp in event.message_obj.message:
if isinstance(comp, Plain) or isinstance(comp, Image):
has_image_or_plain = True
break
if self.ltm_enabled(event) and self.ltm and has_image_or_plain:
need_active = await self.ltm.need_active_reply(event)
group_icl_enable = self.context.get_config()["provider_ltm_settings"][
"group_icl_enable"
]
if group_icl_enable:
"""记录对话"""
try:
await self.ltm.handle_message(event)
except BaseException as e:
logger.error(e)
if need_active:
"""主动回复"""
provider = self.context.get_using_provider(event.unified_msg_origin)
if not provider:
logger.error("未找到任何 LLM 提供商。请先配置。无法主动回复")
return
try:
conv = None
session_curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
event.unified_msg_origin,
)
if not session_curr_cid:
logger.error(
"当前未处于对话状态,无法主动回复,请确保 平台设置->会话隔离(unique_session) 未开启,并使用 /switch 序号 切换或者 /new 创建一个会话。",
)
return
conv = await self.context.conversation_manager.get_conversation(
event.unified_msg_origin,
session_curr_cid,
)
prompt = event.message_str
if not conv:
logger.error("未找到对话,无法主动回复")
return
yield event.request_llm(
prompt=prompt,
func_tool_manager=self.context.get_llm_tool_manager(),
session_id=event.session_id,
conversation=conv,
)
except BaseException as e:
logger.error(traceback.format_exc())
logger.error(f"主动回复失败: {e}")
@filter.on_llm_request()
async def decorate_llm_req(self, event: AstrMessageEvent, req: ProviderRequest):
"""在请求 LLM 前注入人格信息、Identifier、时间、回复内容等 System Prompt"""
await self.proc_llm_req.process_llm_request(event, req)
if self.ltm and self.ltm_enabled(event):
try:
await self.ltm.on_req_llm(event, req)
except BaseException as e:
logger.error(f"ltm: {e}")
@filter.on_llm_response()
async def record_llm_resp_to_ltm(self, event: AstrMessageEvent, resp: LLMResponse):
"""在 LLM 响应后记录对话"""
if self.ltm and self.ltm_enabled(event):
try:
await self.ltm.after_req_llm(event, resp)
except Exception as e:
logger.error(f"ltm: {e}")
@filter.after_message_sent()
async def after_message_sent(self, event: AstrMessageEvent):
"""消息发送后处理"""
if self.ltm and self.ltm_enabled(event):
try:
clean_session = event.get_extra("_clean_ltm_session", False)
if clean_session:
await self.ltm.remove_session(event)
except Exception as e:
logger.error(f"ltm: {e}")
@@ -0,0 +1,4 @@
name: astrbot
desc: AstrBot 自带插件,包含人格注入、思考内容注入、群聊上下文感知等功能的实现,禁用后将无法使用这些功能。
author: Soulter
version: 4.1.0
@@ -3,11 +3,18 @@ import copy
import datetime
import zoneinfo
from astrbot.api import logger, star
from astrbot.api import logger, sp, star
from astrbot.api.event import AstrMessageEvent
from astrbot.api.message_components import Image, Reply
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:
@@ -21,16 +28,48 @@ class ProcessLLMRequest:
else:
logger.info(f"Timezone set to: {self.timezone}")
def _ensure_persona(self, req: ProviderRequest, cfg: dict):
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,
):
"""确保用户人格已加载"""
if not req.conversation:
return
# persona inject
persona_id = req.conversation.persona_id or cfg.get("default_personality")
if not persona_id and persona_id != "[%None]": # [%None] 为用户取消人格
default_persona = self.ctx.persona_manager.selected_default_persona_v3
if default_persona:
persona_id = default_persona["name"]
# custom rule is preferred
persona_id = (
await sp.get_async(
scope="umo", scope_id=umo, key="session_service_config", default={}
)
).get("persona_id")
if not persona_id:
persona_id = req.conversation.persona_id or cfg.get("default_personality")
if not persona_id and persona_id != "[%None]": # [%None] 为用户取消人格
default_persona = self.ctx.persona_manager.selected_default_persona_v3
if default_persona:
persona_id = default_persona["name"]
# ChatUI special default persona
if platform_type == "webchat":
# non-existent persona_id to let following codes not working
persona_id = "_chatui_default_"
req.system_prompt += CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT
persona = next(
builtins.filter(
lambda persona: persona["name"] == persona_id,
@@ -44,6 +83,30 @@ 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:
@@ -59,7 +122,13 @@ class ProcessLLMRequest:
tool = tmgr.get_func(tool_name)
if tool and tool.active:
toolset.add_tool(tool)
req.func_tool = toolset
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()
)
logger.debug(f"Tool set for persona {persona_id}: {toolset.names()}")
async def _ensure_img_caption(
@@ -75,7 +144,9 @@ class ProcessLLMRequest:
req.image_urls,
)
if caption:
req.prompt = f"(Image Caption: {caption})\n\n{req.prompt}"
req.extra_user_content_parts.append(
TextPart(text=f"<image_caption>{caption}</image_caption>")
)
req.image_urls = []
except Exception as e:
logger.error(f"处理图片描述失败: {e}")
@@ -110,6 +181,8 @@ 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"):
@@ -119,19 +192,25 @@ class ProcessLLMRequest:
else:
req.prompt = prefix + req.prompt
# 收集系统提醒信息
system_parts = []
# user identifier
if cfg.get("identifier"):
user_id = event.message_obj.sender.user_id
user_nickname = event.message_obj.sender.nickname
req.prompt = (
f"\n[User ID: {user_id}, Nickname: {user_nickname}]\n{req.prompt}"
)
system_parts.append(f"User ID: {user_id}, Nickname: {user_nickname}")
# group name identifier
if cfg.get("group_name_display") and event.message_obj.group_id:
if not event.message_obj.group:
logger.error(
f"Group name display enabled but group object is None. Group ID: {event.message_obj.group_id}"
)
return
group_name = event.message_obj.group.group_name
if group_name:
req.system_prompt += f"\nGroup name: {group_name}\n"
system_parts.append(f"Group name: {group_name}")
# time info
if cfg.get("datetime_system_prompt"):
@@ -147,12 +226,15 @@ class ProcessLLMRequest:
current_time = (
datetime.datetime.now().astimezone().strftime("%Y-%m-%d %H:%M (%Z)")
)
req.system_prompt += f"\nCurrent datetime: {current_time}\n"
system_parts.append(f"Current datetime: {current_time}")
img_cap_prov_id: str = cfg.get("default_image_caption_provider_id") or ""
if req.conversation:
# inject persona for this request
self._ensure_persona(req, cfg)
platform_type = event.get_platform_name()
await self._ensure_persona(
req, cfg, event.unified_msg_origin, platform_type, event
)
# image caption
if img_cap_prov_id and req.image_urls:
@@ -166,37 +248,61 @@ class ProcessLLMRequest:
quote = comp
break
if quote:
sender_info = ""
if quote.sender_nickname:
sender_info = f"(Sent by {quote.sender_nickname})"
message_str = quote.message_str or "[Empty Text]"
req.system_prompt += (
f"\nUser is quoting a message{sender_info}.\n"
f"Here are the information of the quoted message: Text Content: {message_str}.\n"
content_parts = []
# 1. 处理引用的文本
sender_info = (
f"({quote.sender_nickname}): " if quote.sender_nickname else ""
)
message_str = quote.message_str or "[Empty Text]"
content_parts.append(f"{sender_info}{message_str}")
# 2. 处理引用的图片 (保留原有逻辑,但改变输出目标)
image_seg = None
if quote.chain:
for comp in quote.chain:
if isinstance(comp, Image):
image_seg = comp
break
if image_seg:
try:
# 找到可以生成图片描述的 provider
prov = None
if img_cap_prov_id:
prov = self.ctx.get_provider_by_id(img_cap_prov_id)
if prov is None:
prov = self.ctx.get_using_provider(event.unified_msg_origin)
# 调用 provider 生成图片描述
if prov and isinstance(prov, Provider):
llm_resp = await prov.text_chat(
prompt="Please describe the image content.",
image_urls=[await image_seg.convert_to_file_path()],
)
if llm_resp.completion_text:
req.system_prompt += (
f"Image Caption: {llm_resp.completion_text}\n"
# 将图片描述作为文本添加到 content_parts
content_parts.append(
f"[Image Caption in quoted message]: {llm_resp.completion_text}"
)
else:
logger.warning("No provider found for image captioning.")
logger.warning(
"No provider found for image captioning in quote."
)
except BaseException as e:
logger.error(f"处理引用图片失败: {e}")
# 3. 将所有部分组合成文本并添加到 extra_user_content_parts 中
# 确保引用内容被正确的标签包裹
quoted_content = "\n".join(content_parts)
# 确保所有内容都在<Quoted Message>标签内
quoted_text = f"<Quoted Message>\n{quoted_content}\n</Quoted Message>"
req.extra_user_content_parts.append(TextPart(text=quoted_text))
# 统一包裹所有系统提醒
if system_parts:
system_content = (
"<system_reminder>" + "\n".join(system_parts) + "</system_reminder>"
)
req.extra_user_content_parts.append(TextPart(text=system_content))
@@ -11,7 +11,6 @@ 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__ = [
@@ -27,5 +26,4 @@ __all__ = [
"SetUnsetCommands",
"T2ICommand",
"TTSCommand",
"ToolCommands",
]
@@ -71,6 +71,7 @@ class AdminCommands:
event.set_result(MessageEventResult().message("此 SID 不在白名单内。"))
async def update_dashboard(self, event: AstrMessageEvent):
"""更新管理面板"""
await event.send(MessageChain().message("正在尝试更新管理面板..."))
await download_dashboard(version=f"v{VERSION}", latest=False)
await event.send(MessageChain().message("管理面板更新完成。"))
@@ -1,20 +1,23 @@
import datetime
from astrbot.api import logger, sp, star
from astrbot.api import sp, star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
from astrbot.core.platform.astr_message_event import MessageSesion
from astrbot.core.platform.astr_message_event import MessageSession
from astrbot.core.platform.message_type import MessageType
from astrbot.core.provider.sources.coze_source import ProviderCoze
from astrbot.core.provider.sources.dify_source import ProviderDify
from ..long_term_memory import LongTermMemory
from .utils.rst_scene import RstScene
THIRD_PARTY_AGENT_RUNNER_KEY = {
"dify": "dify_conversation_id",
"coze": "coze_conversation_id",
"dashscope": "dashscope_conversation_id",
}
THIRD_PARTY_AGENT_RUNNER_STR = ", ".join(THIRD_PARTY_AGENT_RUNNER_KEY.keys())
class ConversationCommands:
def __init__(self, context: star.Context, ltm: LongTermMemory | None = None):
def __init__(self, context: star.Context):
self.context = context
self.ltm = ltm
async def _get_current_persona_id(self, session_id):
curr = await self.context.conversation_manager.get_curr_conversation_id(
@@ -26,21 +29,15 @@ class ConversationCommands:
session_id,
curr,
)
if not conv:
return None
return conv.persona_id
def ltm_enabled(self, event: AstrMessageEvent):
if not self.ltm:
return False
ltmse = self.context.get_config(umo=event.unified_msg_origin)[
"provider_ltm_settings"
]
return ltmse["group_icl_enable"] or ltmse["active_reply"]["enable"]
async def reset(self, message: AstrMessageEvent):
"""重置 LLM 会话"""
is_unique_session = self.context.get_config()["platform_settings"][
"unique_session"
]
umo = message.unified_msg_origin
cfg = self.context.get_config(umo=message.unified_msg_origin)
is_unique_session = cfg["platform_settings"]["unique_session"]
is_group = bool(message.get_group_id())
scene = RstScene.get_scene(is_group, is_unique_session)
@@ -63,28 +60,23 @@ class ConversationCommands:
)
return
if not self.context.get_using_provider(message.unified_msg_origin):
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
await sp.remove_async(
scope="umo",
scope_id=umo,
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
)
message.set_result(MessageEventResult().message("重置对话成功。"))
return
if not self.context.get_using_provider(umo):
message.set_result(
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
)
return
provider = self.context.get_using_provider(message.unified_msg_origin)
if provider and provider.meta().type in ["dify", "coze"]:
assert isinstance(provider, (ProviderDify, ProviderCoze)), (
"provider type is not dify or coze"
)
await provider.forget(message.unified_msg_origin)
message.set_result(
MessageEventResult().message(
"已重置当前 Dify / Coze 会话,新聊天将更换到新的会话。",
),
)
return
cid = await self.context.conversation_manager.get_curr_conversation_id(
message.unified_msg_origin,
)
cid = await self.context.conversation_manager.get_curr_conversation_id(umo)
if not cid:
message.set_result(
@@ -95,15 +87,14 @@ class ConversationCommands:
return
await self.context.conversation_manager.update_conversation(
message.unified_msg_origin,
umo,
cid,
[],
)
ret = "清除会话 LLM 聊天历史成功"
if self.ltm and self.ltm_enabled(message):
cnt = await self.ltm.remove_session(event=message)
ret += f"\n聊天增强: 已清除 {cnt} 条聊天记录。"
ret = "清除聊天历史成功"
message.set_extra("_clean_ltm_session", True)
message.set_result(MessageEventResult().message(ret))
@@ -152,29 +143,14 @@ class ConversationCommands:
async def convs(self, message: AstrMessageEvent, page: int = 1):
"""查看对话列表"""
provider = self.context.get_using_provider(message.unified_msg_origin)
if provider and provider.meta().type == "dify":
"""原有的Dify处理逻辑保持不变"""
parts = ["Dify 对话列表:\n"]
assert isinstance(provider, ProviderDify)
data = await provider.api_client.get_chat_convs(message.unified_msg_origin)
idx = 1
for conv in data["data"]:
ts_h = datetime.datetime.fromtimestamp(conv["updated_at"]).strftime(
"%m-%d %H:%M",
)
parts.append(
f"{idx}. {conv['name']}({conv['id'][:4]})\n 上次更新:{ts_h}\n"
)
idx += 1
if idx == 1:
parts.append("没有找到任何对话。")
dify_cid = provider.conversation_ids.get(message.unified_msg_origin, None)
parts.append(
f"\n\n用户: {message.unified_msg_origin}\n当前对话: {dify_cid}\n使用 /switch <序号> 切换对话。"
cfg = self.context.get_config(umo=message.unified_msg_origin)
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
message.set_result(
MessageEventResult().message(
f"{THIRD_PARTY_AGENT_RUNNER_STR} 对话列表功能暂不支持。",
),
)
ret = "".join(parts)
message.set_result(MessageEventResult().message(ret))
return
size_per_page = 6
@@ -227,9 +203,8 @@ class ConversationCommands:
else:
ret += "\n当前对话: 无"
unique_session = self.context.get_config()["platform_settings"][
"unique_session"
]
cfg = self.context.get_config(umo=message.unified_msg_origin)
unique_session = cfg["platform_settings"]["unique_session"]
if unique_session:
ret += "\n会话隔离粒度: 个人"
else:
@@ -243,15 +218,15 @@ class ConversationCommands:
async def new_conv(self, message: AstrMessageEvent):
"""创建新对话"""
provider = self.context.get_using_provider(message.unified_msg_origin)
if provider and provider.meta().type in ["dify", "coze"]:
assert isinstance(provider, (ProviderDify, ProviderCoze)), (
"provider type is not dify or coze"
)
await provider.forget(message.unified_msg_origin)
message.set_result(
MessageEventResult().message("成功,下次聊天将是新对话。"),
cfg = self.context.get_config(umo=message.unified_msg_origin)
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
await sp.remove_async(
scope="umo",
scope_id=message.unified_msg_origin,
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
)
message.set_result(MessageEventResult().message("已创建新对话。"))
return
cpersona = await self._get_current_persona_id(message.unified_msg_origin)
@@ -261,12 +236,7 @@ class ConversationCommands:
persona_id=cpersona,
)
# 长期记忆
if self.ltm and self.ltm_enabled(message):
try:
await self.ltm.remove_session(event=message)
except Exception as e:
logger.error(f"清理聊天增强记录失败: {e}")
message.set_extra("_clean_ltm_session", True)
message.set_result(
MessageEventResult().message(f"切换到新对话: 新对话({cid[:4]})。"),
@@ -274,19 +244,9 @@ class ConversationCommands:
async def groupnew_conv(self, message: AstrMessageEvent, sid: str = ""):
"""创建新群聊对话"""
provider = self.context.get_using_provider(message.unified_msg_origin)
if provider and provider.meta().type in ["dify", "coze"]:
assert isinstance(provider, (ProviderDify, ProviderCoze)), (
"provider type is not dify or coze"
)
await provider.forget(message.unified_msg_origin)
message.set_result(
MessageEventResult().message("成功,下次聊天将是新对话。"),
)
return
if sid:
session = str(
MessageSesion(
MessageSession(
platform_name=message.platform_meta.id,
message_type=MessageType("GroupMessage"),
session_id=sid,
@@ -321,31 +281,6 @@ class ConversationCommands:
)
return
provider = self.context.get_using_provider(message.unified_msg_origin)
if provider and provider.meta().type == "dify":
assert isinstance(provider, ProviderDify), "provider type is not dify"
data = await provider.api_client.get_chat_convs(message.unified_msg_origin)
if not data["data"]:
message.set_result(MessageEventResult().message("未找到任何对话。"))
return
selected_conv = None
if index is not None:
try:
selected_conv = data["data"][index - 1]
except IndexError:
message.set_result(
MessageEventResult().message("对话序号错误,请使用 /ls 查看"),
)
return
else:
selected_conv = data["data"][0]
ret = (
f"Dify 切换到对话: {selected_conv['name']}({selected_conv['id'][:4]})。"
)
provider.conversation_ids[message.unified_msg_origin] = selected_conv["id"]
message.set_result(MessageEventResult().message(ret))
return
if index is None:
message.set_result(
MessageEventResult().message(
@@ -378,19 +313,6 @@ class ConversationCommands:
if not new_name:
message.set_result(MessageEventResult().message("请输入新的对话名称。"))
return
provider = self.context.get_using_provider(message.unified_msg_origin)
if provider and provider.meta().type == "dify":
assert isinstance(provider, ProviderDify)
cid = provider.conversation_ids.get(message.unified_msg_origin, None)
if not cid:
message.set_result(MessageEventResult().message("未找到当前对话。"))
return
await provider.api_client.rename(cid, new_name, message.unified_msg_origin)
message.set_result(MessageEventResult().message("重命名对话成功。"))
return
await self.context.conversation_manager.update_conversation_title(
message.unified_msg_origin,
new_name,
@@ -399,9 +321,8 @@ class ConversationCommands:
async def del_conv(self, message: AstrMessageEvent):
"""删除当前对话"""
is_unique_session = self.context.get_config()["platform_settings"][
"unique_session"
]
cfg = self.context.get_config(umo=message.unified_msg_origin)
is_unique_session = cfg["platform_settings"]["unique_session"]
if message.get_group_id() and not is_unique_session and message.role != "admin":
# 群聊,没开独立会话,发送人不是管理员
message.set_result(
@@ -411,20 +332,14 @@ class ConversationCommands:
)
return
provider = self.context.get_using_provider(message.unified_msg_origin)
if provider and provider.meta().type == "dify":
assert isinstance(provider, ProviderDify)
dify_cid = provider.conversation_ids.pop(message.unified_msg_origin, None)
if dify_cid:
await provider.api_client.delete_chat_conv(
message.unified_msg_origin,
dify_cid,
)
message.set_result(
MessageEventResult().message(
"删除当前对话成功。不再处于对话状态,使用 /switch 序号 切换到其他对话或 /new 创建。",
),
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
await sp.remove_async(
scope="umo",
scope_id=message.unified_msg_origin,
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
)
message.set_result(MessageEventResult().message("重置对话成功。"))
return
session_curr_cid = (
@@ -447,7 +362,5 @@ class ConversationCommands:
)
ret = "删除当前对话成功。不再处于对话状态,使用 /switch 序号 切换到其他对话或 /new 创建。"
if self.ltm and self.ltm_enabled(message):
cnt = await self.ltm.remove_session(event=message)
ret += f"\n聊天增强: 已清除 {cnt} 条聊天记录。"
message.set_extra("_clean_ltm_session", True)
message.set_result(MessageEventResult().message(ret))
@@ -0,0 +1,88 @@
import aiohttp
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
from astrbot.core.config.default import VERSION
from astrbot.core.star import command_management
from astrbot.core.utils.io import get_dashboard_version
class HelpCommand:
def __init__(self, context: star.Context):
self.context = context
async def _query_astrbot_notice(self):
try:
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(
"https://astrbot.app/notice.json",
timeout=2,
) as resp:
return (await resp.json())["notice"]
except BaseException:
return ""
async def _build_reserved_command_lines(self) -> list[str]:
"""
使用实时指令配置生成内置指令清单,确保重命名/禁用后与实际生效状态保持一致。
"""
try:
commands = await command_management.list_commands()
except BaseException:
return []
lines: list[str] = []
hidden_commands = {"set", "unset", "websearch"}
def walk(items: list[dict], indent: int = 0):
for item in items:
if not item.get("reserved") or not item.get("enabled"):
continue
# 仅展示顶级指令或指令组
if item.get("type") == "sub_command":
continue
if item.get("parent_signature"):
continue
effective = (
item.get("effective_command")
or item.get("original_command")
or item.get("handler_name")
)
if not effective:
continue
if effective in hidden_commands:
continue
description = item.get("description") or ""
desc_text = f" - {description}" if description else ""
indent_prefix = " " * indent
lines.append(f"{indent_prefix}/{effective}{desc_text}")
walk(commands)
return lines
async def help(self, event: AstrMessageEvent):
"""查看帮助"""
notice = ""
try:
notice = await self._query_astrbot_notice()
except BaseException:
pass
dashboard_version = await get_dashboard_version()
command_lines = await self._build_reserved_command_lines()
commands_section = (
"\n".join(command_lines) if command_lines else "暂无启用的内置指令"
)
msg_parts = [
f"AstrBot v{VERSION}(WebUI: {dashboard_version})",
"内置指令:",
commands_section,
]
if notice:
msg_parts.append(notice)
msg = "\n".join(msg_parts)
event.set_result(MessageEventResult().message(msg).use_t2i(False))
@@ -1,13 +1,55 @@
import builtins
from typing import TYPE_CHECKING
from astrbot.api import star
from astrbot.api import sp, star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
if TYPE_CHECKING:
from astrbot.core.db.po import Persona
class PersonaCommands:
def __init__(self, context: star.Context):
self.context = context
def _build_tree_output(
self,
folder_tree: list[dict],
all_personas: list["Persona"],
depth: int = 0,
) -> list[str]:
"""递归构建树状输出,使用短线条表示层级"""
lines: list[str] = []
# 使用短线条作为缩进前缀,每层只用 "│" 加一个空格
prefix = "" * depth
for folder in folder_tree:
# 输出文件夹
lines.append(f"{prefix}├ 📁 {folder['name']}/")
# 获取该文件夹下的人格
folder_personas = [
p for p in all_personas if p.folder_id == folder["folder_id"]
]
child_prefix = "" * (depth + 1)
# 输出该文件夹下的人格
for persona in folder_personas:
lines.append(f"{child_prefix}├ 👤 {persona.persona_id}")
# 递归处理子文件夹
children = folder.get("children", [])
if children:
lines.extend(
self._build_tree_output(
children,
all_personas,
depth + 1,
)
)
return lines
async def persona(self, message: AstrMessageEvent):
l = message.message_str.split(" ") # noqa: E741
umo = message.unified_msg_origin
@@ -17,6 +59,13 @@ class PersonaCommands:
default_persona = await self.context.persona_manager.get_default_persona_v3(
umo=umo,
)
force_applied_persona_id = (
await sp.get_async(
scope="umo", scope_id=umo, key="session_service_config", default={}
)
).get("persona_id")
curr_cid_title = ""
if cid:
conv = await self.context.conversation_manager.get_conversation(
@@ -36,6 +85,9 @@ class PersonaCommands:
else:
curr_persona_name = conv.persona_id
if force_applied_persona_id:
curr_persona_name = f"{curr_persona_name} (自定义规则)"
curr_cid_title = conv.title if conv.title else "新对话"
curr_cid_title += f"({cid[:4]})"
@@ -59,12 +111,32 @@ class PersonaCommands:
.use_t2i(False),
)
elif l[1] == "list":
parts = ["人格列表:\n"]
for persona in self.context.provider_manager.personas:
parts.append(f"- {persona['name']}\n")
parts.append("\n\n*输入 `/persona view 人格名` 查看人格详细信息")
msg = "".join(parts)
message.set_result(MessageEventResult().message(msg))
# 获取文件夹树和所有人格
folder_tree = await self.context.persona_manager.get_folder_tree()
all_personas = self.context.persona_manager.personas
lines = ["📂 人格列表:\n"]
# 构建树状输出
tree_lines = self._build_tree_output(folder_tree, all_personas)
lines.extend(tree_lines)
# 输出根目录下的人格(没有文件夹的)
root_personas = [p for p in all_personas if p.folder_id is None]
if root_personas:
if tree_lines: # 如果有文件夹内容,加个空行
lines.append("")
for persona in root_personas:
lines.append(f"👤 {persona.persona_id}")
# 统计信息
total_count = len(all_personas)
lines.append(f"\n{total_count} 个人格")
lines.append("\n*使用 `/persona <人格名>` 设置人格")
lines.append("*使用 `/persona view <人格名>` 查看详细信息")
msg = "\n".join(lines)
message.set_result(MessageEventResult().message(msg).use_t2i(False))
elif l[1] == "view":
if len(l) == 2:
message.set_result(MessageEventResult().message("请输入人格情景名"))
@@ -113,9 +185,15 @@ class PersonaCommands:
message.unified_msg_origin,
ps,
)
force_warn_msg = ""
if force_applied_persona_id:
force_warn_msg = (
"提醒:由于自定义规则,您现在切换的人格将不会生效。"
)
message.set_result(
MessageEventResult().message(
"设置成功。如果您正在切换到不同的人格,请注意使用 /reset 来清空上下文,防止原人格对话影响现人格。",
f"设置成功。如果您正在切换到不同的人格,请注意使用 /reset 来清空上下文,防止原人格对话影响现人格。{force_warn_msg}",
),
)
else:
@@ -1,5 +1,7 @@
import asyncio
import re
from astrbot import logger
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
from astrbot.core.provider.entities import ProviderType
@@ -9,6 +11,39 @@ class ProviderCommands:
def __init__(self, context: star.Context):
self.context = context
def _log_reachability_failure(
self,
provider,
provider_capability_type: ProviderType | None,
err_code: str,
err_reason: str,
):
"""记录不可达原因到日志。"""
meta = provider.meta()
logger.warning(
"Provider reachability check failed: id=%s type=%s code=%s reason=%s",
meta.id,
provider_capability_type.name if provider_capability_type else "unknown",
err_code,
err_reason,
)
async def _test_provider_capability(self, provider):
"""测试单个 provider 的可用性"""
meta = provider.meta()
provider_capability_type = meta.provider_type
try:
await provider.test()
return True, None, None
except Exception as e:
err_code = "TEST_FAILED"
err_reason = str(e)
self._log_reachability_failure(
provider, provider_capability_type, err_code, err_reason
)
return False, err_code, err_reason
async def provider(
self,
event: AstrMessageEvent,
@@ -17,46 +52,131 @@ class ProviderCommands:
):
"""查看或者切换 LLM Provider"""
umo = event.unified_msg_origin
cfg = self.context.get_config(umo).get("provider_settings", {})
reachability_check_enabled = cfg.get("reachability_check", True)
if idx is None:
parts = ["## 载入的 LLM 提供商\n"]
for idx, llm in enumerate(self.context.get_all_providers()):
id_ = llm.meta().id
line = f"{idx + 1}. {id_} ({llm.meta().model})"
# 获取所有类型的提供商
llms = list(self.context.get_all_providers())
ttss = self.context.get_all_tts_providers()
stts = self.context.get_all_stt_providers()
# 构造待检测列表: [(provider, type_label), ...]
all_providers = []
all_providers.extend([(p, "llm") for p in llms])
all_providers.extend([(p, "tts") for p in ttss])
all_providers.extend([(p, "stt") for p in stts])
# 并发测试连通性
if reachability_check_enabled:
if all_providers:
await event.send(
MessageEventResult().message(
"正在进行提供商可达性测试,请稍候..."
)
)
check_results = await asyncio.gather(
*[self._test_provider_capability(p) for p, _ in all_providers],
return_exceptions=True,
)
else:
# 用 None 表示未检测
check_results = [None for _ in all_providers]
# 整合结果
display_data = []
for (p, p_type), reachable in zip(all_providers, check_results):
meta = p.meta()
id_ = meta.id
error_code = None
if isinstance(reachable, Exception):
# 异常情况下兜底处理,避免单个 provider 导致列表失败
self._log_reachability_failure(
p,
None,
reachable.__class__.__name__,
str(reachable),
)
reachable_flag = False
error_code = reachable.__class__.__name__
elif isinstance(reachable, tuple):
reachable_flag, error_code, _ = reachable
else:
reachable_flag = reachable
# 根据类型构建显示名称
if p_type == "llm":
info = f"{id_} ({meta.model})"
else:
info = f"{id_}"
# 确定状态标记
if reachable_flag is True:
mark = ""
elif reachable_flag is False:
if error_code:
mark = f" ❌(错误码: {error_code})"
else:
mark = ""
else:
mark = "" # 不支持检测时不显示标记
display_data.append(
{
"type": p_type,
"info": info,
"mark": mark,
"provider": p,
}
)
# 分组输出
# 1. LLM
llm_data = [d for d in display_data if d["type"] == "llm"]
for i, d in enumerate(llm_data):
line = f"{i + 1}. {d['info']}{d['mark']}"
provider_using = self.context.get_using_provider(umo=umo)
if provider_using and provider_using.meta().id == id_:
if (
provider_using
and provider_using.meta().id == d["provider"].meta().id
):
line += " (当前使用)"
parts.append(line + "\n")
tts_providers = self.context.get_all_tts_providers()
if tts_providers:
# 2. TTS
tts_data = [d for d in display_data if d["type"] == "tts"]
if tts_data:
parts.append("\n## 载入的 TTS 提供商\n")
for idx, tts in enumerate(tts_providers):
id_ = tts.meta().id
line = f"{idx + 1}. {id_}"
for i, d in enumerate(tts_data):
line = f"{i + 1}. {d['info']}{d['mark']}"
tts_using = self.context.get_using_tts_provider(umo=umo)
if tts_using and tts_using.meta().id == id_:
if tts_using and tts_using.meta().id == d["provider"].meta().id:
line += " (当前使用)"
parts.append(line + "\n")
stt_providers = self.context.get_all_stt_providers()
if stt_providers:
# 3. STT
stt_data = [d for d in display_data if d["type"] == "stt"]
if stt_data:
parts.append("\n## 载入的 STT 提供商\n")
for idx, stt in enumerate(stt_providers):
id_ = stt.meta().id
line = f"{idx + 1}. {id_}"
for i, d in enumerate(stt_data):
line = f"{i + 1}. {d['info']}{d['mark']}"
stt_using = self.context.get_using_stt_provider(umo=umo)
if stt_using and stt_using.meta().id == id_:
if stt_using and stt_using.meta().id == d["provider"].meta().id:
line += " (当前使用)"
parts.append(line + "\n")
parts.append("\n使用 /provider <序号> 切换 LLM 提供商。")
ret = "".join(parts)
if tts_providers:
if ttss:
ret += "\n使用 /provider tts <序号> 切换 TTS 提供商。"
if stt_providers:
ret += "\n使用 /provider stt <切换> STT 提供商。"
if stts:
ret += "\n使用 /provider stt <序号> 切换 STT 提供商。"
if not reachability_check_enabled:
ret += "\n已跳过提供商可达性检测,如需检测请在配置文件中开启。"
event.set_result(MessageEventResult().message(ret))
elif idx == "tts":
@@ -64,7 +184,8 @@ class ProviderCommands:
event.set_result(MessageEventResult().message("请输入序号。"))
return
if idx2 > len(self.context.get_all_tts_providers()) or idx2 < 1:
event.set_result(MessageEventResult().message("无效的序号。"))
event.set_result(MessageEventResult().message("无效的提供商序号。"))
return
provider = self.context.get_all_tts_providers()[idx2 - 1]
id_ = provider.meta().id
await self.context.provider_manager.set_provider(
@@ -78,7 +199,8 @@ class ProviderCommands:
event.set_result(MessageEventResult().message("请输入序号。"))
return
if idx2 > len(self.context.get_all_stt_providers()) or idx2 < 1:
event.set_result(MessageEventResult().message("无效的序号。"))
event.set_result(MessageEventResult().message("无效的提供商序号。"))
return
provider = self.context.get_all_stt_providers()[idx2 - 1]
id_ = provider.meta().id
await self.context.provider_manager.set_provider(
@@ -89,8 +211,8 @@ class ProviderCommands:
event.set_result(MessageEventResult().message(f"成功切换到 {id_}"))
elif isinstance(idx, int):
if idx > len(self.context.get_all_providers()) or idx < 1:
event.set_result(MessageEventResult().message("无效的序号。"))
event.set_result(MessageEventResult().message("无效的提供商序号。"))
return
provider = self.context.get_all_providers()[idx - 1]
id_ = provider.meta().id
await self.context.provider_manager.set_provider(
@@ -14,13 +14,13 @@ class TTSCommand:
async def tts(self, event: AstrMessageEvent):
"""开关文本转语音(会话级别)"""
umo = event.unified_msg_origin
ses_tts = SessionServiceManager.is_tts_enabled_for_session(umo)
ses_tts = await SessionServiceManager.is_tts_enabled_for_session(umo)
cfg = self.context.get_config(umo=umo)
tts_enable = cfg["provider_tts_settings"]["enable"]
# 切换状态
new_status = not ses_tts
SessionServiceManager.set_tts_status_for_session(umo, new_status)
await SessionServiceManager.set_tts_status_for_session(umo, new_status)
status_text = "已开启" if new_status else "已关闭"
@@ -1,11 +1,5 @@
import traceback
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent, filter
from astrbot.api.message_components import Image, Plain
from astrbot.api.provider import LLMResponse, ProviderRequest
from astrbot.core import logger
from astrbot.core.provider.sources.dify_source import ProviderDify
from .commands import (
AdminCommands,
@@ -19,28 +13,19 @@ from .commands import (
SetUnsetCommands,
SIDCommand,
T2ICommand,
ToolCommands,
TTSCommand,
)
from .long_term_memory import LongTermMemory
from .process_llm_request import ProcessLLMRequest
class Main(star.Star):
def __init__(self, context: star.Context) -> None:
self.context = context
self.ltm = None
try:
self.ltm = LongTermMemory(self.context.astrbot_config_mgr, self.context)
except BaseException as e:
logger.error(f"聊天增强 err: {e}")
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, self.ltm)
self.conversation_c = ConversationCommands(self.context)
self.provider_c = ProviderCommands(self.context)
self.persona_c = PersonaCommands(self.context)
self.alter_cmd_c = AlterCmdCommands(self.context)
@@ -48,13 +33,6 @@ class Main(star.Star):
self.t2i_c = T2ICommand(self.context)
self.tts_c = TTSCommand(self.context)
self.sid_c = SIDCommand(self.context)
self.proc_llm_req = ProcessLLMRequest(self.context)
def ltm_enabled(self, event: AstrMessageEvent):
ltmse = self.context.get_config(umo=event.unified_msg_origin)[
"provider_ltm_settings"
]
return ltmse["group_icl_enable"] or ltmse["active_reply"]["enable"]
@filter.command("help")
async def help(self, event: AstrMessageEvent):
@@ -67,33 +45,9 @@ class Main(star.Star):
"""开启/关闭 LLM"""
await self.llm_c.llm(event)
@filter.command_group("tool")
def tool(self):
pass
@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):
pass
"""插件管理"""
@plugin.command("ls")
async def plugin_ls(self, event: AstrMessageEvent):
@@ -239,6 +193,7 @@ class Main(star.Star):
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("dashboard_update")
async def update_dashboard(self, event: AstrMessageEvent):
"""更新管理面板"""
await self.admin_c.update_dashboard(event)
@filter.command("set")
@@ -249,111 +204,6 @@ class Main(star.Star):
async def unset_variable(self, event: AstrMessageEvent, key: str):
await self.setunset_c.unset_variable(event, key)
@filter.platform_adapter_type(filter.PlatformAdapterType.ALL)
async def on_message(self, event: AstrMessageEvent):
"""群聊记忆增强"""
has_image_or_plain = False
for comp in event.message_obj.message:
if isinstance(comp, Plain) or isinstance(comp, Image):
has_image_or_plain = True
break
if self.ltm_enabled(event) and self.ltm and has_image_or_plain:
need_active = await self.ltm.need_active_reply(event)
group_icl_enable = self.context.get_config()["provider_ltm_settings"][
"group_icl_enable"
]
if group_icl_enable:
"""记录对话"""
try:
await self.ltm.handle_message(event)
except BaseException as e:
logger.error(e)
if need_active:
"""主动回复"""
provider = self.context.get_using_provider(event.unified_msg_origin)
if not provider:
logger.error("未找到任何 LLM 提供商。请先配置。无法主动回复")
return
try:
conv = None
if provider.meta().type != "dify":
session_curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
event.unified_msg_origin,
)
if not session_curr_cid:
logger.error(
"当前未处于对话状态,无法主动回复,请确保 平台设置->会话隔离(unique_session) 未开启,并使用 /switch 序号 切换或者 /new 创建一个会话。",
)
return
conv = await self.context.conversation_manager.get_conversation(
event.unified_msg_origin,
session_curr_cid,
)
else:
# Dify 自己有维护对话,不需要 bot 端维护。
assert isinstance(provider, ProviderDify)
cid = provider.conversation_ids.get(
event.unified_msg_origin,
None,
)
if cid is None:
logger.error(
"[Dify] 当前未处于对话状态,无法主动回复,请确保 平台设置->会话隔离(unique_session) 未开启,并使用 /switch 序号 切换或者 /new 创建一个会话。",
)
return
prompt = event.message_str
if not conv:
logger.error("未找到对话,无法主动回复")
return
yield event.request_llm(
prompt=prompt,
func_tool_manager=self.context.get_llm_tool_manager(),
session_id=event.session_id,
conversation=conv,
)
except BaseException as e:
logger.error(traceback.format_exc())
logger.error(f"主动回复失败: {e}")
@filter.on_llm_request()
async def decorate_llm_req(self, event: AstrMessageEvent, req: ProviderRequest):
"""在请求 LLM 前注入人格信息、Identifier、时间、回复内容等 System Prompt"""
await self.proc_llm_req.process_llm_request(event, req)
if self.ltm and self.ltm_enabled(event):
try:
await self.ltm.on_req_llm(event, req)
except BaseException as e:
logger.error(f"ltm: {e}")
@filter.on_llm_response()
async def inject_reasoning(self, event: AstrMessageEvent, resp: LLMResponse):
"""在 LLM 响应后基于配置注入思考过程文本"""
umo = event.unified_msg_origin
cfg = self.context.get_config(umo).get("provider_settings", {})
show_reasoning = cfg.get("display_reasoning_text", False)
if show_reasoning and resp.reasoning_content:
resp.completion_text = (
f"🤔 思考: {resp.reasoning_content}\n\n{resp.completion_text}"
)
@filter.after_message_sent()
async def after_llm_req(self, event: AstrMessageEvent):
"""在 LLM 请求后记录对话"""
if self.ltm and self.ltm_enabled(event):
try:
await self.ltm.after_req_llm(event)
except Exception as e:
logger.error(f"ltm: {e}")
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("alter_cmd", alias={"alter"})
async def alter_cmd(self, event: AstrMessageEvent):
@@ -0,0 +1,4 @@
name: builtin_commands
desc: AstrBot 自带指令,提供常用的对话管理、工具使用、插件管理等功能。
author: Soulter
version: 0.0.1
@@ -5,6 +5,7 @@ import uuid
import zoneinfo
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from astrbot.api import llm_tool, logger, star
from astrbot.api.event import AstrMessageEvent, MessageEventResult, filter
@@ -62,13 +63,13 @@ class Main(star.Star):
misfire_grace_time=60,
)
elif "cron" in reminder:
trigger = CronTrigger(**self._parse_cron_expr(reminder["cron"]))
self.scheduler.add_job(
self._reminder_callback,
trigger="cron",
trigger=trigger,
id=id_,
args=[group, reminder],
misfire_grace_time=60,
**self._parse_cron_expr(reminder["cron"]),
)
def check_is_outdated(self, reminder: dict):
@@ -101,10 +102,10 @@ class Main(star.Star):
async def reminder_tool(
self,
event: AstrMessageEvent,
text: str = None,
datetime_str: str = None,
cron_expression: str = None,
human_readable_cron: str = None,
text: str | None = None,
datetime_str: str | None = None,
cron_expression: str | None = None,
human_readable_cron: str | None = None,
):
"""Call this function when user is asking for setting a reminder.
@@ -139,17 +140,19 @@ class Main(star.Star):
"id": str(uuid.uuid4()),
}
self.reminder_data[event.unified_msg_origin].append(d)
trigger = CronTrigger(**self._parse_cron_expr(cron_expression))
self.scheduler.add_job(
self._reminder_callback,
"cron",
trigger,
id=d["id"],
misfire_grace_time=60,
**self._parse_cron_expr(cron_expression),
args=[event.unified_msg_origin, d],
)
if human_readable_cron:
reminder_time = f"{human_readable_cron}(Cron: {cron_expression})"
else:
if datetime_str is None:
raise ValueError("datetime_str cannot be None.")
d = {"text": text, "datetime": datetime_str, "id": str(uuid.uuid4())}
self.reminder_data[event.unified_msg_origin].append(d)
datetime_scheduled = datetime.datetime.strptime(
@@ -176,7 +179,7 @@ class Main(star.Star):
@filter.command_group("reminder")
def reminder(self):
"""The command group of the reminder."""
"""待办提醒"""
async def get_upcoming_reminders(self, unified_msg_origin: str):
"""Get upcoming reminders."""
@@ -14,7 +14,7 @@ from astrbot.core.utils.session_waiter import (
)
class Waiter(Star):
class Main(Star):
"""会话控制"""
def __init__(self, context: Context):
@@ -3,7 +3,7 @@ import urllib.parse
from dataclasses import dataclass
from aiohttp import ClientSession
from bs4 import BeautifulSoup
from bs4 import BeautifulSoup, Tag
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 6.1; rv:84.0) Gecko/20100101 Firefox/84.0",
@@ -32,6 +32,7 @@ class SearchResult:
title: str
url: str
snippet: str
favicon: str | None = None
def __str__(self) -> str:
return f"{self.title} - {self.url}\n{self.snippet}"
@@ -45,13 +46,13 @@ class SearchEngine:
self.page = 1
self.headers = HEADERS
def _set_selector(self, selector: str) -> None:
def _set_selector(self, selector: str) -> str:
raise NotImplementedError
def _get_next_page(self):
def _get_next_page(self, query: str):
raise NotImplementedError
async def _get_html(self, url: str, data: dict = None) -> str:
async def _get_html(self, url: str, data: dict | None = None) -> str:
headers = self.headers
headers["Referer"] = url
headers["User-Agent"] = random.choice(USER_AGENTS)
@@ -83,6 +84,9 @@ class SearchEngine:
"""清理文本,去除空格、换行符等"""
return text.strip().replace("\n", " ").replace("\r", " ").replace(" ", " ")
def _get_url(self, tag: Tag) -> str:
return self.tidy_text(tag.get_text())
async def search(self, query: str, num_results: int) -> list[SearchResult]:
query = urllib.parse.quote(query)
@@ -92,12 +96,16 @@ class SearchEngine:
links = soup.select(self._set_selector("links"))
results = []
for link in links:
title = self.tidy_text(
link.select_one(self._set_selector("title")).text,
)
url = link.select_one(self._set_selector("url"))
# Safely get the title text (select_one may return None)
title_elem = link.select_one(self._set_selector("title"))
title = ""
if title_elem is not None:
title = self.tidy_text(title_elem.get_text())
url_tag = link.select_one(self._set_selector("url"))
snippet = ""
if title and url:
if title and url_tag:
url = self._get_url(url_tag)
results.append(SearchResult(title=title, url=url, snippet=snippet))
return results[:num_results] if len(results) > num_results else results
except Exception as e:
@@ -1,4 +1,4 @@
from . import USER_AGENT_BING, SearchEngine, SearchResult
from . import USER_AGENT_BING, SearchEngine
class Bing(SearchEngine):
@@ -28,11 +28,3 @@ class Bing(SearchEngine):
self.base_url = base_url
continue
raise Exception("Bing search failed")
async def search(self, query: str, num_results: int) -> list[SearchResult]:
results = await super().search(query, num_results)
for result in results:
if not isinstance(result.url, str):
result.url = result.url.text
return results
@@ -1,7 +1,8 @@
import random
import re
from typing import cast
from bs4 import BeautifulSoup
from bs4 import BeautifulSoup, Tag
from . import USER_AGENTS, SearchEngine, SearchResult
@@ -26,10 +27,12 @@ class Sogo(SearchEngine):
url = f"{self.base_url}/web?query={query}"
return await self._get_html(url, None)
def _get_url(self, tag: Tag) -> str:
return cast(str, tag.get("href"))
async def search(self, query: str, num_results: int) -> list[SearchResult]:
results = await super().search(query, num_results)
for result in results:
result.url = result.url.get("href")
if result.url.startswith("/link?"):
result.url = self.base_url + result.url
result.url = await self._parse_url(result.url)
@@ -40,7 +43,10 @@ class Sogo(SearchEngine):
soup = BeautifulSoup(html, "html.parser")
script = soup.find("script")
if script:
url = re.search(r'window.location.replace\("(.+?)"\)', script.string).group(
1,
script_text = (
script.string if script.string is not None else script.get_text()
)
match = re.search(r'window.location.replace\("(.+?)"\)', script_text)
if match:
url = match.group(1)
return url
@@ -1,11 +1,13 @@
import asyncio
import json
import random
import uuid
import aiohttp
from bs4 import BeautifulSoup
from readability import Document
from astrbot.api import AstrBotConfig, llm_tool, logger, star
from astrbot.api import AstrBotConfig, llm_tool, logger, sp, star
from astrbot.api.event import AstrMessageEvent, MessageEventResult, filter
from astrbot.api.provider import ProviderRequest
from astrbot.core.provider.func_tool_manager import FunctionToolManager
@@ -151,6 +153,7 @@ class Main(star.Star):
title=item.get("title"),
url=item.get("url"),
snippet=item.get("content"),
favicon=item.get("favicon"),
)
results.append(result)
return results
@@ -185,6 +188,7 @@ class Main(star.Star):
@filter.command("websearch")
async def websearch(self, event: AstrMessageEvent, oper: str | None = None):
"""网页搜索指令(已废弃)"""
event.set_result(
MessageEventResult().message(
"此指令已经被废弃,请在 WebUI 中开启或关闭网页搜索功能。",
@@ -271,7 +275,7 @@ class Main(star.Star):
self,
event: AstrMessageEvent,
query: str,
max_results: int = 5,
max_results: int = 7,
search_depth: str = "basic",
topic: str = "general",
days: int = 3,
@@ -284,7 +288,7 @@ class Main(star.Star):
Args:
query(string): Required. Search query.
max_results(number): Optional. The maximum number of results to return. Default is 5. Range is 5-20.
max_results(number): Optional. The maximum number of results to return. Default is 7. Range is 5-20.
search_depth(string): Optional. The depth of the search, must be one of 'basic', 'advanced'. Default is "basic".
topic(string): Optional. The topic of the search, must be one of 'general', 'news'. Default is "general".
days(number): Optional. The number of days back from the current date to include in the search results. Please note that this feature is only available when using the 'news' search topic.
@@ -295,15 +299,12 @@ class Main(star.Star):
"""
logger.info(f"web_searcher - search_from_tavily: {query}")
cfg = self.context.get_config(umo=event.unified_msg_origin)
websearch_link = cfg["provider_settings"].get("web_search_link", False)
# websearch_link = cfg["provider_settings"].get("web_search_link", False)
if not cfg.get("provider_settings", {}).get("websearch_tavily_key", []):
raise ValueError("Error: Tavily API key is not configured in AstrBot.")
# build payload
payload = {
"query": query,
"max_results": max_results,
}
payload = {"query": query, "max_results": max_results, "include_favicon": True}
if search_depth not in ["basic", "advanced"]:
search_depth = "basic"
payload["search_depth"] = search_depth
@@ -327,14 +328,22 @@ class Main(star.Star):
return "Error: Tavily web searcher does not return any results."
ret_ls = []
for result in results:
ret_ls.append(f"\nTitle: {result.title}")
ret_ls.append(f"URL: {result.url}")
ret_ls.append(f"Content: {result.snippet}")
ret = "\n".join(ret_ls)
if websearch_link:
ret += "\n\n针对问题,请根据上面的结果分点总结,并且在结尾处附上对应内容的参考链接(如有)。"
ref_uuid = str(uuid.uuid4())[:4]
for idx, result in enumerate(results, 1):
index = f"{ref_uuid}.{idx}"
ret_ls.append(
{
"title": f"{result.title}",
"url": f"{result.url}",
"snippet": f"{result.snippet}",
# TODO: do not need ref for non-webchat platform adapter
"index": index,
}
)
if result.favicon:
sp.temorary_cache["_ws_favicon"][result.url] = result.favicon
# ret = "\n".join(ret_ls)
ret = json.dumps({"results": ret_ls}, ensure_ascii=False)
return ret
@llm_tool("tavily_extract_web_page")
+1 -1
View File
@@ -1 +1 @@
__version__ = "3.5.23"
__version__ = "4.13.0"
+2
View File
@@ -20,6 +20,8 @@ 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)
+243
View File
@@ -0,0 +1,243 @@
from typing import TYPE_CHECKING, Protocol, runtime_checkable
from ..message import Message
if TYPE_CHECKING:
from astrbot import logger
else:
try:
from astrbot import logger
except ImportError:
import logging
logger = logging.getLogger("astrbot")
if TYPE_CHECKING:
from astrbot.core.provider.provider import Provider
from ..context.truncator import ContextTruncator
@runtime_checkable
class ContextCompressor(Protocol):
"""
Protocol for context compressors.
Provides an interface for compressing message lists.
"""
def should_compress(
self, messages: list[Message], current_tokens: int, max_tokens: int
) -> bool:
"""Check if compression is needed.
Args:
messages: The message list to evaluate.
current_tokens: The current token count.
max_tokens: The maximum allowed tokens for the model.
Returns:
True if compression is needed, False otherwise.
"""
...
async def __call__(self, messages: list[Message]) -> list[Message]:
"""Compress the message list.
Args:
messages: The original message list.
Returns:
The compressed message list.
"""
...
class TruncateByTurnsCompressor:
"""Truncate by turns compressor implementation.
Truncates the message list by removing older turns.
"""
def __init__(self, truncate_turns: int = 1, compression_threshold: float = 0.82):
"""Initialize the truncate by turns compressor.
Args:
truncate_turns: The number of turns to remove when truncating (default: 1).
compression_threshold: The compression trigger threshold (default: 0.82).
"""
self.truncate_turns = truncate_turns
self.compression_threshold = compression_threshold
def should_compress(
self, messages: list[Message], current_tokens: int, max_tokens: int
) -> bool:
"""Check if compression is needed.
Args:
messages: The message list to evaluate.
current_tokens: The current token count.
max_tokens: The maximum allowed tokens.
Returns:
True if compression is needed, False otherwise.
"""
if max_tokens <= 0 or current_tokens <= 0:
return False
usage_rate = current_tokens / max_tokens
return usage_rate > self.compression_threshold
async def __call__(self, messages: list[Message]) -> list[Message]:
truncator = ContextTruncator()
truncated_messages = truncator.truncate_by_dropping_oldest_turns(
messages,
drop_turns=self.truncate_turns,
)
return truncated_messages
def split_history(
messages: list[Message], keep_recent: int
) -> tuple[list[Message], list[Message], list[Message]]:
"""Split the message list into system messages, messages to summarize, and recent messages.
Ensures that the split point is between complete user-assistant pairs to maintain conversation flow.
Args:
messages: The original message list.
keep_recent: The number of latest messages to keep.
Returns:
tuple: (system_messages, messages_to_summarize, recent_messages)
"""
# keep the system messages
first_non_system = 0
for i, msg in enumerate(messages):
if msg.role != "system":
first_non_system = i
break
system_messages = messages[:first_non_system]
non_system_messages = messages[first_non_system:]
if len(non_system_messages) <= keep_recent:
return system_messages, [], non_system_messages
# Find the split point, ensuring recent_messages starts with a user message
# This maintains complete conversation turns
split_index = len(non_system_messages) - keep_recent
# Search backward from split_index to find the first user message
# This ensures recent_messages starts with a user message (complete turn)
while split_index > 0 and non_system_messages[split_index].role != "user":
# TODO: +=1 or -=1 ? calculate by tokens
split_index -= 1
# If we couldn't find a user message, keep all messages as recent
if split_index == 0:
return system_messages, [], non_system_messages
messages_to_summarize = non_system_messages[:split_index]
recent_messages = non_system_messages[split_index:]
return system_messages, messages_to_summarize, recent_messages
class LLMSummaryCompressor:
"""LLM-based summary compressor.
Uses LLM to summarize the old conversation history, keeping the latest messages.
"""
def __init__(
self,
provider: "Provider",
keep_recent: int = 4,
instruction_text: str | None = None,
compression_threshold: float = 0.82,
):
"""Initialize the LLM summary compressor.
Args:
provider: The LLM provider instance.
keep_recent: The number of latest messages to keep (default: 4).
instruction_text: Custom instruction for summary generation.
compression_threshold: The compression trigger threshold (default: 0.82).
"""
self.provider = provider
self.keep_recent = keep_recent
self.compression_threshold = compression_threshold
self.instruction_text = instruction_text or (
"Based on our full conversation history, produce a concise summary of key takeaways and/or project progress.\n"
"1. Systematically cover all core topics discussed and the final conclusion/outcome for each; clearly highlight the latest primary focus.\n"
"2. If any tools were used, summarize tool usage (total call count) and extract the most valuable insights from tool outputs.\n"
"3. If there was an initial user goal, state it first and describe the current progress/status.\n"
"4. Write the summary in the user's language.\n"
)
def should_compress(
self, messages: list[Message], current_tokens: int, max_tokens: int
) -> bool:
"""Check if compression is needed.
Args:
messages: The message list to evaluate.
current_tokens: The current token count.
max_tokens: The maximum allowed tokens.
Returns:
True if compression is needed, False otherwise.
"""
if max_tokens <= 0 or current_tokens <= 0:
return False
usage_rate = current_tokens / max_tokens
return usage_rate > self.compression_threshold
async def __call__(self, messages: list[Message]) -> list[Message]:
"""Use LLM to generate a summary of the conversation history.
Process:
1. Divide messages: keep the system message and the latest N messages.
2. Send the old messages + the instruction message to the LLM.
3. Reconstruct the message list: [system message, summary message, latest messages].
"""
if len(messages) <= self.keep_recent + 1:
return messages
system_messages, messages_to_summarize, recent_messages = split_history(
messages, self.keep_recent
)
if not messages_to_summarize:
return messages
# build payload
instruction_message = Message(role="user", content=self.instruction_text)
llm_payload = messages_to_summarize + [instruction_message]
# generate summary
try:
response = await self.provider.text_chat(contexts=llm_payload)
summary_content = response.completion_text
except Exception as e:
logger.error(f"Failed to generate summary: {e}")
return messages
# build result
result = []
result.extend(system_messages)
result.append(
Message(
role="user",
content=f"Our previous history conversation summary: {summary_content}",
)
)
result.append(
Message(
role="assistant",
content="Acknowledged the summary of our previous conversation history.",
)
)
result.extend(recent_messages)
return result
+35
View File
@@ -0,0 +1,35 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING
from .compressor import ContextCompressor
from .token_counter import TokenCounter
if TYPE_CHECKING:
from astrbot.core.provider.provider import Provider
@dataclass
class ContextConfig:
"""Context configuration class."""
max_context_tokens: int = 0
"""Maximum number of context tokens. <= 0 means no limit."""
enforce_max_turns: int = -1 # -1 means no limit
"""Maximum number of conversation turns to keep. -1 means no limit. Executed before compression."""
truncate_turns: int = 1
"""Number of conversation turns to discard at once when truncation is triggered.
Two processes will use this value:
1. Enforce max turns truncation.
2. Truncation by turns compression strategy.
"""
llm_compress_instruction: str | None = None
"""Instruction prompt for LLM-based compression."""
llm_compress_keep_recent: int = 0
"""Number of recent messages to keep during LLM-based compression."""
llm_compress_provider: "Provider | None" = None
"""LLM provider used for compression tasks. If None, truncation strategy is used."""
custom_token_counter: TokenCounter | None = None
"""Custom token counting method. If None, the default method is used."""
custom_compressor: ContextCompressor | None = None
"""Custom context compression method. If None, the default method is used."""
+120
View File
@@ -0,0 +1,120 @@
from astrbot import logger
from ..message import Message
from .compressor import LLMSummaryCompressor, TruncateByTurnsCompressor
from .config import ContextConfig
from .token_counter import EstimateTokenCounter
from .truncator import ContextTruncator
class ContextManager:
"""Context compression manager."""
def __init__(
self,
config: ContextConfig,
):
"""Initialize the context manager.
There are two strategies to handle context limit reached:
1. Truncate by turns: remove older messages by turns.
2. LLM-based compression: use LLM to summarize old messages.
Args:
config: The context configuration.
"""
self.config = config
self.token_counter = config.custom_token_counter or EstimateTokenCounter()
self.truncator = ContextTruncator()
if config.custom_compressor:
self.compressor = config.custom_compressor
elif config.llm_compress_provider:
self.compressor = LLMSummaryCompressor(
provider=config.llm_compress_provider,
keep_recent=config.llm_compress_keep_recent,
instruction_text=config.llm_compress_instruction,
)
else:
self.compressor = TruncateByTurnsCompressor(
truncate_turns=config.truncate_turns
)
async def process(
self, messages: list[Message], trusted_token_usage: int = 0
) -> list[Message]:
"""Process the messages.
Args:
messages: The original message list.
Returns:
The processed message list.
"""
try:
result = messages
# 1. 基于轮次的截断 (Enforce max turns)
if self.config.enforce_max_turns != -1:
result = self.truncator.truncate_by_turns(
result,
keep_most_recent_turns=self.config.enforce_max_turns,
drop_turns=self.config.truncate_turns,
)
# 2. 基于 token 的压缩
if self.config.max_context_tokens > 0:
total_tokens = self.token_counter.count_tokens(
result, trusted_token_usage
)
if self.compressor.should_compress(
result, total_tokens, self.config.max_context_tokens
):
result = await self._run_compression(result, total_tokens)
return result
except Exception as e:
logger.error(f"Error during context processing: {e}", exc_info=True)
return messages
async def _run_compression(
self, messages: list[Message], prev_tokens: int
) -> list[Message]:
"""
Compress/truncate the messages.
Args:
messages: The original message list.
prev_tokens: The token count before compression.
Returns:
The compressed/truncated message list.
"""
logger.debug("Compress triggered, starting compression...")
messages = await self.compressor(messages)
# double check
tokens_after_summary = self.token_counter.count_tokens(messages)
# calculate compress rate
compress_rate = (tokens_after_summary / self.config.max_context_tokens) * 100
logger.info(
f"Compress completed."
f" {prev_tokens} -> {tokens_after_summary} tokens,"
f" compression rate: {compress_rate:.2f}%.",
)
# last check
if self.compressor.should_compress(
messages, tokens_after_summary, self.config.max_context_tokens
):
logger.info(
"Context still exceeds max tokens after compression, applying halving truncation..."
)
# still need compress, truncate by half
messages = self.truncator.truncate_by_halving(messages)
return messages
@@ -0,0 +1,64 @@
import json
from typing import Protocol, runtime_checkable
from ..message import Message, TextPart
@runtime_checkable
class TokenCounter(Protocol):
"""
Protocol for token counters.
Provides an interface for counting tokens in message lists.
"""
def count_tokens(
self, messages: list[Message], trusted_token_usage: int = 0
) -> int:
"""Count the total tokens in the message list.
Args:
messages: The message list.
trusted_token_usage: The total token usage that LLM API returned.
For some cases, this value is more accurate.
But some API does not return it, so the value defaults to 0.
Returns:
The total token count.
"""
...
class EstimateTokenCounter:
"""Estimate token counter implementation.
Provides a simple estimation of token count based on character types.
"""
def count_tokens(
self, messages: list[Message], trusted_token_usage: int = 0
) -> int:
if trusted_token_usage > 0:
return trusted_token_usage
total = 0
for msg in messages:
content = msg.content
if isinstance(content, str):
total += self._estimate_tokens(content)
elif isinstance(content, list):
# 处理多模态内容
for part in content:
if isinstance(part, TextPart):
total += self._estimate_tokens(part.text)
# 处理 Tool Calls
if msg.tool_calls:
for tc in msg.tool_calls:
tc_str = json.dumps(tc if isinstance(tc, dict) else tc.model_dump())
total += self._estimate_tokens(tc_str)
return total
def _estimate_tokens(self, text: str) -> int:
chinese_count = len([c for c in text if "\u4e00" <= c <= "\u9fff"])
other_count = len(text) - chinese_count
return int(chinese_count * 0.6 + other_count * 0.3)
+141
View File
@@ -0,0 +1,141 @@
from ..message import Message
class ContextTruncator:
"""Context truncator."""
def fix_messages(self, messages: list[Message]) -> list[Message]:
fixed_messages = []
for message in messages:
if message.role == "tool":
# tool block 前面必须要有 user 和 assistant block
if len(fixed_messages) < 2:
# 这种情况可能是上下文被截断导致的
# 我们直接将之前的上下文都清空
fixed_messages = []
else:
fixed_messages.append(message)
else:
fixed_messages.append(message)
return fixed_messages
def truncate_by_turns(
self,
messages: list[Message],
keep_most_recent_turns: int,
drop_turns: int = 1,
) -> list[Message]:
"""截断上下文列表,确保不超过最大长度。
一个 turn 包含一个 user 消息和一个 assistant 消息。
这个方法会保证截断后的上下文列表符合 OpenAI 的上下文格式。
Args:
messages: 上下文列表
keep_most_recent_turns: 保留最近的对话轮数
drop_turns: 一次性丢弃的对话轮数
Returns:
截断后的上下文列表
"""
if keep_most_recent_turns == -1:
return messages
first_non_system = 0
for i, msg in enumerate(messages):
if msg.role != "system":
first_non_system = i
break
system_messages = messages[:first_non_system]
non_system_messages = messages[first_non_system:]
if len(non_system_messages) // 2 <= keep_most_recent_turns:
return messages
num_to_keep = keep_most_recent_turns - drop_turns + 1
if num_to_keep <= 0:
truncated_contexts = []
else:
truncated_contexts = non_system_messages[-num_to_keep * 2 :]
# 找到第一个 role 为 user 的索引,确保上下文格式正确
index = next(
(i for i, item in enumerate(truncated_contexts) if item.role == "user"),
None,
)
if index is not None and index > 0:
truncated_contexts = truncated_contexts[index:]
result = system_messages + truncated_contexts
return self.fix_messages(result)
def truncate_by_dropping_oldest_turns(
self,
messages: list[Message],
drop_turns: int = 1,
) -> list[Message]:
"""丢弃最旧的 N 个对话轮次。"""
if drop_turns <= 0:
return messages
first_non_system = 0
for i, msg in enumerate(messages):
if msg.role != "system":
first_non_system = i
break
system_messages = messages[:first_non_system]
non_system_messages = messages[first_non_system:]
if len(non_system_messages) // 2 <= drop_turns:
truncated_non_system = []
else:
truncated_non_system = non_system_messages[drop_turns * 2 :]
index = next(
(i for i, item in enumerate(truncated_non_system) if item.role == "user"),
None,
)
if index is not None:
truncated_non_system = truncated_non_system[index:]
elif truncated_non_system:
truncated_non_system = []
result = system_messages + truncated_non_system
return self.fix_messages(result)
def truncate_by_halving(
self,
messages: list[Message],
) -> list[Message]:
"""对半砍策略,删除 50% 的消息"""
if len(messages) <= 2:
return messages
first_non_system = 0
for i, msg in enumerate(messages):
if msg.role != "system":
first_non_system = i
break
system_messages = messages[:first_non_system]
non_system_messages = messages[first_non_system:]
messages_to_delete = len(non_system_messages) // 2
if messages_to_delete == 0:
return messages
truncated_non_system = non_system_messages[messages_to_delete:]
index = next(
(i for i, item in enumerate(truncated_non_system) if item.role == "user"),
None,
)
if index is not None:
truncated_non_system = truncated_non_system[index:]
result = system_messages + truncated_non_system
return self.fix_messages(result)
+153 -27
View File
@@ -4,6 +4,14 @@ from contextlib import AsyncExitStack
from datetime import timedelta
from typing import Generic
from tenacity import (
before_sleep_log,
retry,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
from astrbot import logger
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.utils.log_pipe import LogPipe
@@ -12,21 +20,24 @@ from .run_context import TContext
from .tool import FunctionTool
try:
import anyio
import mcp
from mcp.client.sse import sse_client
except (ModuleNotFoundError, ImportError):
logger.warning("警告: 缺少依赖库 'mcp',将无法使用 MCP 服务。")
logger.warning(
"Warning: Missing 'mcp' dependency, MCP services will be unavailable."
)
try:
from mcp.client.streamable_http import streamablehttp_client
except (ModuleNotFoundError, ImportError):
logger.warning(
"警告: 缺少依赖库 'mcp' 或者 mcp 库版本过低,无法使用 Streamable HTTP 连接方式。",
"Warning: Missing 'mcp' dependency or MCP library version too old, Streamable HTTP connection unavailable.",
)
def _prepare_config(config: dict) -> dict:
"""准备配置,处理嵌套格式"""
"""Prepare configuration, handle nested format"""
if config.get("mcpServers"):
first_key = next(iter(config["mcpServers"]))
config = config["mcpServers"][first_key]
@@ -35,7 +46,7 @@ def _prepare_config(config: dict) -> dict:
async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]:
"""快速测试 MCP 服务器可达性"""
"""Quick test MCP server connectivity"""
import aiohttp
cfg = _prepare_config(config.copy())
@@ -50,7 +61,7 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]:
elif "type" in cfg:
transport_type = cfg["type"]
else:
raise Exception("MCP 连接配置缺少 transport type 字段")
raise Exception("MCP connection config missing transport or type field")
async with aiohttp.ClientSession() as session:
if transport_type == "streamable_http":
@@ -91,7 +102,7 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]:
return False, f"HTTP {response.status}: {response.reason}"
except asyncio.TimeoutError:
return False, f"连接超时: {timeout}"
return False, f"Connection timeout: {timeout} seconds"
except Exception as e:
return False, f"{e!s}"
@@ -101,6 +112,7 @@ class MCPClient:
# Initialize session and client objects
self.session: mcp.ClientSession | None = None
self.exit_stack = AsyncExitStack()
self._old_exit_stacks: list[AsyncExitStack] = [] # Track old stacks for cleanup
self.name: str | None = None
self.active: bool = True
@@ -108,22 +120,32 @@ class MCPClient:
self.server_errlogs: list[str] = []
self.running_event = asyncio.Event()
async def connect_to_server(self, mcp_server_config: dict, name: str):
"""连接到 MCP 服务器
# Store connection config for reconnection
self._mcp_server_config: dict | None = None
self._server_name: str | None = None
self._reconnect_lock = asyncio.Lock() # Lock for thread-safe reconnection
self._reconnecting: bool = False # For logging and debugging
如果 `url` 参数存在:
1. 当 transport 指定为 `streamable_http` 时,使用 Streamable HTTP 连接方式。
1. 当 transport 指定为 `sse` 时,使用 SSE 连接方式。
2. 如果没有指定,默认使用 SSE 的方式连接到 MCP 服务。
async def connect_to_server(self, mcp_server_config: dict, name: str):
"""Connect to MCP server
If `url` parameter exists:
1. When transport is specified as `streamable_http`, use Streamable HTTP connection.
2. When transport is specified as `sse`, use SSE connection.
3. If not specified, default to SSE connection to MCP service.
Args:
mcp_server_config (dict): Configuration for the MCP server. See https://modelcontextprotocol.io/quickstart/server
"""
# Store config for reconnection
self._mcp_server_config = mcp_server_config
self._server_name = name
cfg = _prepare_config(mcp_server_config.copy())
def logging_callback(msg: str):
# 处理 MCP 服务的错误日志
# Handle MCP service error logs
print(f"MCP Server {name} Error: {msg}")
self.server_errlogs.append(msg)
@@ -137,7 +159,7 @@ class MCPClient:
elif "type" in cfg:
transport_type = cfg["type"]
else:
raise Exception("MCP 连接配置缺少 transport type 字段")
raise Exception("MCP connection config missing transport or type field")
if transport_type != "streamable_http":
# SSE transport method
@@ -193,7 +215,7 @@ class MCPClient:
)
def callback(msg: str):
# 处理 MCP 服务的错误日志
# Handle MCP service error logs
self.server_errlogs.append(msg)
stdio_transport = await self.exit_stack.enter_async_context(
@@ -222,10 +244,120 @@ class MCPClient:
self.tools = response.tools
return response
async def _reconnect(self) -> None:
"""Reconnect to the MCP server using the stored configuration.
Uses asyncio.Lock to ensure thread-safe reconnection in concurrent environments.
Raises:
Exception: raised when reconnection fails
"""
async with self._reconnect_lock:
# Check if already reconnecting (useful for logging)
if self._reconnecting:
logger.debug(
f"MCP Client {self._server_name} is already reconnecting, skipping"
)
return
if not self._mcp_server_config or not self._server_name:
raise Exception("Cannot reconnect: missing connection configuration")
self._reconnecting = True
try:
logger.info(
f"Attempting to reconnect to MCP server {self._server_name}..."
)
# Save old exit_stack for later cleanup (don't close it now to avoid cancel scope issues)
if self.exit_stack:
self._old_exit_stacks.append(self.exit_stack)
# Mark old session as invalid
self.session = None
# Create new exit stack for new connection
self.exit_stack = AsyncExitStack()
# Reconnect using stored config
await self.connect_to_server(self._mcp_server_config, self._server_name)
await self.list_tools_and_save()
logger.info(
f"Successfully reconnected to MCP server {self._server_name}"
)
except Exception as e:
logger.error(
f"Failed to reconnect to MCP server {self._server_name}: {e}"
)
raise
finally:
self._reconnecting = False
async def call_tool_with_reconnect(
self,
tool_name: str,
arguments: dict,
read_timeout_seconds: timedelta,
) -> mcp.types.CallToolResult:
"""Call MCP tool with automatic reconnection on failure, max 2 retries.
Args:
tool_name: tool name
arguments: tool arguments
read_timeout_seconds: read timeout
Returns:
MCP tool call result
Raises:
ValueError: MCP session is not available
anyio.ClosedResourceError: raised after reconnection failure
"""
@retry(
retry=retry_if_exception_type(anyio.ClosedResourceError),
stop=stop_after_attempt(2),
wait=wait_exponential(multiplier=1, min=1, max=3),
before_sleep=before_sleep_log(logger, logging.WARNING),
reraise=True,
)
async def _call_with_retry():
if not self.session:
raise ValueError("MCP session is not available for MCP function tools.")
try:
return await self.session.call_tool(
name=tool_name,
arguments=arguments,
read_timeout_seconds=read_timeout_seconds,
)
except anyio.ClosedResourceError:
logger.warning(
f"MCP tool {tool_name} call failed (ClosedResourceError), attempting to reconnect..."
)
# Attempt to reconnect
await self._reconnect()
# Reraise the exception to trigger tenacity retry
raise
return await _call_with_retry()
async def cleanup(self):
"""Clean up resources"""
await self.exit_stack.aclose()
self.running_event.set() # Set the running event to indicate cleanup is done
"""Clean up resources including old exit stacks from reconnections"""
# Close current exit stack
try:
await self.exit_stack.aclose()
except Exception as e:
logger.debug(f"Error closing current exit stack: {e}")
# Don't close old exit stacks as they may be in different task contexts
# They will be garbage collected naturally
# Just clear the list to release references
self._old_exit_stacks.clear()
# Set running_event first to unblock any waiting tasks
self.running_event.set()
class MCPTool(FunctionTool, Generic[TContext]):
@@ -246,14 +378,8 @@ class MCPTool(FunctionTool, Generic[TContext]):
async def call(
self, context: ContextWrapper[TContext], **kwargs
) -> mcp.types.CallToolResult:
session = self.mcp_client.session
if not session:
raise ValueError("MCP session is not available for MCP function tools.")
res = await session.call_tool(
name=self.mcp_tool.name,
return await self.mcp_client.call_tool_with_reconnect(
tool_name=self.mcp_tool.name,
arguments=kwargs,
read_timeout_seconds=timedelta(
seconds=context.tool_call_timeout,
),
read_timeout_seconds=timedelta(seconds=context.tool_call_timeout),
)
return res
+62 -5
View File
@@ -3,7 +3,7 @@
from typing import Any, ClassVar, Literal, cast
from pydantic import BaseModel, GetCoreSchemaHandler
from pydantic import BaseModel, GetCoreSchemaHandler, model_serializer, model_validator
from pydantic_core import core_schema
@@ -12,7 +12,7 @@ class ContentPart(BaseModel):
__content_part_registry: ClassVar[dict[str, type["ContentPart"]]] = {}
type: str
type: Literal["text", "think", "image_url", "audio_url"]
def __init_subclass__(cls, **kwargs: Any) -> None:
super().__init_subclass__(**kwargs)
@@ -63,6 +63,28 @@ class TextPart(ContentPart):
text: str
class ThinkPart(ContentPart):
"""
>>> ThinkPart(think="I think I need to think about this.").model_dump()
{'type': 'think', 'think': 'I think I need to think about this.', 'encrypted': None}
"""
type: str = "think"
think: str
encrypted: str | None = None
"""Encrypted thinking content, or signature."""
def merge_in_place(self, other: Any) -> bool:
if not isinstance(other, ThinkPart):
return False
if self.encrypted:
return False
self.think += other.think
if other.encrypted:
self.encrypted = other.encrypted
return True
class ImageURLPart(ContentPart):
"""
>>> ImageURLPart(image_url="http://example.com/image.jpg").model_dump()
@@ -119,6 +141,15 @@ class ToolCall(BaseModel):
"""The ID of the tool call."""
function: FunctionBody
"""The function body of the tool call."""
extra_content: dict[str, Any] | None = None
"""Extra metadata for the tool call."""
@model_serializer(mode="wrap")
def serialize(self, handler):
data = handler(self)
if self.extra_content is None:
data.pop("extra_content", None)
return data
class ToolCallPart(BaseModel):
@@ -138,22 +169,48 @@ class Message(BaseModel):
"tool",
]
content: str | list[ContentPart]
content: str | list[ContentPart] | None = None
"""The content of the message."""
tool_calls: list[ToolCall] | list[dict] | None = None
"""The tool calls of the message."""
tool_call_id: str | None = None
"""The ID of the tool call."""
@model_validator(mode="after")
def check_content_required(self):
# assistant + tool_calls is not None: allow content to be None
if self.role == "assistant" and self.tool_calls is not None:
return self
# other all cases: content is required
if self.content is None:
raise ValueError(
"content is required unless role='assistant' and tool_calls is not None"
)
return self
@model_serializer(mode="wrap")
def serialize(self, handler):
data = handler(self)
if self.tool_calls is None:
data.pop("tool_calls", None)
if self.tool_call_id is None:
data.pop("tool_call_id", None)
return data
class AssistantMessageSegment(Message):
"""A message segment from the assistant."""
role: Literal["assistant"] = "assistant"
tool_calls: list[ToolCall] | list[dict] | None = None
class ToolCallMessageSegment(Message):
"""A message segment representing a tool call."""
role: Literal["tool"] = "tool"
tool_call_id: str
class UserMessageSegment(Message):
+22 -1
View File
@@ -1,7 +1,8 @@
import typing as T
from dataclasses import dataclass
from dataclasses import dataclass, field
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import TokenUsage
class AgentResponseData(T.TypedDict):
@@ -12,3 +13,23 @@ class AgentResponseData(T.TypedDict):
class AgentResponse:
type: str
data: AgentResponseData
@dataclass
class AgentStats:
token_usage: TokenUsage = field(default_factory=TokenUsage)
start_time: float = 0.0
end_time: float = 0.0
time_to_first_token: float = 0.0
@property
def duration(self) -> float:
return self.end_time - self.start_time
def to_dict(self) -> dict:
return {
"token_usage": self.token_usage.__dict__,
"start_time": self.start_time,
"end_time": self.end_time,
"time_to_first_token": self.time_to_first_token,
}
+1 -1
View File
@@ -9,7 +9,7 @@ from .message import Message
TContext = TypeVar("TContext", default=Any)
@dataclass(config={"arbitrary_types_allowed": True})
@dataclass
class ContextWrapper(Generic[TContext]):
"""A context for running an agent, which can be used to pass additional data or state."""
+7 -4
View File
@@ -2,13 +2,12 @@ import abc
import typing as T
from enum import Enum, auto
from astrbot.core.provider import Provider
from astrbot import logger
from astrbot.core.provider.entities import LLMResponse
from ..hooks import BaseAgentRunHooks
from ..response import AgentResponse
from ..run_context import ContextWrapper, TContext
from ..tool_executor import BaseFunctionToolExecutor
class AgentState(Enum):
@@ -24,9 +23,7 @@ class BaseAgentRunner(T.Generic[TContext]):
@abc.abstractmethod
async def reset(
self,
provider: Provider,
run_context: ContextWrapper[TContext],
tool_executor: BaseFunctionToolExecutor[TContext],
agent_hooks: BaseAgentRunHooks[TContext],
**kwargs: T.Any,
) -> None:
@@ -60,3 +57,9 @@ class BaseAgentRunner(T.Generic[TContext]):
This method should be called after the agent is done.
"""
...
def _transition_state(self, new_state: AgentState) -> None:
"""Transition the agent state."""
if self._state != new_state:
logger.debug(f"Agent state transition: {self._state} -> {new_state}")
self._state = new_state
@@ -0,0 +1,367 @@
import base64
import json
import sys
import typing as T
import astrbot.core.message.components as Comp
from astrbot import logger
from astrbot.core import sp
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import (
LLMResponse,
ProviderRequest,
)
from ...hooks import BaseAgentRunHooks
from ...response import AgentResponseData
from ...run_context import ContextWrapper, TContext
from ..base import AgentResponse, AgentState, BaseAgentRunner
from .coze_api_client import CozeAPIClient
if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import override
class CozeAgentRunner(BaseAgentRunner[TContext]):
"""Coze Agent Runner"""
@override
async def reset(
self,
request: ProviderRequest,
run_context: ContextWrapper[TContext],
agent_hooks: BaseAgentRunHooks[TContext],
provider_config: dict,
**kwargs: T.Any,
) -> None:
self.req = request
self.streaming = kwargs.get("streaming", False)
self.final_llm_resp = None
self._state = AgentState.IDLE
self.agent_hooks = agent_hooks
self.run_context = run_context
self.api_key = provider_config.get("coze_api_key", "")
if not self.api_key:
raise Exception("Coze API Key 不能为空。")
self.bot_id = provider_config.get("bot_id", "")
if not self.bot_id:
raise Exception("Coze Bot ID 不能为空。")
self.api_base: str = provider_config.get("coze_api_base", "https://api.coze.cn")
if not isinstance(self.api_base, str) or not self.api_base.startswith(
("http://", "https://"),
):
raise Exception(
"Coze API Base URL 格式不正确,必须以 http:// 或 https:// 开头。",
)
self.timeout = provider_config.get("timeout", 120)
if isinstance(self.timeout, str):
self.timeout = int(self.timeout)
self.auto_save_history = provider_config.get("auto_save_history", True)
# 创建 API 客户端
self.api_client = CozeAPIClient(api_key=self.api_key, api_base=self.api_base)
# 会话相关缓存
self.file_id_cache: dict[str, dict[str, str]] = {}
@override
async def step(self):
"""
执行 Coze Agent 的一个步骤
"""
if not self.req:
raise ValueError("Request is not set. Please call reset() first.")
if self._state == AgentState.IDLE:
try:
await self.agent_hooks.on_agent_begin(self.run_context)
except Exception as e:
logger.error(f"Error in on_agent_begin hook: {e}", exc_info=True)
# 开始处理,转换到运行状态
self._transition_state(AgentState.RUNNING)
try:
# 执行 Coze 请求并处理结果
async for response in self._execute_coze_request():
yield response
except Exception as e:
logger.error(f"Coze 请求失败:{str(e)}")
self._transition_state(AgentState.ERROR)
self.final_llm_resp = LLMResponse(
role="err", completion_text=f"Coze 请求失败:{str(e)}"
)
yield AgentResponse(
type="err",
data=AgentResponseData(
chain=MessageChain().message(f"Coze 请求失败:{str(e)}")
),
)
finally:
await self.api_client.close()
@override
async def step_until_done(
self, max_step: int = 30
) -> T.AsyncGenerator[AgentResponse, None]:
while not self.done():
async for resp in self.step():
yield resp
async def _execute_coze_request(self):
"""执行 Coze 请求的核心逻辑"""
prompt = self.req.prompt or ""
session_id = self.req.session_id or "unknown"
image_urls = self.req.image_urls or []
contexts = self.req.contexts or []
system_prompt = self.req.system_prompt
# 用户ID参数
user_id = session_id
# 获取或创建会话ID
conversation_id = await sp.get_async(
scope="umo",
scope_id=user_id,
key="coze_conversation_id",
default="",
)
# 构建消息
additional_messages = []
if system_prompt:
if not self.auto_save_history or not conversation_id:
additional_messages.append(
{
"role": "system",
"content": system_prompt,
"content_type": "text",
},
)
# 处理历史上下文
if not self.auto_save_history and contexts:
for ctx in contexts:
if isinstance(ctx, dict) and "role" in ctx and "content" in ctx:
# 处理上下文中的图片
content = ctx["content"]
if isinstance(content, list):
# 多模态内容,需要处理图片
processed_content = []
for item in content:
if isinstance(item, dict):
if item.get("type") == "text":
processed_content.append(item)
elif item.get("type") == "image_url":
# 处理图片上传
try:
image_data = item.get("image_url", {})
url = image_data.get("url", "")
if url:
file_id = (
await self._download_and_upload_image(
url, session_id
)
)
processed_content.append(
{
"type": "file",
"file_id": file_id,
"file_url": url,
}
)
except Exception as e:
logger.warning(f"处理上下文图片失败: {e}")
continue
if processed_content:
additional_messages.append(
{
"role": ctx["role"],
"content": processed_content,
"content_type": "object_string",
}
)
else:
# 纯文本内容
additional_messages.append(
{
"role": ctx["role"],
"content": content,
"content_type": "text",
}
)
# 构建当前消息
if prompt or image_urls:
if image_urls:
# 多模态
object_string_content = []
if prompt:
object_string_content.append({"type": "text", "text": prompt})
for url in image_urls:
# the url is a base64 string
try:
image_data = base64.b64decode(url)
file_id = await self.api_client.upload_file(image_data)
object_string_content.append(
{
"type": "image",
"file_id": file_id,
}
)
except Exception as e:
logger.warning(f"处理图片失败 {url}: {e}")
continue
if object_string_content:
content = json.dumps(object_string_content, ensure_ascii=False)
additional_messages.append(
{
"role": "user",
"content": content,
"content_type": "object_string",
}
)
elif prompt:
# 纯文本
additional_messages.append(
{
"role": "user",
"content": prompt,
"content_type": "text",
},
)
# 执行 Coze API 请求
accumulated_content = ""
message_started = False
async for chunk in self.api_client.chat_messages(
bot_id=self.bot_id,
user_id=user_id,
additional_messages=additional_messages,
conversation_id=conversation_id,
auto_save_history=self.auto_save_history,
stream=True,
timeout=self.timeout,
):
event_type = chunk.get("event")
data = chunk.get("data", {})
if event_type == "conversation.chat.created":
if isinstance(data, dict) and "conversation_id" in data:
await sp.put_async(
scope="umo",
scope_id=user_id,
key="coze_conversation_id",
value=data["conversation_id"],
)
if event_type == "conversation.message.delta":
# 增量消息
content = data.get("content", "")
if not content and "delta" in data:
content = data["delta"].get("content", "")
if not content and "text" in data:
content = data.get("text", "")
if content:
accumulated_content += content
message_started = True
# 如果是流式响应,发送增量数据
if self.streaming:
yield AgentResponse(
type="streaming_delta",
data=AgentResponseData(
chain=MessageChain().message(content)
),
)
elif event_type == "conversation.message.completed":
# 消息完成
logger.debug("Coze message completed")
message_started = True
elif event_type == "conversation.chat.completed":
# 对话完成
logger.debug("Coze chat completed")
break
elif event_type == "error":
# 错误处理
error_msg = data.get("msg", "未知错误")
error_code = data.get("code", "UNKNOWN")
logger.error(f"Coze 出现错误: {error_code} - {error_msg}")
raise Exception(f"Coze 出现错误: {error_code} - {error_msg}")
if not message_started and not accumulated_content:
logger.warning("Coze 未返回任何内容")
accumulated_content = ""
# 创建最终响应
chain = MessageChain(chain=[Comp.Plain(accumulated_content)])
self.final_llm_resp = LLMResponse(role="assistant", result_chain=chain)
self._transition_state(AgentState.DONE)
try:
await self.agent_hooks.on_agent_done(self.run_context, self.final_llm_resp)
except Exception as e:
logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
# 返回最终结果
yield AgentResponse(
type="llm_result",
data=AgentResponseData(chain=chain),
)
async def _download_and_upload_image(
self,
image_url: str,
session_id: str | None = None,
) -> str:
"""下载图片并上传到 Coze,返回 file_id"""
import hashlib
# 计算哈希实现缓存
cache_key = hashlib.md5(image_url.encode("utf-8")).hexdigest()
if session_id:
if session_id not in self.file_id_cache:
self.file_id_cache[session_id] = {}
if cache_key in self.file_id_cache[session_id]:
file_id = self.file_id_cache[session_id][cache_key]
logger.debug(f"[Coze] 使用缓存的 file_id: {file_id}")
return file_id
try:
image_data = await self.api_client.download_image(image_url)
file_id = await self.api_client.upload_file(image_data)
if session_id:
self.file_id_cache[session_id][cache_key] = file_id
logger.debug(f"[Coze] 图片上传成功并缓存,file_id: {file_id}")
return file_id
except Exception as e:
logger.error(f"处理图片失败 {image_url}: {e!s}")
raise Exception(f"处理图片失败: {e!s}")
@override
def done(self) -> bool:
"""检查 Agent 是否已完成工作"""
return self._state in (AgentState.DONE, AgentState.ERROR)
@override
def get_final_llm_resp(self) -> LLMResponse | None:
return self.final_llm_resp
@@ -0,0 +1,403 @@
import asyncio
import functools
import queue
import re
import sys
import threading
import typing as T
from dashscope import Application
from dashscope.app.application_response import ApplicationResponse
import astrbot.core.message.components as Comp
from astrbot.core import logger, sp
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import (
LLMResponse,
ProviderRequest,
)
from ...hooks import BaseAgentRunHooks
from ...response import AgentResponseData
from ...run_context import ContextWrapper, TContext
from ..base import AgentResponse, AgentState, BaseAgentRunner
if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import override
class DashscopeAgentRunner(BaseAgentRunner[TContext]):
"""Dashscope Agent Runner"""
@override
async def reset(
self,
request: ProviderRequest,
run_context: ContextWrapper[TContext],
agent_hooks: BaseAgentRunHooks[TContext],
provider_config: dict,
**kwargs: T.Any,
) -> None:
self.req = request
self.streaming = kwargs.get("streaming", False)
self.final_llm_resp = None
self._state = AgentState.IDLE
self.agent_hooks = agent_hooks
self.run_context = run_context
self.api_key = provider_config.get("dashscope_api_key", "")
if not self.api_key:
raise Exception("阿里云百炼 API Key 不能为空。")
self.app_id = provider_config.get("dashscope_app_id", "")
if not self.app_id:
raise Exception("阿里云百炼 APP ID 不能为空。")
self.dashscope_app_type = provider_config.get("dashscope_app_type", "")
if not self.dashscope_app_type:
raise Exception("阿里云百炼 APP 类型不能为空。")
self.variables: dict = provider_config.get("variables", {}) or {}
self.rag_options: dict = provider_config.get("rag_options", {})
self.output_reference = self.rag_options.get("output_reference", False)
self.rag_options = self.rag_options.copy()
self.rag_options.pop("output_reference", None)
self.timeout = provider_config.get("timeout", 120)
if isinstance(self.timeout, str):
self.timeout = int(self.timeout)
def has_rag_options(self):
"""判断是否有 RAG 选项
Returns:
bool: 是否有 RAG 选项
"""
if self.rag_options and (
len(self.rag_options.get("pipeline_ids", [])) > 0
or len(self.rag_options.get("file_ids", [])) > 0
):
return True
return False
@override
async def step(self):
"""
执行 Dashscope Agent 的一个步骤
"""
if not self.req:
raise ValueError("Request is not set. Please call reset() first.")
if self._state == AgentState.IDLE:
try:
await self.agent_hooks.on_agent_begin(self.run_context)
except Exception as e:
logger.error(f"Error in on_agent_begin hook: {e}", exc_info=True)
# 开始处理,转换到运行状态
self._transition_state(AgentState.RUNNING)
try:
# 执行 Dashscope 请求并处理结果
async for response in self._execute_dashscope_request():
yield response
except Exception as e:
logger.error(f"阿里云百炼请求失败:{str(e)}")
self._transition_state(AgentState.ERROR)
self.final_llm_resp = LLMResponse(
role="err", completion_text=f"阿里云百炼请求失败:{str(e)}"
)
yield AgentResponse(
type="err",
data=AgentResponseData(
chain=MessageChain().message(f"阿里云百炼请求失败:{str(e)}")
),
)
@override
async def step_until_done(
self, max_step: int = 30
) -> T.AsyncGenerator[AgentResponse, None]:
while not self.done():
async for resp in self.step():
yield resp
def _consume_sync_generator(
self, response: T.Any, response_queue: queue.Queue
) -> None:
"""在线程中消费同步generator,将结果放入队列
Args:
response: 同步generator对象
response_queue: 用于传递数据的队列
"""
try:
if self.streaming:
for chunk in response:
response_queue.put(("data", chunk))
else:
response_queue.put(("data", response))
except Exception as e:
response_queue.put(("error", e))
finally:
response_queue.put(("done", None))
async def _process_stream_chunk(
self, chunk: ApplicationResponse, output_text: str
) -> tuple[str, list | None, AgentResponse | None]:
"""处理流式响应的单个chunk
Args:
chunk: Dashscope响应chunk
output_text: 当前累积的输出文本
Returns:
(更新后的output_text, doc_references, AgentResponse或None)
"""
logger.debug(f"dashscope stream chunk: {chunk}")
if chunk.status_code != 200:
logger.error(
f"阿里云百炼请求失败: request_id={chunk.request_id}, code={chunk.status_code}, message={chunk.message}, 请参考文档:https://help.aliyun.com/zh/model-studio/developer-reference/error-code",
)
self._transition_state(AgentState.ERROR)
error_msg = (
f"阿里云百炼请求失败: message={chunk.message} code={chunk.status_code}"
)
self.final_llm_resp = LLMResponse(
role="err",
result_chain=MessageChain().message(error_msg),
)
return (
output_text,
None,
AgentResponse(
type="err",
data=AgentResponseData(chain=MessageChain().message(error_msg)),
),
)
chunk_text = chunk.output.get("text", "") or ""
# RAG 引用脚标格式化
chunk_text = re.sub(r"<ref>\[(\d+)\]</ref>", r"[\1]", chunk_text)
response = None
if chunk_text:
output_text += chunk_text
response = AgentResponse(
type="streaming_delta",
data=AgentResponseData(chain=MessageChain().message(chunk_text)),
)
# 获取文档引用
doc_references = chunk.output.get("doc_references", None)
return output_text, doc_references, response
def _format_doc_references(self, doc_references: list) -> str:
"""格式化文档引用为文本
Args:
doc_references: 文档引用列表
Returns:
格式化后的引用文本
"""
ref_parts = []
for ref in doc_references:
ref_title = (
ref.get("title", "") if ref.get("title") else ref.get("doc_name", "")
)
ref_parts.append(f"{ref['index_id']}. {ref_title}\n")
ref_str = "".join(ref_parts)
return f"\n\n回答来源:\n{ref_str}"
async def _build_request_payload(
self, prompt: str, session_id: str, contexts: list, system_prompt: str
) -> dict:
"""构建请求payload
Args:
prompt: 用户输入
session_id: 会话ID
contexts: 上下文列表
system_prompt: 系统提示词
Returns:
请求payload字典
"""
conversation_id = await sp.get_async(
scope="umo",
scope_id=session_id,
key="dashscope_conversation_id",
default="",
)
# 获得会话变量
payload_vars = self.variables.copy()
session_var = await sp.get_async(
scope="umo",
scope_id=session_id,
key="session_variables",
default={},
)
payload_vars.update(session_var)
if (
self.dashscope_app_type in ["agent", "dialog-workflow"]
and not self.has_rag_options()
):
# 支持多轮对话的
p = {
"app_id": self.app_id,
"api_key": self.api_key,
"prompt": prompt,
"biz_params": payload_vars or None,
"stream": self.streaming,
"incremental_output": True,
}
if conversation_id:
p["session_id"] = conversation_id
return p
else:
# 不支持多轮对话的
payload = {
"app_id": self.app_id,
"prompt": prompt,
"api_key": self.api_key,
"biz_params": payload_vars or None,
"stream": self.streaming,
"incremental_output": True,
}
if self.rag_options:
payload["rag_options"] = self.rag_options
return payload
async def _handle_streaming_response(
self, response: T.Any, session_id: str
) -> T.AsyncGenerator[AgentResponse, None]:
"""处理流式响应
Args:
response: Dashscope 流式响应 generator
Yields:
AgentResponse 对象
"""
response_queue = queue.Queue()
consumer_thread = threading.Thread(
target=self._consume_sync_generator,
args=(response, response_queue),
daemon=True,
)
consumer_thread.start()
output_text = ""
doc_references = None
while True:
try:
item_type, item_data = await asyncio.get_event_loop().run_in_executor(
None, response_queue.get, True, 1
)
except queue.Empty:
continue
if item_type == "done":
break
elif item_type == "error":
raise item_data
elif item_type == "data":
chunk = item_data
assert isinstance(chunk, ApplicationResponse)
(
output_text,
chunk_doc_refs,
response,
) = await self._process_stream_chunk(chunk, output_text)
if response:
if response.type == "err":
yield response
return
yield response
if chunk_doc_refs:
doc_references = chunk_doc_refs
if chunk.output.session_id:
await sp.put_async(
scope="umo",
scope_id=session_id,
key="dashscope_conversation_id",
value=chunk.output.session_id,
)
# 添加 RAG 引用
if self.output_reference and doc_references:
ref_text = self._format_doc_references(doc_references)
output_text += ref_text
if self.streaming:
yield AgentResponse(
type="streaming_delta",
data=AgentResponseData(chain=MessageChain().message(ref_text)),
)
# 创建最终响应
chain = MessageChain(chain=[Comp.Plain(output_text)])
self.final_llm_resp = LLMResponse(role="assistant", result_chain=chain)
self._transition_state(AgentState.DONE)
try:
await self.agent_hooks.on_agent_done(self.run_context, self.final_llm_resp)
except Exception as e:
logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
# 返回最终结果
yield AgentResponse(
type="llm_result",
data=AgentResponseData(chain=chain),
)
async def _execute_dashscope_request(self):
"""执行 Dashscope 请求的核心逻辑"""
prompt = self.req.prompt or ""
session_id = self.req.session_id or "unknown"
image_urls = self.req.image_urls or []
contexts = self.req.contexts or []
system_prompt = self.req.system_prompt
# 检查图片输入
if image_urls:
logger.warning("阿里云百炼暂不支持图片输入,将自动忽略图片内容。")
# 构建请求payload
payload = await self._build_request_payload(
prompt, session_id, contexts, system_prompt
)
if not self.streaming:
payload["incremental_output"] = False
# 发起请求
partial = functools.partial(Application.call, **payload)
response = await asyncio.get_event_loop().run_in_executor(None, partial)
async for resp in self._handle_streaming_response(response, session_id):
yield resp
@override
def done(self) -> bool:
"""检查 Agent 是否已完成工作"""
return self._state in (AgentState.DONE, AgentState.ERROR)
@override
def get_final_llm_resp(self) -> LLMResponse | None:
return self.final_llm_resp
@@ -0,0 +1,336 @@
import base64
import os
import sys
import typing as T
import astrbot.core.message.components as Comp
from astrbot.core import logger, sp
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import (
LLMResponse,
ProviderRequest,
)
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.core.utils.io import download_file
from ...hooks import BaseAgentRunHooks
from ...response import AgentResponseData
from ...run_context import ContextWrapper, TContext
from ..base import AgentResponse, AgentState, BaseAgentRunner
from .dify_api_client import DifyAPIClient
if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import override
class DifyAgentRunner(BaseAgentRunner[TContext]):
"""Dify Agent Runner"""
@override
async def reset(
self,
request: ProviderRequest,
run_context: ContextWrapper[TContext],
agent_hooks: BaseAgentRunHooks[TContext],
provider_config: dict,
**kwargs: T.Any,
) -> None:
self.req = request
self.streaming = kwargs.get("streaming", False)
self.final_llm_resp = None
self._state = AgentState.IDLE
self.agent_hooks = agent_hooks
self.run_context = run_context
self.api_key = provider_config.get("dify_api_key", "")
self.api_base = provider_config.get("dify_api_base", "https://api.dify.ai/v1")
self.api_type = provider_config.get("dify_api_type", "chat")
self.workflow_output_key = provider_config.get(
"dify_workflow_output_key",
"astrbot_wf_output",
)
self.dify_query_input_key = provider_config.get(
"dify_query_input_key",
"astrbot_text_query",
)
self.variables: dict = provider_config.get("variables", {}) or {}
self.timeout = provider_config.get("timeout", 60)
if isinstance(self.timeout, str):
self.timeout = int(self.timeout)
self.api_client = DifyAPIClient(self.api_key, self.api_base)
@override
async def step(self):
"""
执行 Dify Agent 的一个步骤
"""
if not self.req:
raise ValueError("Request is not set. Please call reset() first.")
if self._state == AgentState.IDLE:
try:
await self.agent_hooks.on_agent_begin(self.run_context)
except Exception as e:
logger.error(f"Error in on_agent_begin hook: {e}", exc_info=True)
# 开始处理,转换到运行状态
self._transition_state(AgentState.RUNNING)
try:
# 执行 Dify 请求并处理结果
async for response in self._execute_dify_request():
yield response
except Exception as e:
logger.error(f"Dify 请求失败:{str(e)}")
self._transition_state(AgentState.ERROR)
self.final_llm_resp = LLMResponse(
role="err", completion_text=f"Dify 请求失败:{str(e)}"
)
yield AgentResponse(
type="err",
data=AgentResponseData(
chain=MessageChain().message(f"Dify 请求失败:{str(e)}")
),
)
finally:
await self.api_client.close()
@override
async def step_until_done(
self, max_step: int = 30
) -> T.AsyncGenerator[AgentResponse, None]:
while not self.done():
async for resp in self.step():
yield resp
async def _execute_dify_request(self):
"""执行 Dify 请求的核心逻辑"""
prompt = self.req.prompt or ""
session_id = self.req.session_id or "unknown"
image_urls = self.req.image_urls or []
system_prompt = self.req.system_prompt
conversation_id = await sp.get_async(
scope="umo",
scope_id=session_id,
key="dify_conversation_id",
default="",
)
result = ""
# 处理图片上传
files_payload = []
for image_url in image_urls:
# image_url is a base64 string
try:
image_data = base64.b64decode(image_url)
file_response = await self.api_client.file_upload(
file_data=image_data,
user=session_id,
mime_type="image/png",
file_name="image.png",
)
logger.debug(f"Dify 上传图片响应:{file_response}")
if "id" not in file_response:
logger.warning(
f"上传图片后得到未知的 Dify 响应:{file_response},图片将忽略。"
)
continue
files_payload.append(
{
"type": "image",
"transfer_method": "local_file",
"upload_file_id": file_response["id"],
}
)
except Exception as e:
logger.warning(f"上传图片失败:{e}")
continue
# 获得会话变量
payload_vars = self.variables.copy()
# 动态变量
session_var = await sp.get_async(
scope="umo",
scope_id=session_id,
key="session_variables",
default={},
)
payload_vars.update(session_var)
payload_vars["system_prompt"] = system_prompt
# 处理不同的 API 类型
match self.api_type:
case "chat" | "agent" | "chatflow":
if not prompt:
prompt = "请描述这张图片。"
async for chunk in self.api_client.chat_messages(
inputs={
**payload_vars,
},
query=prompt,
user=session_id,
conversation_id=conversation_id,
files=files_payload,
timeout=self.timeout,
):
logger.debug(f"dify resp chunk: {chunk}")
if chunk["event"] == "message" or chunk["event"] == "agent_message":
result += chunk["answer"]
if not conversation_id:
await sp.put_async(
scope="umo",
scope_id=session_id,
key="dify_conversation_id",
value=chunk["conversation_id"],
)
conversation_id = chunk["conversation_id"]
# 如果是流式响应,发送增量数据
if self.streaming and chunk["answer"]:
yield AgentResponse(
type="streaming_delta",
data=AgentResponseData(
chain=MessageChain().message(chunk["answer"])
),
)
elif chunk["event"] == "message_end":
logger.debug("Dify message end")
break
elif chunk["event"] == "error":
logger.error(f"Dify 出现错误:{chunk}")
raise Exception(
f"Dify 出现错误 status: {chunk['status']} message: {chunk['message']}"
)
case "workflow":
async for chunk in self.api_client.workflow_run(
inputs={
self.dify_query_input_key: prompt,
"astrbot_session_id": session_id,
**payload_vars,
},
user=session_id,
files=files_payload,
timeout=self.timeout,
):
logger.debug(f"dify workflow resp chunk: {chunk}")
match chunk["event"]:
case "workflow_started":
logger.info(
f"Dify 工作流(ID: {chunk['workflow_run_id']})开始运行。"
)
case "node_finished":
logger.debug(
f"Dify 工作流节点(ID: {chunk['data']['node_id']} Title: {chunk['data'].get('title', '')})运行结束。"
)
case "text_chunk":
if self.streaming and chunk["data"]["text"]:
yield AgentResponse(
type="streaming_delta",
data=AgentResponseData(
chain=MessageChain().message(
chunk["data"]["text"]
)
),
)
case "workflow_finished":
logger.info(
f"Dify 工作流(ID: {chunk['workflow_run_id']})运行结束"
)
logger.debug(f"Dify 工作流结果:{chunk}")
if chunk["data"]["error"]:
logger.error(
f"Dify 工作流出现错误:{chunk['data']['error']}"
)
raise Exception(
f"Dify 工作流出现错误:{chunk['data']['error']}"
)
if self.workflow_output_key not in chunk["data"]["outputs"]:
raise Exception(
f"Dify 工作流的输出不包含指定的键名:{self.workflow_output_key}"
)
result = chunk
case _:
raise Exception(f"未知的 Dify API 类型:{self.api_type}")
if not result:
logger.warning("Dify 请求结果为空,请查看 Debug 日志。")
# 解析结果
chain = await self.parse_dify_result(result)
# 创建最终响应
self.final_llm_resp = LLMResponse(role="assistant", result_chain=chain)
self._transition_state(AgentState.DONE)
try:
await self.agent_hooks.on_agent_done(self.run_context, self.final_llm_resp)
except Exception as e:
logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
# 返回最终结果
yield AgentResponse(
type="llm_result",
data=AgentResponseData(chain=chain),
)
async def parse_dify_result(self, chunk: dict | str) -> MessageChain:
"""解析 Dify 的响应结果"""
if isinstance(chunk, str):
# Chat
return MessageChain(chain=[Comp.Plain(chunk)])
async def parse_file(item: dict):
match item["type"]:
case "image":
return Comp.Image(file=item["url"], url=item["url"])
case "audio":
# 仅支持 wav
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
path = os.path.join(temp_dir, f"{item['filename']}.wav")
await download_file(item["url"], path)
return Comp.Image(file=item["url"], url=item["url"])
case "video":
return Comp.Video(file=item["url"])
case _:
return Comp.File(name=item["filename"], file=item["url"])
output = chunk["data"]["outputs"][self.workflow_output_key]
chains = []
if isinstance(output, str):
# 纯文本输出
chains.append(Comp.Plain(output))
elif isinstance(output, list):
# 主要适配 Dify 的 HTTP 请求结点的多模态输出
for item in output:
# handle Array[File]
if (
not isinstance(item, dict)
or item.get("dify_model_identity", "") != "__dify__file__"
):
chains.append(Comp.Plain(str(output)))
break
else:
chains.append(Comp.Plain(str(output)))
# scan file
files = chunk["data"].get("files", [])
for item in files:
comp = await parse_file(item)
chains.append(comp)
return MessageChain(chain=chains)
@override
def done(self) -> bool:
"""检查 Agent 是否已完成工作"""
return self._state in (AgentState.DONE, AgentState.ERROR)
@override
def get_final_llm_resp(self) -> LLMResponse | None:
return self.final_llm_resp
@@ -3,7 +3,7 @@ import json
from collections.abc import AsyncGenerator
from typing import Any
from aiohttp import ClientResponse, ClientSession
from aiohttp import ClientResponse, ClientSession, FormData
from astrbot.core import logger
@@ -101,21 +101,59 @@ class DifyAPIClient:
async def file_upload(
self,
file_path: str,
user: str,
file_path: str | None = None,
file_data: bytes | None = None,
file_name: str | None = None,
mime_type: str | None = None,
) -> dict[str, Any]:
"""Upload a file to Dify. Must provide either file_path or file_data.
Args:
user: The user ID.
file_path: The path to the file to upload.
file_data: The file data in bytes.
file_name: Optional file name when using file_data.
Returns:
A dictionary containing the uploaded file information.
"""
url = f"{self.api_base}/files/upload"
with open(file_path, "rb") as f:
payload = {
"user": user,
"file": f,
}
async with self.session.post(
url,
data=payload,
headers=self.headers,
) as resp:
return await resp.json() # {"id": "xxx", ...}
form = FormData()
form.add_field("user", user)
if file_data is not None:
# 使用 bytes 数据
form.add_field(
"file",
file_data,
filename=file_name or "uploaded_file",
content_type=mime_type or "application/octet-stream",
)
elif file_path is not None:
# 使用文件路径
import os
with open(file_path, "rb") as f:
file_content = f.read()
form.add_field(
"file",
file_content,
filename=os.path.basename(file_path),
content_type=mime_type or "application/octet-stream",
)
else:
raise ValueError("file_path 和 file_data 不能同时为 None")
async with self.session.post(
url,
data=form,
headers=self.headers, # 不包含 Content-Type,让 aiohttp 自动设置
) as resp:
if resp.status != 200 and resp.status != 201:
text = await resp.text()
raise Exception(f"Dify 文件上传失败:{resp.status}. {text}")
return await resp.json() # {"id": "xxx", ...}
async def close(self):
await self.session.close()
@@ -1,4 +1,6 @@
import copy
import sys
import time
import traceback
import typing as T
@@ -12,6 +14,9 @@ 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,
)
@@ -22,9 +27,13 @@ from astrbot.core.provider.entities import (
)
from astrbot.core.provider.provider import Provider
from ..context.compressor import ContextCompressor
from ..context.config import ContextConfig
from ..context.manager import ContextManager
from ..context.token_counter import TokenCounter
from ..hooks import BaseAgentRunHooks
from ..message import AssistantMessageSegment, Message, ToolCallMessageSegment
from ..response import AgentResponseData
from ..response import AgentResponseData, AgentStats
from ..run_context import ContextWrapper, TContext
from ..tool_executor import BaseFunctionToolExecutor
from .base import AgentResponse, AgentState, BaseAgentRunner
@@ -44,10 +53,48 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
run_context: ContextWrapper[TContext],
tool_executor: BaseFunctionToolExecutor[TContext],
agent_hooks: BaseAgentRunHooks[TContext],
streaming: bool = False,
# enforce max turns, will discard older turns when exceeded BEFORE compression
# -1 means no limit
enforce_max_turns: int = -1,
# llm compressor
llm_compress_instruction: str | None = None,
llm_compress_keep_recent: int = 0,
llm_compress_provider: Provider | None = None,
# truncate by turns compressor
truncate_turns: int = 1,
# 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
self.streaming = kwargs.get("streaming", False)
self.streaming = streaming
self.enforce_max_turns = enforce_max_turns
self.llm_compress_instruction = llm_compress_instruction
self.llm_compress_keep_recent = llm_compress_keep_recent
self.llm_compress_provider = llm_compress_provider
self.truncate_turns = truncate_turns
self.custom_token_counter = custom_token_counter
self.custom_compressor = custom_compressor
# we will do compress when:
# 1. before requesting LLM
# TODO: 2. after LLM output a tool call
self.context_config = ContextConfig(
# <=0 will never do compress
max_context_tokens=provider.provider_config.get("max_context_tokens", 0),
# enforce max turns before compression
enforce_max_turns=self.enforce_max_turns,
truncate_turns=self.truncate_turns,
llm_compress_instruction=self.llm_compress_instruction,
llm_compress_keep_recent=self.llm_compress_keep_recent,
llm_compress_provider=self.llm_compress_provider,
custom_token_counter=self.custom_token_counter,
custom_compressor=self.custom_compressor,
)
self.context_manager = ContextManager(self.context_config)
self.provider = provider
self.final_llm_resp = None
self._state = AgentState.IDLE
@@ -55,6 +102,24 @@ 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:
@@ -69,20 +134,25 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
)
self.run_context.messages = messages
def _transition_state(self, new_state: AgentState) -> None:
"""转换 Agent 状态"""
if self._state != new_state:
logger.debug(f"Agent state transition: {self._state} -> {new_state}")
self._state = new_state
self.stats = AgentStats()
self.stats.start_time = time.time()
async def _iter_llm_responses(self) -> T.AsyncGenerator[LLMResponse, None]:
"""Yields chunks *and* a final LLMResponse."""
payload = {
"contexts": self.run_context.messages, # list[Message]
"func_tool": self.req.func_tool,
"model": self.req.model, # NOTE: in fact, this arg is None in most cases
"session_id": self.req.session_id,
"extra_user_content_parts": self.req.extra_user_content_parts, # list[ContentPart]
}
if self.streaming:
stream = self.provider.text_chat_stream(**self.req.__dict__)
stream = self.provider.text_chat_stream(**payload)
async for resp in stream: # type: ignore
yield resp
else:
yield await self.provider.text_chat(**self.req.__dict__)
yield await self.provider.text_chat(**payload)
@override
async def step(self):
@@ -102,9 +172,18 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
self._transition_state(AgentState.RUNNING)
llm_resp_result = None
# do truncate and compress
token_usage = self.req.conversation.token_usage if self.req.conversation else 0
self.run_context.messages = await self.context_manager.process(
self.run_context.messages, trusted_token_usage=token_usage
)
async for llm_response in self._iter_llm_responses():
assert isinstance(llm_response, LLMResponse)
if llm_response.is_chunk:
# update ttft
if self.stats.time_to_first_token == 0:
self.stats.time_to_first_token = time.time() - self.stats.start_time
if llm_response.result_chain:
yield AgentResponse(
type="streaming_delta",
@@ -128,6 +207,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
)
continue
llm_resp_result = llm_response
if not llm_response.is_chunk and llm_response.usage:
# only count the token usage of the final response for computation purpose
self.stats.token_usage += llm_response.usage
break # got final response
if not llm_resp_result:
@@ -139,6 +222,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
if llm_resp.role == "err":
# 如果 LLM 响应错误,转换到错误状态
self.final_llm_resp = llm_resp
self.stats.end_time = time.time()
self._transition_state(AgentState.ERROR)
yield AgentResponse(
type="err",
@@ -153,13 +237,22 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
# 如果没有工具调用,转换到完成状态
self.final_llm_resp = llm_resp
self._transition_state(AgentState.DONE)
self.stats.end_time = time.time()
# record the final assistant message
self.run_context.messages.append(
Message(
role="assistant",
content=llm_resp.completion_text or "",
),
)
parts = []
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
parts.append(
ThinkPart(
think=llm_resp.reasoning_content,
encrypted=llm_resp.reasoning_signature,
)
)
if llm_resp.completion_text:
parts.append(TextPart(text=llm_resp.completion_text))
self.run_context.messages.append(Message(role="assistant", content=parts))
# call the on_agent_done hook
try:
await self.agent_hooks.on_agent_done(self.run_context, llm_resp)
except Exception as e:
@@ -181,30 +274,41 @@ 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 = []
for tool_call_name in llm_resp.tools_call_name:
yield AgentResponse(
type="tool_call",
data=AgentResponseData(
chain=MessageChain(type="tool_call").message(
f"🔨 调用工具: {tool_call_name}"
),
),
)
async for result in self._handle_function_tools(self.req, llm_resp):
if isinstance(result, list):
tool_call_result_blocks = result
elif isinstance(result, MessageChain):
result.type = "tool_call_result"
if result.type is None:
# should not happen
continue
if result.type == "tool_direct_result":
ar_type = "tool_call_result"
else:
ar_type = result.type
yield AgentResponse(
type="tool_call_result",
type=ar_type,
data=AgentResponseData(chain=result),
)
# 将结果添加到上下文中
parts = []
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
parts.append(
ThinkPart(
think=llm_resp.reasoning_content,
encrypted=llm_resp.reasoning_signature,
)
)
if llm_resp.completion_text:
parts.append(TextPart(text=llm_resp.completion_text))
tool_calls_result = ToolCallsResult(
tool_calls_info=AssistantMessageSegment(
tool_calls=llm_resp.to_openai_to_calls_model(),
content=llm_resp.completion_text,
content=parts,
),
tool_calls_result=tool_call_result_blocks,
)
@@ -225,6 +329,25 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
async for resp in self.step():
yield resp
# 如果循环结束了但是 agent 还没有完成,说明是达到了 max_step
if not self.done():
logger.warning(
f"Agent reached max steps ({max_step}), forcing a final response."
)
# 拔掉所有工具
if self.req:
self.req.func_tool = None
# 注入提示词
self.run_context.messages.append(
Message(
role="user",
content="工具调用次数已达到上限,请停止使用工具,并根据已经收集到的信息,对你的任务和发现进行总结,然后直接回复用户。",
)
)
# 再执行最后一步
async for resp in self.step():
yield resp
async def _handle_function_tools(
self,
req: ProviderRequest,
@@ -240,10 +363,23 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
llm_response.tools_call_args,
llm_response.tools_call_ids,
):
yield MessageChain(
type="tool_call",
chain=[
Json(
data={
"id": func_tool_id,
"name": func_tool_name,
"args": func_tool_args,
"ts": time.time(),
}
)
],
)
try:
if not req.func_tool:
return
func_tool = req.func_tool.get_func(func_tool_name)
func_tool = req.func_tool.get_tool(func_tool_name)
logger.info(f"使用工具:{func_tool_name},参数:{func_tool_args}")
if not func_tool:
@@ -252,7 +388,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content=f"error: 未找到工具 {func_tool_name}",
content=f"error: Tool {func_tool_name} not found.",
),
)
continue
@@ -313,13 +449,12 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
content=res.content[0].text,
),
)
yield MessageChain().message(res.content[0].text)
elif isinstance(res.content[0], ImageContent):
tool_call_result_blocks.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content="返回了图片(已直接发送给用户)",
content="The tool has successfully returned an image and sent directly to the user. You can describe it in your next response.",
),
)
yield MessageChain(type="tool_direct_result").base64_image(
@@ -335,7 +470,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
content=resource.text,
),
)
yield MessageChain().message(resource.text)
elif (
isinstance(resource, BlobResourceContents)
and resource.mimeType
@@ -345,7 +479,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content="返回了图片(已直接发送给用户)",
content="The tool has successfully returned an image and sent directly to the user. You can describe it in your next response.",
),
)
yield MessageChain(
@@ -356,23 +490,37 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content="返回的数据类型不受支持",
content="The tool has returned a data type that is not supported.",
),
)
yield MessageChain().message("返回的数据类型不受支持。")
elif resp is None:
# Tool 直接请求发送消息给用户
# 这里我们将直接结束 Agent Loop
# 发送消息逻辑在 ToolExecutor 中处理了
# 这里我们将直接结束 Agent Loop
# 发送消息逻辑在 ToolExecutor 中处理了
logger.warning(
f"{func_tool_name} 没有没有返回值或者将结果直接发送给用户,此工具调用不会被记录到历史中"
f"{func_tool_name} 没有返回值或者将结果直接发送给用户。"
)
self._transition_state(AgentState.DONE)
self.stats.end_time = time.time()
tool_call_result_blocks.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content="The tool has no return value, or has sent the result directly to the user.",
),
)
else:
# 不应该出现其他类型
logger.warning(
f"Tool 返回了不支持的类型: {type(resp)},将忽略",
f"Tool 返回了不支持的类型: {type(resp)}",
)
tool_call_result_blocks.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content="*The tool has returned an unsupported type. Please tell the user to check the definition and implementation of this tool.*",
),
)
try:
@@ -394,10 +542,91 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
),
)
# yield the last tool call result
if tool_call_result_blocks:
last_tcr_content = str(tool_call_result_blocks[-1].content)
yield MessageChain(
type="tool_call_result",
chain=[
Json(
data={
"id": func_tool_id,
"ts": time.time(),
"result": last_tcr_content,
}
)
],
)
# 处理函数调用响应
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)
+68 -22
View File
@@ -1,4 +1,5 @@
from collections.abc import Awaitable, Callable
import copy
from collections.abc import AsyncGenerator, Awaitable, Callable
from typing import Any, Generic
import jsonschema
@@ -7,6 +8,8 @@ from deprecated import deprecated
from pydantic import Field, model_validator
from pydantic.dataclasses import dataclass
from astrbot.core.message.message_event_result import MessageEventResult
from .run_context import ContextWrapper, TContext
ParametersType = dict[str, Any]
@@ -38,7 +41,10 @@ class ToolSchema:
class FunctionTool(ToolSchema, Generic[TContext]):
"""A callable tool, for function calling."""
handler: Callable[..., Awaitable[Any]] | None = None
handler: (
Callable[..., Awaitable[str | None] | AsyncGenerator[MessageEventResult, None]]
| None
) = None
"""a callable that implements the tool's functionality. It should be an async function."""
handler_module_path: str | None = None
@@ -97,6 +103,47 @@ 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,
@@ -142,18 +189,15 @@ 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,
"description": tool.description,
},
}
func_def = {"type": "function", "function": {"name": tool.name}}
if tool.description:
func_def["function"]["description"] = tool.description
if (
tool.parameters and tool.parameters.get("properties")
) or not omit_empty_parameter_field:
func_def["function"]["parameters"] = tool.parameters
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
result.append(func_def)
return result
@@ -166,11 +210,9 @@ class ToolSet:
if tool.parameters:
input_schema["properties"] = tool.parameters.get("properties", {})
input_schema["required"] = tool.parameters.get("required", [])
tool_def = {
"name": tool.name,
"description": tool.description,
"input_schema": input_schema,
}
tool_def = {"name": tool.name, "input_schema": input_schema}
if tool.description:
tool_def["description"] = tool.description
result.append(tool_def)
return result
@@ -240,10 +282,9 @@ class ToolSet:
tools = []
for tool in self.tools:
d: dict[str, Any] = {
"name": tool.name,
"description": tool.description,
}
d: dict[str, Any] = {"name": tool.name}
if tool.description:
d["description"] = tool.description
if tool.parameters:
d["parameters"] = convert_schema(tool.parameters)
tools.append(d)
@@ -269,6 +310,11 @@ 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)
+3 -1
View File
@@ -6,8 +6,10 @@ from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.star.context import Context
@dataclass(config={"arbitrary_types_allowed": True})
@dataclass
class AstrAgentContext:
__pydantic_config__ = {"arbitrary_types_allowed": True}
context: Context
"""The star context instance"""
event: AstrMessageEvent
+52
View File
@@ -3,6 +3,7 @@ from typing import Any
from mcp.types import CallToolResult
from astrbot.core.agent.hooks import BaseAgentRunHooks
from astrbot.core.agent.message import Message
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import FunctionTool
from astrbot.core.astr_agent_context import AstrAgentContext
@@ -13,12 +14,31 @@ from astrbot.core.star.star_handler import EventType
class MainAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
async def on_agent_done(self, run_context, llm_response):
# 执行事件钩子
if llm_response and llm_response.reasoning_content:
# we will use this in result_decorate stage to inject reasoning content to chain
run_context.context.event.set_extra(
"_llm_reasoning_content", llm_response.reasoning_content
)
await call_event_hook(
run_context.context.event,
EventType.OnLLMResponseEvent,
llm_response,
)
async def on_tool_start(
self,
run_context: ContextWrapper[AstrAgentContext],
tool: FunctionTool[Any],
tool_args: dict | None,
):
await call_event_hook(
run_context.context.event,
EventType.OnUsingLLMToolEvent,
tool,
tool_args,
)
async def on_tool_end(
self,
run_context: ContextWrapper[AstrAgentContext],
@@ -27,6 +47,38 @@ class MainAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
tool_result: CallToolResult | None,
):
run_context.context.event.clear_result()
await call_event_hook(
run_context.context.event,
EventType.OnLLMToolRespondEvent,
tool,
tool_args,
tool_result,
)
# special handle web_search_tavily
platform_name = run_context.context.event.get_platform_name()
if (
platform_name == "webchat"
and tool.name == "web_search_tavily"
and len(run_context.messages) > 0
and tool_result
and len(tool_result.content)
):
# inject system prompt
first_part = run_context.messages[0]
if (
isinstance(first_part, Message)
and first_part.role == "system"
and first_part.content
and isinstance(first_part.content, str)
):
# we assume system part is str
first_part.content += (
"Always cite web search results you rely on. "
"Index is a unique identifier for each search result. "
"Use the exact citation format <ref>index</ref> (e.g. <ref>abcd.3</ref>) "
"after the sentence that uses the information. Do not invent citations."
)
class EmptyAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
+299 -4
View File
@@ -1,14 +1,21 @@
import asyncio
import re
import time
import traceback
from collections.abc import AsyncGenerator
from astrbot.core import logger
from astrbot.core.agent.message import Message
from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.message.components import BaseMessageComponent, Json, Plain
from astrbot.core.message.message_event_result import (
MessageChain,
MessageEventResult,
ResultContentType,
)
from astrbot.core.provider.entities import LLMResponse
from astrbot.core.provider.provider import TTSProvider
AgentRunner = ToolLoopAgentRunner[AstrAgentContext]
@@ -22,8 +29,25 @@ async def run_agent(
) -> AsyncGenerator[MessageChain | None, None]:
step_idx = 0
astr_event = agent_runner.run_context.context.event
while step_idx < max_step:
while step_idx < max_step + 1:
step_idx += 1
if step_idx == max_step + 1:
logger.warning(
f"Agent reached max steps ({max_step}), forcing a final response."
)
if not agent_runner.done():
# 拔掉所有工具
if agent_runner.req:
agent_runner.req.func_tool = None
# 注入提示词
agent_runner.run_context.messages.append(
Message(
role="user",
content="工具调用次数已达到上限,请停止使用工具,并根据已经收集到的信息,对你的任务和发现进行总结,然后直接回复用户。",
)
)
try:
async for resp in agent_runner.step():
if astr_event.is_stopped():
@@ -32,16 +56,27 @@ async def run_agent(
msg_chain = resp.data["chain"]
if msg_chain.type == "tool_direct_result":
# tool_direct_result 用于标记 llm tool 需要直接发送给用户的内容
await astr_event.send(resp.data["chain"])
await astr_event.send(msg_chain)
continue
if astr_event.get_platform_id() == "webchat":
await astr_event.send(msg_chain)
# 对于其他情况,暂时先不处理
continue
elif resp.type == "tool_call":
if agent_runner.streaming:
# 用来标记流式响应需要分节
yield MessageChain(chain=[], type="break")
if show_tool_use:
if astr_event.get_platform_name() == "webchat":
await astr_event.send(resp.data["chain"])
elif show_tool_use:
json_comp = resp.data["chain"].chain[0]
if isinstance(json_comp, Json):
m = f"🔨 调用工具: {json_comp.data.get('name')}"
else:
m = "🔨 调用工具..."
chain = MessageChain(type="tool_call").message(m)
await astr_event.send(chain)
continue
if stream_to_general and resp.type == "streaming_delta":
@@ -68,13 +103,273 @@ async def run_agent(
continue
yield resp.data["chain"] # MessageChain
if agent_runner.done():
# send agent stats to webchat
if astr_event.get_platform_name() == "webchat":
await astr_event.send(
MessageChain(
type="agent_stats",
chain=[Json(data=agent_runner.stats.to_dict())],
)
)
break
except Exception as e:
logger.error(traceback.format_exc())
err_msg = f"\n\nAstrBot 请求失败。\n错误类型: {type(e).__name__}\n错误信息: {e!s}\n\n请在控制台查看和分享错误详情。\n"
err_msg = f"\n\nAstrBot 请求失败。\n错误类型: {type(e).__name__}\n错误信息: {e!s}\n\n请在平台日志查看和分享错误详情。\n"
error_llm_response = LLMResponse(
role="err",
completion_text=err_msg,
)
try:
await agent_runner.agent_hooks.on_agent_done(
agent_runner.run_context, error_llm_response
)
except Exception:
logger.exception("Error in on_agent_done hook")
if agent_runner.streaming:
yield MessageChain().message(err_msg)
else:
astr_event.set_result(MessageEventResult().message(err_msg))
return
async def run_live_agent(
agent_runner: AgentRunner,
tts_provider: TTSProvider | None = None,
max_step: int = 30,
show_tool_use: bool = True,
show_reasoning: bool = False,
) -> AsyncGenerator[MessageChain | None, None]:
"""Live Mode 的 Agent 运行器,支持流式 TTS
Args:
agent_runner: Agent 运行器
tts_provider: TTS Provider 实例
max_step: 最大步数
show_tool_use: 是否显示工具使用
show_reasoning: 是否显示推理过程
Yields:
MessageChain: 包含文本或音频数据的消息链
"""
# 如果没有 TTS Provider,直接发送文本
if not tts_provider:
async for chain in run_agent(
agent_runner,
max_step=max_step,
show_tool_use=show_tool_use,
stream_to_general=False,
show_reasoning=show_reasoning,
):
yield chain
return
support_stream = tts_provider.support_stream()
if support_stream:
logger.info("[Live Agent] 使用流式 TTS(原生支持 get_audio_stream")
else:
logger.info(
f"[Live Agent] 使用 TTS{tts_provider.meta().type} "
"使用 get_audio,将按句子分块生成音频)"
)
# 统计数据初始化
tts_start_time = time.time()
tts_first_frame_time = 0.0
first_chunk_received = False
# 创建队列
text_queue: asyncio.Queue[str | None] = asyncio.Queue()
# audio_queue stored bytes or (text, bytes)
audio_queue: asyncio.Queue[bytes | tuple[str, bytes] | None] = asyncio.Queue()
# 1. 启动 Agent Feeder 任务:负责运行 Agent 并将文本分句喂给 text_queue
feeder_task = asyncio.create_task(
_run_agent_feeder(
agent_runner, text_queue, max_step, show_tool_use, show_reasoning
)
)
# 2. 启动 TTS 任务:负责从 text_queue 读取文本并生成音频到 audio_queue
if support_stream:
tts_task = asyncio.create_task(
_safe_tts_stream_wrapper(tts_provider, text_queue, audio_queue)
)
else:
tts_task = asyncio.create_task(
_simulated_stream_tts(tts_provider, text_queue, audio_queue)
)
# 3. 主循环:从 audio_queue 读取音频并 yield
try:
while True:
queue_item = await audio_queue.get()
if queue_item is None:
break
text = None
if isinstance(queue_item, tuple):
text, audio_data = queue_item
else:
audio_data = queue_item
if not first_chunk_received:
# 记录首帧延迟(从开始处理到收到第一个音频块)
tts_first_frame_time = time.time() - tts_start_time
first_chunk_received = True
# 将音频数据封装为 MessageChain
import base64
audio_b64 = base64.b64encode(audio_data).decode("utf-8")
comps: list[BaseMessageComponent] = [Plain(audio_b64)]
if text:
comps.append(Json(data={"text": text}))
chain = MessageChain(chain=comps, type="audio_chunk")
yield chain
except Exception as e:
logger.error(f"[Live Agent] 运行时发生错误: {e}", exc_info=True)
finally:
# 清理任务
if not feeder_task.done():
feeder_task.cancel()
if not tts_task.done():
tts_task.cancel()
# 确保队列被消费
pass
tts_end_time = time.time()
# 发送 TTS 统计信息
try:
astr_event = agent_runner.run_context.context.event
if astr_event.get_platform_name() == "webchat":
tts_duration = tts_end_time - tts_start_time
await astr_event.send(
MessageChain(
type="tts_stats",
chain=[
Json(
data={
"tts_total_time": tts_duration,
"tts_first_frame_time": tts_first_frame_time,
"tts": tts_provider.meta().type,
"chat_model": agent_runner.provider.get_model(),
}
)
],
)
)
except Exception as e:
logger.error(f"发送 TTS 统计信息失败: {e}")
async def _run_agent_feeder(
agent_runner: AgentRunner,
text_queue: asyncio.Queue,
max_step: int,
show_tool_use: bool,
show_reasoning: bool,
):
"""运行 Agent 并将文本输出分句放入队列"""
buffer = ""
try:
async for chain in run_agent(
agent_runner,
max_step=max_step,
show_tool_use=show_tool_use,
stream_to_general=False,
show_reasoning=show_reasoning,
):
if chain is None:
continue
# 提取文本
text = chain.get_plain_text()
if text:
buffer += text
# 分句逻辑:匹配标点符号
# r"([.。!?\n]+)" 会保留分隔符
parts = re.split(r"([.。!?\n]+)", buffer)
if len(parts) > 1:
# 处理完整的句子
# range step 2 因为 split 后是 [text, delim, text, delim, ...]
temp_buffer = ""
for i in range(0, len(parts) - 1, 2):
sentence = parts[i]
delim = parts[i + 1]
full_sentence = sentence + delim
temp_buffer += full_sentence
if len(temp_buffer) >= 10:
if temp_buffer.strip():
logger.info(f"[Live Agent Feeder] 分句: {temp_buffer}")
await text_queue.put(temp_buffer)
temp_buffer = ""
# 更新 buffer 为剩余部分
buffer = temp_buffer + parts[-1]
# 处理剩余 buffer
if buffer.strip():
await text_queue.put(buffer)
except Exception as e:
logger.error(f"[Live Agent Feeder] Error: {e}", exc_info=True)
finally:
# 发送结束信号
await text_queue.put(None)
async def _safe_tts_stream_wrapper(
tts_provider: TTSProvider,
text_queue: asyncio.Queue[str | None],
audio_queue: "asyncio.Queue[bytes | tuple[str, bytes] | None]",
):
"""包装原生流式 TTS 确保异常处理和队列关闭"""
try:
await tts_provider.get_audio_stream(text_queue, audio_queue)
except Exception as e:
logger.error(f"[Live TTS Stream] Error: {e}", exc_info=True)
finally:
await audio_queue.put(None)
async def _simulated_stream_tts(
tts_provider: TTSProvider,
text_queue: asyncio.Queue[str | None],
audio_queue: "asyncio.Queue[bytes | tuple[str, bytes] | None]",
):
"""模拟流式 TTS 分句生成音频"""
try:
while True:
text = await text_queue.get()
if text is None:
break
try:
audio_path = await tts_provider.get_audio(text)
if audio_path:
with open(audio_path, "rb") as f:
audio_data = f.read()
await audio_queue.put((text, audio_data))
except Exception as e:
logger.error(
f"[Live TTS Simulated] Error processing text '{text[:20]}...': {e}"
)
# 继续处理下一句
except Exception as e:
logger.error(f"[Live TTS Simulated] Critical Error: {e}", exc_info=True)
finally:
await audio_queue.put(None)
+41 -7
View File
@@ -185,7 +185,11 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
async def call_local_llm_tool(
context: ContextWrapper[AstrAgentContext],
handler: T.Callable[..., T.Awaitable[T.Any]],
handler: T.Callable[
...,
T.Awaitable[MessageEventResult | mcp.types.CallToolResult | str | None]
| T.AsyncGenerator[MessageEventResult | CommandResult | str | None, None],
],
method_name: str,
*args,
**kwargs,
@@ -205,12 +209,42 @@ async def call_local_llm_tool(
else:
raise ValueError(f"未知的方法名: {method_name}")
except ValueError as e:
logger.error(f"调用本地 LLM 工具时出错: {e}", exc_info=True)
except TypeError:
logger.error("处理函数参数不匹配,请检查 handler 的定义。", exc_info=True)
raise Exception(f"Tool execution ValueError: {e}") from e
except TypeError as e:
# 获取函数的签名(包括类型),除了第一个 event/context 参数。
try:
sig = inspect.signature(handler)
params = list(sig.parameters.values())
# 跳过第一个参数(event 或 context
if params:
params = params[1:]
param_strs = []
for param in params:
param_str = param.name
if param.annotation != inspect.Parameter.empty:
# 获取类型注解的字符串表示
if isinstance(param.annotation, type):
type_str = param.annotation.__name__
else:
type_str = str(param.annotation)
param_str += f": {type_str}"
if param.default != inspect.Parameter.empty:
param_str += f" = {param.default!r}"
param_strs.append(param_str)
handler_param_str = (
", ".join(param_strs) if param_strs else "(no additional parameters)"
)
except Exception:
handler_param_str = "(unable to inspect signature)"
raise Exception(
f"Tool handler parameter mismatch, please check the handler definition. Handler parameters: {handler_param_str}"
) from e
except Exception as e:
trace_ = traceback.format_exc()
logger.error(f"调用本地 LLM 工具时出错: {e}\n{trace_}")
raise Exception(f"Tool execution error: {e}. Traceback: {trace_}") from e
if not ready_to_call:
return
@@ -222,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
@@ -239,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:
+26
View File
@@ -0,0 +1,26 @@
"""AstrBot 备份与恢复模块
提供数据导出和导入功能,支持用户在服务器迁移时一键备份和恢复所有数据。
"""
# 从 constants 模块导入共享常量
from .constants import (
BACKUP_MANIFEST_VERSION,
KB_METADATA_MODELS,
MAIN_DB_MODELS,
get_backup_directories,
)
# 导入导出器和导入器
from .exporter import AstrBotExporter
from .importer import AstrBotImporter, ImportPreCheckResult
__all__ = [
"AstrBotExporter",
"AstrBotImporter",
"ImportPreCheckResult",
"MAIN_DB_MODELS",
"KB_METADATA_MODELS",
"get_backup_directories",
"BACKUP_MANIFEST_VERSION",
]
+77
View File
@@ -0,0 +1,77 @@
"""AstrBot 备份模块共享常量
此文件定义了导出器和导入器共享的常量,确保两端配置一致。
"""
from sqlmodel import SQLModel
from astrbot.core.db.po import (
Attachment,
CommandConfig,
CommandConflict,
ConversationV2,
Persona,
PlatformMessageHistory,
PlatformSession,
PlatformStat,
Preference,
)
from astrbot.core.knowledge_base.models import (
KBDocument,
KBMedia,
KnowledgeBase,
)
from astrbot.core.utils.astrbot_path import (
get_astrbot_config_path,
get_astrbot_plugin_data_path,
get_astrbot_plugin_path,
get_astrbot_t2i_templates_path,
get_astrbot_temp_path,
get_astrbot_webchat_path,
)
# ============================================================
# 共享常量 - 确保导出和导入端配置一致
# ============================================================
# 主数据库模型类映射
MAIN_DB_MODELS: dict[str, type[SQLModel]] = {
"platform_stats": PlatformStat,
"conversations": ConversationV2,
"personas": Persona,
"preferences": Preference,
"platform_message_history": PlatformMessageHistory,
"platform_sessions": PlatformSession,
"attachments": Attachment,
"command_configs": CommandConfig,
"command_conflicts": CommandConflict,
}
# 知识库元数据模型类映射
KB_METADATA_MODELS: dict[str, type[SQLModel]] = {
"knowledge_bases": KnowledgeBase,
"kb_documents": KBDocument,
"kb_media": KBMedia,
}
def get_backup_directories() -> dict[str, str]:
"""获取需要备份的目录列表
使用 astrbot_path 模块动态获取路径,支持通过环境变量 ASTRBOT_ROOT 自定义根目录。
Returns:
dict: 键为备份文件中的目录名称,值为目录的绝对路径
"""
return {
"plugins": get_astrbot_plugin_path(), # 插件本体
"plugin_data": get_astrbot_plugin_data_path(), # 插件数据
"config": get_astrbot_config_path(), # 配置目录
"t2i_templates": get_astrbot_t2i_templates_path(), # T2I 模板
"webchat": get_astrbot_webchat_path(), # WebChat 数据
"temp": get_astrbot_temp_path(), # 临时文件
}
# 备份清单版本号
BACKUP_MANIFEST_VERSION = "1.1"
+477
View File
@@ -0,0 +1,477 @@
"""AstrBot 数据导出器
负责将所有数据导出为 ZIP 备份文件。
导出格式为 JSON,这是数据库无关的方案,支持未来向 MySQL/PostgreSQL 迁移。
"""
import hashlib
import json
import os
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Any
from sqlalchemy import select
from astrbot.core import logger
from astrbot.core.config.default import VERSION
from astrbot.core.db import BaseDatabase
from astrbot.core.utils.astrbot_path import (
get_astrbot_backups_path,
get_astrbot_data_path,
)
# 从共享常量模块导入
from .constants import (
BACKUP_MANIFEST_VERSION,
KB_METADATA_MODELS,
MAIN_DB_MODELS,
get_backup_directories,
)
if TYPE_CHECKING:
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
CMD_CONFIG_FILE_PATH = os.path.join(get_astrbot_data_path(), "cmd_config.json")
class AstrBotExporter:
"""AstrBot 数据导出器
导出内容:
- 主数据库所有表(data/data_v4.db
- 知识库元数据(data/knowledge_base/kb.db
- 每个知识库的向量文档数据
- 配置文件(data/cmd_config.json
- 附件文件
- 知识库多媒体文件
- 插件目录(data/plugins
- 插件数据目录(data/plugin_data
- 配置目录(data/config
- T2I 模板目录(data/t2i_templates
- WebChat 数据目录(data/webchat
- 临时文件目录(data/temp
"""
def __init__(
self,
main_db: BaseDatabase,
kb_manager: "KnowledgeBaseManager | None" = None,
config_path: str = CMD_CONFIG_FILE_PATH,
):
self.main_db = main_db
self.kb_manager = kb_manager
self.config_path = config_path
self._checksums: dict[str, str] = {}
async def export_all(
self,
output_dir: str | None = None,
progress_callback: Any | None = None,
) -> str:
"""导出所有数据到 ZIP 文件
Args:
output_dir: 输出目录
progress_callback: 进度回调函数,接收参数 (stage, current, total, message)
Returns:
str: 生成的 ZIP 文件路径
"""
if output_dir is None:
output_dir = get_astrbot_backups_path()
# 确保输出目录存在
Path(output_dir).mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
zip_filename = f"astrbot_backup_{timestamp}.zip"
zip_path = os.path.join(output_dir, zip_filename)
logger.info(f"开始导出备份到 {zip_path}")
try:
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
# 1. 导出主数据库
if progress_callback:
await progress_callback("main_db", 0, 100, "正在导出主数据库...")
main_data = await self._export_main_database()
main_db_json = json.dumps(
main_data, ensure_ascii=False, indent=2, default=str
)
zf.writestr("databases/main_db.json", main_db_json)
self._add_checksum("databases/main_db.json", main_db_json)
if progress_callback:
await progress_callback("main_db", 100, 100, "主数据库导出完成")
# 2. 导出知识库数据
kb_meta_data: dict[str, Any] = {
"knowledge_bases": [],
"kb_documents": [],
"kb_media": [],
}
if self.kb_manager:
if progress_callback:
await progress_callback(
"kb_metadata", 0, 100, "正在导出知识库元数据..."
)
kb_meta_data = await self._export_kb_metadata()
kb_meta_json = json.dumps(
kb_meta_data, ensure_ascii=False, indent=2, default=str
)
zf.writestr("databases/kb_metadata.json", kb_meta_json)
self._add_checksum("databases/kb_metadata.json", kb_meta_json)
if progress_callback:
await progress_callback(
"kb_metadata", 100, 100, "知识库元数据导出完成"
)
# 导出每个知识库的文档数据
kb_insts = self.kb_manager.kb_insts
total_kbs = len(kb_insts)
for idx, (kb_id, kb_helper) in enumerate(kb_insts.items()):
if progress_callback:
await progress_callback(
"kb_documents",
idx,
total_kbs,
f"正在导出知识库 {kb_helper.kb.kb_name} 的文档数据...",
)
doc_data = await self._export_kb_documents(kb_helper)
doc_json = json.dumps(
doc_data, ensure_ascii=False, indent=2, default=str
)
doc_path = f"databases/kb_{kb_id}/documents.json"
zf.writestr(doc_path, doc_json)
self._add_checksum(doc_path, doc_json)
# 导出 FAISS 索引文件
await self._export_faiss_index(zf, kb_helper, kb_id)
# 导出知识库多媒体文件
await self._export_kb_media_files(zf, kb_helper, kb_id)
if progress_callback:
await progress_callback(
"kb_documents", total_kbs, total_kbs, "知识库文档导出完成"
)
# 3. 导出配置文件
if progress_callback:
await progress_callback("config", 0, 100, "正在导出配置文件...")
if os.path.exists(self.config_path):
with open(self.config_path, encoding="utf-8") as f:
config_content = f.read()
zf.writestr("config/cmd_config.json", config_content)
self._add_checksum("config/cmd_config.json", config_content)
if progress_callback:
await progress_callback("config", 100, 100, "配置文件导出完成")
# 4. 导出附件文件
if progress_callback:
await progress_callback("attachments", 0, 100, "正在导出附件...")
await self._export_attachments(zf, main_data.get("attachments", []))
if progress_callback:
await progress_callback("attachments", 100, 100, "附件导出完成")
# 5. 导出插件和其他目录
if progress_callback:
await progress_callback(
"directories", 0, 100, "正在导出插件和数据目录..."
)
dir_stats = await self._export_directories(zf)
if progress_callback:
await progress_callback("directories", 100, 100, "目录导出完成")
# 6. 生成 manifest
if progress_callback:
await progress_callback("manifest", 0, 100, "正在生成清单...")
manifest = self._generate_manifest(main_data, kb_meta_data, dir_stats)
manifest_json = json.dumps(manifest, ensure_ascii=False, indent=2)
zf.writestr("manifest.json", manifest_json)
if progress_callback:
await progress_callback("manifest", 100, 100, "清单生成完成")
logger.info(f"备份导出完成: {zip_path}")
return zip_path
except Exception as e:
logger.error(f"备份导出失败: {e}")
# 清理失败的文件
if os.path.exists(zip_path):
os.remove(zip_path)
raise
async def _export_main_database(self) -> dict[str, list[dict]]:
"""导出主数据库所有表"""
export_data: dict[str, list[dict]] = {}
async with self.main_db.get_db() as session:
for table_name, model_class in MAIN_DB_MODELS.items():
try:
result = await session.execute(select(model_class))
records = result.scalars().all()
export_data[table_name] = [
self._model_to_dict(record) for record in records
]
logger.debug(
f"导出表 {table_name}: {len(export_data[table_name])} 条记录"
)
except Exception as e:
logger.warning(f"导出表 {table_name} 失败: {e}")
export_data[table_name] = []
return export_data
async def _export_kb_metadata(self) -> dict[str, list[dict]]:
"""导出知识库元数据库"""
if not self.kb_manager:
return {"knowledge_bases": [], "kb_documents": [], "kb_media": []}
export_data: dict[str, list[dict]] = {}
async with self.kb_manager.kb_db.get_db() as session:
for table_name, model_class in KB_METADATA_MODELS.items():
try:
result = await session.execute(select(model_class))
records = result.scalars().all()
export_data[table_name] = [
self._model_to_dict(record) for record in records
]
logger.debug(
f"导出知识库表 {table_name}: {len(export_data[table_name])} 条记录"
)
except Exception as e:
logger.warning(f"导出知识库表 {table_name} 失败: {e}")
export_data[table_name] = []
return export_data
async def _export_kb_documents(self, kb_helper: Any) -> dict[str, Any]:
"""导出知识库的文档块数据"""
try:
from astrbot.core.db.vec_db.faiss_impl.vec_db import FaissVecDB
vec_db: FaissVecDB = kb_helper.vec_db
if not vec_db or not vec_db.document_storage:
return {"documents": []}
# 获取所有文档
docs = await vec_db.document_storage.get_documents(
metadata_filters={},
offset=0,
limit=None, # 获取全部
)
return {"documents": docs}
except Exception as e:
logger.warning(f"导出知识库文档失败: {e}")
return {"documents": []}
async def _export_faiss_index(
self,
zf: zipfile.ZipFile,
kb_helper: Any,
kb_id: str,
) -> None:
"""导出 FAISS 索引文件"""
try:
index_path = kb_helper.kb_dir / "index.faiss"
if index_path.exists():
archive_path = f"databases/kb_{kb_id}/index.faiss"
zf.write(str(index_path), archive_path)
logger.debug(f"导出 FAISS 索引: {archive_path}")
except Exception as e:
logger.warning(f"导出 FAISS 索引失败: {e}")
async def _export_kb_media_files(
self, zf: zipfile.ZipFile, kb_helper: Any, kb_id: str
) -> None:
"""导出知识库的多媒体文件"""
try:
media_dir = kb_helper.kb_medias_dir
if not media_dir.exists():
return
for root, _, files in os.walk(media_dir):
for file in files:
file_path = Path(root) / file
# 计算相对路径
rel_path = file_path.relative_to(kb_helper.kb_dir)
archive_path = f"files/kb_media/{kb_id}/{rel_path}"
zf.write(str(file_path), archive_path)
except Exception as e:
logger.warning(f"导出知识库媒体文件失败: {e}")
async def _export_directories(
self, zf: zipfile.ZipFile
) -> dict[str, dict[str, int]]:
"""导出插件和其他数据目录
Returns:
dict: 每个目录的统计信息 {dir_name: {"files": count, "size": bytes}}
"""
stats: dict[str, dict[str, int]] = {}
backup_directories = get_backup_directories()
for dir_name, dir_path in backup_directories.items():
full_path = Path(dir_path)
if not full_path.exists():
logger.debug(f"目录不存在,跳过: {full_path}")
continue
file_count = 0
total_size = 0
try:
for root, dirs, files in os.walk(full_path):
# 跳过 __pycache__ 目录
dirs[:] = [d for d in dirs if d != "__pycache__"]
for file in files:
# 跳过 .pyc 文件
if file.endswith(".pyc"):
continue
file_path = Path(root) / file
try:
# 计算相对路径
rel_path = file_path.relative_to(full_path)
archive_path = f"directories/{dir_name}/{rel_path}"
zf.write(str(file_path), archive_path)
file_count += 1
total_size += file_path.stat().st_size
except Exception as e:
logger.warning(f"导出文件 {file_path} 失败: {e}")
stats[dir_name] = {"files": file_count, "size": total_size}
logger.debug(
f"导出目录 {dir_name}: {file_count} 个文件, {total_size} 字节"
)
except Exception as e:
logger.warning(f"导出目录 {dir_path} 失败: {e}")
stats[dir_name] = {"files": 0, "size": 0}
return stats
async def _export_attachments(
self, zf: zipfile.ZipFile, attachments: list[dict]
) -> None:
"""导出附件文件"""
for attachment in attachments:
try:
file_path = attachment.get("path", "")
if file_path and os.path.exists(file_path):
# 使用 attachment_id 作为文件名
attachment_id = attachment.get("attachment_id", "")
ext = os.path.splitext(file_path)[1]
archive_path = f"files/attachments/{attachment_id}{ext}"
zf.write(file_path, archive_path)
except Exception as e:
logger.warning(f"导出附件失败: {e}")
def _model_to_dict(self, record: Any) -> dict:
"""将 SQLModel 实例转换为字典
这是数据库无关的序列化方式,支持未来迁移到其他数据库。
"""
# 使用 SQLModel 内置的 model_dump 方法(如果可用)
if hasattr(record, "model_dump"):
data = record.model_dump(mode="python")
# 处理 datetime 类型
for key, value in data.items():
if isinstance(value, datetime):
data[key] = value.isoformat()
return data
# 回退到手动提取
data = {}
# 使用 inspect 获取表信息
from sqlalchemy import inspect as sa_inspect
mapper = sa_inspect(record.__class__)
for column in mapper.columns:
value = getattr(record, column.name)
# 处理 datetime 类型 - 统一转为 ISO 格式字符串
if isinstance(value, datetime):
value = value.isoformat()
data[column.name] = value
return data
def _add_checksum(self, path: str, content: str | bytes) -> None:
"""计算并添加文件校验和"""
if isinstance(content, str):
content = content.encode("utf-8")
checksum = hashlib.sha256(content).hexdigest()
self._checksums[path] = f"sha256:{checksum}"
def _generate_manifest(
self,
main_data: dict[str, list[dict]],
kb_meta_data: dict[str, list[dict]],
dir_stats: dict[str, dict[str, int]] | None = None,
) -> dict:
"""生成备份清单"""
if dir_stats is None:
dir_stats = {}
# 收集知识库 ID
kb_document_tables = {}
if self.kb_manager:
for kb_id in self.kb_manager.kb_insts.keys():
kb_document_tables[kb_id] = "documents"
# 收集附件文件列表
attachment_files = []
for attachment in main_data.get("attachments", []):
attachment_id = attachment.get("attachment_id", "")
path = attachment.get("path", "")
if attachment_id and path:
ext = os.path.splitext(path)[1]
attachment_files.append(f"{attachment_id}{ext}")
# 收集知识库媒体文件
kb_media_files: dict[str, list[str]] = {}
if self.kb_manager:
for kb_id, kb_helper in self.kb_manager.kb_insts.items():
media_files: list[str] = []
media_dir = kb_helper.kb_medias_dir
if media_dir.exists():
for root, _, files in os.walk(media_dir):
for file in files:
media_files.append(file)
if media_files:
kb_media_files[kb_id] = media_files
manifest = {
"version": BACKUP_MANIFEST_VERSION,
"astrbot_version": VERSION,
"exported_at": datetime.now(timezone.utc).isoformat(),
"origin": "exported", # 标记备份来源:exported=本实例导出, uploaded=用户上传
"schema_version": {
"main_db": "v4",
"kb_db": "v1",
},
"tables": {
"main_db": list(main_data.keys()),
"kb_metadata": list(kb_meta_data.keys()),
"kb_documents": kb_document_tables,
},
"files": {
"attachments": attachment_files,
"kb_media": kb_media_files,
},
"directories": list(dir_stats.keys()),
"checksums": self._checksums,
"statistics": {
"main_db": {
table: len(records) for table, records in main_data.items()
},
"kb_metadata": {
table: len(records) for table, records in kb_meta_data.items()
},
"directories": dir_stats,
},
}
return manifest
+761
View File
@@ -0,0 +1,761 @@
"""AstrBot 数据导入器
负责从 ZIP 备份文件恢复所有数据。
导入时进行版本校验:
- 主版本(前两位)不同时直接拒绝导入
- 小版本(第三位)不同时提示警告,用户可选择强制导入
- 版本匹配时也需要用户确认
"""
import json
import os
import shutil
import zipfile
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
from sqlalchemy import delete
from astrbot.core import logger
from astrbot.core.config.default import VERSION
from astrbot.core.db import BaseDatabase
from astrbot.core.utils.astrbot_path import (
get_astrbot_data_path,
get_astrbot_knowledge_base_path,
)
from astrbot.core.utils.version_comparator import VersionComparator
# 从共享常量模块导入
from .constants import (
KB_METADATA_MODELS,
MAIN_DB_MODELS,
get_backup_directories,
)
if TYPE_CHECKING:
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
def _get_major_version(version_str: str) -> str:
"""提取版本的主版本部分(前两位)
Args:
version_str: 版本字符串,如 "4.9.1", "4.10.0-beta"
Returns:
主版本字符串,如 "4.9", "4.10"
"""
if not version_str:
return "0.0"
# 移除 v 前缀和预发布标签
version = version_str.lower().replace("v", "").split("-")[0].split("+")[0]
parts = [p for p in version.split(".") if p] # 过滤空字符串
if len(parts) >= 2:
return f"{parts[0]}.{parts[1]}"
elif len(parts) == 1 and parts[0]:
return f"{parts[0]}.0"
return "0.0"
CMD_CONFIG_FILE_PATH = os.path.join(get_astrbot_data_path(), "cmd_config.json")
KB_PATH = get_astrbot_knowledge_base_path()
@dataclass
class ImportPreCheckResult:
"""导入预检查结果
用于在实际导入前检查备份文件的版本兼容性,
并返回确认信息让用户决定是否继续导入。
"""
# 检查是否通过(文件有效且版本可导入)
valid: bool = False
# 是否可以导入(版本兼容)
can_import: bool = False
# 版本状态: match(完全匹配), minor_diff(小版本差异), major_diff(主版本不同,拒绝)
version_status: str = ""
# 备份文件中的 AstrBot 版本
backup_version: str = ""
# 当前运行的 AstrBot 版本
current_version: str = VERSION
# 备份创建时间
backup_time: str = ""
# 确认消息(显示给用户)
confirm_message: str = ""
# 警告消息列表
warnings: list[str] = field(default_factory=list)
# 错误消息(如果检查失败)
error: str = ""
# 备份包含的内容摘要
backup_summary: dict = field(default_factory=dict)
def to_dict(self) -> dict:
return {
"valid": self.valid,
"can_import": self.can_import,
"version_status": self.version_status,
"backup_version": self.backup_version,
"current_version": self.current_version,
"backup_time": self.backup_time,
"confirm_message": self.confirm_message,
"warnings": self.warnings,
"error": self.error,
"backup_summary": self.backup_summary,
}
class ImportResult:
"""导入结果"""
def __init__(self):
self.success = True
self.imported_tables: dict[str, int] = {}
self.imported_files: dict[str, int] = {}
self.imported_directories: dict[str, int] = {}
self.warnings: list[str] = []
self.errors: list[str] = []
def add_warning(self, msg: str) -> None:
self.warnings.append(msg)
logger.warning(msg)
def add_error(self, msg: str) -> None:
self.errors.append(msg)
self.success = False
logger.error(msg)
def to_dict(self) -> dict:
return {
"success": self.success,
"imported_tables": self.imported_tables,
"imported_files": self.imported_files,
"imported_directories": self.imported_directories,
"warnings": self.warnings,
"errors": self.errors,
}
class AstrBotImporter:
"""AstrBot 数据导入器
导入备份文件中的所有数据,包括:
- 主数据库所有表
- 知识库元数据和文档
- 配置文件
- 附件文件
- 知识库多媒体文件
- 插件目录(data/plugins
- 插件数据目录(data/plugin_data
- 配置目录(data/config
- T2I 模板目录(data/t2i_templates
- WebChat 数据目录(data/webchat
- 临时文件目录(data/temp
"""
def __init__(
self,
main_db: BaseDatabase,
kb_manager: "KnowledgeBaseManager | None" = None,
config_path: str = CMD_CONFIG_FILE_PATH,
kb_root_dir: str = KB_PATH,
):
self.main_db = main_db
self.kb_manager = kb_manager
self.config_path = config_path
self.kb_root_dir = kb_root_dir
def pre_check(self, zip_path: str) -> ImportPreCheckResult:
"""预检查备份文件
在实际导入前检查备份文件的有效性和版本兼容性。
返回检查结果供前端显示确认对话框。
Args:
zip_path: ZIP 备份文件路径
Returns:
ImportPreCheckResult: 预检查结果
"""
result = ImportPreCheckResult()
result.current_version = VERSION
if not os.path.exists(zip_path):
result.error = f"备份文件不存在: {zip_path}"
return result
try:
with zipfile.ZipFile(zip_path, "r") as zf:
# 读取 manifest
try:
manifest_data = zf.read("manifest.json")
manifest = json.loads(manifest_data)
except KeyError:
result.error = "备份文件缺少 manifest.json,不是有效的 AstrBot 备份"
return result
except json.JSONDecodeError as e:
result.error = f"manifest.json 格式错误: {e}"
return result
# 提取基本信息
result.backup_version = manifest.get("astrbot_version", "未知")
result.backup_time = manifest.get("exported_at", "未知")
result.valid = True
# 构建备份摘要
result.backup_summary = {
"tables": list(manifest.get("tables", {}).keys()),
"has_knowledge_bases": manifest.get("has_knowledge_bases", False),
"has_config": manifest.get("has_config", False),
"directories": manifest.get("directories", []),
}
# 检查版本兼容性
version_check = self._check_version_compatibility(result.backup_version)
result.version_status = version_check["status"]
result.can_import = version_check["can_import"]
# 版本信息由前端根据 version_status 和 i18n 生成显示
# 不再将版本消息添加到 warnings 列表中,避免中文硬编码
# warnings 列表保留用于其他非版本相关的警告
return result
except zipfile.BadZipFile:
result.error = "无效的 ZIP 文件"
return result
except Exception as e:
result.error = f"检查备份文件失败: {e}"
return result
def _check_version_compatibility(self, backup_version: str) -> dict:
"""检查版本兼容性
规则:
- 主版本(前两位,如 4.9)必须一致,否则拒绝
- 小版本(第三位,如 4.9.1 vs 4.9.2)不同时,警告但允许导入
Returns:
dict: {status, can_import, message}
"""
if not backup_version:
return {
"status": "major_diff",
"can_import": False,
"message": "备份文件缺少版本信息",
}
# 提取主版本(前两位)进行比较
backup_major = _get_major_version(backup_version)
current_major = _get_major_version(VERSION)
# 比较主版本
if VersionComparator.compare_version(backup_major, current_major) != 0:
return {
"status": "major_diff",
"can_import": False,
"message": (
f"主版本不兼容: 备份版本 {backup_version}, 当前版本 {VERSION}"
f"跨主版本导入可能导致数据损坏,请使用相同主版本的 AstrBot。"
),
}
# 比较完整版本
version_cmp = VersionComparator.compare_version(backup_version, VERSION)
if version_cmp != 0:
return {
"status": "minor_diff",
"can_import": True,
"message": (
f"小版本差异: 备份版本 {backup_version}, 当前版本 {VERSION}"
),
}
return {
"status": "match",
"can_import": True,
"message": "版本匹配",
}
async def import_all(
self,
zip_path: str,
mode: str = "replace", # "replace" 清空后导入
progress_callback: Any | None = None,
) -> ImportResult:
"""从 ZIP 文件导入所有数据
Args:
zip_path: ZIP 备份文件路径
mode: 导入模式,目前仅支持 "replace"(清空后导入)
progress_callback: 进度回调函数,接收参数 (stage, current, total, message)
Returns:
ImportResult: 导入结果
"""
result = ImportResult()
if not os.path.exists(zip_path):
result.add_error(f"备份文件不存在: {zip_path}")
return result
logger.info(f"开始从 {zip_path} 导入备份")
try:
with zipfile.ZipFile(zip_path, "r") as zf:
# 1. 读取并验证 manifest
if progress_callback:
await progress_callback("validate", 0, 100, "正在验证备份文件...")
try:
manifest_data = zf.read("manifest.json")
manifest = json.loads(manifest_data)
except KeyError:
result.add_error("备份文件缺少 manifest.json")
return result
except json.JSONDecodeError as e:
result.add_error(f"manifest.json 格式错误: {e}")
return result
# 版本校验
try:
self._validate_version(manifest)
except ValueError as e:
result.add_error(str(e))
return result
if progress_callback:
await progress_callback("validate", 100, 100, "验证完成")
# 2. 导入主数据库
if progress_callback:
await progress_callback("main_db", 0, 100, "正在导入主数据库...")
try:
main_data_content = zf.read("databases/main_db.json")
main_data = json.loads(main_data_content)
if mode == "replace":
await self._clear_main_db()
imported = await self._import_main_database(main_data)
result.imported_tables.update(imported)
except Exception as e:
result.add_error(f"导入主数据库失败: {e}")
return result
if progress_callback:
await progress_callback("main_db", 100, 100, "主数据库导入完成")
# 3. 导入知识库
if self.kb_manager and "databases/kb_metadata.json" in zf.namelist():
if progress_callback:
await progress_callback("kb", 0, 100, "正在导入知识库...")
try:
kb_meta_content = zf.read("databases/kb_metadata.json")
kb_meta_data = json.loads(kb_meta_content)
if mode == "replace":
await self._clear_kb_data()
await self._import_knowledge_bases(zf, kb_meta_data, result)
except Exception as e:
result.add_warning(f"导入知识库失败: {e}")
if progress_callback:
await progress_callback("kb", 100, 100, "知识库导入完成")
# 4. 导入配置文件
if progress_callback:
await progress_callback("config", 0, 100, "正在导入配置文件...")
if "config/cmd_config.json" in zf.namelist():
try:
config_content = zf.read("config/cmd_config.json")
# 备份现有配置
if os.path.exists(self.config_path):
backup_path = f"{self.config_path}.bak"
shutil.copy2(self.config_path, backup_path)
with open(self.config_path, "wb") as f:
f.write(config_content)
result.imported_files["config"] = 1
except Exception as e:
result.add_warning(f"导入配置文件失败: {e}")
if progress_callback:
await progress_callback("config", 100, 100, "配置文件导入完成")
# 5. 导入附件文件
if progress_callback:
await progress_callback("attachments", 0, 100, "正在导入附件...")
attachment_count = await self._import_attachments(
zf, main_data.get("attachments", [])
)
result.imported_files["attachments"] = attachment_count
if progress_callback:
await progress_callback("attachments", 100, 100, "附件导入完成")
# 6. 导入插件和其他目录
if progress_callback:
await progress_callback(
"directories", 0, 100, "正在导入插件和数据目录..."
)
dir_stats = await self._import_directories(zf, manifest, result)
result.imported_directories = dir_stats
if progress_callback:
await progress_callback("directories", 100, 100, "目录导入完成")
logger.info(f"备份导入完成: {result.to_dict()}")
return result
except zipfile.BadZipFile:
result.add_error("无效的 ZIP 文件")
return result
except Exception as e:
result.add_error(f"导入失败: {e}")
return result
def _validate_version(self, manifest: dict) -> None:
"""验证版本兼容性 - 仅允许相同主版本导入
注意:此方法仅在 import_all 中调用,用于双重校验。
前端应先调用 pre_check 获取详细的版本信息并让用户确认。
"""
backup_version = manifest.get("astrbot_version")
if not backup_version:
raise ValueError("备份文件缺少版本信息")
# 使用新的版本兼容性检查
version_check = self._check_version_compatibility(backup_version)
if version_check["status"] == "major_diff":
raise ValueError(version_check["message"])
# minor_diff 和 match 都允许导入
if version_check["status"] == "minor_diff":
logger.warning(f"版本差异警告: {version_check['message']}")
async def _clear_main_db(self) -> None:
"""清空主数据库所有表"""
async with self.main_db.get_db() as session:
async with session.begin():
for table_name, model_class in MAIN_DB_MODELS.items():
try:
await session.execute(delete(model_class))
logger.debug(f"已清空表 {table_name}")
except Exception as e:
logger.warning(f"清空表 {table_name} 失败: {e}")
async def _clear_kb_data(self) -> None:
"""清空知识库数据"""
if not self.kb_manager:
return
# 清空知识库元数据表
async with self.kb_manager.kb_db.get_db() as session:
async with session.begin():
for table_name, model_class in KB_METADATA_MODELS.items():
try:
await session.execute(delete(model_class))
logger.debug(f"已清空知识库表 {table_name}")
except Exception as e:
logger.warning(f"清空知识库表 {table_name} 失败: {e}")
# 删除知识库文件目录
for kb_id in list(self.kb_manager.kb_insts.keys()):
try:
kb_helper = self.kb_manager.kb_insts[kb_id]
await kb_helper.terminate()
if kb_helper.kb_dir.exists():
shutil.rmtree(kb_helper.kb_dir)
except Exception as e:
logger.warning(f"清理知识库 {kb_id} 失败: {e}")
self.kb_manager.kb_insts.clear()
async def _import_main_database(
self, data: dict[str, list[dict]]
) -> dict[str, int]:
"""导入主数据库数据"""
imported: dict[str, int] = {}
async with self.main_db.get_db() as session:
async with session.begin():
for table_name, rows in data.items():
model_class = MAIN_DB_MODELS.get(table_name)
if not model_class:
logger.warning(f"未知的表: {table_name}")
continue
count = 0
for row in rows:
try:
# 转换 datetime 字符串为 datetime 对象
row = self._convert_datetime_fields(row, model_class)
obj = model_class(**row)
session.add(obj)
count += 1
except Exception as e:
logger.warning(f"导入记录到 {table_name} 失败: {e}")
imported[table_name] = count
logger.debug(f"导入表 {table_name}: {count} 条记录")
return imported
async def _import_knowledge_bases(
self,
zf: zipfile.ZipFile,
kb_meta_data: dict[str, list[dict]],
result: ImportResult,
) -> None:
"""导入知识库数据"""
if not self.kb_manager:
return
# 1. 导入知识库元数据
async with self.kb_manager.kb_db.get_db() as session:
async with session.begin():
for table_name, rows in kb_meta_data.items():
model_class = KB_METADATA_MODELS.get(table_name)
if not model_class:
continue
count = 0
for row in rows:
try:
row = self._convert_datetime_fields(row, model_class)
obj = model_class(**row)
session.add(obj)
count += 1
except Exception as e:
logger.warning(f"导入知识库记录到 {table_name} 失败: {e}")
result.imported_tables[f"kb_{table_name}"] = count
# 2. 导入每个知识库的文档和文件
for kb_data in kb_meta_data.get("knowledge_bases", []):
kb_id = kb_data.get("kb_id")
if not kb_id:
continue
# 创建知识库目录
kb_dir = Path(self.kb_root_dir) / kb_id
kb_dir.mkdir(parents=True, exist_ok=True)
# 导入文档数据
doc_path = f"databases/kb_{kb_id}/documents.json"
if doc_path in zf.namelist():
try:
doc_content = zf.read(doc_path)
doc_data = json.loads(doc_content)
# 导入到文档存储数据库
await self._import_kb_documents(kb_id, doc_data)
except Exception as e:
result.add_warning(f"导入知识库 {kb_id} 的文档失败: {e}")
# 导入 FAISS 索引
faiss_path = f"databases/kb_{kb_id}/index.faiss"
if faiss_path in zf.namelist():
try:
target_path = kb_dir / "index.faiss"
with zf.open(faiss_path) as src, open(target_path, "wb") as dst:
dst.write(src.read())
except Exception as e:
result.add_warning(f"导入知识库 {kb_id} 的 FAISS 索引失败: {e}")
# 导入媒体文件
media_prefix = f"files/kb_media/{kb_id}/"
for name in zf.namelist():
if name.startswith(media_prefix):
try:
rel_path = name[len(media_prefix) :]
target_path = kb_dir / rel_path
target_path.parent.mkdir(parents=True, exist_ok=True)
with zf.open(name) as src, open(target_path, "wb") as dst:
dst.write(src.read())
except Exception as e:
result.add_warning(f"导入媒体文件 {name} 失败: {e}")
# 3. 重新加载知识库实例
await self.kb_manager.load_kbs()
async def _import_kb_documents(self, kb_id: str, doc_data: dict) -> None:
"""导入知识库文档到向量数据库"""
from astrbot.core.db.vec_db.faiss_impl.document_storage import DocumentStorage
kb_dir = Path(self.kb_root_dir) / kb_id
doc_db_path = kb_dir / "doc.db"
# 初始化文档存储
doc_storage = DocumentStorage(str(doc_db_path))
await doc_storage.initialize()
try:
documents = doc_data.get("documents", [])
for doc in documents:
try:
await doc_storage.insert_document(
doc_id=doc.get("doc_id", ""),
text=doc.get("text", ""),
metadata=json.loads(doc.get("metadata", "{}")),
)
except Exception as e:
logger.warning(f"导入文档块失败: {e}")
finally:
await doc_storage.close()
async def _import_attachments(
self,
zf: zipfile.ZipFile,
attachments: list[dict],
) -> int:
"""导入附件文件"""
count = 0
attachments_dir = Path(self.config_path).parent / "attachments"
attachments_dir.mkdir(parents=True, exist_ok=True)
attachment_prefix = "files/attachments/"
for name in zf.namelist():
if name.startswith(attachment_prefix) and name != attachment_prefix:
try:
# 从附件记录中找到原始路径
attachment_id = os.path.splitext(os.path.basename(name))[0]
original_path = None
for att in attachments:
if att.get("attachment_id") == attachment_id:
original_path = att.get("path")
break
if original_path:
target_path = Path(original_path)
else:
target_path = attachments_dir / os.path.basename(name)
target_path.parent.mkdir(parents=True, exist_ok=True)
with zf.open(name) as src, open(target_path, "wb") as dst:
dst.write(src.read())
count += 1
except Exception as e:
logger.warning(f"导入附件 {name} 失败: {e}")
return count
async def _import_directories(
self,
zf: zipfile.ZipFile,
manifest: dict,
result: ImportResult,
) -> dict[str, int]:
"""导入插件和其他数据目录
Args:
zf: ZIP 文件对象
manifest: 备份清单
result: 导入结果对象
Returns:
dict: 每个目录导入的文件数量
"""
dir_stats: dict[str, int] = {}
# 检查备份版本是否支持目录备份(需要版本 >= 1.1)
backup_version = manifest.get("version", "1.0")
if VersionComparator.compare_version(backup_version, "1.1") < 0:
logger.info("备份版本不支持目录备份,跳过目录导入")
return dir_stats
backed_up_dirs = manifest.get("directories", [])
backup_directories = get_backup_directories()
for dir_name in backed_up_dirs:
if dir_name not in backup_directories:
result.add_warning(f"未知的目录类型: {dir_name}")
continue
target_dir = Path(backup_directories[dir_name])
archive_prefix = f"directories/{dir_name}/"
file_count = 0
try:
# 获取该目录下的所有文件
dir_files = [
name
for name in zf.namelist()
if name.startswith(archive_prefix) and name != archive_prefix
]
if not dir_files:
continue
# 备份现有目录(如果存在)
if target_dir.exists():
backup_path = Path(f"{target_dir}.bak")
if backup_path.exists():
shutil.rmtree(backup_path)
shutil.move(str(target_dir), str(backup_path))
logger.debug(f"已备份现有目录 {target_dir}{backup_path}")
# 创建目标目录
target_dir.mkdir(parents=True, exist_ok=True)
# 解压文件
for name in dir_files:
try:
# 计算相对路径
rel_path = name[len(archive_prefix) :]
if not rel_path: # 跳过目录条目
continue
target_path = target_dir / rel_path
target_path.parent.mkdir(parents=True, exist_ok=True)
with zf.open(name) as src, open(target_path, "wb") as dst:
dst.write(src.read())
file_count += 1
except Exception as e:
result.add_warning(f"导入文件 {name} 失败: {e}")
dir_stats[dir_name] = file_count
logger.debug(f"导入目录 {dir_name}: {file_count} 个文件")
except Exception as e:
result.add_warning(f"导入目录 {dir_name} 失败: {e}")
dir_stats[dir_name] = 0
return dir_stats
def _convert_datetime_fields(self, row: dict, model_class: type) -> dict:
"""转换 datetime 字符串字段为 datetime 对象"""
result = row.copy()
# 获取模型的 datetime 字段
from sqlalchemy import inspect as sa_inspect
try:
mapper = sa_inspect(model_class)
for column in mapper.columns:
if column.name in result and result[column.name] is not None:
# 检查是否是 datetime 类型的列
from sqlalchemy import DateTime
if isinstance(column.type, DateTime):
value = result[column.name]
if isinstance(value, str):
# 解析 ISO 格式的日期时间字符串
result[column.name] = datetime.fromisoformat(value)
except Exception:
pass
return result
+31
View File
@@ -0,0 +1,31 @@
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
class ComputerBooter:
@property
def fs(self) -> FileSystemComponent: ...
@property
def python(self) -> PythonComponent: ...
@property
def shell(self) -> ShellComponent: ...
async def boot(self, session_id: str) -> None: ...
async def shutdown(self) -> None: ...
async def upload_file(self, path: str, file_name: str) -> dict:
"""Upload file to the computer.
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."""
...
async def available(self) -> bool:
"""Check if the computer is available."""
...
+186
View File
@@ -0,0 +1,186 @@
import asyncio
import random
from typing import Any
import aiohttp
import boxlite
from shipyard.filesystem import FileSystemComponent as ShipyardFileSystemComponent
from shipyard.python import PythonComponent as ShipyardPythonComponent
from shipyard.shell import ShellComponent as ShipyardShellComponent
from astrbot.api import logger
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
from .base import ComputerBooter
class MockShipyardSandboxClient:
def __init__(self, sb_url: str) -> None:
self.sb_url = sb_url.rstrip("/")
async def _exec_operation(
self,
ship_id: str,
operation_type: str,
payload: dict[str, Any],
session_id: str,
) -> dict[str, Any]:
async with aiohttp.ClientSession() as session:
headers = {"X-SESSION-ID": session_id}
async with session.post(
f"{self.sb_url}/{operation_type}",
json=payload,
headers=headers,
) as response:
if response.status == 200:
return await response.json()
else:
error_text = await response.text()
raise Exception(
f"Failed to exec operation: {response.status} {error_text}"
)
async def upload_file(self, path: str, remote_path: str) -> dict:
"""Upload a file to the sandbox"""
url = f"http://{self.sb_url}/upload"
try:
# Read file content
with open(path, "rb") as f:
file_content = f.read()
# Create multipart form data
data = aiohttp.FormData()
data.add_field(
"file",
file_content,
filename=remote_path.split("/")[-1],
content_type="application/octet-stream",
)
data.add_field("file_path", remote_path)
timeout = aiohttp.ClientTimeout(total=120) # 2 minutes for file upload
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(url, data=data) as response:
if response.status == 200:
return {
"success": True,
"message": "File uploaded successfully",
"file_path": remote_path,
}
else:
error_text = await response.text()
return {
"success": False,
"error": f"Server returned {response.status}: {error_text}",
"message": "File upload failed",
}
except aiohttp.ClientError as e:
logger.error(f"Failed to upload file: {e}")
return {
"success": False,
"error": f"Connection error: {str(e)}",
"message": "File upload failed",
}
except asyncio.TimeoutError:
return {
"success": False,
"error": "File upload timeout",
"message": "File upload failed",
}
except FileNotFoundError:
logger.error(f"File not found: {path}")
return {
"success": False,
"error": f"File not found: {path}",
"message": "File upload failed",
}
except Exception as e:
logger.error(f"Unexpected error uploading file: {e}")
return {
"success": False,
"error": f"Internal error: {str(e)}",
"message": "File upload failed",
}
async def wait_healthy(self, ship_id: str, session_id: str) -> None:
"""Mock wait healthy"""
loop = 60
while loop > 0:
try:
logger.info(
f"Checking health for sandbox {ship_id} on {self.sb_url}..."
)
url = f"{self.sb_url}/health"
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
if response.status == 200:
logger.info(f"Sandbox {ship_id} is healthy")
return
except Exception:
await asyncio.sleep(1)
loop -= 1
class BoxliteBooter(ComputerBooter):
async def boot(self, session_id: str) -> None:
logger.info(
f"Booting(Boxlite) for session: {session_id}, this may take a while..."
)
random_port = random.randint(20000, 30000)
self.box = boxlite.SimpleBox(
image="soulter/shipyard-ship",
memory_mib=512,
cpus=1,
ports=[
{
"host_port": random_port,
"guest_port": 8123,
}
],
)
await self.box.start()
logger.info(f"Boxlite booter started for session: {session_id}")
self.mocked = MockShipyardSandboxClient(
sb_url=f"http://127.0.0.1:{random_port}"
)
self._fs = ShipyardFileSystemComponent(
client=self.mocked, # type: ignore
ship_id=self.box.id,
session_id=session_id,
)
self._python = ShipyardPythonComponent(
client=self.mocked, # type: ignore
ship_id=self.box.id,
session_id=session_id,
)
self._shell = ShipyardShellComponent(
client=self.mocked, # type: ignore
ship_id=self.box.id,
session_id=session_id,
)
await self.mocked.wait_healthy(self.box.id, session_id)
async def shutdown(self) -> None:
logger.info(f"Shutting down Boxlite booter for ship: {self.box.id}")
self.box.shutdown()
logger.info(f"Boxlite booter for ship: {self.box.id} stopped")
@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:
"""Upload file to sandbox"""
return await self.mocked.upload_file(path, file_name)
+234
View File
@@ -0,0 +1,234 @@
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
+67
View File
@@ -0,0 +1,67 @@
from shipyard import ShipyardClient, Spec
from astrbot.api import logger
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
from .base import ComputerBooter
class ShipyardBooter(ComputerBooter):
def __init__(
self,
endpoint_url: str,
access_token: str,
ttl: int = 3600,
session_num: int = 10,
) -> None:
self._sandbox_client = ShipyardClient(
endpoint_url=endpoint_url, access_token=access_token
)
self._ttl = ttl
self._session_num = session_num
async def boot(self, session_id: str) -> None:
ship = await self._sandbox_client.create_ship(
ttl=self._ttl,
spec=Spec(cpus=1.0, memory="512m"),
max_session_num=self._session_num,
session_id=session_id,
)
logger.info(f"Got sandbox ship: {ship.id} for session: {session_id}")
self._ship = ship
async def shutdown(self) -> None:
pass
@property
def fs(self) -> FileSystemComponent:
return self._ship.fs
@property
def python(self) -> PythonComponent:
return self._ship.python
@property
def shell(self) -> ShellComponent:
return self._ship.shell
async def upload_file(self, path: str, file_name: str) -> dict:
"""Upload file to sandbox"""
return await self._ship.upload_file(path, file_name)
async def download_file(self, remote_path: str, local_path: str):
"""Download file from sandbox."""
return await self._ship.download_file(remote_path, local_path)
async def available(self) -> bool:
"""Check if the sandbox is available."""
try:
ship_id = self._ship.id
data = await self._sandbox_client.get_ship(ship_id)
if not data:
return False
health = bool(data.get("status", 0) == 1)
return health
except Exception as e:
logger.error(f"Error checking Shipyard sandbox availability: {e}")
return False
+102
View File
@@ -0,0 +1,102 @@
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
+5
View File
@@ -0,0 +1,5 @@
from .filesystem import FileSystemComponent
from .python import PythonComponent
from .shell import ShellComponent
__all__ = ["PythonComponent", "ShellComponent", "FileSystemComponent"]
@@ -0,0 +1,33 @@
"""
File system component
"""
from typing import Any, Protocol
class FileSystemComponent(Protocol):
async def create_file(
self, path: str, content: str = "", mode: int = 0o644
) -> dict[str, Any]:
"""Create a file with the specified content"""
...
async def read_file(self, path: str, encoding: str = "utf-8") -> dict[str, Any]:
"""Read file content"""
...
async def write_file(
self, path: str, content: str, mode: str = "w", encoding: str = "utf-8"
) -> dict[str, Any]:
"""Write content to file"""
...
async def delete_file(self, path: str) -> dict[str, Any]:
"""Delete file or directory"""
...
async def list_dir(
self, path: str = ".", show_hidden: bool = False
) -> dict[str, Any]:
"""List directory contents"""
...
+19
View File
@@ -0,0 +1,19 @@
"""
Python/IPython component
"""
from typing import Any, Protocol
class PythonComponent(Protocol):
"""Python/IPython operations component"""
async def exec(
self,
code: str,
kernel_id: str | None = None,
timeout: int = 30,
silent: bool = False,
) -> dict[str, Any]:
"""Execute Python code"""
...
+21
View File
@@ -0,0 +1,21 @@
"""
Shell component
"""
from typing import Any, Protocol
class ShellComponent(Protocol):
"""Shell operations component"""
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]:
"""Execute shell command"""
...
+11
View File
@@ -0,0 +1,11 @@
from .fs import FileDownloadTool, FileUploadTool
from .python import LocalPythonTool, PythonTool
from .shell import ExecuteShellTool
__all__ = [
"FileUploadTool",
"PythonTool",
"LocalPythonTool",
"ExecuteShellTool",
"FileDownloadTool",
]
+188
View File
@@ -0,0 +1,188 @@
import os
from dataclasses import dataclass, field
from astrbot.api import FunctionTool, logger
from astrbot.api.event import MessageChain
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.message.components import File
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from ..computer_client import get_booter
# @dataclass
# class CreateFileTool(FunctionTool):
# name: str = "astrbot_create_file"
# description: str = "Create a new file in the sandbox."
# parameters: dict = field(
# default_factory=lambda: {
# "type": "object",
# "properties": {
# "path": {
# "path": "string",
# "description": "The path where the file should be created, relative to the sandbox root. Must not use absolute paths or traverse outside the sandbox.",
# },
# "content": {
# "type": "string",
# "description": "The content to write into the file.",
# },
# },
# "required": ["path", "content"],
# }
# )
# async def call(
# self, context: ContextWrapper[AstrAgentContext], path: str, content: str
# ) -> ToolExecResult:
# sb = await get_booter(
# context.context.context,
# context.context.event.unified_msg_origin,
# )
# try:
# result = await sb.fs.create_file(path, content)
# return json.dumps(result)
# except Exception as e:
# return f"Error creating file: {str(e)}"
# @dataclass
# class ReadFileTool(FunctionTool):
# name: str = "astrbot_read_file"
# description: str = "Read the content of a file in the sandbox."
# parameters: dict = field(
# default_factory=lambda: {
# "type": "object",
# "properties": {
# "path": {
# "type": "string",
# "description": "The path of the file to read, relative to the sandbox root. Must not use absolute paths or traverse outside the sandbox.",
# },
# },
# "required": ["path"],
# }
# )
# async def call(self, context: ContextWrapper[AstrAgentContext], path: str):
# sb = await get_booter(
# context.context.context,
# context.context.event.unified_msg_origin,
# )
# try:
# result = await sb.fs.read_file(path)
# return result
# except Exception as e:
# return f"Error reading file: {str(e)}"
@dataclass
class FileUploadTool(FunctionTool):
name: str = "astrbot_upload_file"
description: str = "Upload a local file to the sandbox. The file must exist on the local filesystem."
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"local_path": {
"type": "string",
"description": "The local file path to upload. This must be an absolute path to an existing file on the local filesystem.",
},
# "remote_path": {
# "type": "string",
# "description": "The filename to use in the sandbox. If not provided, file will be saved to the working directory with the same name as the local file.",
# },
},
"required": ["local_path"],
}
)
async def call(
self,
context: ContextWrapper[AstrAgentContext],
local_path: str,
):
sb = await get_booter(
context.context.context,
context.context.event.unified_msg_origin,
)
try:
# Check if file exists
if not os.path.exists(local_path):
return f"Error: File does not exist: {local_path}"
if not os.path.isfile(local_path):
return f"Error: Path is not a file: {local_path}"
# Use basename if sandbox_filename is not provided
remote_path = os.path.basename(local_path)
# Upload file to sandbox
result = await sb.upload_file(local_path, remote_path)
logger.debug(f"Upload result: {result}")
success = result.get("success", False)
if not success:
return f"Error uploading file: {result.get('message', 'Unknown error')}"
file_path = result.get("file_path", "")
logger.info(f"File {local_path} uploaded to sandbox at {file_path}")
return f"File uploaded successfully to {file_path}"
except Exception as e:
logger.error(f"Error uploading file {local_path}: {e}")
return f"Error uploading file: {str(e)}"
@dataclass
class FileDownloadTool(FunctionTool):
name: str = "astrbot_download_file"
description: str = "Download a file from the sandbox. Only call this when user explicitly need you to download a file."
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"remote_path": {
"type": "string",
"description": "The path of the file in the sandbox to download.",
}
},
"required": ["remote_path"],
}
)
async def call(
self,
context: ContextWrapper[AstrAgentContext],
remote_path: str,
) -> ToolExecResult:
sb = await get_booter(
context.context.context,
context.context.event.unified_msg_origin,
)
try:
name = os.path.basename(remote_path)
local_path = os.path.join(get_astrbot_temp_path(), name)
# Download file from sandbox
await sb.download_file(remote_path, local_path)
logger.info(f"File {remote_path} downloaded from sandbox to {local_path}")
try:
name = os.path.basename(local_path)
await context.context.event.send(
MessageChain(chain=[File(name=name, file=local_path)])
)
except Exception as e:
logger.error(f"Error sending file message: {e}")
# remove
try:
os.remove(local_path)
except Exception as e:
logger.error(f"Error removing temp file {local_path}: {e}")
return f"File downloaded successfully to {local_path}"
except Exception as e:
logger.error(f"Error downloading file {remote_path}: {e}")
return f"Error downloading file: {str(e)}"
+94
View File
@@ -0,0 +1,94 @@
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)}"
+63
View File
@@ -0,0 +1,63 @@
import json
from dataclasses import dataclass, field
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 ..computer_client import get_booter, get_local_booter
@dataclass
class ExecuteShellTool(FunctionTool):
name: str = "astrbot_execute_shell"
description: str = "Execute a command in the shell."
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The bash command to execute. Equal to 'cd {working_dir} && {your_command}'.",
},
"background": {
"type": "boolean",
"description": "Whether to run the command in the background.",
"default": False,
},
"env": {
"type": "object",
"description": "Optional environment variables to set for the file creation process.",
"additionalProperties": {"type": "string"},
"default": {},
},
},
"required": ["command"],
}
)
is_local: bool = False
async def call(
self,
context: ContextWrapper[AstrAgentContext],
command: str,
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,
)
try:
result = await sb.shell.exec(command, background=background, env=env)
return json.dumps(result)
except Exception as e:
return f"Error executing command: {str(e)}"
+6
View File
@@ -24,6 +24,10 @@ class AstrBotConfig(dict):
- 如果传入了 schema,将会通过 schema 解析出 default_config,此时传入的 default_config 会被忽略。
"""
config_path: str
default_config: dict
schema: dict | None
def __init__(
self,
config_path: str = ASTRBOT_CONFIG_PATH,
@@ -76,6 +80,8 @@ class AstrBotConfig(dict):
if v["type"] == "object":
conf[k] = {}
_parse_schema(v["items"], conf[k])
elif v["type"] == "template_list":
conf[k] = default
else:
conf[k] = default
File diff suppressed because it is too large Load Diff
+111
View File
@@ -0,0 +1,111 @@
"""
配置元数据国际化工具
提供配置元数据的国际化键转换功能
"""
from typing import Any
class ConfigMetadataI18n:
"""配置元数据国际化转换器"""
@staticmethod
def _get_i18n_key(group: str, section: str, field: str, attr: str) -> str:
"""
生成国际化键
Args:
group: 配置组 'ai_group', 'platform_group'
section: 配置节 'agent_runner', 'general'
field: 字段名 'enable', 'default_provider'
attr: 属性类型 'description', 'hint', 'labels'
Returns:
国际化键格式如: 'ai_group.agent_runner.enable.description'
"""
if field:
return f"{group}.{section}.{field}.{attr}"
else:
return f"{group}.{section}.{attr}"
@staticmethod
def convert_to_i18n_keys(metadata: dict[str, Any]) -> dict[str, Any]:
"""
将配置元数据转换为使用国际化键
Args:
metadata: 原始配置元数据字典
Returns:
使用国际化键的配置元数据字典
"""
result = {}
for group_key, group_data in metadata.items():
group_result = {
"name": f"{group_key}.name",
"metadata": {},
}
for section_key, section_data in group_data.get("metadata", {}).items():
section_result = {
"description": f"{group_key}.{section_key}.description",
"type": section_data.get("type"),
}
# 复制其他属性
for key in ["items", "condition", "_special", "invisible"]:
if key in section_data:
section_result[key] = section_data[key]
# 处理 hint
if "hint" in section_data:
section_result["hint"] = f"{group_key}.{section_key}.hint"
# 处理 items 中的字段
if "items" in section_data and isinstance(section_data["items"], dict):
items_result = {}
for field_key, field_data in section_data["items"].items():
# 处理嵌套的点号字段名(如 provider_settings.enable
field_name = field_key
field_result = {}
# 复制基本属性
for attr in [
"type",
"condition",
"_special",
"invisible",
"options",
"slider",
]:
if attr in field_data:
field_result[attr] = field_data[attr]
# 转换文本属性为国际化键
if "description" in field_data:
field_result["description"] = (
f"{group_key}.{section_key}.{field_name}.description"
)
if "hint" in field_data:
field_result["hint"] = (
f"{group_key}.{section_key}.{field_name}.hint"
)
if "labels" in field_data:
field_result["labels"] = (
f"{group_key}.{section_key}.{field_name}.labels"
)
items_result[field_key] = field_result
section_result["items"] = items_result
group_result["metadata"][section_key] = section_result
result[group_key] = group_result
return result
+4
View File
@@ -69,6 +69,7 @@ class ConversationManager:
persona_id=conv_v2.persona_id,
created_at=created_at,
updated_at=updated_at,
token_usage=conv_v2.token_usage,
)
async def new_conversation(
@@ -256,6 +257,7 @@ class ConversationManager:
history: list[dict] | None = None,
title: str | None = None,
persona_id: str | None = None,
token_usage: int | None = None,
) -> None:
"""更新会话的对话.
@@ -263,6 +265,7 @@ class ConversationManager:
unified_msg_origin (str): 统一的消息来源字符串格式为 platform_name:message_type:session_id
conversation_id (str): 对话 ID, uuid 格式的字符串
history (List[Dict]): 对话历史记录, 是一个字典列表, 每个字典包含 role content 字段
token_usage (int | None): token 使用量None 表示不更新
"""
if not conversation_id:
@@ -274,6 +277,7 @@ class ConversationManager:
title=title,
persona_id=persona_id,
content=history,
token_usage=token_usage,
)
async def update_conversation_title(
+22 -8
View File
@@ -16,12 +16,12 @@ import time
import traceback
from asyncio import Queue
from astrbot.core import LogBroker, logger, sp
from astrbot.api import logger, sp
from astrbot.core import LogBroker, LogManager
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
from astrbot.core.config.default import VERSION
from astrbot.core.conversation_mgr import ConversationManager
from astrbot.core.db import BaseDatabase
from astrbot.core.db.migration.migra_45_to_46 import migrate_45_to_46
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
from astrbot.core.persona_mgr import PersonaManager
from astrbot.core.pipeline.scheduler import PipelineContext, PipelineScheduler
@@ -33,6 +33,8 @@ from astrbot.core.star.context import Context
from astrbot.core.star.star_handler import EventType, star_handlers_registry, star_map
from astrbot.core.umop_config_router import UmopConfigRouter
from astrbot.core.updator import AstrBotUpdator
from astrbot.core.utils.llm_metadata import update_llm_metadata
from astrbot.core.utils.migra_helper import migra
from . import astrbot_config, html_renderer
from .event_bus import EventBus
@@ -78,9 +80,13 @@ class AstrBotCoreLifecycle:
# 初始化日志代理
logger.info("AstrBot v" + VERSION)
if os.environ.get("TESTING", ""):
logger.setLevel("DEBUG") # 测试模式下设置日志级别为 DEBUG
LogManager.configure_logger(
logger, self.astrbot_config, override_level="DEBUG"
)
LogManager.configure_trace_logger(self.astrbot_config)
else:
logger.setLevel(self.astrbot_config["log_level"]) # 设置日志级别
LogManager.configure_logger(logger, self.astrbot_config)
LogManager.configure_trace_logger(self.astrbot_config)
await self.db.initialize()
@@ -88,6 +94,7 @@ class AstrBotCoreLifecycle:
# 初始化 UMOP 配置路由器
self.umop_config_router = UmopConfigRouter(sp=sp)
await self.umop_config_router.initialize()
# 初始化 AstrBot 配置管理器
self.astrbot_config_mgr = AstrBotConfigManager(
@@ -96,11 +103,16 @@ class AstrBotCoreLifecycle:
sp=sp,
)
# 4.5 to 4.6 migration for umop_config_router
# apply migration
try:
await migrate_45_to_46(self.astrbot_config_mgr, self.umop_config_router)
await migra(
self.db,
self.astrbot_config_mgr,
self.umop_config_router,
self.astrbot_config_mgr,
)
except Exception as e:
logger.error(f"Migration from version 4.5 to 4.6 failed: {e!s}")
logger.error(f"AstrBot migration failed: {e!s}")
logger.error(traceback.format_exc())
# 初始化事件队列
@@ -179,6 +191,8 @@ class AstrBotCoreLifecycle:
# 初始化关闭控制面板的事件
self.dashboard_shutdown_event = asyncio.Event()
asyncio.create_task(update_llm_metadata())
def _load(self) -> None:
"""加载事件总线和任务并初始化."""
# 创建一个异步任务来执行事件总线的 dispatch() 方法
@@ -191,7 +205,7 @@ class AstrBotCoreLifecycle:
# 把插件中注册的所有协程函数注册到事件总线中并执行
extra_tasks = []
for task in self.star_context._register_tasks:
extra_tasks.append(asyncio.create_task(task, name=task.__name__))
extra_tasks.append(asyncio.create_task(task, name=task.__name__)) # type: ignore
tasks_ = [event_bus_task, *extra_tasks]
for task in tasks_:
+331 -6
View File
@@ -5,16 +5,21 @@ from contextlib import asynccontextmanager
from dataclasses import dataclass
from deprecated import deprecated
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from astrbot.core.db.po import (
Attachment,
ChatUIProject,
CommandConfig,
CommandConflict,
ConversationV2,
Persona,
PersonaFolder,
PlatformMessageHistory,
PlatformSession,
PlatformStat,
Preference,
SessionProjectRelation,
Stats,
)
@@ -31,7 +36,7 @@ class BaseDatabase(abc.ABC):
echo=False,
future=True,
)
self.AsyncSessionLocal = sessionmaker(
self.AsyncSessionLocal = async_sessionmaker(
self.engine,
class_=AsyncSession,
expire_on_commit=False,
@@ -150,6 +155,7 @@ class BaseDatabase(abc.ABC):
title: str | None = None,
persona_id: str | None = None,
content: list[dict] | None = None,
token_usage: int | None = None,
) -> None:
"""Update a conversation's history."""
...
@@ -172,7 +178,7 @@ class BaseDatabase(abc.ABC):
content: dict,
sender_id: str | None = None,
sender_name: str | None = None,
) -> None:
) -> PlatformMessageHistory:
"""Insert a new platform message history record."""
...
@@ -183,7 +189,7 @@ class BaseDatabase(abc.ABC):
user_id: str,
offset_sec: int = 86400,
) -> None:
"""Delete platform message history records older than the specified offset."""
"""Delete platform message history records newer than the specified offset."""
...
@abc.abstractmethod
@@ -197,6 +203,14 @@ class BaseDatabase(abc.ABC):
"""Get platform message history for a specific user."""
...
@abc.abstractmethod
async def get_platform_message_history_by_id(
self,
message_id: int,
) -> PlatformMessageHistory | None:
"""Get a platform message history record by its ID."""
...
@abc.abstractmethod
async def insert_attachment(
self,
@@ -212,6 +226,27 @@ class BaseDatabase(abc.ABC):
"""Get an attachment by its ID."""
...
@abc.abstractmethod
async def get_attachments(self, attachment_ids: list[str]) -> list[Attachment]:
"""Get multiple attachments by their IDs."""
...
@abc.abstractmethod
async def delete_attachment(self, attachment_id: str) -> bool:
"""Delete an attachment by its ID.
Returns True if the attachment was deleted, False if it was not found.
"""
...
@abc.abstractmethod
async def delete_attachments(self, attachment_ids: list[str]) -> int:
"""Delete multiple attachments by their IDs.
Returns the number of attachments deleted.
"""
...
@abc.abstractmethod
async def insert_persona(
self,
@@ -219,8 +254,21 @@ 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:
"""Insert a new persona record."""
"""Insert a new persona record.
Args:
persona_id: Unique identifier for the persona
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)
"""
...
@abc.abstractmethod
@@ -240,6 +288,7 @@ 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."""
...
@@ -249,6 +298,84 @@ class BaseDatabase(abc.ABC):
"""Delete a persona by its ID."""
...
# ====
# Persona Folder Management
# ====
@abc.abstractmethod
async def insert_persona_folder(
self,
name: str,
parent_id: str | None = None,
description: str | None = None,
sort_order: int = 0,
) -> PersonaFolder:
"""Insert a new persona folder."""
...
@abc.abstractmethod
async def get_persona_folder_by_id(self, folder_id: str) -> PersonaFolder | None:
"""Get a persona folder by its folder_id."""
...
@abc.abstractmethod
async def get_persona_folders(
self, parent_id: str | None = None
) -> list[PersonaFolder]:
"""Get all persona folders, optionally filtered by parent_id."""
...
@abc.abstractmethod
async def get_all_persona_folders(self) -> list[PersonaFolder]:
"""Get all persona folders."""
...
@abc.abstractmethod
async def update_persona_folder(
self,
folder_id: str,
name: str | None = None,
parent_id: T.Any = None,
description: T.Any = None,
sort_order: int | None = None,
) -> PersonaFolder | None:
"""Update a persona folder."""
...
@abc.abstractmethod
async def delete_persona_folder(self, folder_id: str) -> None:
"""Delete a persona folder by its folder_id."""
...
@abc.abstractmethod
async def move_persona_to_folder(
self, persona_id: str, folder_id: str | None
) -> Persona | None:
"""Move a persona to a folder (or root if folder_id is None)."""
...
@abc.abstractmethod
async def get_personas_by_folder(
self, folder_id: str | None = None
) -> list[Persona]:
"""Get all personas in a specific folder."""
...
@abc.abstractmethod
async def batch_update_sort_order(
self,
items: list[dict],
) -> None:
"""Batch update sort_order for personas and/or folders.
Args:
items: List of dicts with keys:
- id: The persona_id or folder_id
- type: Either "persona" or "folder"
- sort_order: The new sort_order value
"""
...
@abc.abstractmethod
async def insert_preference_or_update(
self,
@@ -285,6 +412,76 @@ class BaseDatabase(abc.ABC):
"""Clear all preferences for a specific scope ID."""
...
@abc.abstractmethod
async def get_command_configs(self) -> list[CommandConfig]:
"""Get all stored command configurations."""
...
@abc.abstractmethod
async def get_command_config(self, handler_full_name: str) -> CommandConfig | None:
"""Fetch a single command configuration by handler."""
...
@abc.abstractmethod
async def upsert_command_config(
self,
handler_full_name: str,
plugin_name: str,
module_path: str,
original_command: str,
*,
resolved_command: str | None = None,
enabled: bool | None = None,
keep_original_alias: bool | None = None,
conflict_key: str | None = None,
resolution_strategy: str | None = None,
note: str | None = None,
extra_data: dict | None = None,
auto_managed: bool | None = None,
) -> CommandConfig:
"""Create or update a command configuration."""
...
@abc.abstractmethod
async def delete_command_config(self, handler_full_name: str) -> None:
"""Delete a single command configuration."""
...
@abc.abstractmethod
async def delete_command_configs(self, handler_full_names: list[str]) -> None:
"""Bulk delete command configurations."""
...
@abc.abstractmethod
async def list_command_conflicts(
self,
status: str | None = None,
) -> list[CommandConflict]:
"""List recorded command conflict entries."""
...
@abc.abstractmethod
async def upsert_command_conflict(
self,
conflict_key: str,
handler_full_name: str,
plugin_name: str,
*,
status: str | None = None,
resolution: str | None = None,
resolved_command: str | None = None,
note: str | None = None,
extra_data: dict | None = None,
auto_generated: bool | None = None,
) -> CommandConflict:
"""Create or update a conflict record."""
...
@abc.abstractmethod
async def delete_command_conflicts(self, ids: list[int]) -> None:
"""Delete conflict records."""
...
# @abc.abstractmethod
# async def insert_llm_message(
# self,
@@ -313,3 +510,131 @@ class BaseDatabase(abc.ABC):
) -> tuple[list[dict], int]:
"""Get paginated session conversations with joined conversation and persona details, support search and platform filter."""
...
# ====
# Platform Session Management
# ====
@abc.abstractmethod
async def create_platform_session(
self,
creator: str,
platform_id: str = "webchat",
session_id: str | None = None,
display_name: str | None = None,
is_group: int = 0,
) -> PlatformSession:
"""Create a new Platform session."""
...
@abc.abstractmethod
async def get_platform_session_by_id(
self, session_id: str
) -> PlatformSession | None:
"""Get a Platform session by its ID."""
...
@abc.abstractmethod
async def get_platform_sessions_by_creator(
self,
creator: str,
platform_id: str | None = None,
page: int = 1,
page_size: int = 20,
) -> list[dict]:
"""Get all Platform sessions for a specific creator (username) and optionally platform.
Returns a list of dicts containing session info and project info (if session belongs to a project).
"""
...
@abc.abstractmethod
async def update_platform_session(
self,
session_id: str,
display_name: str | None = None,
) -> None:
"""Update a Platform session's updated_at timestamp and optionally display_name."""
...
@abc.abstractmethod
async def delete_platform_session(self, session_id: str) -> None:
"""Delete a Platform session by its ID."""
...
# ====
# ChatUI Project Management
# ====
@abc.abstractmethod
async def create_chatui_project(
self,
creator: str,
title: str,
emoji: str | None = "📁",
description: str | None = None,
) -> ChatUIProject:
"""Create a new ChatUI project."""
...
@abc.abstractmethod
async def get_chatui_project_by_id(self, project_id: str) -> ChatUIProject | None:
"""Get a ChatUI project by its ID."""
...
@abc.abstractmethod
async def get_chatui_projects_by_creator(
self,
creator: str,
page: int = 1,
page_size: int = 100,
) -> list[ChatUIProject]:
"""Get all ChatUI projects for a specific creator."""
...
@abc.abstractmethod
async def update_chatui_project(
self,
project_id: str,
title: str | None = None,
emoji: str | None = None,
description: str | None = None,
) -> None:
"""Update a ChatUI project."""
...
@abc.abstractmethod
async def delete_chatui_project(self, project_id: str) -> None:
"""Delete a ChatUI project by its ID."""
...
@abc.abstractmethod
async def add_session_to_project(
self,
session_id: str,
project_id: str,
) -> SessionProjectRelation:
"""Add a session to a project."""
...
@abc.abstractmethod
async def remove_session_from_project(self, session_id: str) -> None:
"""Remove a session from its project."""
...
@abc.abstractmethod
async def get_project_sessions(
self,
project_id: str,
page: int = 1,
page_size: int = 100,
) -> list[PlatformSession]:
"""Get all sessions in a project."""
...
@abc.abstractmethod
async def get_project_by_session(
self, session_id: str, creator: str
) -> ChatUIProject | None:
"""Get the project that a session belongs to."""
...
@@ -70,6 +70,7 @@ async def migration_conversation_table(
logger.info(
f"未找到该条旧会话对应的具体数据: {conversation}, 跳过。",
)
continue
if ":" not in conv.user_id:
continue
session = MessageSesion.from_str(session_str=conv.user_id)
@@ -207,6 +208,7 @@ async def migration_webchat_data(
logger.info(
f"未找到该条旧会话对应的具体数据: {conversation}, 跳过。",
)
continue
if ":" in conv.user_id:
continue
platform_id = "webchat"
@@ -0,0 +1,61 @@
"""Migration script to add token_usage column to conversations table.
This migration adds the token_usage field to track token consumption for each conversation.
Changes:
- Adds token_usage column to conversations table (default: 0)
"""
from sqlalchemy import text
from astrbot.api import logger, sp
from astrbot.core.db import BaseDatabase
async def migrate_token_usage(db_helper: BaseDatabase):
"""Add token_usage column to conversations table.
This migration adds a new column to track token consumption in conversations.
"""
# 检查是否已经完成迁移
migration_done = await db_helper.get_preference(
"global", "global", "migration_done_token_usage_1"
)
if migration_done:
return
logger.info("开始执行数据库迁移(添加 conversations.token_usage 列)...")
# 这里只适配了 SQLite。因为截止至这一版本,AstrBot 仅支持 SQLite。
try:
async with db_helper.get_db() as session:
# 检查列是否已存在
result = await session.execute(text("PRAGMA table_info(conversations)"))
columns = result.fetchall()
column_names = [col[1] for col in columns]
if "token_usage" in column_names:
logger.info("token_usage 列已存在,跳过迁移")
await sp.put_async(
"global", "global", "migration_done_token_usage_1", True
)
return
# 添加 token_usage 列
await session.execute(
text(
"ALTER TABLE conversations ADD COLUMN token_usage INTEGER NOT NULL DEFAULT 0"
)
)
await session.commit()
logger.info("token_usage 列添加成功")
# 标记迁移完成
await sp.put_async("global", "global", "migration_done_token_usage_1", True)
logger.info("token_usage 迁移完成")
except Exception as e:
logger.error(f"迁移过程中发生错误: {e}", exc_info=True)
raise
@@ -0,0 +1,131 @@
"""Migration script for WebChat sessions.
This migration creates PlatformSession from existing platform_message_history records.
Changes:
- Creates platform_sessions table
- Adds platform_id field (default: 'webchat')
- Adds display_name field
- Session_id format: {platform_id}_{uuid}
"""
from sqlalchemy import func, select
from sqlmodel import col
from astrbot.api import logger, sp
from astrbot.core.db import BaseDatabase
from astrbot.core.db.po import ConversationV2, PlatformMessageHistory, PlatformSession
async def migrate_webchat_session(db_helper: BaseDatabase):
"""Create PlatformSession records from platform_message_history.
This migration extracts all unique user_ids from platform_message_history
where platform_id='webchat' and creates corresponding PlatformSession records.
"""
# 检查是否已经完成迁移
migration_done = await db_helper.get_preference(
"global", "global", "migration_done_webchat_session_1"
)
if migration_done:
return
logger.info("开始执行数据库迁移(WebChat 会话迁移)...")
try:
async with db_helper.get_db() as session:
# 从 platform_message_history 创建 PlatformSession
query = (
select(
col(PlatformMessageHistory.user_id),
col(PlatformMessageHistory.sender_name),
func.min(PlatformMessageHistory.created_at).label("earliest"),
func.max(PlatformMessageHistory.updated_at).label("latest"),
)
.where(col(PlatformMessageHistory.platform_id) == "webchat")
.where(col(PlatformMessageHistory.sender_id) != "bot")
.group_by(col(PlatformMessageHistory.user_id))
)
result = await session.execute(query)
webchat_users = result.all()
if not webchat_users:
logger.info("没有找到需要迁移的 WebChat 数据")
await sp.put_async(
"global", "global", "migration_done_webchat_session_1", True
)
return
logger.info(f"找到 {len(webchat_users)} 个 WebChat 会话需要迁移")
# 检查已存在的会话
existing_query = select(col(PlatformSession.session_id))
existing_result = await session.execute(existing_query)
existing_session_ids = {row[0] for row in existing_result.fetchall()}
# 查询 Conversations 表中的 title,用于设置 display_name
# 对于每个 user_id,对应的 conversation user_id 格式为: webchat:FriendMessage:webchat!astrbot!{user_id}
user_ids_to_query = [
f"webchat:FriendMessage:webchat!astrbot!{user_id}"
for user_id, _, _, _ in webchat_users
]
conv_query = select(
col(ConversationV2.user_id), col(ConversationV2.title)
).where(col(ConversationV2.user_id).in_(user_ids_to_query))
conv_result = await session.execute(conv_query)
# 创建 user_id -> title 的映射字典
title_map = {
user_id.replace("webchat:FriendMessage:webchat!astrbot!", ""): title
for user_id, title in conv_result.fetchall()
}
# 批量创建 PlatformSession 记录
sessions_to_add = []
skipped_count = 0
for user_id, sender_name, created_at, updated_at in webchat_users:
# user_id 就是 webchat_conv_id (session_id)
session_id = user_id
# sender_name 通常是 username,但可能为 None
creator = sender_name if sender_name else "guest"
# 检查是否已经存在该会话
if session_id in existing_session_ids:
logger.debug(f"会话 {session_id} 已存在,跳过")
skipped_count += 1
continue
# 从 Conversations 表中获取 display_name
display_name = title_map.get(user_id)
# 创建新的 PlatformSession(保留原有的时间戳)
new_session = PlatformSession(
session_id=session_id,
platform_id="webchat",
creator=creator,
is_group=0,
created_at=created_at,
updated_at=updated_at,
display_name=display_name,
)
sessions_to_add.append(new_session)
# 批量插入
if sessions_to_add:
session.add_all(sessions_to_add)
await session.commit()
logger.info(
f"WebChat 会话迁移完成!成功迁移: {len(sessions_to_add)}, 跳过: {skipped_count}",
)
else:
logger.info("没有新会话需要迁移")
# 标记迁移完成
await sp.put_async("global", "global", "migration_done_webchat_session_1", True)
except Exception as e:
logger.error(f"迁移过程中发生错误: {e}", exc_info=True)
raise

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