Compare commits

...

92 Commits

Author SHA1 Message Date
Soulter
7f6aeffd03 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:23:00 +08:00
Soulter
c51de491a5 refactor: umo custom rules 2025-11-27 13:06:51 +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
adbb84530a chore: bump version to 4.5.8 2025-11-17 09:58:02 +08:00
piexian
6cf169f4f2 fix: ImageURLPart typo (#3665)
* 修复新版本更新对不上格式的问题

entities.py生成的格式:{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,..."}}
ImageURLPart期望的格式:{"type": "image_url", "image_url": "data:image/jpeg;base64,..."}

* Revert "修复新版本更新对不上格式的问题"

This reverts commit 28b4791391.

* fix(core.agent): 修复ImageURLPart的声明,修复pydantic校验失败的问题。

---------

Co-authored-by: piexian <piexian@users.noreply.github.com>
Co-authored-by: Dt8333 <lb0016@foxmail.com>
2025-11-17 09:52:31 +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
113 changed files with 9749 additions and 5481 deletions

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! / 感谢您填写我们的表单!"

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

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

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

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

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

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

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"

View File

@@ -32,7 +32,7 @@
<a href="https://github.com/AstrBotDevs/AstrBot/issues">问题提交</a>
</div>
AstrBot 是一个开源的一站式 Agent 聊天机器人平台及开发框架
AstrBot 是一个开源的一站式 Agent 聊天机器人平台,可无缝接入主流即时通讯软件,为个人、开发者和团队打造可靠、可扩展的对话式智能基础设施。无论是个人 AI 伙伴、智能客服、自动化助手还是企业知识库AstrBot 都能在你的即时通讯软件平台的工作流中快速构建生产可用的 AI 应用
## 主要功能
@@ -42,7 +42,7 @@ AstrBot 是一个开源的一站式 Agent 聊天机器人平台及开发框架
4. **插件扩展**。深度优化的插件机制,支持[开发插件](https://astrbot.app/dev/plugin.html)扩展功能,社区插件生态丰富。
5. **WebUI**。可视化配置和管理机器人,功能齐全。
## 部署方式
## 部署方式
#### Docker 部署(推荐 🥳)

View File

@@ -1,182 +1,233 @@
<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">
_✨ Easy-to-use Multi-platform LLM Chatbot & Development Framework ✨_
<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?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">
</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://github.com/AstrBotDevs/AstrBot/blob/master/README.md">中文</a>
<a href="https://github.com/AstrBotDevs/AstrBot/blob/master/README_ja.md">日本語</a>
<a href="https://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 and development framework.
#### Docker Deployment
## Key Features
See docs: [Deploy with Docker](https://astrbot.app/deploy/astrbot/docker.html#docker-deployment)
1. **LLM Conversations**. Supports integration with various large language model services. Features include multimodal capabilities, tool calling, MCP, native knowledge base, character personas, and more.
2. **Multi-Platform Support**. Integrates with QQ, WeChat Work, WeChat Official Accounts, Feishu, Telegram, DingTalk, Discord, KOOK, and other platforms. Supports rate limiting, whitelisting, and Baidu content moderation.
3. **Agent Capabilities**. Fully optimized agentic features including multi-turn tool calling, built-in sandboxed code executor, web search, and more.
4. **Plugin Extensions**. Deeply optimized plugin mechanism supporting [plugin development](https://astrbot.app/dev/plugin.html) to extend functionality, with a rich community plugin ecosystem.
5. **Web UI**. Visual configuration and management of your bot with comprehensive features.
#### Windows Installer
## Deployment Methods
Requires Python (>3.10). See docs: [Windows Installer Guide](https://astrbot.app/deploy/astrbot/windows.html)
#### Docker Deployment (Recommended 🥳)
#### Replit Deployment
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).
#### 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
## 🌍 Community
> [!TIP]
> Suggestions welcome via Issues <3
### QQ Groups
- [ ] 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
- Group 1: 322154837
- Group 3: 630166526
- Group 5: 822130018
- Group 6: 753075035
- Developer Group: 975206796
## ❤️ Contributions
### Telegram Group
All Issues/PRs welcome! Simply submit your changes to this project :)
<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>
For major features, please discuss via Issues first.
### Discord Server
## 🌟 Support
<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>
- Star this project!
- Support via [Afdian](https://afdian.com/a/soulter)
- WeChat support: [QR Code](https://drive.soulter.top/f/pYfA/d903f4fa49a496fda3f16d2be9e023b5.png)
## Supported Messaging Platforms
## ✨ Demos
**Officially Maintained**
> [!NOTE]
> Code executor file I/O currently tested with Napcat(QQ)/Lagrange(QQ)
- 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)
<div align='center'>
**Community Maintained**
<img src="https://github.com/user-attachments/assets/4ee688d9-467d-45c8-99d6-368f9a8a92d8" width="600">
- [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter)
- [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat)
- [Bilibili Direct Messages](https://github.com/Hina-Chat/astrbot_plugin_bilibili_adapter)
- [wxauto](https://github.com/luosheng520qaq/wxauto-repost-onebotv11)
_✨ Docker-based Sandboxed Code Executor (Beta) ✨_
## Supported Model Services
<img src="https://github.com/user-attachments/assets/0378f407-6079-4f64-ae4c-e97ab20611d2" height=500>
**LLM Services**
_✨ Multimodal Input, Web Search, Text-to-Image ✨_
- 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
<img src="https://github.com/user-attachments/assets/8ec12797-e70f-460a-959e-48eca39ca2bb" height=100>
**LLMOps Platforms**
_✨ Natural Language TODO Lists ✨_
- Dify
- Alibaba Cloud Bailian Applications
- Coze
<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>
**Speech-to-Text Services**
_✨ Plugin System Showcase ✨_
- OpenAI Whisper
- SenseVoice
<img src="https://github.com/user-attachments/assets/592a8630-14c7-4e06-b496-9c0386e4f36c" width=600>
**Text-to-Speech Services**
_✨ Web Dashboard ✨_
- OpenAI TTS
- Gemini TTS
- GPT-Sovits-Inference
- GPT-Sovits
- FishAudio
- Edge TTS
- Alibaba Cloud Bailian TTS
- Azure TTS
- Minimax TTS
- Volcano Engine TTS
![webchat](https://drive.soulter.top/f/vlsA/ezgif-5-fb044b2542.gif)
## ❤️ Contributing
_✨ Built-in Web Chat Interface ✨_
Issues and Pull Requests are always welcome! Feel free to submit your changes to this project :)
</div>
### How to Contribute
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.
### Development Environment
AstrBot uses `ruff` for code formatting and linting.
```bash
git clone https://github.com/AstrBotDevs/AstrBot
pip install pre-commit
pre-commit install
```
## ❤️ 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>
_私は、高性能ですから!_

View File

@@ -1,167 +1,233 @@
<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=%E4%B8%AA&style=for-the-badge&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%9C%BA&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://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>
## ✨ 使用方法
AstrBot は、オープンソースのオールインワン Agent チャットボットプラットフォーム及び開発フレームワークです。
#### Docker デプロイ
## 主な機能
公式ドキュメント [Docker を使用して AstrBot をデプロイする](https://astrbot.app/deploy/astrbot/docker.html#%E4%BD%BF%E7%94%A8-docker-%E9%83%A8%E7%BD%B2-astrbot) を参照してください
1. **大規模言語モデル対話**。多様な大規模言語モデルサービスとの統合をサポート。マルチモーダル、ツール呼び出し、MCP、ネイティブナレッジベース、キャラクター設定などの機能を搭載
2. **マルチメッセージプラットフォームサポート**。QQ、WeChat Work、WeChat公式アカウント、Feishu、Telegram、DingTalk、Discord、KOOK などのプラットフォームと統合可能。レート制限、ホワイトリスト、Baidu コンテンツ審査をサポート。
3. **Agent**。完全に最適化された Agentic 機能。マルチターンツール呼び出し、内蔵サンドボックスコード実行環境、Web 検索などの機能をサポート。
4. **プラグイン拡張**。深く最適化されたプラグインメカニズムで、[プラグイン開発](https://astrbot.app/dev/plugin.html)による機能拡張をサポート。豊富なコミュニティプラグインエコシステム。
5. **WebUI**。ビジュアル設定とボット管理、充実した機能。
#### Windows ワンクリックインストーラーのデプロイ
## デプロイ方法
コンピュータに Python>3.10)がインストールされている必要があります。公式ドキュメント [Windows ワンクリックインストーラーを使用して AstrBot をデプロイする](https://astrbot.app/deploy/astrbot/windows.html) を参照してください。
#### Docker デプロイ(推奨 🥳)
#### Replit デプロイ
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) をご参照ください。
#### 宝塔パネルデプロイ
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 の計画
## 🌍 コミュニティ
## ❤️ 貢献
### QQ グループ
Issue や Pull Request を歓迎します!このプロジェクトに変更を加えるだけです :)
- 1群:322154837
- 3群:630166526
- 5群:822130018
- 6群:753075035
- 開発者群:975206796
新機能の追加については、まず Issue で議論してください。
### 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>
- このプロジェクトに Star を付けてください!
- [愛発電](https://afdian.com/a/soulter)で私をサポートしてください!
- [WeChat](https://drive.soulter.top/f/pYfA/d903f4fa49a496fda3f16d2be9e023b5.png)で私をサポートしてください~
### 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>
> [!NOTE]
> コードエグゼキューターのファイル入力/出力は現在 Napcat(QQ)、Lagrange(QQ) でのみテストされています
## サポートされているメッセージプラットフォーム
<div align='center'>
**公式メンテナンス**
<img src="https://github.com/user-attachments/assets/4ee688d9-467d-45c8-99d6-368f9a8a92d8" width="600">
- QQ (公式プラットフォーム & OneBot)
- Telegram
- WeChat Work アプリケーション & WeChat Work インテリジェントボット
- WeChat カスタマーサービス & WeChat 公式アカウント
- Feishu (Lark)
- DingTalk
- Slack
- Discord
- Satori
- Misskey
- WhatsApp (近日対応予定)
- LINE (近日対応予定)
_✨ Docker ベースのサンドボックス化されたコードエグゼキューターベータテスト中✨_
**コミュニティメンテナンス**
<img src="https://github.com/user-attachments/assets/0378f407-6079-4f64-ae4c-e97ab20611d2" height=500>
- [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)
_✨ 多モーダル、ウェブ検索、長文の画像変換設定可能✨_
## サポートされているモデルサービス
<img src="https://github.com/user-attachments/assets/8ec12797-e70f-460a-959e-48eca39ca2bb" height=100>
**大規模言語モデルサービス**
_✨ 自然言語タスク ✨_
- 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/e137a9e1-340a-4bf2-bb2b-771132780735" height=150>
<img src="https://github.com/user-attachments/assets/480f5e82-cf6a-4955-a869-0d73137aa6e1" height=150>
**LLMOps プラットフォーム**
_✨ プラグインシステム - 一部のプラグインの展示 ✨_
- Dify
- Alibaba Cloud 百炼アプリケーション
- Coze
<img src="https://github.com/user-attachments/assets/592a8630-14c7-4e06-b496-9c0386e4f36c" width="600">
**音声認識サービス**
_✨ 管理パネル ✨_
- OpenAI Whisper
- SenseVoice
![webchat](https://drive.soulter.top/f/vlsA/ezgif-5-fb044b2542.gif)
**音声合成サービス**
_✨ 内蔵 Web Chat、オンラインでボットと対話 ✨_
- OpenAI TTS
- Gemini TTS
- GPT-Sovits-Inference
- GPT-Sovits
- FishAudio
- Edge TTS
- Alibaba Cloud 百炼 TTS
- Azure TTS
- Minimax TTS
- Volcano Engine TTS
</div>
## ❤️ コントリビューション
Issue や Pull Request は大歓迎です!このプロジェクトに変更を送信してください :)
### コントリビュート方法
Issue を確認したり、PR(プルリクエスト)のレビューを手伝うことで貢献できます。どんな Issue や PR への参加も歓迎され、コミュニティ貢献を促進します。もちろん、これらは提案に過ぎず、どんな方法でも貢献できます。新機能の追加については、まず Issue で議論してください。
### 開発環境
AstrBot はコードのフォーマットとチェックに `ruff` を使用しています。
```bash
git clone https://github.com/AstrBotDevs/AstrBot
pip install pre-commit
pre-commit install
```
## ❤️ 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>
_私は、高性能ですから!_

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"""
# Set running_event first to unblock any waiting tasks
self.running_event.set()
# 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()
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

View File

@@ -76,7 +76,7 @@ class ImageURLPart(ContentPart):
"""The ID of the image, to allow LLMs to distinguish different images."""
type: str = "image_url"
image_url: str
image_url: ImageURL
class AudioURLPart(ContentPart):
@@ -119,6 +119,13 @@ 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."""
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
if self.extra_content is None:
kwargs.setdefault("exclude", set()).add("extra_content")
return super().model_dump(**kwargs)
class ToolCallPart(BaseModel):

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

View File

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

View File

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

View File

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

View File

@@ -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()

View File

@@ -69,12 +69,6 @@ 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
async def _iter_llm_responses(self) -> T.AsyncGenerator[LLMResponse, None]:
"""Yields chunks *and* a final LLMResponse."""
if self.streaming:

View File

@@ -4,7 +4,7 @@ import os
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.5.7"
VERSION = "4.6.1"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
# 默认配置
@@ -68,6 +68,10 @@ DEFAULT_CONFIG = {
"dequeue_context_length": 1,
"streaming_response": False,
"show_tool_use_status": False,
"agent_runner_type": "local",
"dify_agent_runner_provider_id": "",
"coze_agent_runner_provider_id": "",
"dashscope_agent_runner_provider_id": "",
"unsupported_streaming_strategy": "realtime_segmenting",
"max_agent_step": 30,
"tool_call_timeout": 60,
@@ -137,10 +141,20 @@ DEFAULT_CONFIG = {
"kb_names": [], # 默认知识库名称列表
"kb_fusion_top_k": 20, # 知识库检索融合阶段返回结果数量
"kb_final_top_k": 5, # 知识库检索最终返回结果数量
"kb_agentic_mode": False,
}
# 配置项的中文描述、值类型
"""
AstrBot v3 时代的配置元数据,目前仅承担以下功能:
1. 保存配置时,配置项的类型验证
2. WebUI 展示提供商和平台适配器模版
WebUI 的配置文件在 `CONFIG_METADATA_3` 中。
未来将会逐步淘汰此配置元数据。
"""
CONFIG_METADATA_2 = {
"platform_group": {
"metadata": {
@@ -633,7 +647,7 @@ CONFIG_METADATA_2 = {
},
"words_count_threshold": {
"type": "int",
"hint": "超过这个字数的消息会被分段回复。默认为 150",
"hint": "分段回复的字数上限。只有字数小于此值的消息会被分段,超过此值的长消息将直接发送(不分段)。默认为 150",
},
"regex": {
"type": "string",
@@ -1010,7 +1024,7 @@ CONFIG_METADATA_2 = {
"id": "dify_app_default",
"provider": "dify",
"type": "dify",
"provider_type": "chat_completion",
"provider_type": "agent_runner",
"enable": True,
"dify_api_type": "chat",
"dify_api_key": "",
@@ -1024,20 +1038,20 @@ CONFIG_METADATA_2 = {
"Coze": {
"id": "coze",
"provider": "coze",
"provider_type": "chat_completion",
"provider_type": "agent_runner",
"type": "coze",
"enable": True,
"coze_api_key": "",
"bot_id": "",
"coze_api_base": "https://api.coze.cn",
"timeout": 60,
"auto_save_history": True,
# "auto_save_history": True,
},
"阿里云百炼应用": {
"id": "dashscope",
"provider": "dashscope",
"type": "dashscope",
"provider_type": "chat_completion",
"provider_type": "agent_runner",
"enable": True,
"dashscope_app_type": "agent",
"dashscope_api_key": "",
@@ -1086,7 +1100,7 @@ CONFIG_METADATA_2 = {
"api_base": "",
"model": "whisper-1",
},
"Whisper(本地加载)": {
"Whisper(Local)": {
"hint": "启用前请 pip 安装 openai-whisper 库N卡用户大约下载 2GB主要是 torch 和 cudaCPU 用户大约下载 1 GB并且安装 ffmpeg。否则将无法正常转文字。",
"provider": "openai",
"type": "openai_whisper_selfhost",
@@ -1095,7 +1109,7 @@ CONFIG_METADATA_2 = {
"id": "whisper_selfhost",
"model": "tiny",
},
"SenseVoice(本地加载)": {
"SenseVoice(Local)": {
"hint": "启用前请 pip 安装 funasr、funasr_onnx、torchaudio、torch、modelscope、jieba 库默认使用CPU大约下载 1 GB并且安装 ffmpeg。否则将无法正常转文字。",
"type": "sensevoice_stt_selfhost",
"provider": "sensevoice",
@@ -1130,7 +1144,7 @@ CONFIG_METADATA_2 = {
"pitch": "+0Hz",
"timeout": 20,
},
"GSV TTS(本地加载)": {
"GSV TTS(Local)": {
"id": "gsv_tts",
"enable": False,
"provider": "gpt_sovits",
@@ -1307,6 +1321,19 @@ CONFIG_METADATA_2 = {
"timeout": 20,
"launch_model_if_not_running": False,
},
"阿里云百炼重排序": {
"id": "bailian_rerank",
"type": "bailian_rerank",
"provider": "bailian",
"provider_type": "rerank",
"enable": True,
"rerank_api_key": "",
"rerank_api_base": "https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank",
"rerank_model": "qwen3-rerank",
"timeout": 30,
"return_documents": False,
"instruct": "",
},
"Xinference STT": {
"id": "xinference_stt",
"type": "xinference_stt",
@@ -1341,6 +1368,16 @@ CONFIG_METADATA_2 = {
"description": "重排序模型名称",
"type": "string",
},
"return_documents": {
"description": "是否在排序结果中返回文档原文",
"type": "bool",
"hint": "默认值false以减少网络传输开销。",
},
"instruct": {
"description": "自定义排序任务类型说明",
"type": "string",
"hint": "仅在使用 qwen3-rerank 模型时生效。建议使用英文撰写。",
},
"launch_model_if_not_running": {
"description": "模型未运行时自动启动",
"type": "bool",
@@ -1883,7 +1920,6 @@ CONFIG_METADATA_2 = {
"enable": {
"description": "启用",
"type": "bool",
"hint": "是否启用。",
},
"key": {
"description": "API Key",
@@ -2013,12 +2049,22 @@ CONFIG_METADATA_2 = {
"unsupported_streaming_strategy": {
"type": "string",
},
"agent_runner_type": {
"type": "string",
},
"dify_agent_runner_provider_id": {
"type": "string",
},
"coze_agent_runner_provider_id": {
"type": "string",
},
"dashscope_agent_runner_provider_id": {
"type": "string",
},
"max_agent_step": {
"description": "工具调用轮数上限",
"type": "int",
},
"tool_call_timeout": {
"description": "工具调用超时时间(秒)",
"type": "int",
},
},
@@ -2146,39 +2192,93 @@ CONFIG_METADATA_2 = {
"kb_names": {"type": "list", "items": {"type": "string"}},
"kb_fusion_top_k": {"type": "int", "default": 20},
"kb_final_top_k": {"type": "int", "default": 5},
"kb_agentic_mode": {"type": "bool"},
},
},
}
"""
v4.7.0 之后name, description, hint 等字段已经实现 i18n 国际化。国际化资源文件位于:
- dashboard/src/i18n/locales/en-US/features/config-metadata.json
- dashboard/src/i18n/locales/zh-CN/features/config-metadata.json
如果在此文件中添加了新的配置字段,请务必同步更新上述两个国际化资源文件。
"""
CONFIG_METADATA_3 = {
"ai_group": {
"name": "AI 配置",
"metadata": {
"ai": {
"description": "模型",
"agent_runner": {
"description": "Agent 执行方式",
"hint": "选择 AI 对话的执行器,默认为 AstrBot 内置 Agent 执行器,可使用 AstrBot 内的知识库、人格、工具调用功能。如果不打算接入 Dify 或 Coze 等第三方 Agent 执行器,不需要修改此节。",
"type": "object",
"items": {
"provider_settings.enable": {
"description": "启用大语言模型聊天",
"description": "启用",
"type": "bool",
"hint": "AI 对话总开关",
},
"provider_settings.agent_runner_type": {
"description": "执行器",
"type": "string",
"options": ["local", "dify", "coze", "dashscope"],
"labels": ["内置 Agent", "Dify", "Coze", "阿里云百炼应用"],
"condition": {
"provider_settings.enable": True,
},
},
"provider_settings.coze_agent_runner_provider_id": {
"description": "Coze Agent 执行器提供商 ID",
"type": "string",
"_special": "select_agent_runner_provider:coze",
"condition": {
"provider_settings.agent_runner_type": "coze",
"provider_settings.enable": True,
},
},
"provider_settings.dify_agent_runner_provider_id": {
"description": "Dify Agent 执行器提供商 ID",
"type": "string",
"_special": "select_agent_runner_provider:dify",
"condition": {
"provider_settings.agent_runner_type": "dify",
"provider_settings.enable": True,
},
},
"provider_settings.dashscope_agent_runner_provider_id": {
"description": "阿里云百炼应用 Agent 执行器提供商 ID",
"type": "string",
"_special": "select_agent_runner_provider:dashscope",
"condition": {
"provider_settings.agent_runner_type": "dashscope",
"provider_settings.enable": True,
},
},
},
},
"ai": {
"description": "模型",
"hint": "当使用非内置 Agent 执行器时,默认聊天模型和默认图片转述模型可能会无效,但某些插件会依赖此配置项来调用 AI 能力。",
"type": "object",
"items": {
"provider_settings.default_provider_id": {
"description": "默认聊天模型",
"type": "string",
"_special": "select_provider",
"hint": "留空时使用第一个模型",
"hint": "留空时使用第一个模型",
},
"provider_settings.default_image_caption_provider_id": {
"description": "默认图片转述模型",
"type": "string",
"_special": "select_provider",
"hint": "留空代表不使用可用于不支持视觉模态的聊天模型",
"hint": "留空代表不使用可用于非多模态模型",
},
"provider_stt_settings.enable": {
"description": "启用语音转文本",
"type": "bool",
"hint": "STT 总开关",
"hint": "STT 总开关",
},
"provider_stt_settings.provider_id": {
"description": "默认语音转文本模型",
@@ -2192,12 +2292,11 @@ CONFIG_METADATA_3 = {
"provider_tts_settings.enable": {
"description": "启用文本转语音",
"type": "bool",
"hint": "TTS 总开关。当关闭时,会话启用 TTS 也不会生效。",
"hint": "TTS 总开关",
},
"provider_tts_settings.provider_id": {
"description": "默认文本转语音模型",
"type": "string",
"hint": "用户也可使用 /provider 单独选择会话的 TTS 模型。",
"_special": "select_provider_tts",
"condition": {
"provider_tts_settings.enable": True,
@@ -2208,6 +2307,9 @@ CONFIG_METADATA_3 = {
"type": "text",
},
},
"condition": {
"provider_settings.enable": True,
},
},
"persona": {
"description": "人格",
@@ -2219,6 +2321,10 @@ CONFIG_METADATA_3 = {
"_special": "select_persona",
},
},
"condition": {
"provider_settings.agent_runner_type": "local",
"provider_settings.enable": True,
},
},
"knowledgebase": {
"description": "知识库",
@@ -2241,6 +2347,15 @@ CONFIG_METADATA_3 = {
"type": "int",
"hint": "从知识库中检索到的结果数量,越大可能获得越多相关信息,但也可能引入噪音。建议根据实际需求调整",
},
"kb_agentic_mode": {
"description": "Agentic 知识库检索",
"type": "bool",
"hint": "启用后,知识库检索将作为 LLM Tool由模型自主决定何时调用知识库进行查询。需要模型支持函数调用能力。",
},
},
"condition": {
"provider_settings.agent_runner_type": "local",
"provider_settings.enable": True,
},
},
"websearch": {
@@ -2278,6 +2393,10 @@ CONFIG_METADATA_3 = {
"type": "bool",
},
},
"condition": {
"provider_settings.agent_runner_type": "local",
"provider_settings.enable": True,
},
},
"others": {
"description": "其他配置",
@@ -2286,34 +2405,51 @@ CONFIG_METADATA_3 = {
"provider_settings.display_reasoning_text": {
"description": "显示思考内容",
"type": "bool",
"condition": {
"provider_settings.agent_runner_type": "local",
},
},
"provider_settings.identifier": {
"description": "用户识别",
"type": "bool",
"hint": "启用后,会在提示词前包含用户 ID 信息。",
},
"provider_settings.group_name_display": {
"description": "显示群名称",
"type": "bool",
"hint": "启用后,在支持的平台(aiocqhttp)上会在 prompt 中包含群名称信息。",
"hint": "启用后,在支持的平台(OneBot v11)上会在提示词前包含群名称信息。",
},
"provider_settings.datetime_system_prompt": {
"description": "现实世界时间感知",
"type": "bool",
"hint": "启用后,会在系统提示词中附带当前时间信息。",
"condition": {
"provider_settings.agent_runner_type": "local",
},
},
"provider_settings.show_tool_use_status": {
"description": "输出函数调用状态",
"type": "bool",
"condition": {
"provider_settings.agent_runner_type": "local",
},
},
"provider_settings.max_agent_step": {
"description": "工具调用轮数上限",
"type": "int",
"condition": {
"provider_settings.agent_runner_type": "local",
},
},
"provider_settings.tool_call_timeout": {
"description": "工具调用超时时间(秒)",
"type": "int",
"condition": {
"provider_settings.agent_runner_type": "local",
},
},
"provider_settings.streaming_response": {
"description": "流式回复",
"description": "流式输出",
"type": "bool",
},
"provider_settings.unsupported_streaming_strategy": {
@@ -2329,17 +2465,23 @@ CONFIG_METADATA_3 = {
"provider_settings.max_context_length": {
"description": "最多携带对话轮数",
"type": "int",
"hint": "超出这个数量时丢弃最旧的部分,一轮聊天记为 1 条-1 为不限制",
"hint": "超出这个数量时丢弃最旧的部分,一轮聊天记为 1 条-1 为不限制",
"condition": {
"provider_settings.agent_runner_type": "local",
},
},
"provider_settings.dequeue_context_length": {
"description": "丢弃对话轮数",
"type": "int",
"hint": "超出最多携带对话轮数时, 一次丢弃的聊天轮数",
"hint": "超出最多携带对话轮数时, 一次丢弃的聊天轮数",
"condition": {
"provider_settings.agent_runner_type": "local",
},
},
"provider_settings.wake_prefix": {
"description": "LLM 聊天额外唤醒前缀 ",
"type": "string",
"hint": "如果唤醒前缀为 `/`, 额外聊天唤醒前缀为 `chat`,则需要 `/chat` 才会触发 LLM 请求。默认为空。",
"hint": "如果唤醒前缀为 /, 额外聊天唤醒前缀为 chat则需要 /chat 才会触发 LLM 请求",
},
"provider_settings.prompt_prefix": {
"description": "用户提示词",
@@ -2351,6 +2493,9 @@ CONFIG_METADATA_3 = {
"type": "bool",
},
},
"condition": {
"provider_settings.enable": True,
},
},
},
},

View File

@@ -0,0 +1,110 @@
"""
配置元数据国际化工具
提供配置元数据的国际化键转换功能
"""
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",
]:
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

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
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,7 @@ 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.migra_helper import migra
from . import astrbot_config, html_renderer
from .event_bus import EventBus
@@ -96,11 +97,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())
# 初始化事件队列

View File

@@ -13,6 +13,7 @@ from astrbot.core.db.po import (
ConversationV2,
Persona,
PlatformMessageHistory,
PlatformSession,
PlatformStat,
Preference,
Stats,
@@ -183,7 +184,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
@@ -313,3 +314,51 @@ 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[PlatformSession]:
"""Get all Platform sessions for a specific creator (username) and optionally platform."""
...
@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."""
...

View File

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

View File

@@ -3,13 +3,7 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import TypedDict
from sqlmodel import (
JSON,
Field,
SQLModel,
Text,
UniqueConstraint,
)
from sqlmodel import JSON, Field, SQLModel, Text, UniqueConstraint
class PlatformStat(SQLModel, table=True):
@@ -18,7 +12,7 @@ class PlatformStat(SQLModel, table=True):
Note: In astrbot v4, we moved `platform` table to here.
"""
__tablename__ = "platform_stats"
__tablename__ = "platform_stats" # type: ignore
id: int = Field(primary_key=True, sa_column_kwargs={"autoincrement": True})
timestamp: datetime = Field(nullable=False)
@@ -37,7 +31,7 @@ class PlatformStat(SQLModel, table=True):
class ConversationV2(SQLModel, table=True):
__tablename__ = "conversations"
__tablename__ = "conversations" # type: ignore
inner_conversation_id: int = Field(
primary_key=True,
@@ -74,7 +68,7 @@ class Persona(SQLModel, table=True):
It can be used to customize the behavior of LLMs.
"""
__tablename__ = "personas"
__tablename__ = "personas" # type: ignore
id: int | None = Field(
primary_key=True,
@@ -104,7 +98,7 @@ class Persona(SQLModel, table=True):
class Preference(SQLModel, table=True):
"""This class represents preferences for bots."""
__tablename__ = "preferences"
__tablename__ = "preferences" # type: ignore
id: int | None = Field(
default=None,
@@ -140,7 +134,7 @@ class PlatformMessageHistory(SQLModel, table=True):
or platform-specific messages.
"""
__tablename__ = "platform_message_history"
__tablename__ = "platform_message_history" # type: ignore
id: int | None = Field(
primary_key=True,
@@ -161,13 +155,55 @@ class PlatformMessageHistory(SQLModel, table=True):
)
class PlatformSession(SQLModel, table=True):
"""Platform session table for managing user sessions across different platforms.
A session represents a chat window for a specific user on a specific platform.
Each session can have multiple conversations (对话) associated with it.
"""
__tablename__ = "platform_sessions" # type: ignore
inner_id: int | None = Field(
primary_key=True,
sa_column_kwargs={"autoincrement": True},
default=None,
)
session_id: str = Field(
max_length=100,
nullable=False,
unique=True,
default_factory=lambda: str(uuid.uuid4()),
)
platform_id: str = Field(default="webchat", nullable=False)
"""Platform identifier (e.g., 'webchat', 'qq', 'discord')"""
creator: str = Field(nullable=False)
"""Username of the session creator"""
display_name: str | None = Field(default=None, max_length=255)
"""Display name for the session"""
is_group: int = Field(default=0, nullable=False)
"""0 for private chat, 1 for group chat (not implemented yet)"""
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
__table_args__ = (
UniqueConstraint(
"session_id",
name="uix_platform_session_id",
),
)
class Attachment(SQLModel, table=True):
"""This class represents attachments for messages in AstrBot.
Attachments can be images, files, or other media types.
"""
__tablename__ = "attachments"
__tablename__ = "attachments" # type: ignore
inner_attachment_id: int | None = Field(
primary_key=True,

View File

@@ -1,7 +1,7 @@
import asyncio
import threading
import typing as T
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import col, delete, desc, func, or_, select, text, update
@@ -12,6 +12,7 @@ from astrbot.core.db.po import (
ConversationV2,
Persona,
PlatformMessageHistory,
PlatformSession,
PlatformStat,
Preference,
SQLModel,
@@ -412,7 +413,7 @@ class SQLiteDatabase(BaseDatabase):
user_id,
offset_sec=86400,
):
"""Delete platform message history records older than the specified offset."""
"""Delete platform message history records newer than the specified offset."""
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
@@ -422,7 +423,7 @@ class SQLiteDatabase(BaseDatabase):
delete(PlatformMessageHistory).where(
col(PlatformMessageHistory.platform_id) == platform_id,
col(PlatformMessageHistory.user_id) == user_id,
col(PlatformMessageHistory.created_at) < cutoff_time,
col(PlatformMessageHistory.created_at) >= cutoff_time,
),
)
@@ -709,3 +710,101 @@ class SQLiteDatabase(BaseDatabase):
t.start()
t.join()
return result
# ====
# Platform Session Management
# ====
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."""
kwargs = {}
if session_id:
kwargs["session_id"] = session_id
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
new_session = PlatformSession(
creator=creator,
platform_id=platform_id,
display_name=display_name,
is_group=is_group,
**kwargs,
)
session.add(new_session)
await session.flush()
await session.refresh(new_session)
return new_session
async def get_platform_session_by_id(
self, session_id: str
) -> PlatformSession | None:
"""Get a Platform session by its ID."""
async with self.get_db() as session:
session: AsyncSession
query = select(PlatformSession).where(
PlatformSession.session_id == session_id,
)
result = await session.execute(query)
return result.scalar_one_or_none()
async def get_platform_sessions_by_creator(
self,
creator: str,
platform_id: str | None = None,
page: int = 1,
page_size: int = 20,
) -> list[PlatformSession]:
"""Get all Platform sessions for a specific creator (username) and optionally platform."""
async with self.get_db() as session:
session: AsyncSession
offset = (page - 1) * page_size
query = select(PlatformSession).where(PlatformSession.creator == creator)
if platform_id:
query = query.where(PlatformSession.platform_id == platform_id)
query = (
query.order_by(desc(PlatformSession.updated_at))
.offset(offset)
.limit(page_size)
)
result = await session.execute(query)
return list(result.scalars().all())
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."""
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
values: dict[str, T.Any] = {"updated_at": datetime.now(timezone.utc)}
if display_name is not None:
values["display_name"] = display_name
await session.execute(
update(PlatformSession)
.where(col(PlatformSession.session_id) == session_id)
.values(**values),
)
async def delete_platform_session(self, session_id: str) -> None:
"""Delete a Platform session by its ID."""
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
await session.execute(
delete(PlatformSession).where(
col(PlatformSession.session_id) == session_id,
),
)

View File

@@ -1,4 +1,7 @@
import asyncio
import json
import re
import time
import uuid
from pathlib import Path
@@ -8,12 +11,98 @@ from astrbot.core import logger
from astrbot.core.db.vec_db.base import BaseVecDB
from astrbot.core.db.vec_db.faiss_impl.vec_db import FaissVecDB
from astrbot.core.provider.manager import ProviderManager
from astrbot.core.provider.provider import EmbeddingProvider, RerankProvider
from astrbot.core.provider.provider import (
EmbeddingProvider,
RerankProvider,
)
from astrbot.core.provider.provider import (
Provider as LLMProvider,
)
from .chunking.base import BaseChunker
from .chunking.recursive import RecursiveCharacterChunker
from .kb_db_sqlite import KBSQLiteDatabase
from .models import KBDocument, KBMedia, KnowledgeBase
from .parsers.url_parser import extract_text_from_url
from .parsers.util import select_parser
from .prompts import TEXT_REPAIR_SYSTEM_PROMPT
class RateLimiter:
"""一个简单的速率限制器"""
def __init__(self, max_rpm: int):
self.max_per_minute = max_rpm
self.interval = 60.0 / max_rpm if max_rpm > 0 else 0
self.last_call_time = 0
async def __aenter__(self):
if self.interval == 0:
return
now = time.monotonic()
elapsed = now - self.last_call_time
if elapsed < self.interval:
await asyncio.sleep(self.interval - elapsed)
self.last_call_time = time.monotonic()
async def __aexit__(self, exc_type, exc_val, exc_tb):
pass
async def _repair_and_translate_chunk_with_retry(
chunk: str,
repair_llm_service: LLMProvider,
rate_limiter: RateLimiter,
max_retries: int = 2,
) -> list[str]:
"""
Repairs, translates, and optionally re-chunks a single text chunk using the small LLM, with rate limiting.
"""
# 为了防止 LLM 上下文污染,在 user_prompt 中也加入明确的指令
user_prompt = f"""IGNORE ALL PREVIOUS INSTRUCTIONS. Your ONLY task is to process the following text chunk according to the system prompt provided.
Text chunk to process:
---
{chunk}
---
"""
for attempt in range(max_retries + 1):
try:
async with rate_limiter:
response = await repair_llm_service.text_chat(
prompt=user_prompt, system_prompt=TEXT_REPAIR_SYSTEM_PROMPT
)
llm_output = response.completion_text
if "<discard_chunk />" in llm_output:
return [] # Signal to discard this chunk
# More robust regex to handle potential LLM formatting errors (spaces, newlines in tags)
matches = re.findall(
r"<\s*repaired_text\s*>\s*(.*?)\s*<\s*/\s*repaired_text\s*>",
llm_output,
re.DOTALL,
)
if matches:
# Further cleaning to ensure no empty strings are returned
return [m.strip() for m in matches if m.strip()]
else:
# If no valid tags and not explicitly discarded, discard it to be safe.
return []
except Exception as e:
logger.warning(
f" - LLM call failed on attempt {attempt + 1}/{max_retries + 1}. Error: {str(e)}"
)
logger.error(
f" - Failed to process chunk after {max_retries + 1} attempts. Using original text."
)
return [chunk]
class KBHelper:
@@ -100,7 +189,7 @@ class KBHelper:
async def upload_document(
self,
file_name: str,
file_content: bytes,
file_content: bytes | None,
file_type: str,
chunk_size: int = 512,
chunk_overlap: int = 50,
@@ -108,6 +197,7 @@ class KBHelper:
tasks_limit: int = 3,
max_retries: int = 3,
progress_callback=None,
pre_chunked_text: list[str] | None = None,
) -> KBDocument:
"""上传并处理文档(带原子性保证和失败清理)
@@ -130,46 +220,63 @@ class KBHelper:
await self._ensure_vec_db()
doc_id = str(uuid.uuid4())
media_paths: list[Path] = []
file_size = 0
# file_path = self.kb_files_dir / f"{doc_id}.{file_type}"
# async with aiofiles.open(file_path, "wb") as f:
# await f.write(file_content)
try:
# 阶段1: 解析文档
if progress_callback:
await progress_callback("parsing", 0, 100)
parser = await select_parser(f".{file_type}")
parse_result = await parser.parse(file_content, file_name)
text_content = parse_result.text
media_items = parse_result.media
if progress_callback:
await progress_callback("parsing", 100, 100)
# 保存媒体文件
chunks_text = []
saved_media = []
for media_item in media_items:
media = await self._save_media(
doc_id=doc_id,
media_type=media_item.media_type,
file_name=media_item.file_name,
content=media_item.content,
mime_type=media_item.mime_type,
if pre_chunked_text is not None:
# 如果提供了预分块文本,直接使用
chunks_text = pre_chunked_text
file_size = sum(len(chunk) for chunk in chunks_text)
logger.info(f"使用预分块文本进行上传,共 {len(chunks_text)} 个块。")
else:
# 否则,执行标准的文件解析和分块流程
if file_content is None:
raise ValueError(
"当未提供 pre_chunked_text 时file_content 不能为空。"
)
file_size = len(file_content)
# 阶段1: 解析文档
if progress_callback:
await progress_callback("parsing", 0, 100)
parser = await select_parser(f".{file_type}")
parse_result = await parser.parse(file_content, file_name)
text_content = parse_result.text
media_items = parse_result.media
if progress_callback:
await progress_callback("parsing", 100, 100)
# 保存媒体文件
for media_item in media_items:
media = await self._save_media(
doc_id=doc_id,
media_type=media_item.media_type,
file_name=media_item.file_name,
content=media_item.content,
mime_type=media_item.mime_type,
)
saved_media.append(media)
media_paths.append(Path(media.file_path))
# 阶段2: 分块
if progress_callback:
await progress_callback("chunking", 0, 100)
chunks_text = await self.chunker.chunk(
text_content,
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
)
saved_media.append(media)
media_paths.append(Path(media.file_path))
# 阶段2: 分块
if progress_callback:
await progress_callback("chunking", 0, 100)
chunks_text = await self.chunker.chunk(
text_content,
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
)
contents = []
metadatas = []
for idx, chunk_text in enumerate(chunks_text):
@@ -205,7 +312,7 @@ class KBHelper:
kb_id=self.kb.kb_id,
doc_name=file_name,
file_type=file_type,
file_size=len(file_content),
file_size=file_size,
# file_path=str(file_path),
file_path="",
chunk_count=len(chunks_text),
@@ -359,3 +466,177 @@ class KBHelper:
)
return media
async def upload_from_url(
self,
url: str,
chunk_size: int = 512,
chunk_overlap: int = 50,
batch_size: int = 32,
tasks_limit: int = 3,
max_retries: int = 3,
progress_callback=None,
enable_cleaning: bool = False,
cleaning_provider_id: str | None = None,
) -> KBDocument:
"""从 URL 上传并处理文档(带原子性保证和失败清理)
Args:
url: 要提取内容的网页 URL
chunk_size: 文本块大小
chunk_overlap: 文本块重叠大小
batch_size: 批处理大小
tasks_limit: 并发任务限制
max_retries: 最大重试次数
progress_callback: 进度回调函数,接收参数 (stage, current, total)
- stage: 当前阶段 ('extracting', 'cleaning', 'parsing', 'chunking', 'embedding')
- current: 当前进度
- total: 总数
Returns:
KBDocument: 上传的文档对象
Raises:
ValueError: 如果 URL 为空或无法提取内容
IOError: 如果网络请求失败
"""
# 获取 Tavily API 密钥
config = self.prov_mgr.acm.default_conf
tavily_keys = config.get("provider_settings", {}).get(
"websearch_tavily_key", []
)
if not tavily_keys:
raise ValueError(
"Error: Tavily API key is not configured in provider_settings."
)
# 阶段1: 从 URL 提取内容
if progress_callback:
await progress_callback("extracting", 0, 100)
try:
text_content = await extract_text_from_url(url, tavily_keys)
except Exception as e:
logger.error(f"Failed to extract content from URL {url}: {e}")
raise OSError(f"Failed to extract content from URL {url}: {e}") from e
if not text_content:
raise ValueError(f"No content extracted from URL: {url}")
if progress_callback:
await progress_callback("extracting", 100, 100)
# 阶段2: (可选)清洗内容并分块
final_chunks = await self._clean_and_rechunk_content(
content=text_content,
url=url,
progress_callback=progress_callback,
enable_cleaning=enable_cleaning,
cleaning_provider_id=cleaning_provider_id,
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
)
if enable_cleaning and not final_chunks:
raise ValueError(
"内容清洗后未提取到有效文本。请尝试关闭内容清洗功能或更换更高性能的LLM模型后重试。"
)
# 创建一个虚拟文件名
file_name = url.split("/")[-1] or f"document_from_{url}"
if not Path(file_name).suffix:
file_name += ".url"
# 复用现有的 upload_document 方法,但传入预分块文本
return await self.upload_document(
file_name=file_name,
file_content=None,
file_type="url", # 使用 'url' 作为特殊文件类型
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
batch_size=batch_size,
tasks_limit=tasks_limit,
max_retries=max_retries,
progress_callback=progress_callback,
pre_chunked_text=final_chunks,
)
async def _clean_and_rechunk_content(
self,
content: str,
url: str,
progress_callback=None,
enable_cleaning: bool = False,
cleaning_provider_id: str | None = None,
repair_max_rpm: int = 60,
chunk_size: int = 512,
chunk_overlap: int = 50,
) -> list[str]:
"""
对从 URL 获取的内容进行清洗、修复、翻译和重新分块。
"""
if not enable_cleaning:
# 如果不启用清洗,则使用从前端传递的参数进行分块
logger.info(
f"内容清洗未启用,使用指定参数进行分块: chunk_size={chunk_size}, chunk_overlap={chunk_overlap}"
)
return await self.chunker.chunk(
content, chunk_size=chunk_size, chunk_overlap=chunk_overlap
)
if not cleaning_provider_id:
logger.warning(
"启用了内容清洗,但未提供 cleaning_provider_id跳过清洗并使用默认分块。"
)
return await self.chunker.chunk(content)
if progress_callback:
await progress_callback("cleaning", 0, 100)
try:
# 获取指定的 LLM Provider
llm_provider = await self.prov_mgr.get_provider_by_id(cleaning_provider_id)
if not llm_provider or not isinstance(llm_provider, LLMProvider):
raise ValueError(
f"无法找到 ID 为 {cleaning_provider_id} 的 LLM Provider 或类型不正确"
)
# 初步分块
# 优化分隔符,优先按段落分割,以获得更高质量的文本块
text_splitter = RecursiveCharacterChunker(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
separators=["\n\n", "\n", " "], # 优先使用段落分隔符
)
initial_chunks = await text_splitter.chunk(content)
logger.info(f"初步分块完成,生成 {len(initial_chunks)} 个块用于修复。")
# 并发处理所有块
rate_limiter = RateLimiter(repair_max_rpm)
tasks = [
_repair_and_translate_chunk_with_retry(
chunk, llm_provider, rate_limiter
)
for chunk in initial_chunks
]
repaired_results = await asyncio.gather(*tasks, return_exceptions=True)
final_chunks = []
for i, result in enumerate(repaired_results):
if isinstance(result, Exception):
logger.warning(f"{i} 处理异常: {str(result)}. 回退到原始块。")
final_chunks.append(initial_chunks[i])
elif isinstance(result, list):
final_chunks.extend(result)
logger.info(
f"文本修复完成: {len(initial_chunks)} 个原始块 -> {len(final_chunks)} 个最终块。"
)
if progress_callback:
await progress_callback("cleaning", 100, 100)
return final_chunks
except Exception as e:
logger.error(f"使用 Provider '{cleaning_provider_id}' 清洗内容失败: {e}")
# 清洗失败,返回默认分块结果,保证流程不中断
return await self.chunker.chunk(content)

View File

@@ -8,7 +8,7 @@ from astrbot.core.provider.manager import ProviderManager
from .chunking.recursive import RecursiveCharacterChunker
from .kb_db_sqlite import KBSQLiteDatabase
from .kb_helper import KBHelper
from .models import KnowledgeBase
from .models import KBDocument, KnowledgeBase
from .retrieval.manager import RetrievalManager, RetrievalResult
from .retrieval.rank_fusion import RankFusion
from .retrieval.sparse_retriever import SparseRetriever
@@ -284,3 +284,47 @@ class KnowledgeBaseManager:
await self.kb_db.close()
except Exception as e:
logger.error(f"关闭知识库元数据数据库失败: {e}")
async def upload_from_url(
self,
kb_id: str,
url: str,
chunk_size: int = 512,
chunk_overlap: int = 50,
batch_size: int = 32,
tasks_limit: int = 3,
max_retries: int = 3,
progress_callback=None,
) -> KBDocument:
"""从 URL 上传文档到指定的知识库
Args:
kb_id: 知识库 ID
url: 要提取内容的网页 URL
chunk_size: 文本块大小
chunk_overlap: 文本块重叠大小
batch_size: 批处理大小
tasks_limit: 并发任务限制
max_retries: 最大重试次数
progress_callback: 进度回调函数
Returns:
KBDocument: 上传的文档对象
Raises:
ValueError: 如果知识库不存在或 URL 为空
IOError: 如果网络请求失败
"""
kb_helper = await self.get_kb(kb_id)
if not kb_helper:
raise ValueError(f"Knowledge base with id {kb_id} not found.")
return await kb_helper.upload_from_url(
url=url,
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
batch_size=batch_size,
tasks_limit=tasks_limit,
max_retries=max_retries,
progress_callback=progress_callback,
)

View File

@@ -0,0 +1,103 @@
import asyncio
import aiohttp
class URLExtractor:
"""URL 内容提取器,封装了 Tavily API 调用和密钥管理"""
def __init__(self, tavily_keys: list[str]):
"""
初始化 URL 提取器
Args:
tavily_keys: Tavily API 密钥列表
"""
if not tavily_keys:
raise ValueError("Error: Tavily API keys are not configured.")
self.tavily_keys = tavily_keys
self.tavily_key_index = 0
self.tavily_key_lock = asyncio.Lock()
async def _get_tavily_key(self) -> str:
"""并发安全的从列表中获取并轮换Tavily API密钥。"""
async with self.tavily_key_lock:
key = self.tavily_keys[self.tavily_key_index]
self.tavily_key_index = (self.tavily_key_index + 1) % len(self.tavily_keys)
return key
async def extract_text_from_url(self, url: str) -> str:
"""
使用 Tavily API 从 URL 提取主要文本内容。
这是 web_searcher 插件中 tavily_extract_web_page 方法的简化版本,
专门为知识库模块设计,不依赖 AstrMessageEvent。
Args:
url: 要提取内容的网页 URL
Returns:
提取的文本内容
Raises:
ValueError: 如果 URL 为空或 API 密钥未配置
IOError: 如果请求失败或返回错误
"""
if not url:
raise ValueError("Error: url must be a non-empty string.")
tavily_key = await self._get_tavily_key()
api_url = "https://api.tavily.com/extract"
headers = {
"Authorization": f"Bearer {tavily_key}",
"Content-Type": "application/json",
}
payload = {
"urls": [url],
"extract_depth": "basic", # 使用基础提取深度
}
try:
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.post(
api_url,
json=payload,
headers=headers,
timeout=30.0, # 增加超时时间,因为内容提取可能需要更长时间
) as response:
if response.status != 200:
reason = await response.text()
raise OSError(
f"Tavily web extraction failed: {reason}, status: {response.status}"
)
data = await response.json()
results = data.get("results", [])
if not results:
raise ValueError(f"No content extracted from URL: {url}")
# 返回第一个结果的内容
return results[0].get("raw_content", "")
except aiohttp.ClientError as e:
raise OSError(f"Failed to fetch URL {url}: {e}") from e
except Exception as e:
raise OSError(f"Failed to extract content from URL {url}: {e}") from e
# 为了向后兼容,提供一个简单的函数接口
async def extract_text_from_url(url: str, tavily_keys: list[str]) -> str:
"""
简单的函数接口,用于从 URL 提取文本内容
Args:
url: 要提取内容的网页 URL
tavily_keys: Tavily API 密钥列表
Returns:
提取的文本内容
"""
extractor = URLExtractor(tavily_keys)
return await extractor.extract_text_from_url(url)

View File

@@ -0,0 +1,65 @@
TEXT_REPAIR_SYSTEM_PROMPT = """You are a meticulous digital archivist. Your mission is to reconstruct a clean, readable article from raw, noisy text chunks.
**Core Task:**
1. **Analyze:** Examine the text chunk to separate "signal" (substantive information) from "noise" (UI elements, ads, navigation, footers).
2. **Process:** Clean and repair the signal. **Do not translate it.** Keep the original language.
**Crucial Rules:**
- **NEVER discard a chunk if it contains ANY valuable information.** Your primary duty is to salvage content.
- **If a chunk contains multiple distinct topics, split them.** Enclose each topic in its own `<repaired_text>` tag.
- Your output MUST be ONLY `<repaired_text>...</repaired_text>` tags or a single `<discard_chunk />` tag.
---
**Example 1: Chunk with Noise and Signal**
*Input Chunk:*
"Home | About | Products | **The Llama is a domesticated South American camelid.** | © 2025 ACME Corp."
*Your Thought Process:*
1. "Home | About | Products..." and "© 2025 ACME Corp." are noise.
2. "The Llama is a domesticated..." is the signal.
3. I must extract the signal and wrap it.
*Your Output:*
<repaired_text>
The Llama is a domesticated South American camelid.
</repaired_text>
---
**Example 2: Chunk with ONLY Noise**
*Input Chunk:*
"Next Page > | Subscribe to our newsletter | Follow us on X"
*Your Thought Process:*
1. This entire chunk is noise. There is no signal.
2. I must discard this.
*Your Output:*
<discard_chunk />
---
**Example 3: Chunk with Multiple Topics (Requires Splitting)**
*Input Chunk:*
"## Chapter 1: The Sun
The Sun is the star at the center of the Solar System.
## Chapter 2: The Moon
The Moon is Earth's only natural satellite."
*Your Thought Process:*
1. This chunk contains two distinct topics.
2. I must process them separately to maintain semantic integrity.
3. I will create two `<repaired_text>` blocks.
*Your Output:*
<repaired_text>
## Chapter 1: The Sun
The Sun is the star at the center of the Solar System.
</repaired_text>
<repaired_text>
## Chapter 2: The Moon
The Moon is Earth's only natural satellite.
</repaired_text>
"""

View File

@@ -0,0 +1,48 @@
from collections.abc import AsyncGenerator
from astrbot.core import logger
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.star.session_llm_manager import SessionServiceManager
from ...context import PipelineContext
from ..stage import Stage
from .agent_sub_stages.internal import InternalAgentSubStage
from .agent_sub_stages.third_party import ThirdPartyAgentSubStage
class AgentRequestSubStage(Stage):
async def initialize(self, ctx: PipelineContext) -> None:
self.ctx = ctx
self.config = ctx.astrbot_config
self.bot_wake_prefixs: list[str] = self.config["wake_prefix"]
self.prov_wake_prefix: str = self.config["provider_settings"]["wake_prefix"]
for bwp in self.bot_wake_prefixs:
if self.prov_wake_prefix.startswith(bwp):
logger.info(
f"识别 LLM 聊天额外唤醒前缀 {self.prov_wake_prefix} 以机器人唤醒前缀 {bwp} 开头,已自动去除。",
)
self.prov_wake_prefix = self.prov_wake_prefix[len(bwp) :]
agent_runner_type = self.config["provider_settings"]["agent_runner_type"]
if agent_runner_type == "local":
self.agent_sub_stage = InternalAgentSubStage()
else:
self.agent_sub_stage = ThirdPartyAgentSubStage()
await self.agent_sub_stage.initialize(ctx)
async def process(self, event: AstrMessageEvent) -> AsyncGenerator[None, None]:
if not self.ctx.astrbot_config["provider_settings"]["enable"]:
logger.debug(
"This pipeline does not enable AI capability, skip processing."
)
return
if not SessionServiceManager.should_process_llm_request(event):
logger.debug(
f"The session {event.unified_msg_origin} has disabled AI capability, skipping processing."
)
return
async for resp in self.agent_sub_stage.process(event, self.prov_wake_prefix):
yield resp

View File

@@ -21,27 +21,24 @@ from astrbot.core.provider.entities import (
LLMResponse,
ProviderRequest,
)
from astrbot.core.star.session_llm_manager import SessionServiceManager
from astrbot.core.star.star_handler import EventType, star_map
from astrbot.core.utils.metrics import Metric
from astrbot.core.utils.session_lock import session_lock_manager
from ....astr_agent_context import AgentContextWrapper
from ....astr_agent_hooks import MAIN_AGENT_HOOKS
from ....astr_agent_run_util import AgentRunner, run_agent
from ....astr_agent_tool_exec import FunctionToolExecutor
from ...context import PipelineContext, call_event_hook
from ..stage import Stage
from ..utils import inject_kb_context
from .....astr_agent_context import AgentContextWrapper
from .....astr_agent_hooks import MAIN_AGENT_HOOKS
from .....astr_agent_run_util import AgentRunner, run_agent
from .....astr_agent_tool_exec import FunctionToolExecutor
from ....context import PipelineContext, call_event_hook
from ...stage import Stage
from ...utils import KNOWLEDGE_BASE_QUERY_TOOL, retrieve_knowledge_base
class LLMRequestSubStage(Stage):
class InternalAgentSubStage(Stage):
async def initialize(self, ctx: PipelineContext) -> None:
self.ctx = ctx
conf = ctx.astrbot_config
settings = conf["provider_settings"]
self.bot_wake_prefixs: list[str] = conf["wake_prefix"] # list
self.provider_wake_prefix: str = settings["wake_prefix"] # str
self.max_context_length = settings["max_context_length"] # int
self.dequeue_context_length: int = min(
max(1, settings["dequeue_context_length"]),
@@ -57,13 +54,7 @@ class LLMRequestSubStage(Stage):
self.max_step = 30
self.show_tool_use: bool = settings.get("show_tool_use_status", True)
self.show_reasoning = settings.get("display_reasoning_text", False)
for bwp in self.bot_wake_prefixs:
if self.provider_wake_prefix.startswith(bwp):
logger.info(
f"识别 LLM 聊天额外唤醒前缀 {self.provider_wake_prefix} 以机器人唤醒前缀 {bwp} 开头,已自动去除。",
)
self.provider_wake_prefix = self.provider_wake_prefix[len(bwp) :]
self.kb_agentic_mode: bool = conf.get("kb_agentic_mode", False)
self.conv_manager = ctx.plugin_manager.context.conversation_manager
@@ -95,20 +86,33 @@ class LLMRequestSubStage(Stage):
raise RuntimeError("无法创建新的对话。")
return conversation
async def _apply_kb_context(
async def _apply_kb(
self,
event: AstrMessageEvent,
req: ProviderRequest,
):
"""应用知识库上下文到请求中"""
try:
await inject_kb_context(
umo=event.unified_msg_origin,
p_ctx=self.ctx,
req=req,
)
except Exception as e:
logger.error(f"调用知识库时遇到问题: {e}")
"""Apply knowledge base context to the provider request"""
if not self.kb_agentic_mode:
if req.prompt is None:
return
try:
kb_result = await retrieve_knowledge_base(
query=req.prompt,
umo=event.unified_msg_origin,
context=self.ctx.plugin_manager.context,
)
if not kb_result:
return
if req.system_prompt is not None:
req.system_prompt += (
f"\n\n[Related Knowledge Base Results]:\n{kb_result}"
)
except Exception as e:
logger.error(f"Error occurred while retrieving knowledge base: {e}")
else:
if req.func_tool is None:
req.func_tool = ToolSet()
req.func_tool.add_tool(KNOWLEDGE_BASE_QUERY_TOOL)
def _truncate_contexts(
self,
@@ -290,21 +294,10 @@ class LLMRequestSubStage(Stage):
return fixed_messages
async def process(
self,
event: AstrMessageEvent,
_nested: bool = False,
) -> None | AsyncGenerator[None, None]:
self, event: AstrMessageEvent, provider_wake_prefix: str
) -> AsyncGenerator[None, None]:
req: ProviderRequest | None = None
if not self.ctx.astrbot_config["provider_settings"]["enable"]:
logger.debug("未启用 LLM 能力,跳过处理。")
return
# 检查会话级别的LLM启停状态
if not SessionServiceManager.should_process_llm_request(event):
logger.debug(f"会话 {event.unified_msg_origin} 禁用了 LLM跳过处理。")
return
provider = self._select_provider(event)
if provider is None:
return
@@ -334,12 +327,12 @@ class LLMRequestSubStage(Stage):
req.image_urls = []
if sel_model := event.get_extra("selected_model"):
req.model = sel_model
if self.provider_wake_prefix and not event.message_str.startswith(
self.provider_wake_prefix
if provider_wake_prefix and not event.message_str.startswith(
provider_wake_prefix
):
return
req.prompt = event.message_str[len(self.provider_wake_prefix) :]
req.prompt = event.message_str[len(provider_wake_prefix) :]
# func_tool selection 现在已经转移到 packages/astrbot 插件中进行选择。
# req.func_tool = self.ctx.plugin_manager.context.get_llm_tool_manager()
for comp in event.message_obj.message:
@@ -356,13 +349,13 @@ class LLMRequestSubStage(Stage):
if not req.prompt and not req.image_urls:
return
# apply knowledge base context
await self._apply_kb_context(event, req)
# call event hook
if await call_event_hook(event, EventType.OnLLMRequestEvent, req):
return
# apply knowledge base feature
await self._apply_kb(event, req)
# fix contexts json str
if isinstance(req.contexts, str):
req.contexts = json.loads(req.contexts)

View File

@@ -0,0 +1,202 @@
import asyncio
from collections.abc import AsyncGenerator
from typing import TYPE_CHECKING
from astrbot.core import logger
from astrbot.core.agent.runners.coze.coze_agent_runner import CozeAgentRunner
from astrbot.core.agent.runners.dashscope.dashscope_agent_runner import (
DashscopeAgentRunner,
)
from astrbot.core.agent.runners.dify.dify_agent_runner import DifyAgentRunner
from astrbot.core.message.components import Image
from astrbot.core.message.message_event_result import (
MessageChain,
MessageEventResult,
ResultContentType,
)
if TYPE_CHECKING:
from astrbot.core.agent.runners.base import BaseAgentRunner
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.provider.entities import (
ProviderRequest,
)
from astrbot.core.star.star_handler import EventType
from astrbot.core.utils.metrics import Metric
from .....astr_agent_context import AgentContextWrapper, AstrAgentContext
from .....astr_agent_hooks import MAIN_AGENT_HOOKS
from ....context import PipelineContext, call_event_hook
from ...stage import Stage
AGENT_RUNNER_TYPE_KEY = {
"dify": "dify_agent_runner_provider_id",
"coze": "coze_agent_runner_provider_id",
"dashscope": "dashscope_agent_runner_provider_id",
}
async def run_third_party_agent(
runner: "BaseAgentRunner",
stream_to_general: bool = False,
) -> AsyncGenerator[MessageChain | None, None]:
"""
运行第三方 agent runner 并转换响应格式
类似于 run_agent 函数,但专门处理第三方 agent runner
"""
try:
async for resp in runner.step_until_done(max_step=30): # type: ignore[misc]
if resp.type == "streaming_delta":
if stream_to_general:
continue
yield resp.data["chain"]
elif resp.type == "llm_result":
if stream_to_general:
yield resp.data["chain"]
except Exception as e:
logger.error(f"Third party agent runner error: {e}")
err_msg = (
f"\nAstrBot 请求失败。\n错误类型: {type(e).__name__}\n"
f"错误信息: {e!s}\n\n请在控制台查看和分享错误详情。\n"
)
yield MessageChain().message(err_msg)
class ThirdPartyAgentSubStage(Stage):
async def initialize(self, ctx: PipelineContext) -> None:
self.ctx = ctx
self.conf = ctx.astrbot_config
self.runner_type = self.conf["provider_settings"]["agent_runner_type"]
self.prov_id = self.conf["provider_settings"].get(
AGENT_RUNNER_TYPE_KEY.get(self.runner_type, ""),
"",
)
settings = ctx.astrbot_config["provider_settings"]
self.streaming_response: bool = settings["streaming_response"]
self.unsupported_streaming_strategy: str = settings[
"unsupported_streaming_strategy"
]
async def process(
self, event: AstrMessageEvent, provider_wake_prefix: str
) -> AsyncGenerator[None, None]:
req: ProviderRequest | None = None
if provider_wake_prefix and not event.message_str.startswith(
provider_wake_prefix
):
return
self.prov_cfg: dict = next(
(p for p in self.conf["provider"] if p["id"] == self.prov_id),
{},
)
if not self.prov_id or not self.prov_cfg:
logger.error(
"Third Party Agent Runner provider ID is not configured properly."
)
return
# make provider request
req = ProviderRequest()
req.session_id = event.unified_msg_origin
req.prompt = event.message_str[len(provider_wake_prefix) :]
for comp in event.message_obj.message:
if isinstance(comp, Image):
image_path = await comp.convert_to_base64()
req.image_urls.append(image_path)
if not req.prompt and not req.image_urls:
return
# call event hook
if await call_event_hook(event, EventType.OnLLMRequestEvent, req):
return
if self.runner_type == "dify":
runner = DifyAgentRunner[AstrAgentContext]()
elif self.runner_type == "coze":
runner = CozeAgentRunner[AstrAgentContext]()
elif self.runner_type == "dashscope":
runner = DashscopeAgentRunner[AstrAgentContext]()
else:
raise ValueError(
f"Unsupported third party agent runner type: {self.runner_type}",
)
astr_agent_ctx = AstrAgentContext(
context=self.ctx.plugin_manager.context,
event=event,
)
streaming_response = self.streaming_response
if (enable_streaming := event.get_extra("enable_streaming")) is not None:
streaming_response = bool(enable_streaming)
stream_to_general = (
self.unsupported_streaming_strategy == "turn_off"
and not event.platform_meta.support_streaming_message
)
await runner.reset(
request=req,
run_context=AgentContextWrapper(
context=astr_agent_ctx,
tool_call_timeout=60,
),
agent_hooks=MAIN_AGENT_HOOKS,
provider_config=self.prov_cfg,
streaming=streaming_response,
)
if streaming_response and not stream_to_general:
# 流式响应
event.set_result(
MessageEventResult()
.set_result_content_type(ResultContentType.STREAMING_RESULT)
.set_async_stream(
run_third_party_agent(
runner,
stream_to_general=False,
),
),
)
yield
if runner.done():
final_resp = runner.get_final_llm_resp()
if final_resp and final_resp.result_chain:
event.set_result(
MessageEventResult(
chain=final_resp.result_chain.chain or [],
result_content_type=ResultContentType.STREAMING_FINISH,
),
)
else:
# 非流式响应或转换为普通响应
async for _ in run_third_party_agent(
runner,
stream_to_general=stream_to_general,
):
yield
final_resp = runner.get_final_llm_resp()
if not final_resp or not final_resp.result_chain:
logger.warning("Agent Runner 未返回最终结果。")
return
event.set_result(
MessageEventResult(
chain=final_resp.result_chain.chain or [],
result_content_type=ResultContentType.LLM_RESULT,
),
)
yield
asyncio.create_task(
Metric.upload(
llm_tick=1,
model_name=self.runner_type,
provider_type=self.runner_type,
),
)

View File

@@ -24,7 +24,7 @@ class StarRequestSubStage(Stage):
async def process(
self,
event: AstrMessageEvent,
) -> None | AsyncGenerator[None, None]:
) -> AsyncGenerator[None, None]:
activated_handlers: list[StarHandlerMetadata] = event.get_extra(
"activated_handlers",
)

View File

@@ -7,7 +7,7 @@ from astrbot.core.star.star_handler import StarHandlerMetadata
from ..context import PipelineContext
from ..stage import Stage, register_stage
from .method.llm_request import LLMRequestSubStage
from .method.agent_request import AgentRequestSubStage
from .method.star_request import StarRequestSubStage
@@ -17,9 +17,12 @@ class ProcessStage(Stage):
self.ctx = ctx
self.config = ctx.astrbot_config
self.plugin_manager = ctx.plugin_manager
self.llm_request_sub_stage = LLMRequestSubStage()
await self.llm_request_sub_stage.initialize(ctx)
# initialize agent sub stage
self.agent_sub_stage = AgentRequestSubStage()
await self.agent_sub_stage.initialize(ctx)
# initialize star request sub stage
self.star_request_sub_stage = StarRequestSubStage()
await self.star_request_sub_stage.initialize(ctx)
@@ -39,7 +42,7 @@ class ProcessStage(Stage):
# Handler 的 LLM 请求
event.set_extra("provider_request", resp)
_t = False
async for _ in self.llm_request_sub_stage.process(event):
async for _ in self.agent_sub_stage.process(event):
_t = True
yield
if not _t:
@@ -67,5 +70,5 @@ class ProcessStage(Stage):
logger.info("未找到可用的 LLM 提供商,请先前往配置服务提供商。")
return
async for _ in self.llm_request_sub_stage.process(event):
async for _ in self.agent_sub_stage.process(event):
yield

View File

@@ -1,23 +1,64 @@
from pydantic import Field
from pydantic.dataclasses import dataclass
from astrbot.api import logger, sp
from astrbot.core.provider.entities import ProviderRequest
from ..context import PipelineContext
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import FunctionTool, ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.star.context import Context
async def inject_kb_context(
@dataclass
class KnowledgeBaseQueryTool(FunctionTool[AstrAgentContext]):
name: str = "astr_kb_search"
description: str = (
"Query the knowledge base for facts or relevant context. "
"Use this tool when the user's question requires factual information, "
"definitions, background knowledge, or previously indexed content. "
"Only send short keywords or a concise question as the query."
)
parameters: dict = Field(
default_factory=lambda: {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "A concise keyword query for the knowledge base.",
},
},
"required": ["query"],
}
)
async def call(
self, context: ContextWrapper[AstrAgentContext], **kwargs
) -> ToolExecResult:
query = kwargs.get("query", "")
if not query:
return "error: Query parameter is empty."
result = await retrieve_knowledge_base(
query=kwargs.get("query", ""),
umo=context.context.event.unified_msg_origin,
context=context.context.context,
)
if not result:
return "No relevant knowledge found."
return result
async def retrieve_knowledge_base(
query: str,
umo: str,
p_ctx: PipelineContext,
req: ProviderRequest,
) -> None:
context: Context,
) -> str | None:
"""Inject knowledge base context into the provider request
Args:
umo: Unique message object (session ID)
p_ctx: Pipeline context
req: Provider request
"""
kb_mgr = p_ctx.plugin_manager.context.kb_manager
kb_mgr = context.kb_manager
config = context.get_config(umo=umo)
# 1. 优先读取会话级配置
session_config = await sp.session_get(umo, "kb_config", default={})
@@ -54,18 +95,18 @@ async def inject_kb_context(
logger.debug(f"[知识库] 使用会话级配置,知识库数量: {len(kb_names)}")
else:
kb_names = p_ctx.astrbot_config.get("kb_names", [])
top_k = p_ctx.astrbot_config.get("kb_final_top_k", 5)
kb_names = config.get("kb_names", [])
top_k = config.get("kb_final_top_k", 5)
logger.debug(f"[知识库] 使用全局配置,知识库数量: {len(kb_names)}")
top_k_fusion = p_ctx.astrbot_config.get("kb_fusion_top_k", 20)
top_k_fusion = config.get("kb_fusion_top_k", 20)
if not kb_names:
return
logger.debug(f"[知识库] 开始检索知识库,数量: {len(kb_names)}, top_k={top_k}")
kb_context = await kb_mgr.retrieve(
query=req.prompt,
query=query,
kb_names=kb_names,
top_k_fusion=top_k_fusion,
top_m_final=top_k,
@@ -78,4 +119,7 @@ async def inject_kb_context(
if formatted:
results = kb_context.get("results", [])
logger.debug(f"[知识库] 为会话 {umo} 注入了 {len(results)} 条相关知识块")
req.system_prompt = f"{formatted}\n\n{req.system_prompt or ''}"
return formatted
KNOWLEDGE_BASE_QUERY_TOOL = KnowledgeBaseQueryTool()

View File

@@ -161,11 +161,21 @@ class ResultDecorateStage(Stage):
# 不分段回复
new_chain.append(comp)
continue
split_response = re.findall(
self.regex,
comp.text,
re.DOTALL | re.MULTILINE,
)
try:
split_response = re.findall(
self.regex,
comp.text,
re.DOTALL | re.MULTILINE,
)
except re.error:
logger.error(
f"分段回复正则表达式错误,使用默认分段方式: {traceback.format_exc()}",
)
split_response = re.findall(
r".*?[。?!~…]+|.+$",
comp.text,
re.DOTALL | re.MULTILINE,
)
if not split_response:
new_chain.append(comp)
continue

View File

@@ -30,7 +30,7 @@ from .wecomai_api import (
WecomAIBotStreamMessageBuilder,
)
from .wecomai_event import WecomAIBotMessageEvent
from .wecomai_queue_mgr import WecomAIQueueMgr, wecomai_queue_mgr
from .wecomai_queue_mgr import WecomAIQueueMgr
from .wecomai_server import WecomAIBotServer
from .wecomai_utils import (
WecomAIBotConstants,
@@ -144,9 +144,12 @@ class WecomAIBotAdapter(Platform):
# 事件循环和关闭信号
self.shutdown_event = asyncio.Event()
# 队列管理器
self.queue_mgr = WecomAIQueueMgr()
# 队列监听器
self.queue_listener = WecomAIQueueListener(
wecomai_queue_mgr,
self.queue_mgr,
self._handle_queued_message,
)
@@ -189,7 +192,7 @@ class WecomAIBotAdapter(Platform):
stream_id,
session_id,
)
wecomai_queue_mgr.set_pending_response(stream_id, callback_params)
self.queue_mgr.set_pending_response(stream_id, callback_params)
resp = WecomAIBotStreamMessageBuilder.make_text_stream(
stream_id,
@@ -207,7 +210,7 @@ class WecomAIBotAdapter(Platform):
elif msgtype == "stream":
# wechat server is requesting for updates of a stream
stream_id = message_data["stream"]["id"]
if not wecomai_queue_mgr.has_back_queue(stream_id):
if not self.queue_mgr.has_back_queue(stream_id):
logger.error(f"Cannot find back queue for stream_id: {stream_id}")
# 返回结束标志,告诉微信服务器流已结束
@@ -222,7 +225,7 @@ class WecomAIBotAdapter(Platform):
callback_params["timestamp"],
)
return resp
queue = wecomai_queue_mgr.get_or_create_back_queue(stream_id)
queue = self.queue_mgr.get_or_create_back_queue(stream_id)
if queue.empty():
logger.debug(
f"No new messages in back queue for stream_id: {stream_id}",
@@ -242,10 +245,9 @@ class WecomAIBotAdapter(Platform):
elif msg["type"] == "end":
# stream end
finish = True
wecomai_queue_mgr.remove_queues(stream_id)
self.queue_mgr.remove_queues(stream_id)
break
else:
pass
logger.debug(
f"Aggregated content: {latest_plain_content}, image: {len(image_base64)}, finish: {finish}",
)
@@ -313,8 +315,8 @@ class WecomAIBotAdapter(Platform):
session_id: str,
):
"""将消息放入队列进行异步处理"""
input_queue = wecomai_queue_mgr.get_or_create_queue(stream_id)
_ = wecomai_queue_mgr.get_or_create_back_queue(stream_id)
input_queue = self.queue_mgr.get_or_create_queue(stream_id)
_ = self.queue_mgr.get_or_create_back_queue(stream_id)
message_payload = {
"message_data": message_data,
"callback_params": callback_params,
@@ -453,6 +455,7 @@ class WecomAIBotAdapter(Platform):
platform_meta=self.meta(),
session_id=message.session_id,
api_client=self.api_client,
queue_mgr=self.queue_mgr,
)
self.commit_event(message_event)

View File

@@ -8,7 +8,7 @@ from astrbot.api.message_components import (
)
from .wecomai_api import WecomAIBotAPIClient
from .wecomai_queue_mgr import wecomai_queue_mgr
from .wecomai_queue_mgr import WecomAIQueueMgr
class WecomAIBotMessageEvent(AstrMessageEvent):
@@ -21,6 +21,7 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
platform_meta,
session_id: str,
api_client: WecomAIBotAPIClient,
queue_mgr: WecomAIQueueMgr,
):
"""初始化消息事件
@@ -34,14 +35,16 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
"""
super().__init__(message_str, message_obj, platform_meta, session_id)
self.api_client = api_client
self.queue_mgr = queue_mgr
@staticmethod
async def _send(
message_chain: MessageChain,
stream_id: str,
queue_mgr: WecomAIQueueMgr,
streaming: bool = False,
):
back_queue = wecomai_queue_mgr.get_or_create_back_queue(stream_id)
back_queue = queue_mgr.get_or_create_back_queue(stream_id)
if not message_chain:
await back_queue.put(
@@ -94,7 +97,7 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
"wecom_ai_bot platform event raw_message should be a dict"
)
stream_id = raw.get("stream_id", self.session_id)
await WecomAIBotMessageEvent._send(message, stream_id)
await WecomAIBotMessageEvent._send(message, stream_id, self.queue_mgr)
await super().send(message)
async def send_streaming(self, generator, use_fallback=False):
@@ -105,7 +108,7 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
"wecom_ai_bot platform event raw_message should be a dict"
)
stream_id = raw.get("stream_id", self.session_id)
back_queue = wecomai_queue_mgr.get_or_create_back_queue(stream_id)
back_queue = self.queue_mgr.get_or_create_back_queue(stream_id)
# 企业微信智能机器人不支持增量发送,因此我们需要在这里将增量内容累积起来,积累发送
increment_plain = ""
@@ -134,6 +137,7 @@ class WecomAIBotMessageEvent(AstrMessageEvent):
final_data += await WecomAIBotMessageEvent._send(
chain,
stream_id=stream_id,
queue_mgr=self.queue_mgr,
streaming=True,
)

View File

@@ -151,7 +151,3 @@ class WecomAIQueueMgr:
"output_queues": len(self.back_queues),
"pending_responses": len(self.pending_responses),
}
# 全局队列管理器实例
wecomai_queue_mgr = WecomAIQueueMgr()

View File

@@ -211,6 +211,8 @@ class LLMResponse:
"""Tool call names."""
tools_call_ids: list[str] = field(default_factory=list)
"""Tool call IDs."""
tools_call_extra_content: dict[str, dict[str, Any]] = field(default_factory=dict)
"""Tool call extra content. tool_call_id -> extra_content dict"""
reasoning_content: str = ""
"""The reasoning content extracted from the LLM, if any."""
@@ -233,6 +235,7 @@ class LLMResponse:
tools_call_args: list[dict[str, Any]] | None = None,
tools_call_name: list[str] | None = None,
tools_call_ids: list[str] | None = None,
tools_call_extra_content: dict[str, dict[str, Any]] | None = None,
raw_completion: ChatCompletion
| GenerateContentResponse
| AnthropicMessage
@@ -256,6 +259,8 @@ class LLMResponse:
tools_call_name = []
if tools_call_ids is None:
tools_call_ids = []
if tools_call_extra_content is None:
tools_call_extra_content = {}
self.role = role
self.completion_text = completion_text
@@ -263,6 +268,7 @@ class LLMResponse:
self.tools_call_args = tools_call_args
self.tools_call_name = tools_call_name
self.tools_call_ids = tools_call_ids
self.tools_call_extra_content = tools_call_extra_content
self.raw_completion = raw_completion
self.is_chunk = is_chunk
@@ -288,16 +294,19 @@ class LLMResponse:
"""Convert to OpenAI tool calls format. Deprecated, use to_openai_to_calls_model instead."""
ret = []
for idx, tool_call_arg in enumerate(self.tools_call_args):
ret.append(
{
"id": self.tools_call_ids[idx],
"function": {
"name": self.tools_call_name[idx],
"arguments": json.dumps(tool_call_arg),
},
"type": "function",
payload = {
"id": self.tools_call_ids[idx],
"function": {
"name": self.tools_call_name[idx],
"arguments": json.dumps(tool_call_arg),
},
)
"type": "function",
}
if self.tools_call_extra_content.get(self.tools_call_ids[idx]):
payload["extra_content"] = self.tools_call_extra_content[
self.tools_call_ids[idx]
]
ret.append(payload)
return ret
def to_openai_to_calls_model(self) -> list[ToolCall]:
@@ -311,6 +320,10 @@ class LLMResponse:
name=self.tools_call_name[idx],
arguments=json.dumps(tool_call_arg),
),
# the extra_content will not serialize if it's None when calling ToolCall.model_dump()
extra_content=self.tools_call_extra_content.get(
self.tools_call_ids[idx]
),
),
)
return ret

View File

@@ -280,19 +280,22 @@ class FunctionToolManager:
async def _terminate_mcp_client(self, name: str) -> None:
"""关闭并清理MCP客户端"""
if name in self.mcp_client_dict:
client = self.mcp_client_dict[name]
try:
# 关闭MCP连接
await self.mcp_client_dict[name].cleanup()
self.mcp_client_dict.pop(name)
await client.cleanup()
except Exception as e:
logger.error(f"清空 MCP 客户端资源 {name}: {e}")
# 移除关联的FuncTool
self.func_list = [
f
for f in self.func_list
if not (isinstance(f, MCPTool) and f.mcp_server_name == name)
]
logger.info(f"已关闭 MCP 服务 {name}")
finally:
# Remove client from dict after cleanup attempt (successful or not)
self.mcp_client_dict.pop(name, None)
# 移除关联的FuncTool
self.func_list = [
f
for f in self.func_list
if not (isinstance(f, MCPTool) and f.mcp_server_name == name)
]
logger.info(f"已关闭 MCP 服务 {name}")
@staticmethod
async def test_mcp_server_connection(config: dict) -> list[str]:

View File

@@ -227,6 +227,8 @@ class ProviderManager:
async def load_provider(self, provider_config: dict):
if not provider_config["enable"]:
return
if provider_config.get("provider_type", "") == "agent_runner":
return
logger.info(
f"载入 {provider_config['type']}({provider_config['id']}) 服务提供商 ...",
@@ -247,14 +249,6 @@ class ProviderManager:
from .sources.anthropic_source import (
ProviderAnthropic as ProviderAnthropic,
)
case "dify":
from .sources.dify_source import ProviderDify as ProviderDify
case "coze":
from .sources.coze_source import ProviderCoze as ProviderCoze
case "dashscope":
from .sources.dashscope_source import (
ProviderDashscope as ProviderDashscope,
)
case "googlegenai_chat_completion":
from .sources.gemini_source import (
ProviderGoogleGenAI as ProviderGoogleGenAI,
@@ -331,6 +325,10 @@ class ProviderManager:
from .sources.xinference_rerank_source import (
XinferenceRerankProvider as XinferenceRerankProvider,
)
case "bailian_rerank":
from .sources.bailian_rerank_source import (
BailianRerankProvider as BailianRerankProvider,
)
except (ImportError, ModuleNotFoundError) as e:
logger.critical(
f"加载 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}。可能是因为有未安装的依赖。",

View File

@@ -0,0 +1,236 @@
import os
import aiohttp
from astrbot import logger
from ..entities import ProviderType, RerankResult
from ..provider import RerankProvider
from ..register import register_provider_adapter
class BailianRerankError(Exception):
"""百炼重排序服务异常基类"""
pass
class BailianAPIError(BailianRerankError):
"""百炼API返回错误"""
pass
class BailianNetworkError(BailianRerankError):
"""百炼网络请求错误"""
pass
@register_provider_adapter(
"bailian_rerank", "阿里云百炼文本排序适配器", provider_type=ProviderType.RERANK
)
class BailianRerankProvider(RerankProvider):
"""阿里云百炼文本重排序适配器."""
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
super().__init__(provider_config, provider_settings)
self.provider_config = provider_config
self.provider_settings = provider_settings
# API配置
self.api_key = provider_config.get("rerank_api_key") or os.getenv(
"DASHSCOPE_API_KEY", ""
)
if not self.api_key:
raise ValueError("阿里云百炼 API Key 不能为空。")
self.model = provider_config.get("rerank_model", "qwen3-rerank")
self.timeout = provider_config.get("timeout", 30)
self.return_documents = provider_config.get("return_documents", False)
self.instruct = provider_config.get("instruct", "")
self.base_url = provider_config.get(
"rerank_api_base",
"https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank",
)
# 设置HTTP客户端
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
self.client = aiohttp.ClientSession(
headers=headers, timeout=aiohttp.ClientTimeout(total=self.timeout)
)
# 设置模型名称
self.set_model(self.model)
logger.info(f"AstrBot 百炼 Rerank 初始化完成。模型: {self.model}")
def _build_payload(
self, query: str, documents: list[str], top_n: int | None
) -> dict:
"""构建请求载荷
Args:
query: 查询文本
documents: 文档列表
top_n: 返回前N个结果如果为None则返回所有结果
Returns:
请求载荷字典
"""
base = {"model": self.model, "input": {"query": query, "documents": documents}}
params = {
k: v
for k, v in [
("top_n", top_n if top_n is not None and top_n > 0 else None),
("return_documents", True if self.return_documents else None),
(
"instruct",
self.instruct
if self.instruct and self.model == "qwen3-rerank"
else None,
),
]
if v is not None
}
if params:
base["parameters"] = params
return base
def _parse_results(self, data: dict) -> list[RerankResult]:
"""解析API响应结果
Args:
data: API响应数据
Returns:
重排序结果列表
Raises:
BailianAPIError: API返回错误
KeyError: 结果缺少必要字段
"""
# 检查响应状态
if data.get("code", "200") != "200":
raise BailianAPIError(
f"百炼 API 错误: {data.get('code')} {data.get('message', '')}"
)
results = data.get("output", {}).get("results", [])
if not results:
logger.warning(f"百炼 Rerank 返回空结果: {data}")
return []
# 转换为RerankResult对象使用.get()避免KeyError
rerank_results = []
for idx, result in enumerate(results):
try:
index = result.get("index", idx)
relevance_score = result.get("relevance_score", 0.0)
if relevance_score is None:
logger.warning(f"结果 {idx} 缺少 relevance_score使用默认值 0.0")
relevance_score = 0.0
rerank_result = RerankResult(
index=index, relevance_score=relevance_score
)
rerank_results.append(rerank_result)
except Exception as e:
logger.warning(f"解析结果 {idx} 时出错: {e}, result={result}")
continue
return rerank_results
def _log_usage(self, data: dict) -> None:
"""记录使用量信息
Args:
data: API响应数据
"""
tokens = data.get("usage", {}).get("total_tokens", 0)
if tokens > 0:
logger.debug(f"百炼 Rerank 消耗 Token: {tokens}")
async def rerank(
self,
query: str,
documents: list[str],
top_n: int | None = None,
) -> list[RerankResult]:
"""
对文档进行重排序
Args:
query: 查询文本
documents: 待排序的文档列表
top_n: 返回前N个结果如果为None则使用配置中的默认值
Returns:
重排序结果列表
"""
if not documents:
logger.warning("文档列表为空,返回空结果")
return []
if not query.strip():
logger.warning("查询文本为空,返回空结果")
return []
# 检查限制
if len(documents) > 500:
logger.warning(
f"文档数量({len(documents)})超过限制(500)将截断前500个文档"
)
documents = documents[:500]
try:
# 构建请求载荷如果top_n为None则返回所有重排序结果
payload = self._build_payload(query, documents, top_n)
logger.debug(
f"百炼 Rerank 请求: query='{query[:50]}...', 文档数量={len(documents)}"
)
# 发送请求
async with self.client.post(self.base_url, json=payload) as response:
response.raise_for_status()
response_data = await response.json()
# 解析结果并记录使用量
results = self._parse_results(response_data)
self._log_usage(response_data)
logger.debug(f"百炼 Rerank 成功返回 {len(results)} 个结果")
return results
except aiohttp.ClientError as e:
error_msg = f"网络请求失败: {e}"
logger.error(f"百炼 Rerank 网络请求失败: {e}")
raise BailianNetworkError(error_msg) from e
except BailianRerankError:
raise
except Exception as e:
error_msg = f"重排序失败: {e}"
logger.error(f"百炼 Rerank 处理失败: {e}")
raise BailianRerankError(error_msg) from e
async def terminate(self) -> None:
"""关闭HTTP客户端会话."""
if self.client:
logger.info("关闭 百炼 Rerank 客户端会话")
try:
await self.client.close()
except Exception as e:
logger.error(f"关闭 百炼 Rerank 客户端时出错: {e}")
finally:
self.client = None

View File

@@ -1,650 +0,0 @@
import base64
import hashlib
import json
import os
from collections.abc import AsyncGenerator
import astrbot.core.message.components as Comp
from astrbot import logger
from astrbot.api.provider import Provider
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import LLMResponse
from ..register import register_provider_adapter
from .coze_api_client import CozeAPIClient
@register_provider_adapter("coze", "Coze (扣子) 智能体适配器")
class ProviderCoze(Provider):
def __init__(
self,
provider_config,
provider_settings,
) -> None:
super().__init__(
provider_config,
provider_settings,
)
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)
self.conversation_ids: dict[str, str] = {}
self.file_id_cache: dict[str, dict[str, str]] = {}
# 创建 API 客户端
self.api_client = CozeAPIClient(api_key=self.api_key, api_base=self.api_base)
def _generate_cache_key(self, data: str, is_base64: bool = False) -> str:
"""生成统一的缓存键
Args:
data: 图片数据或路径
is_base64: 是否是 base64 数据
Returns:
str: 缓存键
"""
try:
if is_base64 and data.startswith("data:image/"):
try:
header, encoded = data.split(",", 1)
image_bytes = base64.b64decode(encoded)
cache_key = hashlib.md5(image_bytes).hexdigest()
return cache_key
except Exception:
cache_key = hashlib.md5(encoded.encode("utf-8")).hexdigest()
return cache_key
elif data.startswith(("http://", "https://")):
# URL图片使用URL作为缓存键
cache_key = hashlib.md5(data.encode("utf-8")).hexdigest()
return cache_key
else:
clean_path = (
data.split("_")[0]
if "_" in data and len(data.split("_")) >= 3
else data
)
if os.path.exists(clean_path):
with open(clean_path, "rb") as f:
file_content = f.read()
cache_key = hashlib.md5(file_content).hexdigest()
return cache_key
cache_key = hashlib.md5(clean_path.encode("utf-8")).hexdigest()
return cache_key
except Exception as e:
cache_key = hashlib.md5(data.encode("utf-8")).hexdigest()
logger.debug(f"[Coze] 异常文件缓存键: {cache_key}, error={e}")
return cache_key
async def _upload_file(
self,
file_data: bytes,
session_id: str | None = None,
cache_key: str | None = None,
) -> str:
"""上传文件到 Coze 并返回 file_id"""
# 使用 API 客户端上传文件
file_id = await self.api_client.upload_file(file_data)
# 缓存 file_id
if session_id and cache_key:
if session_id not in self.file_id_cache:
self.file_id_cache[session_id] = {}
self.file_id_cache[session_id][cache_key] = file_id
logger.debug(f"[Coze] 图片上传成功并缓存file_id: {file_id}")
return file_id
async def _download_and_upload_image(
self,
image_url: str,
session_id: str | None = None,
) -> str:
"""下载图片并上传到 Coze返回 file_id"""
# 计算哈希实现缓存
cache_key = self._generate_cache_key(image_url) if session_id else None
if session_id and cache_key:
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]
return file_id
try:
image_data = await self.api_client.download_image(image_url)
file_id = await self._upload_file(image_data, session_id, cache_key)
if session_id and cache_key:
self.file_id_cache[session_id][cache_key] = file_id
return file_id
except Exception as e:
logger.error(f"处理图片失败 {image_url}: {e!s}")
raise Exception(f"处理图片失败: {e!s}")
async def _process_context_images(
self,
content: str | list,
session_id: str,
) -> str:
"""处理上下文中的图片内容,将 base64 图片上传并替换为 file_id"""
try:
if isinstance(content, str):
return content
processed_content = []
if session_id not in self.file_id_cache:
self.file_id_cache[session_id] = {}
for item in content:
if not isinstance(item, dict):
processed_content.append(item)
continue
if item.get("type") == "text":
processed_content.append(item)
elif item.get("type") == "image_url":
# 处理图片逻辑
if "file_id" in item:
# 已经有 file_id
logger.debug(f"[Coze] 图片已有file_id: {item['file_id']}")
processed_content.append(item)
else:
# 获取图片数据
image_data = ""
if "image_url" in item and isinstance(item["image_url"], dict):
image_data = item["image_url"].get("url", "")
elif "data" in item:
image_data = item.get("data", "")
elif "url" in item:
image_data = item.get("url", "")
if not image_data:
continue
# 计算哈希用于缓存
cache_key = self._generate_cache_key(
image_data,
is_base64=image_data.startswith("data:image/"),
)
# 检查缓存
if cache_key in self.file_id_cache[session_id]:
file_id = self.file_id_cache[session_id][cache_key]
processed_content.append(
{"type": "image", "file_id": file_id},
)
else:
# 上传图片并缓存
if image_data.startswith("data:image/"):
# base64 处理
_, encoded = image_data.split(",", 1)
image_bytes = base64.b64decode(encoded)
file_id = await self._upload_file(
image_bytes,
session_id,
cache_key,
)
elif image_data.startswith(("http://", "https://")):
# URL 图片
file_id = await self._download_and_upload_image(
image_data,
session_id,
)
# 为URL图片也添加缓存
self.file_id_cache[session_id][cache_key] = file_id
elif os.path.exists(image_data):
# 本地文件
with open(image_data, "rb") as f:
image_bytes = f.read()
file_id = await self._upload_file(
image_bytes,
session_id,
cache_key,
)
else:
logger.warning(
f"无法处理的图片格式: {image_data[:50]}...",
)
continue
processed_content.append(
{"type": "image", "file_id": file_id},
)
result = json.dumps(processed_content, ensure_ascii=False)
return result
except Exception as e:
logger.error(f"处理上下文图片失败: {e!s}")
if isinstance(content, str):
return content
return json.dumps(content, ensure_ascii=False)
async def text_chat(
self,
prompt: str,
session_id=None,
image_urls=None,
func_tool=None,
contexts=None,
system_prompt=None,
tool_calls_result=None,
model=None,
**kwargs,
) -> LLMResponse:
"""文本对话, 内部使用流式接口实现非流式
Args:
prompt (str): 用户提示词
session_id (str): 会话ID
image_urls (List[str]): 图片URL列表
func_tool (FuncCall): 函数调用工具(不支持)
contexts (List): 上下文列表
system_prompt (str): 系统提示语
tool_calls_result (ToolCallsResult | List[ToolCallsResult]): 工具调用结果(不支持)
model (str): 模型名称(不支持)
Returns:
LLMResponse: LLM响应对象
"""
accumulated_content = ""
final_response = None
async for llm_response in self.text_chat_stream(
prompt=prompt,
session_id=session_id,
image_urls=image_urls,
func_tool=func_tool,
contexts=contexts,
system_prompt=system_prompt,
tool_calls_result=tool_calls_result,
model=model,
**kwargs,
):
if llm_response.is_chunk:
if llm_response.completion_text:
accumulated_content += llm_response.completion_text
else:
final_response = llm_response
if final_response:
return final_response
if accumulated_content:
chain = MessageChain(chain=[Comp.Plain(accumulated_content)])
return LLMResponse(role="assistant", result_chain=chain)
return LLMResponse(role="assistant", completion_text="")
async def text_chat_stream(
self,
prompt: str,
session_id=None,
image_urls=None,
func_tool=None,
contexts=None,
system_prompt=None,
tool_calls_result=None,
model=None,
**kwargs,
) -> AsyncGenerator[LLMResponse, None]:
"""流式对话接口"""
# 用户ID参数(参考文档, 可以自定义)
user_id = session_id or kwargs.get("user", "default_user")
# 获取或创建会话ID
conversation_id = self.conversation_ids.get(user_id)
# 构建消息
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",
},
)
contexts = self._ensure_message_to_dicts(contexts)
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"]
content_type = ctx.get("content_type", "text")
# 处理可能包含图片的上下文
if (
content_type == "object_string"
or (isinstance(content, str) and content.startswith("["))
or (
isinstance(content, list)
and any(
isinstance(item, dict)
and item.get("type") == "image_url"
for item in content
)
)
):
processed_content = await self._process_context_images(
content,
user_id,
)
additional_messages.append(
{
"role": ctx["role"],
"content": processed_content,
"content_type": "object_string",
},
)
else:
# 纯文本
additional_messages.append(
{
"role": ctx["role"],
"content": (
content
if isinstance(content, str)
else json.dumps(content, ensure_ascii=False)
),
"content_type": "text",
},
)
else:
logger.info(f"[Coze] 跳过格式不正确的上下文: {ctx}")
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:
try:
if url.startswith(("http://", "https://")):
# 网络图片
file_id = await self._download_and_upload_image(
url,
user_id,
)
else:
# 本地文件或 base64
if url.startswith("data:image/"):
# base64
_, encoded = url.split(",", 1)
image_data = base64.b64decode(encoded)
cache_key = self._generate_cache_key(
url,
is_base64=True,
)
file_id = await self._upload_file(
image_data,
user_id,
cache_key,
)
# 本地文件
elif os.path.exists(url):
with open(url, "rb") as f:
image_data = f.read()
# 用文件路径和修改时间来缓存
file_stat = os.stat(url)
cache_key = self._generate_cache_key(
f"{url}_{file_stat.st_mtime}_{file_stat.st_size}",
is_base64=False,
)
file_id = await self._upload_file(
image_data,
user_id,
cache_key,
)
else:
logger.warning(f"图片文件不存在: {url}")
continue
object_string_content.append(
{
"type": "image",
"file_id": file_id,
},
)
except Exception as e:
logger.error(f"处理图片失败 {url}: {e!s}")
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",
},
)
try:
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:
self.conversation_ids[user_id] = data["conversation_id"]
elif event_type == "conversation.message.delta":
if isinstance(data, dict):
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:
message_started = True
accumulated_content += content
yield LLMResponse(
role="assistant",
completion_text=content,
is_chunk=True,
)
elif event_type == "conversation.message.completed":
if isinstance(data, dict):
msg_type = data.get("type")
if msg_type == "answer" and data.get("role") == "assistant":
final_content = data.get("content", "")
if not accumulated_content and final_content:
chain = MessageChain(chain=[Comp.Plain(final_content)])
yield LLMResponse(
role="assistant",
result_chain=chain,
is_chunk=False,
)
elif event_type == "conversation.chat.completed":
if accumulated_content:
chain = MessageChain(chain=[Comp.Plain(accumulated_content)])
yield LLMResponse(
role="assistant",
result_chain=chain,
is_chunk=False,
)
break
elif event_type == "done":
break
elif event_type == "error":
error_msg = (
data.get("message", "未知错误")
if isinstance(data, dict)
else str(data)
)
logger.error(f"Coze 流式响应错误: {error_msg}")
yield LLMResponse(
role="err",
completion_text=f"Coze 错误: {error_msg}",
is_chunk=False,
)
break
if not message_started and not accumulated_content:
yield LLMResponse(
role="assistant",
completion_text="LLM 未响应任何内容。",
is_chunk=False,
)
elif message_started and accumulated_content:
chain = MessageChain(chain=[Comp.Plain(accumulated_content)])
yield LLMResponse(
role="assistant",
result_chain=chain,
is_chunk=False,
)
except Exception as e:
logger.error(f"Coze 流式请求失败: {e!s}")
yield LLMResponse(
role="err",
completion_text=f"Coze 流式请求失败: {e!s}",
is_chunk=False,
)
async def forget(self, session_id: str):
"""清空指定会话的上下文"""
user_id = session_id
conversation_id = self.conversation_ids.get(user_id)
if user_id in self.file_id_cache:
self.file_id_cache.pop(user_id, None)
if not conversation_id:
return True
try:
response = await self.api_client.clear_context(conversation_id)
if "code" in response and response["code"] == 0:
self.conversation_ids.pop(user_id, None)
return True
logger.warning(f"清空 Coze 会话上下文失败: {response}")
return False
except Exception as e:
logger.error(f"清空 Coze 会话失败: {e!s}")
return False
async def get_current_key(self):
"""获取当前API Key"""
return self.api_key
async def set_key(self, key: str):
"""设置新的API Key"""
raise NotImplementedError("Coze 适配器不支持设置 API Key。")
async def get_models(self):
"""获取可用模型列表"""
return [f"bot_{self.bot_id}"]
def get_model(self):
"""获取当前模型"""
return f"bot_{self.bot_id}"
def set_model(self, model: str):
"""设置模型在Coze中是Bot ID"""
if model.startswith("bot_"):
self.bot_id = model[4:]
else:
self.bot_id = model
async def get_human_readable_context(
self,
session_id: str,
page: int = 1,
page_size: int = 10,
):
"""获取人类可读的上下文历史"""
user_id = session_id
conversation_id = self.conversation_ids.get(user_id)
if not conversation_id:
return []
try:
data = await self.api_client.get_message_list(
conversation_id=conversation_id,
order="desc",
limit=page_size,
offset=(page - 1) * page_size,
)
if data.get("code") != 0:
logger.warning(f"获取 Coze 消息历史失败: {data}")
return []
messages = data.get("data", {}).get("messages", [])
readable_history = []
for msg in messages:
role = msg.get("role", "unknown")
content = msg.get("content", "")
msg_type = msg.get("type", "")
if role == "user":
readable_history.append(f"用户: {content}")
elif role == "assistant" and msg_type == "answer":
readable_history.append(f"助手: {content}")
return readable_history
except Exception as e:
logger.error(f"获取 Coze 消息历史失败: {e!s}")
return []
async def terminate(self):
"""清理资源"""
await self.api_client.close()

View File

@@ -1,207 +0,0 @@
import asyncio
import functools
import re
from dashscope import Application
from dashscope.app.application_response import ApplicationResponse
from astrbot.core import logger, sp
from astrbot.core.message.message_event_result import MessageChain
from .. import Provider
from ..entities import LLMResponse
from ..register import register_provider_adapter
from .openai_source import ProviderOpenAIOfficial
@register_provider_adapter("dashscope", "Dashscope APP 适配器。")
class ProviderDashscope(ProviderOpenAIOfficial):
def __init__(
self,
provider_config: dict,
provider_settings: dict,
) -> None:
Provider.__init__(
self,
provider_config,
provider_settings,
)
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.model_name = "dashscope"
self.variables: dict = provider_config.get("variables", {})
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
async def text_chat(
self,
prompt: str,
session_id=None,
image_urls=None,
func_tool=None,
contexts=None,
system_prompt=None,
model=None,
**kwargs,
) -> LLMResponse:
if image_urls is None:
image_urls = []
if contexts is None:
contexts = []
# 获得会话变量
payload_vars = self.variables.copy()
# 动态变量
session_var = await sp.session_get(session_id, "session_variables", default={})
payload_vars.update(session_var)
if (
self.dashscope_app_type in ["agent", "dialog-workflow"]
and not self.has_rag_options()
):
# 支持多轮对话的
new_record = {"role": "user", "content": prompt}
if image_urls:
logger.warning("阿里云百炼暂不支持图片输入,将自动忽略图片内容。")
contexts_no_img = await self._remove_image_from_context(contexts)
context_query = [*contexts_no_img, new_record]
if system_prompt:
context_query.insert(0, {"role": "system", "content": system_prompt})
for part in context_query:
if "_no_save" in part:
del part["_no_save"]
# 调用阿里云百炼 API
payload = {
"app_id": self.app_id,
"api_key": self.api_key,
"messages": context_query,
"biz_params": payload_vars or None,
}
partial = functools.partial(
Application.call,
**payload,
)
response = await asyncio.get_event_loop().run_in_executor(None, partial)
else:
# 不支持多轮对话的
# 调用阿里云百炼 API
payload = {
"app_id": self.app_id,
"prompt": prompt,
"api_key": self.api_key,
"biz_params": payload_vars or None,
}
if self.rag_options:
payload["rag_options"] = self.rag_options
partial = functools.partial(
Application.call,
**payload,
)
response = await asyncio.get_event_loop().run_in_executor(None, partial)
assert isinstance(response, ApplicationResponse)
logger.debug(f"dashscope resp: {response}")
if response.status_code != 200:
logger.error(
f"阿里云百炼请求失败: request_id={response.request_id}, code={response.status_code}, message={response.message}, 请参考文档https://help.aliyun.com/zh/model-studio/developer-reference/error-code",
)
return LLMResponse(
role="err",
result_chain=MessageChain().message(
f"阿里云百炼请求失败: message={response.message} code={response.status_code}",
),
)
output_text = response.output.get("text", "") or ""
# RAG 引用脚标格式化
output_text = re.sub(r"<ref>\[(\d+)\]</ref>", r"[\1]", output_text)
if self.output_reference and response.output.get("doc_references", None):
ref_parts = []
for ref in response.output.get("doc_references", []) or []:
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)
output_text += f"\n\n回答来源:\n{ref_str}"
llm_response = LLMResponse("assistant")
llm_response.result_chain = MessageChain().message(output_text)
return llm_response
async def text_chat_stream(
self,
prompt,
session_id=None,
image_urls=...,
func_tool=None,
contexts=...,
system_prompt=None,
tool_calls_result=None,
model=None,
**kwargs,
):
# raise NotImplementedError("This method is not implemented yet.")
# 调用 text_chat 模拟流式
llm_response = await self.text_chat(
prompt=prompt,
session_id=session_id,
image_urls=image_urls,
func_tool=func_tool,
contexts=contexts,
system_prompt=system_prompt,
tool_calls_result=tool_calls_result,
)
llm_response.is_chunk = True
yield llm_response
llm_response.is_chunk = False
yield llm_response
async def forget(self, session_id):
return True
async def get_current_key(self):
return self.api_key
async def set_key(self, key):
raise Exception("阿里云百炼 适配器不支持设置 API Key。")
async def get_models(self):
return [self.get_model()]
async def get_human_readable_context(self, session_id, page, page_size):
raise Exception("暂不支持获得 阿里云百炼 的历史消息记录。")
async def terminate(self):
pass

View File

@@ -1,285 +0,0 @@
import os
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.utils.astrbot_path import get_astrbot_data_path
from astrbot.core.utils.dify_api_client import DifyAPIClient
from astrbot.core.utils.io import download_file, download_image_by_url
from .. import Provider
from ..entities import LLMResponse
from ..register import register_provider_adapter
@register_provider_adapter("dify", "Dify APP 适配器。")
class ProviderDify(Provider):
def __init__(
self,
provider_config,
provider_settings,
) -> None:
super().__init__(
provider_config,
provider_settings,
)
self.api_key = provider_config.get("dify_api_key", "")
if not self.api_key:
raise Exception("Dify API Key 不能为空。")
api_base = provider_config.get("dify_api_base", "https://api.dify.ai/v1")
self.api_type = provider_config.get("dify_api_type", "")
if not self.api_type:
raise Exception("Dify API 类型不能为空。")
self.model_name = "dify"
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",
)
if not self.dify_query_input_key:
self.dify_query_input_key = "astrbot_text_query"
if not self.workflow_output_key:
self.workflow_output_key = "astrbot_wf_output"
self.variables: dict = provider_config.get("variables", {})
self.timeout = provider_config.get("timeout", 120)
if isinstance(self.timeout, str):
self.timeout = int(self.timeout)
self.conversation_ids = {}
"""记录当前 session id 的对话 ID"""
self.api_client = DifyAPIClient(self.api_key, api_base)
async def text_chat(
self,
prompt: str,
session_id=None,
image_urls=None,
func_tool=None,
contexts=None,
system_prompt=None,
tool_calls_result=None,
model=None,
**kwargs,
) -> LLMResponse:
if image_urls is None:
image_urls = []
result = ""
session_id = session_id or kwargs.get("user") or "unknown" # 1734
conversation_id = self.conversation_ids.get(session_id, "")
files_payload = []
for image_url in image_urls:
image_path = (
await download_image_by_url(image_url)
if image_url.startswith("http")
else image_url
)
file_response = await self.api_client.file_upload(
image_path,
user=session_id,
)
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"],
},
)
# 获得会话变量
payload_vars = self.variables.copy()
# 动态变量
session_var = await sp.session_get(session_id, "session_variables", default={})
payload_vars.update(session_var)
payload_vars["system_prompt"] = system_prompt
try:
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:
self.conversation_ids[session_id] = chunk[
"conversation_id"
]
conversation_id = chunk["conversation_id"]
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,
):
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 "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}")
except Exception as e:
logger.error(f"Dify 请求失败:{e!s}")
return LLMResponse(role="err", completion_text=f"Dify 请求失败:{e!s}")
if not result:
logger.warning("Dify 请求结果为空,请查看 Debug 日志。")
chain = await self.parse_dify_result(result)
return LLMResponse(role="assistant", result_chain=chain)
async def text_chat_stream(
self,
prompt,
session_id=None,
image_urls=...,
func_tool=None,
contexts=...,
system_prompt=None,
tool_calls_result=None,
model=None,
**kwargs,
):
# raise NotImplementedError("This method is not implemented yet.")
# 调用 text_chat 模拟流式
llm_response = await self.text_chat(
prompt=prompt,
session_id=session_id,
image_urls=image_urls,
func_tool=func_tool,
contexts=contexts,
system_prompt=system_prompt,
tool_calls_result=tool_calls_result,
)
llm_response.is_chunk = True
yield llm_response
llm_response.is_chunk = False
yield llm_response
async def parse_dify_result(self, chunk: dict | str) -> MessageChain:
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)
async def forget(self, session_id):
self.conversation_ids[session_id] = ""
return True
async def get_current_key(self):
return self.api_key
async def set_key(self, key):
raise Exception("Dify 适配器不支持设置 API Key。")
async def get_models(self):
return [self.get_model()]
async def get_human_readable_context(self, session_id, page, page_size):
raise Exception("暂不支持获得 Dify 的历史消息记录。")
async def terminate(self):
await self.api_client.close()

View File

@@ -290,13 +290,24 @@ class ProviderGoogleGenAI(Provider):
parts = [types.Part.from_text(text=content)]
append_or_extend(gemini_contents, parts, types.ModelContent)
elif not native_tool_enabled and "tool_calls" in message:
parts = [
types.Part.from_function_call(
parts = []
for tool in message["tool_calls"]:
part = types.Part.from_function_call(
name=tool["function"]["name"],
args=json.loads(tool["function"]["arguments"]),
)
for tool in message["tool_calls"]
]
# we should set thought_signature back to part if exists
# for more info about thought_signature, see:
# https://ai.google.dev/gemini-api/docs/thought-signatures
if "extra_content" in tool and tool["extra_content"]:
ts_bs64 = (
tool["extra_content"]
.get("google", {})
.get("thought_signature")
)
if ts_bs64:
part.thought_signature = base64.b64decode(ts_bs64)
parts.append(part)
append_or_extend(gemini_contents, parts, types.ModelContent)
else:
logger.warning("assistant 角色的消息内容为空,已添加空格占位")
@@ -393,10 +404,15 @@ class ProviderGoogleGenAI(Provider):
llm_response.role = "tool"
llm_response.tools_call_name.append(part.function_call.name)
llm_response.tools_call_args.append(part.function_call.args)
# gemini 返回的 function_call.id 可能为 None
llm_response.tools_call_ids.append(
part.function_call.id or part.function_call.name,
)
# function_call.id might be None, use name as fallback
tool_call_id = part.function_call.id or part.function_call.name
llm_response.tools_call_ids.append(tool_call_id)
# extra_content
if part.thought_signature:
ts_bs64 = base64.b64encode(part.thought_signature).decode("utf-8")
llm_response.tools_call_extra_content[tool_call_id] = {
"google": {"thought_signature": ts_bs64}
}
elif (
part.inline_data
and part.inline_data.mime_type
@@ -435,6 +451,7 @@ class ProviderGoogleGenAI(Provider):
contents=conversation,
config=config,
)
logger.debug(f"genai result: {result}")
if not result.candidates:
logger.error(f"请求失败, 返回的 candidates 为空: {result}")

View File

@@ -8,7 +8,7 @@ import re
from collections.abc import AsyncGenerator
from openai import AsyncAzureOpenAI, AsyncOpenAI
from openai._exceptions import NotFoundError, UnprocessableEntityError
from openai._exceptions import NotFoundError
from openai.lib.streaming.chat._completions import ChatCompletionStreamState
from openai.types.chat.chat_completion import ChatCompletion
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
@@ -279,6 +279,7 @@ class ProviderOpenAIOfficial(Provider):
args_ls = []
func_name_ls = []
tool_call_ids = []
tool_call_extra_content_dict = {}
for tool_call in choice.message.tool_calls:
if isinstance(tool_call, str):
# workaround for #1359
@@ -296,11 +297,16 @@ class ProviderOpenAIOfficial(Provider):
args_ls.append(args)
func_name_ls.append(tool_call.function.name)
tool_call_ids.append(tool_call.id)
# gemini-2.5 / gemini-3 series extra_content handling
extra_content = getattr(tool_call, "extra_content", None)
if extra_content is not None:
tool_call_extra_content_dict[tool_call.id] = extra_content
llm_response.role = "tool"
llm_response.tools_call_args = args_ls
llm_response.tools_call_name = func_name_ls
llm_response.tools_call_ids = tool_call_ids
llm_response.tools_call_extra_content = tool_call_extra_content_dict
# specially handle finish reason
if choice.finish_reason == "content_filter":
raise Exception(
@@ -353,7 +359,7 @@ class ProviderOpenAIOfficial(Provider):
payloads = {"messages": context_query, **model_config}
# xAI 原生搜索参数(最小侵入地在此处注入)
# xAI origin search tool inject
self._maybe_inject_xai_search(payloads, **kwargs)
return payloads, context_query
@@ -475,12 +481,6 @@ class ProviderOpenAIOfficial(Provider):
self.client.api_key = chosen_key
llm_response = await self._query(payloads, func_tool)
break
except UnprocessableEntityError as e:
logger.warning(f"不可处理的实体错误:{e},尝试删除图片。")
# 尝试删除所有 image
new_contexts = await self._remove_image_from_context(context_query)
payloads["messages"] = new_contexts
context_query = new_contexts
except Exception as e:
last_exception = e
(
@@ -545,12 +545,6 @@ class ProviderOpenAIOfficial(Provider):
async for response in self._query_stream(payloads, func_tool):
yield response
break
except UnprocessableEntityError as e:
logger.warning(f"不可处理的实体错误:{e},尝试删除图片。")
# 尝试删除所有 image
new_contexts = await self._remove_image_from_context(context_query)
payloads["messages"] = new_contexts
context_query = new_contexts
except Exception as e:
last_exception = e
(
@@ -646,4 +640,3 @@ class ProviderOpenAIOfficial(Provider):
with open(image_url, "rb") as f:
image_bs64 = base64.b64encode(f.read()).decode("utf-8")
return "data:image/jpeg;base64," + image_bs64
return ""

View File

@@ -85,3 +85,22 @@ class UmopConfigRouter:
self.umop_to_conf_id[umo] = conf_id
await self.sp.global_put("umop_config_routing", self.umop_to_conf_id)
async def delete_route(self, umo: str):
"""删除一条路由
Args:
umo (str): 需要删除的 UMO 字符串
Raises:
ValueError: 当 umo 格式不正确时抛出
"""
if not isinstance(umo, str) or len(umo.split(":")) != 3:
raise ValueError(
"umop must be a string in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
)
if umo in self.umop_to_conf_id:
del self.umop_to_conf_id[umo]
await self.sp.global_put("umop_config_routing", self.umop_to_conf_id)

View File

@@ -0,0 +1,73 @@
import traceback
from astrbot.core import astrbot_config, logger
from astrbot.core.astrbot_config_mgr import AstrBotConfig, AstrBotConfigManager
from astrbot.core.db.migration.migra_45_to_46 import migrate_45_to_46
from astrbot.core.db.migration.migra_webchat_session import migrate_webchat_session
def _migra_agent_runner_configs(conf: AstrBotConfig, ids_map: dict) -> None:
"""
Migra agent runner configs from provider configs.
"""
try:
default_prov_id = conf["provider_settings"]["default_provider_id"]
if default_prov_id in ids_map:
conf["provider_settings"]["default_provider_id"] = ""
p = ids_map[default_prov_id]
if p["type"] == "dify":
conf["provider_settings"]["dify_agent_runner_provider_id"] = p["id"]
conf["provider_settings"]["agent_runner_type"] = "dify"
elif p["type"] == "coze":
conf["provider_settings"]["coze_agent_runner_provider_id"] = p["id"]
conf["provider_settings"]["agent_runner_type"] = "coze"
elif p["type"] == "dashscope":
conf["provider_settings"]["dashscope_agent_runner_provider_id"] = p[
"id"
]
conf["provider_settings"]["agent_runner_type"] = "dashscope"
conf.save_config()
except Exception as e:
logger.error(f"Migration for third party agent runner configs failed: {e!s}")
logger.error(traceback.format_exc())
async def migra(
db, astrbot_config_mgr, umop_config_router, acm: AstrBotConfigManager
) -> None:
"""
Stores the migration logic here.
btw, i really don't like migration :(
"""
# 4.5 to 4.6 migration for umop_config_router
try:
await migrate_45_to_46(astrbot_config_mgr, umop_config_router)
except Exception as e:
logger.error(f"Migration from version 4.5 to 4.6 failed: {e!s}")
logger.error(traceback.format_exc())
# migration for webchat session
try:
await migrate_webchat_session(db)
except Exception as e:
logger.error(f"Migration for webchat session failed: {e!s}")
logger.error(traceback.format_exc())
# migra third party agent runner configs
_c = False
providers = astrbot_config["provider"]
ids_map = {}
for prov in providers:
type_ = prov.get("type")
if type_ in ["dify", "coze", "dashscope"]:
prov["provider_type"] = "agent_runner"
ids_map[prov["id"]] = {
"type": type_,
"id": prov["id"],
}
_c = True
if _c:
astrbot_config.save_config()
for conf in acm.confs.values():
_migra_agent_runner_configs(conf, ids_map)

View File

@@ -40,9 +40,6 @@ class SharedPreferences:
else:
ret = default
return ret
raise ValueError(
"scope_id and key cannot be None when getting a specific preference.",
)
async def range_get_async(
self,
@@ -56,30 +53,6 @@ class SharedPreferences:
ret = await self.db_helper.get_preferences(scope, scope_id, key)
return ret
@overload
async def session_get(
self,
umo: None,
key: str,
default: Any = None,
) -> list[Preference]: ...
@overload
async def session_get(
self,
umo: str,
key: None,
default: Any = None,
) -> list[Preference]: ...
@overload
async def session_get(
self,
umo: None,
key: None,
default: Any = None,
) -> list[Preference]: ...
async def session_get(
self,
umo: str | None,
@@ -88,7 +61,7 @@ class SharedPreferences:
) -> _VT | list[Preference]:
"""获取会话范围的偏好设置
Note: 当 scope_id 或者 key 为 None返回 Preference 列表,其中的 value 属性是一个 dictvalue["val"] 为值。
Note: 当 umo 或者 key 为 None返回 Preference 列表,其中的 value 属性是一个 dictvalue["val"] 为值。
"""
if umo is None or key is None:
return await self.range_get_async("umo", umo, key)

View File

@@ -10,7 +10,6 @@ from quart import g, make_response, request
from astrbot.core import logger
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db import BaseDatabase
from astrbot.core.platform.astr_message_event import MessageSession
from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
@@ -36,11 +35,14 @@ class ChatRoute(Route):
super().__init__(context)
self.routes = {
"/chat/send": ("POST", self.chat),
"/chat/new_conversation": ("GET", self.new_conversation),
"/chat/conversations": ("GET", self.get_conversations),
"/chat/get_conversation": ("GET", self.get_conversation),
"/chat/delete_conversation": ("GET", self.delete_conversation),
"/chat/rename_conversation": ("POST", self.rename_conversation),
"/chat/new_session": ("GET", self.new_session),
"/chat/sessions": ("GET", self.get_sessions),
"/chat/get_session": ("GET", self.get_session),
"/chat/delete_session": ("GET", self.delete_webchat_session),
"/chat/update_session_display_name": (
"POST",
self.update_session_display_name,
),
"/chat/get_file": ("GET", self.get_file),
"/chat/post_image": ("POST", self.post_image),
"/chat/post_file": ("POST", self.post_file),
@@ -53,6 +55,8 @@ class ChatRoute(Route):
self.supported_imgs = ["jpg", "jpeg", "png", "gif", "webp"]
self.conv_mgr = core_lifecycle.conversation_manager
self.platform_history_mgr = core_lifecycle.platform_message_history_manager
self.db = db
self.umop_config_router = core_lifecycle.umop_config_router
self.running_convs: dict[str, bool] = {}
@@ -116,11 +120,14 @@ class ChatRoute(Route):
if "message" not in post_data and "image_url" not in post_data:
return Response().error("Missing key: message or image_url").__dict__
if "conversation_id" not in post_data:
return Response().error("Missing key: conversation_id").__dict__
if "session_id" not in post_data and "conversation_id" not in post_data:
return (
Response().error("Missing key: session_id or conversation_id").__dict__
)
message = post_data["message"]
conversation_id = post_data["conversation_id"]
# conversation_id = post_data["conversation_id"]
session_id = post_data.get("session_id", post_data.get("conversation_id"))
image_url = post_data.get("image_url")
audio_url = post_data.get("audio_url")
selected_provider = post_data.get("selected_provider")
@@ -133,11 +140,11 @@ class ChatRoute(Route):
.error("Message and image_url and audio_url are empty")
.__dict__
)
if not conversation_id:
return Response().error("conversation_id is empty").__dict__
if not session_id:
return Response().error("session_id is empty").__dict__
# 追加用户消息
webchat_conv_id = await self._get_webchat_conv_id_from_conv_id(conversation_id)
webchat_conv_id = session_id
# 获取会话特定的队列
back_queue = webchat_queue_mgr.get_or_create_back_queue(webchat_conv_id)
@@ -245,88 +252,121 @@ class ChatRoute(Route):
response.timeout = None # fix SSE auto disconnect issue
return response
async def _get_webchat_conv_id_from_conv_id(self, conversation_id: str) -> str:
"""从对话 ID 中提取 WebChat 会话 ID
NOTE: 关于这里为什么要单独做一个 WebChat 的 Conversation ID 出来,这个是为了向前兼容。
"""
conversation = await self.conv_mgr.get_conversation(
unified_msg_origin="webchat",
conversation_id=conversation_id,
)
if not conversation:
raise ValueError(f"Conversation with ID {conversation_id} not found.")
conv_user_id = conversation.user_id
webchat_session_id = MessageSession.from_str(conv_user_id).session_id
if "!" not in webchat_session_id:
raise ValueError(f"Invalid conv user ID: {conv_user_id}")
return webchat_session_id.split("!")[-1]
async def delete_conversation(self):
conversation_id = request.args.get("conversation_id")
if not conversation_id:
return Response().error("Missing key: conversation_id").__dict__
async def delete_webchat_session(self):
"""Delete a Platform session and all its related data."""
session_id = request.args.get("session_id")
if not session_id:
return Response().error("Missing key: session_id").__dict__
username = g.get("username", "guest")
# Clean up queues when deleting conversation
webchat_queue_mgr.remove_queues(conversation_id)
webchat_conv_id = await self._get_webchat_conv_id_from_conv_id(conversation_id)
await self.conv_mgr.delete_conversation(
unified_msg_origin=f"webchat:FriendMessage:webchat!{username}!{webchat_conv_id}",
conversation_id=conversation_id,
)
# 验证会话是否存在且属于当前用户
session = await self.db.get_platform_session_by_id(session_id)
if not session:
return Response().error(f"Session {session_id} not found").__dict__
if session.creator != username:
return Response().error("Permission denied").__dict__
# 删除该会话下的所有对话
message_type = "GroupMessage" if session.is_group else "FriendMessage"
unified_msg_origin = f"{session.platform_id}:{message_type}:{session.platform_id}!{username}!{session_id}"
await self.conv_mgr.delete_conversations_by_user_id(unified_msg_origin)
# 删除消息历史
await self.platform_history_mgr.delete(
platform_id="webchat",
user_id=webchat_conv_id,
platform_id=session.platform_id,
user_id=session_id,
offset_sec=99999999,
)
# 删除与会话关联的配置路由
try:
await self.umop_config_router.delete_route(unified_msg_origin)
except ValueError as exc:
logger.warning(
"Failed to delete UMO route %s during session cleanup: %s",
unified_msg_origin,
exc,
)
# 清理队列(仅对 webchat
if session.platform_id == "webchat":
webchat_queue_mgr.remove_queues(session_id)
# 删除会话
await self.db.delete_platform_session(session_id)
return Response().ok().__dict__
async def new_conversation(self):
async def new_session(self):
"""Create a new Platform session (default: webchat)."""
username = g.get("username", "guest")
webchat_conv_id = str(uuid.uuid4())
conv_id = await self.conv_mgr.new_conversation(
unified_msg_origin=f"webchat:FriendMessage:webchat!{username}!{webchat_conv_id}",
platform_id="webchat",
content=[],
# 获取可选的 platform_id 参数,默认为 webchat
platform_id = request.args.get("platform_id", "webchat")
# 创建新会话
session = await self.db.create_platform_session(
creator=username,
platform_id=platform_id,
is_group=0,
)
return Response().ok(data={"conversation_id": conv_id}).__dict__
async def rename_conversation(self):
post_data = await request.json
if "conversation_id" not in post_data or "title" not in post_data:
return Response().error("Missing key: conversation_id or title").__dict__
conversation_id = post_data["conversation_id"]
title = post_data["title"]
await self.conv_mgr.update_conversation(
unified_msg_origin="webchat", # fake
conversation_id=conversation_id,
title=title,
return (
Response()
.ok(
data={
"session_id": session.session_id,
"platform_id": session.platform_id,
}
)
.__dict__
)
return Response().ok(message="重命名成功!").__dict__
async def get_conversations(self):
conversations = await self.conv_mgr.get_conversations(platform_id="webchat")
# remove content
conversations_ = []
for conv in conversations:
conv.history = None
conversations_.append(conv)
return Response().ok(data=conversations_).__dict__
async def get_sessions(self):
"""Get all Platform sessions for the current user."""
username = g.get("username", "guest")
async def get_conversation(self):
conversation_id = request.args.get("conversation_id")
if not conversation_id:
return Response().error("Missing key: conversation_id").__dict__
# 获取可选的 platform_id 参数
platform_id = request.args.get("platform_id")
webchat_conv_id = await self._get_webchat_conv_id_from_conv_id(conversation_id)
sessions = await self.db.get_platform_sessions_by_creator(
creator=username,
platform_id=platform_id,
page=1,
page_size=100, # 暂时返回前100个
)
# Get platform message history
# 转换为字典格式,并添加额外信息
sessions_data = []
for session in sessions:
sessions_data.append(
{
"session_id": session.session_id,
"platform_id": session.platform_id,
"creator": session.creator,
"display_name": session.display_name,
"is_group": session.is_group,
"created_at": session.created_at.astimezone().isoformat(),
"updated_at": session.updated_at.astimezone().isoformat(),
}
)
return Response().ok(data=sessions_data).__dict__
async def get_session(self):
"""Get session information and message history by session_id."""
session_id = request.args.get("session_id")
if not session_id:
return Response().error("Missing key: session_id").__dict__
# 获取会话信息以确定 platform_id
session = await self.db.get_platform_session_by_id(session_id)
platform_id = session.platform_id if session else "webchat"
# Get platform message history using session_id
history_ls = await self.platform_history_mgr.get(
platform_id="webchat",
user_id=webchat_conv_id,
platform_id=platform_id,
user_id=session_id,
page=1,
page_size=1000,
)
@@ -338,8 +378,37 @@ class ChatRoute(Route):
.ok(
data={
"history": history_res,
"is_running": self.running_convs.get(webchat_conv_id, False),
"is_running": self.running_convs.get(session_id, False),
},
)
.__dict__
)
async def update_session_display_name(self):
"""Update a Platform session's display name."""
post_data = await request.json
session_id = post_data.get("session_id")
display_name = post_data.get("display_name")
if not session_id:
return Response().error("Missing key: session_id").__dict__
if display_name is None:
return Response().error("Missing key: display_name").__dict__
username = g.get("username", "guest")
# 验证会话是否存在且属于当前用户
session = await self.db.get_platform_session_by_id(session_id)
if not session:
return Response().error(f"Session {session_id} not found").__dict__
if session.creator != username:
return Response().error("Permission denied").__dict__
# 更新 display_name
await self.db.update_platform_session(
session_id=session_id,
display_name=display_name,
)
return Response().ok().__dict__

View File

@@ -14,6 +14,7 @@ from astrbot.core.config.default import (
DEFAULT_CONFIG,
DEFAULT_VALUE_MAP,
)
from astrbot.core.config.i18n_utils import ConfigMetadataI18n
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.platform.register import platform_cls_map, platform_registry
from astrbot.core.provider import Provider
@@ -133,7 +134,9 @@ def save_config(post_config: dict, config: AstrBotConfig, is_core: bool = False)
is_core,
)
else:
errors, post_config = validate_config(post_config, config.schema, is_core)
errors, post_config = validate_config(
post_config, getattr(config, "schema", {}), is_core
)
except BaseException as e:
logger.error(traceback.format_exc())
logger.warning(f"验证配置时出现异常: {e}")
@@ -247,11 +250,8 @@ class ConfigRoute(Route):
async def get_default_config(self):
"""获取默认配置文件"""
return (
Response()
.ok({"config": DEFAULT_CONFIG, "metadata": CONFIG_METADATA_3})
.__dict__
)
metadata = ConfigMetadataI18n.convert_to_i18n_keys(CONFIG_METADATA_3)
return Response().ok({"config": DEFAULT_CONFIG, "metadata": metadata}).__dict__
async def get_abconf_list(self):
"""获取所有 AstrBot 配置文件的列表"""
@@ -282,17 +282,15 @@ class ConfigRoute(Route):
try:
if system_config:
abconf = self.acm.confs["default"]
return (
Response()
.ok({"config": abconf, "metadata": CONFIG_METADATA_3_SYSTEM})
.__dict__
metadata = ConfigMetadataI18n.convert_to_i18n_keys(
CONFIG_METADATA_3_SYSTEM
)
return Response().ok({"config": abconf, "metadata": metadata}).__dict__
if abconf_id is None:
raise ValueError("abconf_id cannot be None")
abconf = self.acm.confs[abconf_id]
return (
Response()
.ok({"config": abconf, "metadata": CONFIG_METADATA_3})
.__dict__
)
metadata = ConfigMetadataI18n.convert_to_i18n_keys(CONFIG_METADATA_3)
return Response().ok({"config": abconf, "metadata": metadata}).__dict__
except ValueError as e:
return Response().error(str(e)).__dict__
@@ -598,9 +596,15 @@ class ConfigRoute(Route):
return Response().error("缺少参数 provider_id").__dict__
prov_mgr = self.core_lifecycle.provider_manager
provider: Provider | None = prov_mgr.inst_map.get(provider_id, None)
provider = prov_mgr.inst_map.get(provider_id, None)
if not provider:
return Response().error(f"未找到 ID 为 {provider_id} 的提供商").__dict__
if not isinstance(provider, Provider):
return (
Response()
.error(f"提供商 {provider_id} 类型不支持获取模型列表")
.__dict__
)
try:
models = await provider.get_models()

View File

@@ -48,6 +48,7 @@ class KnowledgeBaseRoute(Route):
# 文档管理
"/kb/document/list": ("GET", self.list_documents),
"/kb/document/upload": ("POST", self.upload_document),
"/kb/document/upload/url": ("POST", self.upload_document_from_url),
"/kb/document/upload/progress": ("GET", self.get_upload_progress),
"/kb/document/get": ("GET", self.get_document),
"/kb/document/delete": ("POST", self.delete_document),
@@ -1070,3 +1071,174 @@ class KnowledgeBaseRoute(Route):
logger.error(f"删除会话知识库配置失败: {e}")
logger.error(traceback.format_exc())
return Response().error(f"删除会话知识库配置失败: {e!s}").__dict__
async def upload_document_from_url(self):
"""从 URL 上传文档
Body:
- kb_id: 知识库 ID (必填)
- url: 要提取内容的网页 URL (必填)
- chunk_size: 分块大小 (可选, 默认512)
- chunk_overlap: 块重叠大小 (可选, 默认50)
- batch_size: 批处理大小 (可选, 默认32)
- tasks_limit: 并发任务限制 (可选, 默认3)
- max_retries: 最大重试次数 (可选, 默认3)
返回:
- task_id: 任务ID用于查询上传进度和结果
"""
try:
kb_manager = self._get_kb_manager()
data = await request.json
kb_id = data.get("kb_id")
if not kb_id:
return Response().error("缺少参数 kb_id").__dict__
url = data.get("url")
if not url:
return Response().error("缺少参数 url").__dict__
chunk_size = data.get("chunk_size", 512)
chunk_overlap = data.get("chunk_overlap", 50)
batch_size = data.get("batch_size", 32)
tasks_limit = data.get("tasks_limit", 3)
max_retries = data.get("max_retries", 3)
enable_cleaning = data.get("enable_cleaning", False)
cleaning_provider_id = data.get("cleaning_provider_id")
# 获取知识库
kb_helper = await kb_manager.get_kb(kb_id)
if not kb_helper:
return Response().error("知识库不存在").__dict__
# 生成任务ID
task_id = str(uuid.uuid4())
# 初始化任务状态
self.upload_tasks[task_id] = {
"status": "pending",
"result": None,
"error": None,
}
# 启动后台任务
asyncio.create_task(
self._background_upload_from_url_task(
task_id=task_id,
kb_helper=kb_helper,
url=url,
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
batch_size=batch_size,
tasks_limit=tasks_limit,
max_retries=max_retries,
enable_cleaning=enable_cleaning,
cleaning_provider_id=cleaning_provider_id,
),
)
return (
Response()
.ok(
{
"task_id": task_id,
"url": url,
"message": "URL upload task created, processing in background",
},
)
.__dict__
)
except ValueError as e:
return Response().error(str(e)).__dict__
except Exception as e:
logger.error(f"从URL上传文档失败: {e}")
logger.error(traceback.format_exc())
return Response().error(f"从URL上传文档失败: {e!s}").__dict__
async def _background_upload_from_url_task(
self,
task_id: str,
kb_helper,
url: str,
chunk_size: int,
chunk_overlap: int,
batch_size: int,
tasks_limit: int,
max_retries: int,
enable_cleaning: bool,
cleaning_provider_id: str | None,
):
"""后台上传URL任务"""
try:
# 初始化任务状态
self.upload_tasks[task_id] = {
"status": "processing",
"result": None,
"error": None,
}
self.upload_progress[task_id] = {
"status": "processing",
"file_index": 0,
"file_total": 1,
"file_name": f"URL: {url}",
"stage": "extracting",
"current": 0,
"total": 100,
}
# 创建进度回调函数
async def progress_callback(stage, current, total):
if task_id in self.upload_progress:
self.upload_progress[task_id].update(
{
"status": "processing",
"file_index": 0,
"file_name": f"URL: {url}",
"stage": stage,
"current": current,
"total": total,
},
)
# 上传文档
doc = await kb_helper.upload_from_url(
url=url,
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
batch_size=batch_size,
tasks_limit=tasks_limit,
max_retries=max_retries,
progress_callback=progress_callback,
enable_cleaning=enable_cleaning,
cleaning_provider_id=cleaning_provider_id,
)
# 更新任务完成状态
result = {
"task_id": task_id,
"uploaded": [doc.model_dump()],
"failed": [],
"total": 1,
"success_count": 1,
"failed_count": 0,
}
self.upload_tasks[task_id] = {
"status": "completed",
"result": result,
"error": None,
}
self.upload_progress[task_id]["status"] = "completed"
except Exception as e:
logger.error(f"后台上传URL任务 {task_id} 失败: {e}")
logger.error(traceback.format_exc())
self.upload_tasks[task_id] = {
"status": "failed",
"result": None,
"error": str(e),
}
if task_id in self.upload_progress:
self.upload_progress[task_id]["status"] = "failed"

View File

@@ -1,16 +1,24 @@
import traceback
from quart import request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import col, select
from astrbot.core import logger, sp
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db import BaseDatabase
from astrbot.core.db.po import ConversationV2, Preference
from astrbot.core.provider.entities import ProviderType
from astrbot.core.star.session_llm_manager import SessionServiceManager
from astrbot.core.star.session_plugin_manager import SessionPluginManager
from .route import Response, Route, RouteContext
AVAILABLE_SESSION_RULE_KEYS = [
"session_service_config",
"session_plugin_config",
"kb_config",
f"provider_perf_{ProviderType.CHAT_COMPLETION.value}",
f"provider_perf_{ProviderType.SPEECH_TO_TEXT.value}",
f"provider_perf_{ProviderType.TEXT_TO_SPEECH.value}",
]
class SessionManagementRoute(Route):
def __init__(
@@ -22,667 +30,325 @@ class SessionManagementRoute(Route):
super().__init__(context)
self.db_helper = db_helper
self.routes = {
"/session/list": ("GET", self.list_sessions),
"/session/update_persona": ("POST", self.update_session_persona),
"/session/update_provider": ("POST", self.update_session_provider),
"/session/plugins": ("GET", self.get_session_plugins),
"/session/update_plugin": ("POST", self.update_session_plugin),
"/session/update_llm": ("POST", self.update_session_llm),
"/session/update_tts": ("POST", self.update_session_tts),
"/session/update_name": ("POST", self.update_session_name),
"/session/update_status": ("POST", self.update_session_status),
"/session/delete": ("POST", self.delete_session),
"/session/list-rule": ("GET", self.list_session_rule),
"/session/update-rule": ("POST", self.update_session_rule),
"/session/delete-rule": ("POST", self.delete_session_rule),
"/session/batch-delete-rule": ("POST", self.batch_delete_session_rule),
"/session/active-umos": ("GET", self.list_umos),
}
self.conv_mgr = core_lifecycle.conversation_manager
self.core_lifecycle = core_lifecycle
self.register_routes()
async def list_sessions(self):
"""获取所有会话的列表,包括 persona 和 provider 信息"""
try:
page = int(request.args.get("page", 1))
page_size = int(request.args.get("page_size", 20))
search_query = request.args.get("search", "")
platform = request.args.get("platform", "")
async def _get_umo_rules(
self, page: int = 1, page_size: int = 10, search: str = ""
) -> tuple[dict, int]:
"""获取所有带有自定义规则的 umo 及其规则内容(支持分页和搜索)。
# 获取活跃的会话数据(处于对话内的会话)
sessions_data, total = await self.db_helper.get_session_conversations(
page,
page_size,
search_query,
platform,
如果某个 umo 在 preference 中有以下字段,则表示有自定义规则:
1. session_service_config (包含了 是否启用这个umo, 这个umo是否启用 llm, 这个umo是否启用tts, umo自定义名称。)
2. session_plugin_config (包含了 这个 umo 的 plugin set)
3. provider_perf_{ProviderType.value} (包含了这个 umo 所选择使用的 provider 信息)
4. kb_config (包含了这个 umo 的知识库相关配置)
Args:
page: 页码,从 1 开始
page_size: 每页数量
search: 搜索关键词,匹配 umo 或 custom_name
Returns:
tuple[dict, int]: (umo_rules, total) - 分页后的 umo 规则和总数
"""
umo_rules = {}
async with self.db_helper.get_db() as session:
session: AsyncSession
result = await session.execute(
select(Preference).where(
col(Preference.scope) == "umo",
col(Preference.key).in_(AVAILABLE_SESSION_RULE_KEYS),
)
)
prefs = result.scalars().all()
for pref in prefs:
umo_id = pref.scope_id
if umo_id not in umo_rules:
umo_rules[umo_id] = {}
umo_rules[umo_id][pref.key] = pref.value["val"]
# 搜索过滤
if search:
search_lower = search.lower()
filtered_rules = {}
for umo_id, rules in umo_rules.items():
# 匹配 umo
if search_lower in umo_id.lower():
filtered_rules[umo_id] = rules
continue
# 匹配 custom_name
svc_config = rules.get("session_service_config", {})
custom_name = svc_config.get("custom_name", "") if svc_config else ""
if custom_name and search_lower in custom_name.lower():
filtered_rules[umo_id] = rules
umo_rules = filtered_rules
# 获取总数
total = len(umo_rules)
# 分页处理
all_umo_ids = list(umo_rules.keys())
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
paginated_umo_ids = all_umo_ids[start_idx:end_idx]
# 只返回分页后的数据
paginated_rules = {umo_id: umo_rules[umo_id] for umo_id in paginated_umo_ids}
return paginated_rules, total
async def list_session_rule(self):
"""获取所有自定义的规则(支持分页和搜索)
返回已配置规则的 umo 列表及其规则内容,以及可用的 personas 和 providers
Query 参数:
page: 页码,默认为 1
page_size: 每页数量,默认为 10
search: 搜索关键词,匹配 umo 或 custom_name
"""
try:
# 获取分页和搜索参数
page = request.args.get("page", 1, type=int)
page_size = request.args.get("page_size", 10, type=int)
search = request.args.get("search", "", type=str).strip()
# 参数校验
if page < 1:
page = 1
if page_size < 1:
page_size = 10
if page_size > 100:
page_size = 100
umo_rules, total = await self._get_umo_rules(
page=page, page_size=page_size, search=search
)
# 构建规则列表
rules_list = []
for umo, rules in umo_rules.items():
rule_info = {
"umo": umo,
"rules": rules,
}
# 解析 umo 格式: 平台:消息类型:会话ID
parts = umo.split(":")
if len(parts) >= 3:
rule_info["platform"] = parts[0]
rule_info["message_type"] = parts[1]
rule_info["session_id"] = parts[2]
rules_list.append(rule_info)
# 获取可用的 providers 和 personas
provider_manager = self.core_lifecycle.provider_manager
persona_mgr = self.core_lifecycle.persona_mgr
personas = persona_mgr.personas_v3
sessions = []
# 循环补充非数据库信息,如 provider 和 session 状态
for data in sessions_data:
session_id = data["session_id"]
conversation_id = data["conversation_id"]
conv_persona_id = data["persona_id"]
title = data["title"]
persona_name = data["persona_name"]
# 处理 persona 显示
if persona_name is None:
if conv_persona_id is None:
if default_persona := persona_mgr.selected_default_persona_v3:
persona_name = default_persona["name"]
else:
persona_name = "[%None]"
session_info = {
"session_id": session_id,
"conversation_id": conversation_id,
"persona_id": persona_name,
"chat_provider_id": None,
"stt_provider_id": None,
"tts_provider_id": None,
"session_enabled": SessionServiceManager.is_session_enabled(
session_id,
),
"llm_enabled": SessionServiceManager.is_llm_enabled_for_session(
session_id,
),
"tts_enabled": SessionServiceManager.is_tts_enabled_for_session(
session_id,
),
"platform": session_id.split(":")[0]
if ":" in session_id
else "unknown",
"message_type": session_id.split(":")[1]
if session_id.count(":") >= 1
else "unknown",
"session_name": SessionServiceManager.get_session_display_name(
session_id,
),
"session_raw_name": session_id.split(":")[2]
if session_id.count(":") >= 2
else session_id,
"title": title,
}
# 获取 provider 信息
chat_provider = provider_manager.get_using_provider(
provider_type=ProviderType.CHAT_COMPLETION,
umo=session_id,
)
tts_provider = provider_manager.get_using_provider(
provider_type=ProviderType.TEXT_TO_SPEECH,
umo=session_id,
)
stt_provider = provider_manager.get_using_provider(
provider_type=ProviderType.SPEECH_TO_TEXT,
umo=session_id,
)
if chat_provider:
meta = chat_provider.meta()
session_info["chat_provider_id"] = meta.id
if tts_provider:
meta = tts_provider.meta()
session_info["tts_provider_id"] = meta.id
if stt_provider:
meta = stt_provider.meta()
session_info["stt_provider_id"] = meta.id
sessions.append(session_info)
# 获取可用的 personas 和 providers 列表
available_personas = [
{"name": p["name"], "prompt": p.get("prompt", "")} for p in personas
{"name": p["name"], "prompt": p.get("prompt", "")}
for p in persona_mgr.personas_v3
]
available_chat_providers = []
for provider in provider_manager.provider_insts:
meta = provider.meta()
available_chat_providers.append(
{
"id": meta.id,
"name": meta.id,
"model": meta.model,
"type": meta.type,
},
)
available_stt_providers = []
for provider in provider_manager.stt_provider_insts:
meta = provider.meta()
available_stt_providers.append(
{
"id": meta.id,
"name": meta.id,
"model": meta.model,
"type": meta.type,
},
)
available_tts_providers = []
for provider in provider_manager.tts_provider_insts:
meta = provider.meta()
available_tts_providers.append(
{
"id": meta.id,
"name": meta.id,
"model": meta.model,
"type": meta.type,
},
)
result = {
"sessions": sessions,
"available_personas": available_personas,
"available_chat_providers": available_chat_providers,
"available_stt_providers": available_stt_providers,
"available_tts_providers": available_tts_providers,
"pagination": {
"page": page,
"page_size": page_size,
"total": total,
"total_pages": (total + page_size - 1) // page_size
if page_size > 0
else 0,
},
}
return Response().ok(result).__dict__
except Exception as e:
error_msg = f"获取会话列表失败: {e!s}\n{traceback.format_exc()}"
logger.error(error_msg)
return Response().error(f"获取会话列表失败: {e!s}").__dict__
async def _update_single_session_persona(self, session_id: str, persona_name: str):
"""更新单个会话的 persona 的内部方法"""
conversation_manager = self.core_lifecycle.star_context.conversation_manager
conversation_id = await conversation_manager.get_curr_conversation_id(
session_id,
)
conv = None
if conversation_id:
conv = await conversation_manager.get_conversation(
unified_msg_origin=session_id,
conversation_id=conversation_id,
)
if not conv or not conversation_id:
conversation_id = await conversation_manager.new_conversation(session_id)
# 更新 persona
await conversation_manager.update_conversation_persona_id(
session_id,
persona_name,
)
async def _handle_batch_operation(
self,
session_ids: list,
operation_func,
operation_name: str,
**kwargs,
):
"""通用的批量操作处理方法"""
success_count = 0
error_sessions = []
for session_id in session_ids:
try:
await operation_func(session_id, **kwargs)
success_count += 1
except Exception as e:
logger.error(f"批量{operation_name} 会话 {session_id} 失败: {e!s}")
error_sessions.append(session_id)
if error_sessions:
return (
Response()
.ok(
{
"message": f"批量更新完成,成功: {success_count},失败: {len(error_sessions)}",
"success_count": success_count,
"error_count": len(error_sessions),
"error_sessions": error_sessions,
},
)
.__dict__
)
return (
Response()
.ok(
available_chat_providers = [
{
"message": f"成功批量{operation_name} {success_count} 个会话",
"success_count": success_count,
},
)
.__dict__
)
"id": p.meta().id,
"name": p.meta().id,
"model": p.meta().model,
}
for p in provider_manager.provider_insts
]
async def update_session_persona(self):
"""更新指定会话的 persona支持批量操作"""
try:
data = await request.get_json()
is_batch = data.get("is_batch", False)
persona_name = data.get("persona_name")
available_stt_providers = [
{
"id": p.meta().id,
"name": p.meta().id,
"model": p.meta().model,
}
for p in provider_manager.stt_provider_insts
]
if persona_name is None:
return Response().error("缺少必要参数: persona_name").__dict__
available_tts_providers = [
{
"id": p.meta().id,
"name": p.meta().id,
"model": p.meta().model,
}
for p in provider_manager.tts_provider_insts
]
if is_batch:
session_ids = data.get("session_ids", [])
if not session_ids:
return Response().error("缺少必要参数: session_ids").__dict__
return await self._handle_batch_operation(
session_ids,
self._update_single_session_persona,
"更新人格",
persona_name=persona_name,
)
session_id = data.get("session_id")
if not session_id:
return Response().error("缺少必要参数: session_id").__dict__
await self._update_single_session_persona(session_id, persona_name)
return (
Response()
.ok(
{
"message": f"成功更新会话 {session_id} 的人格为 {persona_name}",
},
"rules": rules_list,
"total": total,
"page": page,
"page_size": page_size,
"available_personas": available_personas,
"available_chat_providers": available_chat_providers,
"available_stt_providers": available_stt_providers,
"available_tts_providers": available_tts_providers,
"available_rule_keys": AVAILABLE_SESSION_RULE_KEYS,
}
)
.__dict__
)
except Exception as e:
error_msg = f"更新会话人格失败: {e!s}\n{traceback.format_exc()}"
logger.error(error_msg)
return Response().error(f"更新会话人格失败: {e!s}").__dict__
logger.error(f"获取规则列表失败: {e!s}")
return Response().error(f"获取规则列表失败: {e!s}").__dict__
async def _update_single_session_provider(
self,
session_id: str,
provider_id: str,
provider_type_enum,
):
"""更新单个会话的 provider 的内部方法"""
provider_manager = self.core_lifecycle.star_context.provider_manager
await provider_manager.set_provider(
provider_id=provider_id,
provider_type=provider_type_enum,
umo=session_id,
)
async def update_session_rule(self):
"""更新某个 umo 的自定义规则
async def update_session_provider(self):
"""更新指定会话的 provider支持批量操作"""
请求体:
{
"umo": "平台:消息类型:会话ID",
"rule_key": "session_service_config" | "session_plugin_config" | "kb_config" | "provider_perf_xxx",
"rule_value": {...} // 规则值,具体结构根据 rule_key 不同而不同
}
"""
try:
data = await request.get_json()
is_batch = data.get("is_batch", False)
provider_id = data.get("provider_id")
provider_type = data.get("provider_type")
umo = data.get("umo")
rule_key = data.get("rule_key")
rule_value = data.get("rule_value")
if not provider_id or not provider_type:
if not umo:
return Response().error("缺少必要参数: umo").__dict__
if not rule_key:
return Response().error("缺少必要参数: rule_key").__dict__
if rule_key not in AVAILABLE_SESSION_RULE_KEYS:
return Response().error(f"不支持的规则键: {rule_key}").__dict__
# 使用 shared preferences 更新规则
await sp.session_put(umo, rule_key, rule_value)
return (
Response()
.ok({"message": f"规则 {rule_key} 已更新", "umo": umo})
.__dict__
)
except Exception as e:
logger.error(f"更新会话规则失败: {e!s}")
return Response().error(f"更新会话规则失败: {e!s}").__dict__
async def delete_session_rule(self):
"""删除某个 umo 的自定义规则
请求体:
{
"umo": "平台:消息类型:会话ID",
"rule_key": "session_service_config" | "session_plugin_config" | ... (可选,不传则删除所有规则)
}
"""
try:
data = await request.get_json()
umo = data.get("umo")
rule_key = data.get("rule_key")
if not umo:
return Response().error("缺少必要参数: umo").__dict__
if rule_key:
# 删除单个规则
if rule_key not in AVAILABLE_SESSION_RULE_KEYS:
return Response().error(f"不支持的规则键: {rule_key}").__dict__
await sp.session_remove(umo, rule_key)
return (
Response()
.error("缺少必要参数: provider_id, provider_type")
.ok({"message": f"规则 {rule_key} 已删除", "umo": umo})
.__dict__
)
else:
# 删除该 umo 的所有规则
await sp.clear_async("umo", umo)
return Response().ok({"message": "所有规则已删除", "umo": umo}).__dict__
except Exception as e:
logger.error(f"删除会话规则失败: {e!s}")
return Response().error(f"删除会话规则失败: {e!s}").__dict__
# 转换 provider_type 字符串为枚举
if provider_type == "chat_completion":
provider_type_enum = ProviderType.CHAT_COMPLETION
elif provider_type == "speech_to_text":
provider_type_enum = ProviderType.SPEECH_TO_TEXT
elif provider_type == "text_to_speech":
provider_type_enum = ProviderType.TEXT_TO_SPEECH
async def batch_delete_session_rule(self):
"""批量删除多个 umo 的自定义规则
请求体:
{
"umos": ["平台:消息类型:会话ID", ...] // umo 列表
}
"""
try:
data = await request.get_json()
umos = data.get("umos", [])
if not umos:
return Response().error("缺少必要参数: umos").__dict__
if not isinstance(umos, list):
return Response().error("参数 umos 必须是数组").__dict__
# 批量删除
deleted_count = 0
failed_umos = []
for umo in umos:
try:
await sp.clear_async("umo", umo)
deleted_count += 1
except Exception as e:
logger.error(f"删除 umo {umo} 的规则失败: {e!s}")
failed_umos.append(umo)
if failed_umos:
return (
Response()
.ok(
{
"message": f"已删除 {deleted_count} 条规则,{len(failed_umos)} 条删除失败",
"deleted_count": deleted_count,
"failed_umos": failed_umos,
}
)
.__dict__
)
else:
return (
Response()
.error(f"不支持的 provider_type: {provider_type}")
.__dict__
)
if is_batch:
session_ids = data.get("session_ids", [])
if not session_ids:
return Response().error("缺少必要参数: session_ids").__dict__
return await self._handle_batch_operation(
session_ids,
self._update_single_session_provider,
f"更新 {provider_type} 提供商",
provider_id=provider_id,
provider_type_enum=provider_type_enum,
)
session_id = data.get("session_id")
if not session_id:
return Response().error("缺少必要参数: session_id").__dict__
await self._update_single_session_provider(
session_id,
provider_id,
provider_type_enum,
)
return (
Response()
.ok(
{
"message": f"成功更新会话 {session_id}{provider_type} 提供商为 {provider_id}",
},
)
.__dict__
)
except Exception as e:
error_msg = f"更新会话提供商失败: {e!s}\n{traceback.format_exc()}"
logger.error(error_msg)
return Response().error(f"更新会话提供商失败: {e!s}").__dict__
async def get_session_plugins(self):
"""获取指定会话的插件配置信息"""
try:
session_id = request.args.get("session_id")
if not session_id:
return Response().error("缺少必要参数: session_id").__dict__
# 获取所有已激活的插件
all_plugins = []
plugin_manager = self.core_lifecycle.plugin_manager
for plugin in plugin_manager.context.get_all_stars():
# 只显示已激活的插件,不包括保留插件
if plugin.activated and not plugin.reserved:
plugin_name = plugin.name or ""
plugin_enabled = SessionPluginManager.is_plugin_enabled_for_session(
session_id,
plugin_name,
)
all_plugins.append(
.ok(
{
"name": plugin_name,
"author": plugin.author,
"desc": plugin.desc,
"enabled": plugin_enabled,
},
"message": f"已删除 {deleted_count} 条规则",
"deleted_count": deleted_count,
}
)
return (
Response()
.ok(
{
"session_id": session_id,
"plugins": all_plugins,
},
)
.__dict__
)
except Exception as e:
error_msg = f"获取会话插件配置失败: {e!s}\n{traceback.format_exc()}"
logger.error(error_msg)
return Response().error(f"获取会话插件配置失败: {e!s}").__dict__
async def update_session_plugin(self):
"""更新指定会话的插件启停状态"""
try:
data = await request.get_json()
session_id = data.get("session_id")
plugin_name = data.get("plugin_name")
enabled = data.get("enabled")
if not session_id:
return Response().error("缺少必要参数: session_id").__dict__
if not plugin_name:
return Response().error("缺少必要参数: plugin_name").__dict__
if enabled is None:
return Response().error("缺少必要参数: enabled").__dict__
# 验证插件是否存在且已激活
plugin_manager = self.core_lifecycle.plugin_manager
plugin = plugin_manager.context.get_registered_star(plugin_name)
if not plugin:
return Response().error(f"插件 {plugin_name} 不存在").__dict__
if not plugin.activated:
return Response().error(f"插件 {plugin_name} 未激活").__dict__
if plugin.reserved:
return (
Response()
.error(f"插件 {plugin_name} 是系统保留插件,无法管理")
.__dict__
)
# 使用 SessionPluginManager 更新插件状态
SessionPluginManager.set_plugin_status_for_session(
session_id,
plugin_name,
enabled,
)
return (
Response()
.ok(
{
"message": f"插件 {plugin_name}{'启用' if enabled else '禁用'}",
"session_id": session_id,
"plugin_name": plugin_name,
"enabled": enabled,
},
)
.__dict__
)
except Exception as e:
error_msg = f"更新会话插件状态失败: {e!s}\n{traceback.format_exc()}"
logger.error(error_msg)
return Response().error(f"更新会话插件状态失败: {e!s}").__dict__
logger.error(f"批量删除会话规则失败: {e!s}")
return Response().error(f"批量删除会话规则失败: {e!s}").__dict__
async def _update_single_session_llm(self, session_id: str, enabled: bool):
"""更新单个会话的LLM状态的内部方法"""
SessionServiceManager.set_llm_status_for_session(session_id, enabled)
async def list_umos(self):
"""列出所有有对话记录的 umo从 Conversations 表中找
async def update_session_llm(self):
"""更新指定会话的LLM启停状态支持批量操作"""
仅返回 umo 字符串列表,用于用户在创建规则时选择 umo
"""
try:
data = await request.get_json()
is_batch = data.get("is_batch", False)
enabled = data.get("enabled")
if enabled is None:
return Response().error("缺少必要参数: enabled").__dict__
if is_batch:
session_ids = data.get("session_ids", [])
if not session_ids:
return Response().error("缺少必要参数: session_ids").__dict__
result = await self._handle_batch_operation(
session_ids,
self._update_single_session_llm,
f"{'启用' if enabled else '禁用'}LLM",
enabled=enabled,
# 从 Conversation 表获取所有 distinct user_id (即 umo)
async with self.db_helper.get_db() as session:
session: AsyncSession
result = await session.execute(
select(ConversationV2.user_id)
.distinct()
.order_by(ConversationV2.user_id)
)
return result
session_id = data.get("session_id")
if not session_id:
return Response().error("缺少必要参数: session_id").__dict__
await self._update_single_session_llm(session_id, enabled)
return (
Response()
.ok(
{
"message": f"LLM已{'启用' if enabled else '禁用'}",
"session_id": session_id,
"llm_enabled": enabled,
},
)
.__dict__
)
umos = [row[0] for row in result.fetchall()]
return Response().ok({"umos": umos}).__dict__
except Exception as e:
error_msg = f"更新会话LLM状态失败: {e!s}\n{traceback.format_exc()}"
logger.error(error_msg)
return Response().error(f"更新会话LLM状态失败: {e!s}").__dict__
async def _update_single_session_tts(self, session_id: str, enabled: bool):
"""更新单个会话的TTS状态的内部方法"""
SessionServiceManager.set_tts_status_for_session(session_id, enabled)
async def update_session_tts(self):
"""更新指定会话的TTS启停状态支持批量操作"""
try:
data = await request.get_json()
is_batch = data.get("is_batch", False)
enabled = data.get("enabled")
if enabled is None:
return Response().error("缺少必要参数: enabled").__dict__
if is_batch:
session_ids = data.get("session_ids", [])
if not session_ids:
return Response().error("缺少必要参数: session_ids").__dict__
result = await self._handle_batch_operation(
session_ids,
self._update_single_session_tts,
f"{'启用' if enabled else '禁用'}TTS",
enabled=enabled,
)
return result
session_id = data.get("session_id")
if not session_id:
return Response().error("缺少必要参数: session_id").__dict__
await self._update_single_session_tts(session_id, enabled)
return (
Response()
.ok(
{
"message": f"TTS已{'启用' if enabled else '禁用'}",
"session_id": session_id,
"tts_enabled": enabled,
},
)
.__dict__
)
except Exception as e:
error_msg = f"更新会话TTS状态失败: {e!s}\n{traceback.format_exc()}"
logger.error(error_msg)
return Response().error(f"更新会话TTS状态失败: {e!s}").__dict__
async def update_session_name(self):
"""更新指定会话的自定义名称"""
try:
data = await request.get_json()
session_id = data.get("session_id")
custom_name = data.get("custom_name", "")
if not session_id:
return Response().error("缺少必要参数: session_id").__dict__
# 使用 SessionServiceManager 更新会话名称
SessionServiceManager.set_session_custom_name(session_id, custom_name)
return (
Response()
.ok(
{
"message": f"会话名称已更新为: {custom_name if custom_name.strip() else '已清除自定义名称'}",
"session_id": session_id,
"custom_name": custom_name,
"display_name": SessionServiceManager.get_session_display_name(
session_id,
),
},
)
.__dict__
)
except Exception as e:
error_msg = f"更新会话名称失败: {e!s}\n{traceback.format_exc()}"
logger.error(error_msg)
return Response().error(f"更新会话名称失败: {e!s}").__dict__
async def update_session_status(self):
"""更新指定会话的整体启停状态"""
try:
data = await request.get_json()
session_id = data.get("session_id")
session_enabled = data.get("session_enabled")
if not session_id:
return Response().error("缺少必要参数: session_id").__dict__
if session_enabled is None:
return Response().error("缺少必要参数: session_enabled").__dict__
# 使用 SessionServiceManager 更新会话整体状态
SessionServiceManager.set_session_status(session_id, session_enabled)
return (
Response()
.ok(
{
"message": f"会话整体状态已更新为: {'启用' if session_enabled else '禁用'}",
"session_id": session_id,
"session_enabled": session_enabled,
},
)
.__dict__
)
except Exception as e:
error_msg = f"更新会话整体状态失败: {e!s}\n{traceback.format_exc()}"
logger.error(error_msg)
return Response().error(f"更新会话整体状态失败: {e!s}").__dict__
async def delete_session(self):
"""删除指定会话及其所有相关数据"""
try:
data = await request.get_json()
session_id = data.get("session_id")
if not session_id:
return Response().error("缺少必要参数: session_id").__dict__
# 删除会话的所有相关数据
conversation_manager = self.core_lifecycle.conversation_manager
# 1. 删除会话的所有对话
try:
await conversation_manager.delete_conversations_by_user_id(session_id)
except Exception as e:
logger.warning(f"删除会话 {session_id} 的对话失败: {e!s}")
# 2. 清除会话的偏好设置数据(清空该会话的所有配置)
try:
await sp.clear_async("umo", session_id)
except Exception as e:
logger.warning(f"清除会话 {session_id} 的偏好设置失败: {e!s}")
return (
Response()
.ok(
{
"message": f"会话 {session_id} 及其相关所有对话数据已成功删除",
"session_id": session_id,
},
)
.__dict__
)
except Exception as e:
error_msg = f"删除会话失败: {e!s}\n{traceback.format_exc()}"
logger.error(error_msg)
return Response().error(f"删除会话失败: {e!s}").__dict__
logger.error(f"获取 UMO 列表失败: {e!s}")
return Response().error(f"获取 UMO 列表失败: {e!s}").__dict__

5
changelogs/v4.5.8.md Normal file
View File

@@ -0,0 +1,5 @@
## What's Changed
hot fix of 4.5.7
fix: 无法正常发送图片,报错 `pydantic_core._pydantic_core.ValidationError`

23
changelogs/v4.6.0.md Normal file
View File

@@ -0,0 +1,23 @@
## What's Changed
1. 新增: 支持 gemini-3 系列的 thought signature ([#3698](https://github.com/AstrBotDevs/AstrBot/issues/3698))
2. 新增: 支持知识库的 Agentic 检索功能 ([#3667](https://github.com/AstrBotDevs/AstrBot/issues/3667))
3. 新增: 为知识库添加 URL 文档解析器 ([#3622](https://github.com/AstrBotDevs/AstrBot/issues/3622))
4. 修复(core.platform): 修复启用多个企业微信智能机器人适配器时消息混乱的问题 ([#3693](https://github.com/AstrBotDevs/AstrBot/issues/3693))
5. 修复: MCP Server 连接成功一段时间后,调用 mcp 工具时可能出现 `anyio.ClosedResourceError` 错误 ([#3700](https://github.com/AstrBotDevs/AstrBot/issues/3700))
6. 新增(chat): 重构聊天组件结构并添加新功能 ([#3701](https://github.com/AstrBotDevs/AstrBot/issues/3701))
7. 修复(dashboard.i18n): 完善缺失的英文国际化键值 ([#3699](https://github.com/AstrBotDevs/AstrBot/issues/3699))
8. 重构: 实现 WebChat 会话管理及从版本 4.6 迁移到 4.7
9. 持续集成(docker-build): 每日构建 Nightly 版本 Docker 镜像 ([#3120](https://github.com/AstrBotDevs/AstrBot/issues/3120))
---
1. feat: add supports for gemini-3 series thought signature ([#3698](https://github.com/AstrBotDevs/AstrBot/issues/3698))
2. feat: supports knowledge base agentic search ([#3667](https://github.com/AstrBotDevs/AstrBot/issues/3667))
3. feat: Add URL document parser for knowledge base ([#3622](https://github.com/AstrBotDevs/AstrBot/issues/3622))
4. fix(core.platform): fix message mix-up issue when enabling multiple WeCom AI Bot adapters ([#3693](https://github.com/AstrBotDevs/AstrBot/issues/3693))
5. fix: fix `anyio.ClosedResourceError` that may occur when calling mcp tools after a period of successful connection to MCP Server ([#3700](https://github.com/AstrBotDevs/AstrBot/issues/3700))
6. feat(chat): refactor chat component structure and add new features ([#3701](https://github.com/AstrBotDevs/AstrBot/issues/3701))
7. fix(dashboard.i18n): complete the missing i18n keys for en([#3699](https://github.com/AstrBotDevs/AstrBot/issues/3699))
8. refactor: Implement WebChat session management and migration from version 4.6 to 4.7
9. ci(docker-build): build nightly image everyday ([#3120](https://github.com/AstrBotDevs/AstrBot/issues/3120))

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

@@ -0,0 +1,29 @@
## What's Changed
**hot fix of v4.6.0**
fix(core.db): 修复升级后 webchat 相关对话数据未正确迁移的问题 ([#3745](https://github.com/AstrBotDevs/AstrBot/issues/3745))
---
1. 新增: 支持 gemini-3 系列的 thought signature ([#3698](https://github.com/AstrBotDevs/AstrBot/issues/3698))
2. 新增: 支持知识库的 Agentic 检索功能 ([#3667](https://github.com/AstrBotDevs/AstrBot/issues/3667))
3. 新增: 为知识库添加 URL 文档解析器 ([#3622](https://github.com/AstrBotDevs/AstrBot/issues/3622))
4. 修复(core.platform): 修复启用多个企业微信智能机器人适配器时消息混乱的问题 ([#3693](https://github.com/AstrBotDevs/AstrBot/issues/3693))
5. 修复: MCP Server 连接成功一段时间后,调用 mcp 工具时可能出现 `anyio.ClosedResourceError` 错误 ([#3700](https://github.com/AstrBotDevs/AstrBot/issues/3700))
6. 新增(chat): 重构聊天组件结构并添加新功能 ([#3701](https://github.com/AstrBotDevs/AstrBot/issues/3701))
7. 修复(dashboard.i18n): 完善缺失的英文国际化键值 ([#3699](https://github.com/AstrBotDevs/AstrBot/issues/3699))
8. 重构: 实现 WebChat 会话管理及从版本 4.6 迁移到 4.7
9. 持续集成(docker-build): 每日构建 Nightly 版本 Docker 镜像 ([#3120](https://github.com/AstrBotDevs/AstrBot/issues/3120))
---
1. feat: add supports for gemini-3 series thought signature ([#3698](https://github.com/AstrBotDevs/AstrBot/issues/3698))
2. feat: supports knowledge base agentic search ([#3667](https://github.com/AstrBotDevs/AstrBot/issues/3667))
3. feat: Add URL document parser for knowledge base ([#3622](https://github.com/AstrBotDevs/AstrBot/issues/3622))
4. fix(core.platform): fix message mix-up issue when enabling multiple WeCom AI Bot adapters ([#3693](https://github.com/AstrBotDevs/AstrBot/issues/3693))
5. fix: fix `anyio.ClosedResourceError` that may occur when calling mcp tools after a period of successful connection to MCP Server ([#3700](https://github.com/AstrBotDevs/AstrBot/issues/3700))
6. feat(chat): refactor chat component structure and add new features ([#3701](https://github.com/AstrBotDevs/AstrBot/issues/3701))
7. fix(dashboard.i18n): complete the missing i18n keys for en([#3699](https://github.com/AstrBotDevs/AstrBot/issues/3699))
8. refactor: Implement WebChat session management and migration from version 4.6 to 4.7
9. ci(docker-build): build nightly image everyday ([#3120](https://github.com/AstrBotDevs/AstrBot/issues/3120))

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,312 @@
<template>
<div class="input-area fade-in">
<div class="input-container"
style="width: 85%; max-width: 900px; margin: 0 auto; border: 1px solid #e0e0e0; border-radius: 24px;">
<textarea
ref="inputField"
v-model="localPrompt"
@keydown="handleKeyDown"
:disabled="disabled"
placeholder="Ask AstrBot..."
style="width: 100%; resize: none; outline: none; border: 1px solid var(--v-theme-border); border-radius: 12px; padding: 8px 16px; min-height: 40px; font-family: inherit; font-size: 16px; background-color: var(--v-theme-surface);"></textarea>
<div style="display: flex; justify-content: space-between; align-items: center; padding: 0px 12px;">
<div style="display: flex; justify-content: flex-start; margin-top: 4px; align-items: center; gap: 8px;">
<ConfigSelector
:session-id="sessionId || null"
:platform-id="sessionPlatformId"
:is-group="sessionIsGroup"
:initial-config-id="props.configId"
@config-changed="handleConfigChange"
/>
<ProviderModelSelector v-if="showProviderSelector" ref="providerModelSelectorRef" />
<v-tooltip :text="enableStreaming ? tm('streaming.enabled') : tm('streaming.disabled')" location="top">
<template v-slot:activator="{ props }">
<v-chip v-bind="props" @click="$emit('toggleStreaming')" size="x-small" class="streaming-toggle-chip">
<v-icon start :icon="enableStreaming ? 'mdi-flash' : 'mdi-flash-off'" size="small"></v-icon>
{{ enableStreaming ? tm('streaming.on') : tm('streaming.off') }}
</v-chip>
</template>
</v-tooltip>
</div>
<div style="display: flex; justify-content: flex-end; margin-top: 8px; align-items: center;">
<input type="file" ref="imageInputRef" @change="handleFileSelect" accept="image/*"
style="display: none" multiple />
<v-progress-circular v-if="disabled" indeterminate size="16" class="mr-1" width="1.5" />
<v-btn @click="triggerImageInput" icon="mdi-plus" variant="text" color="deep-purple"
class="add-btn" size="small" />
<v-btn @click="handleRecordClick"
:icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'" variant="text"
:color="isRecording ? 'error' : 'deep-purple'" class="record-btn" size="small" />
<v-btn @click="$emit('send')" icon="mdi-send" variant="text" color="deep-purple"
:disabled="!canSend" class="send-btn" size="small" />
</div>
</div>
</div>
<!-- 附件预览区 -->
<div class="attachments-preview" v-if="stagedImagesUrl.length > 0 || stagedAudioUrl">
<div v-for="(img, index) in stagedImagesUrl" :key="index" class="image-preview">
<img :src="img" class="preview-image" />
<v-btn @click="$emit('removeImage', index)" class="remove-attachment-btn" icon="mdi-close"
size="small" color="error" variant="text" />
</div>
<div v-if="stagedAudioUrl" class="audio-preview">
<v-chip color="deep-purple-lighten-4" class="audio-chip">
<v-icon start icon="mdi-microphone" size="small"></v-icon>
{{ tm('voice.recording') }}
</v-chip>
<v-btn @click="$emit('removeAudio')" class="remove-attachment-btn" icon="mdi-close" size="small"
color="error" variant="text" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import ProviderModelSelector from './ProviderModelSelector.vue';
import ConfigSelector from './ConfigSelector.vue';
import type { Session } from '@/composables/useSessions';
interface Props {
prompt: string;
stagedImagesUrl: string[];
stagedAudioUrl: string;
disabled: boolean;
enableStreaming: boolean;
isRecording: boolean;
sessionId?: string | null;
currentSession?: Session | null;
configId?: string | null;
}
const props = withDefaults(defineProps<Props>(), {
sessionId: null,
currentSession: null,
configId: null
});
const emit = defineEmits<{
'update:prompt': [value: string];
send: [];
toggleStreaming: [];
removeImage: [index: number];
removeAudio: [];
startRecording: [];
stopRecording: [];
pasteImage: [event: ClipboardEvent];
fileSelect: [files: FileList];
}>();
const { tm } = useModuleI18n('features/chat');
const inputField = ref<HTMLTextAreaElement | null>(null);
const imageInputRef = ref<HTMLInputElement | null>(null);
const providerModelSelectorRef = ref<InstanceType<typeof ProviderModelSelector> | null>(null);
const showProviderSelector = ref(true);
const localPrompt = computed({
get: () => props.prompt,
set: (value) => emit('update:prompt', value)
});
const sessionPlatformId = computed(() => props.currentSession?.platform_id || 'webchat');
const sessionIsGroup = computed(() => Boolean(props.currentSession?.is_group));
const canSend = computed(() => {
return (props.prompt && props.prompt.trim()) || props.stagedImagesUrl.length > 0 || props.stagedAudioUrl;
});
// Ctrl+B 长按录音相关
const ctrlKeyDown = ref(false);
const ctrlKeyTimer = ref<number | null>(null);
const ctrlKeyLongPressThreshold = 300;
function handleKeyDown(e: KeyboardEvent) {
// Enter 发送消息
if (e.keyCode === 13 && !e.shiftKey) {
e.preventDefault();
if (canSend.value) {
emit('send');
}
}
// Ctrl+B 录音
if (e.ctrlKey && e.keyCode === 66) {
e.preventDefault();
if (ctrlKeyDown.value) return;
ctrlKeyDown.value = true;
ctrlKeyTimer.value = window.setTimeout(() => {
if (ctrlKeyDown.value && !props.isRecording) {
emit('startRecording');
}
}, ctrlKeyLongPressThreshold);
}
}
function handleKeyUp(e: KeyboardEvent) {
if (e.keyCode === 66) {
ctrlKeyDown.value = false;
if (ctrlKeyTimer.value) {
clearTimeout(ctrlKeyTimer.value);
ctrlKeyTimer.value = null;
}
if (props.isRecording) {
emit('stopRecording');
}
}
}
function handlePaste(e: ClipboardEvent) {
emit('pasteImage', e);
}
function triggerImageInput() {
imageInputRef.value?.click();
}
function handleFileSelect(event: Event) {
const target = event.target as HTMLInputElement;
const files = target.files;
if (files) {
emit('fileSelect', files);
}
target.value = '';
}
function handleRecordClick() {
if (props.isRecording) {
emit('stopRecording');
} else {
emit('startRecording');
}
}
function handleConfigChange(payload: { configId: string; agentRunnerType: string }) {
const runnerType = (payload.agentRunnerType || '').toLowerCase();
const isInternal = runnerType === 'internal' || runnerType === 'local';
showProviderSelector.value = isInternal;
}
function getCurrentSelection() {
if (!showProviderSelector.value) {
return null;
}
return providerModelSelectorRef.value?.getCurrentSelection();
}
onMounted(() => {
if (inputField.value) {
inputField.value.addEventListener('paste', handlePaste);
}
document.addEventListener('keyup', handleKeyUp);
});
onBeforeUnmount(() => {
if (inputField.value) {
inputField.value.removeEventListener('paste', handlePaste);
}
document.removeEventListener('keyup', handleKeyUp);
});
defineExpose({
getCurrentSelection
});
</script>
<style scoped>
.input-area {
padding: 16px;
background-color: var(--v-theme-surface);
position: relative;
border-top: 1px solid var(--v-theme-border);
flex-shrink: 0;
}
.attachments-preview {
display: flex;
gap: 8px;
margin-top: 8px;
max-width: 900px;
margin: 8px auto 0;
flex-wrap: wrap;
}
.image-preview,
.audio-preview {
position: relative;
display: inline-flex;
}
.preview-image {
width: 60px;
height: 60px;
object-fit: cover;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.audio-chip {
height: 36px;
border-radius: 18px;
}
.remove-attachment-btn {
position: absolute;
top: -8px;
right: -8px;
opacity: 0.8;
transition: opacity 0.2s;
}
.remove-attachment-btn:hover {
opacity: 1;
}
.streaming-toggle-chip {
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
}
.streaming-toggle-chip:hover {
opacity: 0.8;
}
.fade-in {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 768px) {
.input-area {
padding: 0 !important;
}
.input-container {
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
border-radius: 0 !important;
border-left: none !important;
border-right: none !important;
border-bottom: none !important;
}
}
</style>

View File

@@ -0,0 +1,313 @@
<template>
<div>
<v-tooltip text="选择用于当前会话的配置文件" location="top">
<template #activator="{ props: tooltipProps }">
<v-chip
v-bind="tooltipProps"
class="text-none config-chip"
variant="tonal"
size="x-small"
rounded="lg"
@click="openDialog"
:disabled="loadingConfigs || saving"
>
<v-icon start size="14">mdi-cog</v-icon>
{{ selectedConfigLabel }}
</v-chip>
</template>
</v-tooltip>
<v-dialog v-model="dialog" max-width="480" persistent>
<v-card>
<v-card-title class="d-flex align-center justify-space-between">
<span>选择配置文件</span>
<v-btn icon variant="text" @click="closeDialog">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text>
<div v-if="loadingConfigs" class="text-center py-6">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
</div>
<v-list v-else class="config-list" density="comfortable">
<v-list-item
v-for="config in configOptions"
:key="config.id"
:active="tempSelectedConfig === config.id"
rounded="lg"
variant="text"
@click="tempSelectedConfig = config.id"
>
<v-list-item-title>{{ config.name }}</v-list-item-title>
<v-list-item-subtitle class="text-caption text-grey">
{{ config.id }}
</v-list-item-subtitle>
<template #append>
<v-icon v-if="tempSelectedConfig === config.id" color="primary">mdi-check</v-icon>
</template>
</v-list-item>
<div v-if="configOptions.length === 0" class="text-center text-body-2 text-medium-emphasis">
暂无可选配置请先在配置页创建
</div>
</v-list>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn variant="text" @click="closeDialog">取消</v-btn>
<v-btn
color="primary"
@click="confirmSelection"
:disabled="!tempSelectedConfig"
:loading="saving"
>
应用
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import axios from 'axios';
import { useToast } from '@/utils/toast';
interface ConfigInfo {
id: string;
name: string;
}
interface ConfigChangedPayload {
configId: string;
agentRunnerType: string;
}
const STORAGE_KEY = 'chat.selectedConfigId';
const props = withDefaults(defineProps<{
sessionId?: string | null;
platformId?: string;
isGroup?: boolean;
initialConfigId?: string | null;
}>(), {
sessionId: null,
platformId: 'webchat',
isGroup: false,
initialConfigId: null
});
const emit = defineEmits<{ 'config-changed': [ConfigChangedPayload] }>();
const configOptions = ref<ConfigInfo[]>([]);
const loadingConfigs = ref(false);
const dialog = ref(false);
const tempSelectedConfig = ref('');
const selectedConfigId = ref('default');
const agentRunnerType = ref('local');
const saving = ref(false);
const pendingSync = ref(false);
const routingEntries = ref<Array<{ pattern: string; confId: string }>>([]);
const configCache = ref<Record<string, string>>({});
const toast = useToast();
const normalizedSessionId = computed(() => {
const id = props.sessionId?.trim();
return id ? id : null;
});
const hasActiveSession = computed(() => !!normalizedSessionId.value);
const messageType = computed(() => (props.isGroup ? 'GroupMessage' : 'FriendMessage'));
const username = computed(() => localStorage.getItem('user') || 'guest');
const sessionKey = computed(() => {
if (!normalizedSessionId.value) {
return null;
}
return `${props.platformId}!${username.value}!${normalizedSessionId.value}`;
});
const targetUmo = computed(() => {
if (!sessionKey.value) {
return null;
}
return `${props.platformId}:${messageType.value}:${sessionKey.value}`;
});
const selectedConfigLabel = computed(() => {
const target = configOptions.value.find((item) => item.id === selectedConfigId.value);
return target?.name || selectedConfigId.value || 'default';
});
function openDialog() {
tempSelectedConfig.value = selectedConfigId.value;
dialog.value = true;
}
function closeDialog() {
dialog.value = false;
}
async function fetchConfigList() {
loadingConfigs.value = true;
try {
const res = await axios.get('/api/config/abconfs');
configOptions.value = res.data.data?.info_list || [];
} catch (error) {
console.error('加载配置文件列表失败', error);
configOptions.value = [];
} finally {
loadingConfigs.value = false;
}
}
async function fetchRoutingEntries() {
try {
const res = await axios.get('/api/config/umo_abconf_routes');
const routing = res.data.data?.routing || {};
routingEntries.value = Object.entries(routing).map(([pattern, confId]) => ({
pattern,
confId: confId as string
}));
} catch (error) {
console.error('获取配置路由失败', error);
routingEntries.value = [];
}
}
function matchesPattern(pattern: string, target: string): boolean {
const parts = pattern.split(':');
const targetParts = target.split(':');
if (parts.length !== 3 || targetParts.length !== 3) {
return false;
}
return parts.every((part, index) => part === '' || part === '*' || part === targetParts[index]);
}
function resolveConfigId(umo: string | null): string {
if (!umo) {
return 'default';
}
for (const entry of routingEntries.value) {
if (matchesPattern(entry.pattern, umo)) {
return entry.confId;
}
}
return 'default';
}
async function getAgentRunnerType(confId: string): Promise<string> {
if (configCache.value[confId]) {
return configCache.value[confId];
}
try {
const res = await axios.get('/api/config/abconf', {
params: { id: confId }
});
const type = res.data.data?.config?.provider_settings?.agent_runner_type || 'local';
configCache.value[confId] = type;
return type;
} catch (error) {
console.error('获取配置文件详情失败', error);
return 'local';
}
}
async function setSelection(confId: string) {
const normalized = confId || 'default';
selectedConfigId.value = normalized;
const runnerType = await getAgentRunnerType(normalized);
agentRunnerType.value = runnerType;
emit('config-changed', {
configId: normalized,
agentRunnerType: runnerType
});
}
async function applySelectionToBackend(confId: string): Promise<boolean> {
if (!targetUmo.value) {
pendingSync.value = true;
return true;
}
saving.value = true;
try {
await axios.post('/api/config/umo_abconf_route/update', {
umo: targetUmo.value,
conf_id: confId
});
const filtered = routingEntries.value.filter((entry) => entry.pattern !== targetUmo.value);
filtered.push({ pattern: targetUmo.value, confId });
routingEntries.value = filtered;
return true;
} catch (error) {
const err = error as any;
console.error('更新配置文件失败', err);
toast.error(err?.response?.data?.message || '配置文件应用失败');
return false;
} finally {
saving.value = false;
}
}
async function confirmSelection() {
if (!tempSelectedConfig.value) {
return;
}
const previousId = selectedConfigId.value;
await setSelection(tempSelectedConfig.value);
localStorage.setItem(STORAGE_KEY, tempSelectedConfig.value);
const applied = await applySelectionToBackend(tempSelectedConfig.value);
if (!applied) {
localStorage.setItem(STORAGE_KEY, previousId);
await setSelection(previousId);
}
dialog.value = false;
}
async function syncSelectionForSession() {
if (!targetUmo.value) {
pendingSync.value = true;
return;
}
if (pendingSync.value) {
pendingSync.value = false;
await applySelectionToBackend(selectedConfigId.value);
return;
}
await fetchRoutingEntries();
const resolved = resolveConfigId(targetUmo.value);
await setSelection(resolved);
localStorage.setItem(STORAGE_KEY, resolved);
}
watch(
() => [props.sessionId, props.platformId, props.isGroup],
async () => {
await syncSelectionForSession();
}
);
onMounted(async () => {
await fetchConfigList();
const stored = props.initialConfigId || localStorage.getItem(STORAGE_KEY) || 'default';
selectedConfigId.value = stored;
await setSelection(stored);
await syncSelectionForSession();
});
</script>
<style scoped>
.config-chip {
cursor: pointer;
justify-content: flex-start;
}
.config-list {
max-height: 360px;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,303 @@
<template>
<div class="sidebar-panel"
:class="{
'sidebar-collapsed': sidebarCollapsed && !isMobile,
'mobile-sidebar-open': isMobile && mobileMenuOpen,
'mobile-sidebar': isMobile
}"
:style="{ 'background-color': isDark ? sidebarCollapsed ? '#1e1e1e' : '#2d2d2d' : sidebarCollapsed ? '#ffffff' : '#f1f4f9' }"
@mouseenter="handleSidebarMouseEnter"
@mouseleave="handleSidebarMouseLeave">
<div style="display: flex; align-items: center; justify-content: center; padding: 16px; padding-bottom: 0px;"
v-if="chatboxMode">
<img width="50" src="@/assets/images/icon-no-shadow.svg" alt="AstrBot Logo">
<span v-if="!sidebarCollapsed"
style="font-weight: 1000; font-size: 26px; margin-left: 8px;">AstrBot</span>
</div>
<div class="sidebar-collapse-btn-container" v-if="!isMobile">
<v-btn icon class="sidebar-collapse-btn" @click="toggleSidebar" variant="text" color="deep-purple">
<v-icon>{{ (sidebarCollapsed || (!sidebarCollapsed && sidebarHoverExpanded)) ?
'mdi-chevron-right' : 'mdi-chevron-left' }}</v-icon>
</v-btn>
</div>
<div class="sidebar-collapse-btn-container" v-if="isMobile">
<v-btn icon class="sidebar-collapse-btn" @click="$emit('closeMobileSidebar')" variant="text"
color="deep-purple">
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
<div style="padding: 16px; padding-top: 8px;">
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId"
v-if="!sidebarCollapsed || isMobile" prepend-icon="mdi-plus"
style="background-color: transparent !important; border-radius: 4px;">{{ tm('actions.newChat') }}</v-btn>
<v-btn icon="mdi-plus" rounded="lg" @click="$emit('newChat')" :disabled="!currSessionId"
v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn>
</div>
<div v-if="!sidebarCollapsed || isMobile">
<v-divider class="mx-4"></v-divider>
</div>
<div style="overflow-y: auto; flex-grow: 1;" :class="{ 'fade-in': sidebarHoverExpanded }"
v-if="!sidebarCollapsed || isMobile">
<v-card v-if="sessions.length > 0" flat style="background-color: transparent;">
<v-list density="compact" nav class="conversation-list"
style="background-color: transparent;" :selected="selectedSessions"
@update:selected="$emit('selectConversation', $event)">
<v-list-item v-for="item in sessions" :key="item.session_id" :value="item.session_id"
rounded="lg" class="conversation-item" active-color="secondary">
<v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title">
{{ item.display_name || tm('conversation.newConversation') }}
</v-list-item-title>
<v-list-item-subtitle v-if="!sidebarCollapsed || isMobile" class="timestamp">
{{ new Date(item.updated_at).toLocaleString() }}
</v-list-item-subtitle>
<template v-if="!sidebarCollapsed || isMobile" v-slot:append>
<div class="conversation-actions">
<v-btn icon="mdi-pencil" size="x-small" variant="text"
class="edit-title-btn"
@click.stop="$emit('editTitle', item.session_id, item.display_name)" />
<v-btn icon="mdi-delete" size="x-small" variant="text"
class="delete-conversation-btn" color="error"
@click.stop="handleDeleteConversation(item)" />
</div>
</template>
</v-list-item>
</v-list>
</v-card>
<v-fade-transition>
<div class="no-conversations" v-if="sessions.length === 0">
<v-icon icon="mdi-message-text-outline" size="large" color="grey-lighten-1"></v-icon>
<div class="no-conversations-text" v-if="!sidebarCollapsed || sidebarHoverExpanded || isMobile">
{{ tm('conversation.noHistory') }}
</div>
</div>
</v-fade-transition>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import type { Session } from '@/composables/useSessions';
interface Props {
sessions: Session[];
selectedSessions: string[];
currSessionId: string;
isDark: boolean;
chatboxMode: boolean;
isMobile: boolean;
mobileMenuOpen: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
newChat: [];
selectConversation: [sessionIds: string[]];
editTitle: [sessionId: string, title: string];
deleteConversation: [sessionId: string];
closeMobileSidebar: [];
}>();
const { tm } = useModuleI18n('features/chat');
const sidebarCollapsed = ref(true);
const sidebarHovered = ref(false);
const sidebarHoverTimer = ref<number | null>(null);
const sidebarHoverExpanded = ref(false);
const sidebarHoverDelay = 100;
// 从 localStorage 读取侧边栏折叠状态
const savedCollapsedState = localStorage.getItem('sidebarCollapsed');
if (savedCollapsedState !== null) {
sidebarCollapsed.value = JSON.parse(savedCollapsedState);
} else {
sidebarCollapsed.value = true;
}
function toggleSidebar() {
if (sidebarHoverExpanded.value) {
sidebarHoverExpanded.value = false;
return;
}
sidebarCollapsed.value = !sidebarCollapsed.value;
localStorage.setItem('sidebarCollapsed', JSON.stringify(sidebarCollapsed.value));
}
function handleSidebarMouseEnter() {
if (!sidebarCollapsed.value || props.isMobile) return;
sidebarHovered.value = true;
sidebarHoverTimer.value = window.setTimeout(() => {
if (sidebarHovered.value) {
sidebarHoverExpanded.value = true;
sidebarCollapsed.value = false;
}
}, sidebarHoverDelay);
}
function handleSidebarMouseLeave() {
sidebarHovered.value = false;
if (sidebarHoverTimer.value) {
clearTimeout(sidebarHoverTimer.value);
sidebarHoverTimer.value = null;
}
if (sidebarHoverExpanded.value) {
sidebarCollapsed.value = true;
}
sidebarHoverExpanded.value = false;
}
function handleDeleteConversation(session: Session) {
const sessionTitle = session.display_name || tm('conversation.newConversation');
const message = tm('conversation.confirmDelete', { name: sessionTitle });
if (window.confirm(message)) {
emit('deleteConversation', session.session_id);
}
}
</script>
<style scoped>
.sidebar-panel {
max-width: 270px;
min-width: 240px;
display: flex;
flex-direction: column;
padding: 0;
border-right: 1px solid rgba(0, 0, 0, 0.04);
height: 100%;
max-height: 100%;
position: relative;
transition: all 0.3s ease;
overflow: hidden;
}
.sidebar-collapsed {
max-width: 75px;
min-width: 75px;
transition: all 0.3s ease;
}
.mobile-sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
max-width: 280px !important;
min-width: 280px !important;
transform: translateX(-100%);
transition: transform 0.3s ease;
z-index: 1000;
}
.mobile-sidebar-open {
transform: translateX(0) !important;
}
.sidebar-collapse-btn-container {
margin: 16px;
margin-bottom: 0px;
z-index: 10;
}
.sidebar-collapse-btn {
opacity: 0.6;
max-height: none;
overflow-y: visible;
padding: 0;
}
.conversation-item {
margin-bottom: 4px;
border-radius: 8px !important;
transition: all 0.2s ease;
height: auto !important;
min-height: 56px;
padding: 8px 16px !important;
position: relative;
}
.conversation-item:hover {
background-color: rgba(103, 58, 183, 0.05);
}
.conversation-item:hover .conversation-actions {
opacity: 1;
visibility: visible;
}
.conversation-actions {
display: flex;
gap: 4px;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
}
.edit-title-btn,
.delete-conversation-btn {
opacity: 0.7;
transition: opacity 0.2s ease;
}
.edit-title-btn:hover,
.delete-conversation-btn:hover {
opacity: 1;
}
.conversation-title {
font-weight: 500;
font-size: 14px;
line-height: 1.3;
margin-bottom: 2px;
transition: opacity 0.25s ease;
}
.timestamp {
font-size: 11px;
color: var(--v-theme-secondaryText);
line-height: 1;
transition: opacity 0.25s ease;
}
.no-conversations {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 150px;
opacity: 0.6;
gap: 12px;
}
.no-conversations-text {
font-size: 14px;
color: var(--v-theme-secondaryText);
transition: opacity 0.25s ease;
}
.fade-in {
animation: fadeInContent 0.3s ease;
}
@keyframes fadeInContent {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>

View File

@@ -37,42 +37,49 @@
</v-avatar>
<div class="bot-message-content">
<div class="message-bubble bot-bubble">
<!-- Reasoning Block (Collapsible) -->
<div v-if="msg.content.reasoning && msg.content.reasoning.trim()" class="reasoning-container">
<div class="reasoning-header" @click="toggleReasoning(index)">
<v-icon size="small" class="reasoning-icon">
{{ isReasoningExpanded(index) ? 'mdi-chevron-down' : 'mdi-chevron-right' }}
</v-icon>
<span class="reasoning-label">{{ tm('reasoning.thinking') }}</span>
</div>
<div v-if="isReasoningExpanded(index)" class="reasoning-content">
<div v-html="md.render(msg.content.reasoning)" class="markdown-content reasoning-text"></div>
</div>
<!-- Loading state -->
<div v-if="msg.content.isLoading" class="loading-container">
<span class="loading-text">{{ tm('message.loading') }}</span>
</div>
<!-- Text -->
<div v-if="msg.content.message && msg.content.message.trim()"
v-html="md.render(msg.content.message)" class="markdown-content"></div>
<!-- Image -->
<div class="embedded-images"
v-if="msg.content.embedded_images && msg.content.embedded_images.length > 0">
<div v-for="(img, imgIndex) in msg.content.embedded_images" :key="imgIndex"
class="embedded-image">
<img :src="img" class="bot-embedded-image"
@click="$emit('openImagePreview', img)" />
<template v-else>
<!-- Reasoning Block (Collapsible) -->
<div v-if="msg.content.reasoning && msg.content.reasoning.trim()" class="reasoning-container">
<div class="reasoning-header" @click="toggleReasoning(index)">
<v-icon size="small" class="reasoning-icon">
{{ isReasoningExpanded(index) ? 'mdi-chevron-down' : 'mdi-chevron-right' }}
</v-icon>
<span class="reasoning-label">{{ tm('reasoning.thinking') }}</span>
</div>
<div v-if="isReasoningExpanded(index)" class="reasoning-content">
<div v-html="md.render(msg.content.reasoning)" class="markdown-content reasoning-text"></div>
</div>
</div>
</div>
<!-- Text -->
<div v-if="msg.content.message && msg.content.message.trim()"
v-html="md.render(msg.content.message)" class="markdown-content"></div>
<!-- Audio -->
<div class="embedded-audio" v-if="msg.content.embedded_audio">
<audio controls class="audio-player">
<source :src="msg.content.embedded_audio" type="audio/wav">
{{ t('messages.errors.browser.audioNotSupported') }}
</audio>
</div>
<!-- Image -->
<div class="embedded-images"
v-if="msg.content.embedded_images && msg.content.embedded_images.length > 0">
<div v-for="(img, imgIndex) in msg.content.embedded_images" :key="imgIndex"
class="embedded-image">
<img :src="img" class="bot-embedded-image"
@click="$emit('openImagePreview', img)" />
</div>
</div>
<!-- Audio -->
<div class="embedded-audio" v-if="msg.content.embedded_audio">
<audio controls class="audio-player">
<source :src="msg.content.embedded_audio" type="audio/wav">
{{ t('messages.errors.browser.audioNotSupported') }}
</audio>
</div>
</template>
</div>
<div class="message-actions">
<div class="message-actions" v-if="!msg.content.isLoading">
<v-btn :icon="getCopyIcon(index)" size="small" variant="text" class="copy-message-btn"
:class="{ 'copy-success': isCopySuccess(index) }"
@click="copyBotMessage(msg.content.message, index)" :title="t('core.common.copy')" />
@@ -841,6 +848,29 @@ export default {
margin: 10px 0;
}
.loading-container {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
margin-top: 2px;
}
.loading-text {
font-size: 14px;
color: var(--v-theme-secondaryText);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 0.6;
}
50% {
opacity: 1;
}
}
.markdown-content blockquote {
border-left: 4px solid var(--v-theme-secondary);
padding-left: 16px;

View File

@@ -3,6 +3,7 @@
<!-- 选择提供商和模型按钮 -->
<v-chip class="text-none" variant="tonal" size="x-small"
v-if="selectedProviderId && selectedModelName" @click="openDialog">
<v-icon start size="14">mdi-creation</v-icon>
{{ selectedProviderId }} / {{ selectedModelName }}
</v-chip>
<v-chip variant="tonal" rounded="xl" size="x-small" v-else @click="openDialog">

View File

@@ -0,0 +1,319 @@
<template>
<v-card class="standalone-chat-card" elevation="0" rounded="0">
<v-card-text class="standalone-chat-container">
<div class="chat-layout">
<!-- 聊天内容区域 -->
<div class="chat-content-panel">
<MessageList v-if="messages && messages.length > 0" :messages="messages" :isDark="isDark"
:isStreaming="isStreaming || isConvRunning" @openImagePreview="openImagePreview"
ref="messageList" />
<div class="welcome-container fade-in" v-else>
<div class="welcome-title">
<span>Hello, I'm</span>
<span class="bot-name">AstrBot ⭐</span>
</div>
<p class="text-caption text-medium-emphasis mt-2">
测试配置: {{ configId || 'default' }}
</p>
</div>
<!-- 输入区域 -->
<ChatInput
v-model:prompt="prompt"
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:disabled="isStreaming || isConvRunning"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
:session-id="currSessionId || null"
:current-session="getCurrentSession"
:config-id="configId"
@send="handleSendMessage"
@toggleStreaming="toggleStreaming"
@removeImage="removeImage"
@removeAudio="removeAudio"
@startRecording="handleStartRecording"
@stopRecording="handleStopRecording"
@pasteImage="handlePaste"
@fileSelect="handleFileSelect"
ref="chatInputRef"
/>
</div>
</div>
</v-card-text>
</v-card>
<!-- 图片预览对话框 -->
<v-dialog v-model="imagePreviewDialog" max-width="90vw" max-height="90vh">
<v-card class="image-preview-card" elevation="8">
<v-card-title class="d-flex justify-space-between align-center pa-4">
<span>{{ t('core.common.imagePreview') }}</span>
<v-btn icon="mdi-close" variant="text" @click="imagePreviewDialog = false" />
</v-card-title>
<v-card-text class="text-center pa-4">
<img :src="previewImageUrl" class="preview-image-large" />
</v-card-text>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
import axios from 'axios';
import { useCustomizerStore } from '@/stores/customizer';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { useTheme } from 'vuetify';
import MessageList from '@/components/chat/MessageList.vue';
import ChatInput from '@/components/chat/ChatInput.vue';
import { useMessages } from '@/composables/useMessages';
import { useMediaHandling } from '@/composables/useMediaHandling';
import { useRecording } from '@/composables/useRecording';
import { useToast } from '@/utils/toast';
interface Props {
configId?: string | null;
}
const props = withDefaults(defineProps<Props>(), {
configId: null
});
const { t } = useI18n();
const { error: showError } = useToast();
// UI 状态
const imagePreviewDialog = ref(false);
const previewImageUrl = ref('');
// 会话管理(不使用 useSessions 避免路由跳转)
const currSessionId = ref('');
const getCurrentSession = computed(() => null); // 独立测试模式不需要会话信息
async function newSession() {
try {
const response = await axios.get('/api/chat/new_session');
const sessionId = response.data.data.session_id;
currSessionId.value = sessionId;
return sessionId;
} catch (err) {
console.error(err);
throw err;
}
}
function updateSessionTitle(sessionId: string, title: string) {
// 独立模式不需要更新会话标题
}
function getSessions() {
// 独立模式不需要加载会话列表
}
const {
stagedImagesName,
stagedImagesUrl,
stagedAudioUrl,
getMediaFile,
processAndUploadImage,
handlePaste,
removeImage,
removeAudio,
clearStaged,
cleanupMediaCache
} = useMediaHandling();
const { isRecording, startRecording: startRec, stopRecording: stopRec } = useRecording();
const {
messages,
isStreaming,
isConvRunning,
enableStreaming,
getSessionMessages: getSessionMsg,
sendMessage: sendMsg,
toggleStreaming
} = useMessages(currSessionId, getMediaFile, updateSessionTitle, getSessions);
// 组件引用
const messageList = ref<InstanceType<typeof MessageList> | null>(null);
const chatInputRef = ref<InstanceType<typeof ChatInput> | null>(null);
// 输入状态
const prompt = ref('');
const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark');
function openImagePreview(imageUrl: string) {
previewImageUrl.value = imageUrl;
imagePreviewDialog.value = true;
}
async function handleStartRecording() {
await startRec();
}
async function handleStopRecording() {
const audioFilename = await stopRec();
stagedAudioUrl.value = audioFilename;
}
async function handleFileSelect(files: FileList) {
for (const file of files) {
await processAndUploadImage(file);
}
}
async function handleSendMessage() {
if (!prompt.value.trim() && stagedImagesName.value.length === 0 && !stagedAudioUrl.value) {
return;
}
try {
if (!currSessionId.value) {
await newSession();
}
const promptToSend = prompt.value.trim();
const imageNamesToSend = [...stagedImagesName.value];
const audioNameToSend = stagedAudioUrl.value;
// 清空输入和附件
prompt.value = '';
clearStaged();
// 获取选择的提供商和模型
const selection = chatInputRef.value?.getCurrentSelection();
const selectedProviderId = selection?.providerId || '';
const selectedModelName = selection?.modelName || '';
await sendMsg(
promptToSend,
imageNamesToSend,
audioNameToSend,
selectedProviderId,
selectedModelName
);
// 滚动到底部
nextTick(() => {
messageList.value?.scrollToBottom();
});
} catch (err) {
console.error('Failed to send message:', err);
showError(t('features.chat.errors.sendMessageFailed'));
// 恢复输入内容,让用户可以重试
// 注意:附件已经上传到服务器,所以不恢复附件
}
}
onMounted(async () => {
// 独立模式在挂载时创建新会话
try {
await newSession();
} catch (err) {
console.error('Failed to create initial session:', err);
showError(t('features.chat.errors.createSessionFailed'));
}
});
onBeforeUnmount(() => {
cleanupMediaCache();
});
</script>
<style scoped>
/* 基础动画 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.standalone-chat-card {
width: 100%;
height: 100%;
max-height: 100%;
overflow: hidden;
}
.standalone-chat-container {
width: 100%;
height: 100%;
max-height: 100%;
padding: 0;
overflow: hidden;
}
.chat-layout {
height: 100%;
max-height: 100%;
display: flex;
overflow: hidden;
}
.chat-content-panel {
height: 100%;
max-height: 100%;
width: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.conversation-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
padding-left: 16px;
border-bottom: 1px solid var(--v-theme-border);
width: 100%;
padding-right: 32px;
flex-shrink: 0;
}
.conversation-header-info h4 {
margin: 0;
font-weight: 500;
}
.conversation-header-actions {
display: flex;
gap: 8px;
align-items: center;
}
.welcome-container {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.welcome-title {
font-size: 28px;
margin-bottom: 8px;
}
.bot-name {
font-weight: 700;
margin-left: 8px;
color: var(--v-theme-secondary);
}
.fade-in {
animation: fadeIn 0.3s ease-in-out;
}
.preview-image-large {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
}
</style>

View File

@@ -4,7 +4,7 @@
:align-tabs="$vuetify.display.mobile ? 'left' : 'start'" color="deep-purple-accent-4" class="config-tabs">
<v-tab v-for="(val, key, index) in metadata" :key="index" :value="index"
style="font-weight: 1000; font-size: 15px">
{{ metadata[key]['name'] }}
{{ tm(metadata[key]['name']) }}
</v-tab>
</v-tabs>
<v-tabs-window v-model="tab" class="config-tabs-window" :style="readonly ? 'pointer-events: none; opacity: 0.6;' : ''">
@@ -59,7 +59,17 @@ export default {
}
},
setup() {
const { tm } = useModuleI18n('features/config');
const { tm: tmConfig } = useModuleI18n('features/config');
const { tm: tmMetadata } = useModuleI18n('features/config-metadata');
const tm = (key) => {
const metadataResult = tmMetadata(key);
if (!metadataResult.startsWith('[MISSING:') && !metadataResult.startsWith('[INVALID:')) {
return metadataResult;
}
return tmConfig(key);
};
return {
tm
};

View File

@@ -7,6 +7,10 @@
<v-icon start>mdi-message-text</v-icon>
{{ tm('dialogs.addProvider.tabs.basic') }}
</v-tab>
<v-tab value="agent_runner" class="font-weight-medium px-3">
<v-icon start>mdi-cogs</v-icon>
{{ tm('dialogs.addProvider.tabs.agentRunner') }}
</v-tab>
<v-tab value="speech_to_text" class="font-weight-medium px-3">
<v-icon start>mdi-microphone-message</v-icon>
{{ tm('dialogs.addProvider.tabs.speechToText') }}
@@ -27,7 +31,7 @@
<v-window v-model="activeProviderTab" class="mt-4">
<v-window-item
v-for="tabType in ['chat_completion', 'speech_to_text', 'text_to_speech', 'embedding', 'rerank']"
v-for="tabType in ['chat_completion', 'agent_runner', 'speech_to_text', 'text_to_speech', 'embedding', 'rerank']"
:key="tabType" :value="tabType">
<v-row class="mt-1">
<v-col v-for="(template, name) in getTemplatesByType(tabType)" :key="name" cols="12" sm="6"
@@ -36,7 +40,7 @@
@click="selectProviderTemplate(name)">
<div class="provider-card-content">
<div class="provider-card-text">
<v-card-title class="provider-card-title">接入 {{ name }}</v-card-title>
<v-card-title class="provider-card-title">{{ name }}</v-card-title>
<v-card-text
class="text-caption text-medium-emphasis provider-card-description">
{{ getProviderDescription(template, name) }}
@@ -54,7 +58,7 @@
</v-col>
<v-col v-if="Object.keys(getTemplatesByType(tabType)).length === 0" cols="12">
<v-alert type="info" variant="tonal">
{{ tm('dialogs.addProvider.noTemplates', { type: getTabTypeName(tabType) }) }}
{{ tm('dialogs.addProvider.noTemplates') }}
</v-alert>
</v-col>
</v-row>
@@ -104,19 +108,6 @@ export default {
this.$emit('update:show', value);
}
},
// 翻译消息的计算属性
messages() {
return {
tabTypes: {
'chat_completion': this.tm('providers.tabs.chatCompletion'),
'speech_to_text': this.tm('providers.tabs.speechToText'),
'text_to_speech': this.tm('providers.tabs.textToSpeech'),
'embedding': this.tm('providers.tabs.embedding'),
'rerank': this.tm('providers.tabs.rerank')
}
};
}
},
methods: {
closeDialog() {
@@ -140,11 +131,6 @@ export default {
// 从工具函数导入
getProviderIcon,
// 获取Tab类型的中文名称
getTabTypeName(tabType) {
return this.messages.tabTypes[tabType] || tabType;
},
// 获取提供商简介
getProviderDescription(template, name) {
return getProviderDescription(template, name, this.tm);

View File

@@ -8,7 +8,7 @@ import PersonaSelector from './PersonaSelector.vue'
import KnowledgeBaseSelector from './KnowledgeBaseSelector.vue'
import PluginSetSelector from './PluginSetSelector.vue'
import T2ITemplateEditor from './T2ITemplateEditor.vue'
import { useI18n } from '@/i18n/composables'
import { useI18n, useModuleI18n } from '@/i18n/composables'
const props = defineProps({
@@ -27,6 +27,34 @@ const props = defineProps({
})
const { t } = useI18n()
const { tm } = useModuleI18n('features/config-metadata')
// 翻译器函数 - 如果是国际化键则翻译,否则原样返回
const translateIfKey = (value) => {
if (!value || typeof value !== 'string') return value
return tm(value)
}
// 处理labels翻译 - labels可以是数组或国际化键
const getTranslatedLabels = (itemMeta) => {
if (!itemMeta?.labels) return null
// 如果labels是字符串国际化键
if (typeof itemMeta.labels === 'string') {
const translatedLabels = tm(itemMeta.labels)
// 如果翻译成功且是数组,返回翻译结果
if (Array.isArray(translatedLabels)) {
return translatedLabels
}
}
// 如果labels是数组直接返回
if (Array.isArray(itemMeta.labels)) {
return itemMeta.labels
}
return null
}
const dialog = ref(false)
const currentEditingKey = ref('')
@@ -101,6 +129,21 @@ function shouldShowItem(itemMeta, itemKey) {
return true
}
// 检查最外层的 object 是否应该显示
function shouldShowSection() {
const sectionMeta = props.metadata[props.metadataKey]
if (!sectionMeta?.condition) {
return true
}
for (const [conditionKey, expectedValue] of Object.entries(sectionMeta.condition)) {
const actualValue = getValueBySelector(props.iterable, conditionKey)
if (actualValue !== expectedValue) {
return false
}
}
return true
}
function hasVisibleItemsAfter(items, currentIndex) {
const itemEntries = Object.entries(items)
@@ -114,19 +157,40 @@ function hasVisibleItemsAfter(items, currentIndex) {
return false
}
function parseSpecialValue(value) {
if (!value || typeof value !== 'string') {
return { name: '', subtype: '' }
}
const [name, ...rest] = value.split(':')
return {
name,
subtype: rest.join(':') || ''
}
}
function getSpecialName(value) {
return parseSpecialValue(value).name
}
function getSpecialSubtype(value) {
return parseSpecialValue(value).subtype
}
</script>
<template>
<v-card style="margin-bottom: 16px; padding-bottom: 8px; background-color: rgb(var(--v-theme-background));" rounded="md" variant="outlined">
<v-card v-if="shouldShowSection()" style="margin-bottom: 16px; padding-bottom: 8px; background-color: rgb(var(--v-theme-background));"
rounded="md" variant="outlined">
<v-card-text class="config-section" v-if="metadata[metadataKey]?.type === 'object'" style="padding-bottom: 8px;">
<v-list-item-title class="config-title">
{{ metadata[metadataKey]?.description }}
{{ translateIfKey(metadata[metadataKey]?.description) }}
</v-list-item-title>
<v-list-item-subtitle class="config-hint">
<span v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint" class="important-hint"></span>
{{ metadata[metadataKey]?.hint }}
{{ translateIfKey(metadata[metadataKey]?.hint) }}
</v-list-item-subtitle>
</v-card-text>
@@ -140,13 +204,13 @@ function hasVisibleItemsAfter(items, currentIndex) {
<v-col cols="12" sm="6" class="property-info">
<v-list-item density="compact">
<v-list-item-title class="property-name">
{{ itemMeta?.description || itemKey }}
{{ translateIfKey(itemMeta?.description) || itemKey }}
<span class="property-key">({{ itemKey }})</span>
</v-list-item-title>
<v-list-item-subtitle class="property-hint">
<span v-if="itemMeta?.obvious_hint && itemMeta?.hint" class="important-hint"></span>
{{ itemMeta?.hint }}
{{ translateIfKey(itemMeta?.hint) }}
</v-list-item-subtitle>
</v-list-item>
</v-col>
@@ -154,7 +218,12 @@ function hasVisibleItemsAfter(items, currentIndex) {
<div class="w-100" v-if="!itemMeta?._special">
<!-- Select input for JSON selector -->
<v-select v-if="itemMeta?.options" v-model="createSelectorModel(itemKey).value"
:items="itemMeta?.labels ? itemMeta.options.map((value, index) => ({ title: itemMeta.labels[index] || value, value: value })) : itemMeta.options"
:items="(() => {
const labels = getTranslatedLabels(itemMeta);
return labels
? itemMeta.options.map((value, index) => ({ title: labels[index] || value, value: value }))
: itemMeta.options;
})()"
:disabled="itemMeta?.readonly" density="compact" variant="outlined"
class="config-field" hide-details></v-select>
@@ -187,22 +256,16 @@ function hasVisibleItemsAfter(items, currentIndex) {
<!-- Boolean switch for JSON selector -->
<v-switch v-else-if="itemMeta?.type === 'bool'" v-model="createSelectorModel(itemKey).value"
color="primary" inset density="compact" hide-details style="display: flex; justify-content: end;"></v-switch>
color="primary" inset density="compact" hide-details
style="display: flex; justify-content: end;"></v-switch>
<!-- List item for JSON selector -->
<ListConfigItem
v-else-if="itemMeta?.type === 'list'"
v-model="createSelectorModel(itemKey).value"
button-text="修改"
class="config-field"
/>
<ListConfigItem v-else-if="itemMeta?.type === 'list'" v-model="createSelectorModel(itemKey).value"
button-text="修改" class="config-field" />
<!-- Object editor for JSON selector -->
<ObjectEditor
v-else-if="itemMeta?.type === 'dict'"
v-model="createSelectorModel(itemKey).value"
class="config-field"
/>
<ObjectEditor v-else-if="itemMeta?.type === 'dict'" v-model="createSelectorModel(itemKey).value"
class="config-field" />
<!-- Fallback for JSON selector -->
<v-text-field v-else v-model="createSelectorModel(itemKey).value" density="compact" variant="outlined"
@@ -211,50 +274,36 @@ function hasVisibleItemsAfter(items, currentIndex) {
<!-- Special handling for specific metadata types -->
<div v-else-if="itemMeta?._special === 'select_provider'">
<ProviderSelector
v-model="createSelectorModel(itemKey).value"
:provider-type="'chat_completion'"
/>
<ProviderSelector v-model="createSelectorModel(itemKey).value" :provider-type="'chat_completion'" />
</div>
<div v-else-if="itemMeta?._special === 'select_provider_stt'">
<ProviderSelector
v-model="createSelectorModel(itemKey).value"
:provider-type="'speech_to_text'"
/>
<ProviderSelector v-model="createSelectorModel(itemKey).value" :provider-type="'speech_to_text'" />
</div>
<div v-else-if="itemMeta?._special === 'select_provider_tts'">
<ProviderSelector v-model="createSelectorModel(itemKey).value" :provider-type="'text_to_speech'" />
</div>
<div v-else-if="getSpecialName(itemMeta?._special) === 'select_agent_runner_provider'">
<ProviderSelector
v-model="createSelectorModel(itemKey).value"
:provider-type="'text_to_speech'"
:provider-type="'agent_runner'"
:provider-subtype="getSpecialSubtype(itemMeta?._special)"
/>
</div>
<div v-else-if="itemMeta?._special === 'provider_pool'">
<ProviderSelector
v-model="createSelectorModel(itemKey).value"
:provider-type="'chat_completion'"
button-text="选择提供商池..."
/>
<ProviderSelector v-model="createSelectorModel(itemKey).value" :provider-type="'chat_completion'"
button-text="选择提供商池..." />
</div>
<div v-else-if="itemMeta?._special === 'select_persona'">
<PersonaSelector
v-model="createSelectorModel(itemKey).value"
/>
<PersonaSelector v-model="createSelectorModel(itemKey).value" />
</div>
<div v-else-if="itemMeta?._special === 'persona_pool'">
<PersonaSelector
v-model="createSelectorModel(itemKey).value"
button-text="选择人格池..."
/>
<PersonaSelector v-model="createSelectorModel(itemKey).value" button-text="选择人格池..." />
</div>
<div v-else-if="itemMeta?._special === 'select_knowledgebase'">
<KnowledgeBaseSelector
v-model="createSelectorModel(itemKey).value"
/>
<KnowledgeBaseSelector v-model="createSelectorModel(itemKey).value" />
</div>
<div v-else-if="itemMeta?._special === 'select_plugin_set'">
<PluginSetSelector
v-model="createSelectorModel(itemKey).value"
/>
<PluginSetSelector v-model="createSelectorModel(itemKey).value" />
</div>
<div v-else-if="itemMeta?._special === 't2i_template'">
<T2ITemplateEditor />
@@ -263,21 +312,17 @@ function hasVisibleItemsAfter(items, currentIndex) {
</v-row>
<!-- Plugin Set Selector 全宽显示区域 -->
<v-row v-if="!itemMeta?.invisible && itemMeta?._special === 'select_plugin_set'" class="plugin-set-display-row">
<v-row v-if="!itemMeta?.invisible && itemMeta?._special === 'select_plugin_set'"
class="plugin-set-display-row">
<v-col cols="12" class="plugin-set-display">
<div v-if="createSelectorModel(itemKey).value && createSelectorModel(itemKey).value.length > 0" class="selected-plugins-full-width">
<div v-if="createSelectorModel(itemKey).value && createSelectorModel(itemKey).value.length > 0"
class="selected-plugins-full-width">
<div class="plugins-header">
<small class="text-grey">已选择的插件</small>
</div>
<div class="d-flex flex-wrap ga-2 mt-2">
<v-chip
v-for="plugin in (createSelectorModel(itemKey).value || [])"
:key="plugin"
size="small"
label
color="primary"
variant="outlined"
>
<v-chip v-for="plugin in (createSelectorModel(itemKey).value || [])" :key="plugin" size="small" label
color="primary" variant="outlined">
{{ plugin === '*' ? '所有插件' : plugin }}
</v-chip>
</div>
@@ -285,7 +330,8 @@ function hasVisibleItemsAfter(items, currentIndex) {
</v-col>
</v-row>
</template>
<v-divider class="config-divider" v-if="shouldShowItem(itemMeta, itemKey) && hasVisibleItemsAfter(metadata[metadataKey].items, index)"></v-divider>
<v-divider class="config-divider"
v-if="shouldShowItem(itemMeta, itemKey) && hasVisibleItemsAfter(metadata[metadataKey].items, index)"></v-divider>
</div>
</div>

View File

@@ -3,7 +3,7 @@
<div style="flex: 1; min-width: 0; overflow: hidden;">
<span v-if="!modelValue || (Array.isArray(modelValue) && modelValue.length === 0)"
style="color: rgb(var(--v-theme-primaryText));">
未选择
{{ tm('knowledgeBaseSelector.notSelected') }}
</span>
<div v-else class="d-flex flex-wrap gap-1">
<v-chip
@@ -28,7 +28,7 @@
<v-dialog v-model="dialog" max-width="600px">
<v-card>
<v-card-title class="text-h3 py-4" style="font-weight: normal;">
选择知识库
{{ tm('knowledgeBaseSelector.dialogTitle') }}
</v-card-title>
<v-card-text class="pa-0" style="max-height: 400px; overflow-y: auto;">
@@ -50,9 +50,9 @@
</template>
<v-list-item-title>{{ kb.kb_name }}</v-list-item-title>
<v-list-item-subtitle>
{{ kb.description || '无描述' }}
<span v-if="kb.doc_count !== undefined"> - {{ kb.doc_count }} 个文档</span>
<span v-if="kb.chunk_count !== undefined"> - {{ kb.chunk_count }} 个块</span>
{{ kb.description || tm('knowledgeBaseSelector.noDescription') }}
<span v-if="kb.doc_count !== undefined"> - {{ tm('knowledgeBaseSelector.documentCount', { count: kb.doc_count }) }}</span>
<span v-if="kb.chunk_count !== undefined"> - {{ tm('knowledgeBaseSelector.chunkCount', { count: kb.chunk_count }) }}</span>
</v-list-item-subtitle>
<template v-slot:append>
@@ -68,9 +68,9 @@
<!-- 当没有知识库时显示创建提示 -->
<div v-if="knowledgeBaseList.length === 0" class="text-center py-8">
<v-icon size="64" color="grey-lighten-1">mdi-database-off</v-icon>
<p class="text-grey mt-4 mb-4">暂无知识库</p>
<p class="text-grey mt-4 mb-4">{{ tm('knowledgeBaseSelector.noKnowledgeBases') }}</p>
<v-btn color="primary" variant="tonal" @click="goToKnowledgeBasePage">
创建知识库
{{ tm('knowledgeBaseSelector.createKnowledgeBase') }}
</v-btn>
</div>
</v-list>
@@ -78,14 +78,14 @@
<v-card-actions class="pa-4">
<div v-if="selectedKnowledgeBases.length > 0" class="text-caption text-grey">
已选择 {{ selectedKnowledgeBases.length }} 个知识库
{{ tm('knowledgeBaseSelector.selectedCount', { count: selectedKnowledgeBases.length }) }}
</div>
<v-spacer></v-spacer>
<v-btn variant="text" @click="cancelSelection">取消</v-btn>
<v-btn variant="text" @click="cancelSelection">{{ tm('knowledgeBaseSelector.cancelSelection') }}</v-btn>
<v-btn
color="primary"
@click="confirmSelection">
确认选择
{{ tm('knowledgeBaseSelector.confirmSelection') }}
</v-btn>
</v-card-actions>
</v-card>
@@ -96,6 +96,7 @@
import { ref, watch } from 'vue'
import axios from 'axios'
import { useRouter } from 'vue-router'
import { useModuleI18n } from '@/i18n/composables'
const props = defineProps({
modelValue: {
@@ -110,6 +111,7 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue'])
const router = useRouter()
const { tm } = useModuleI18n('core.shared')
const dialog = ref(false)
const knowledgeBaseList = ref([])

View File

@@ -4,13 +4,13 @@
<div class="d-flex align-center justify-space-between mb-2">
<div class="flex-grow-1">
<span v-if="!modelValue || modelValue.length === 0" style="color: rgb(var(--v-theme-primaryText));">
未启用任何插件
{{ tm('pluginSetSelector.notSelected') }}
</span>
<span v-else-if="isAllPlugins" style="color: rgb(var(--v-theme-primaryText));">
启用所有插件 (*)
{{ tm('pluginSetSelector.allPlugins') }}
</span>
<span v-else style="color: rgb(var(--v-theme-primaryText));">
已选择 {{ modelValue.length }} 个插件
{{ tm('pluginSetSelector.selectedCount', { count: modelValue.length }) }}
</span>
</div>
<v-btn size="small" color="primary" variant="tonal" @click="openDialog">
@@ -23,7 +23,7 @@
<v-dialog v-model="dialog" max-width="700px">
<v-card>
<v-card-title class="text-h3 py-4" style="font-weight: normal;">
选择插件集合
{{ tm('pluginSetSelector.dialogTitle') }}
</v-card-title>
<v-card-text class="pa-4">
@@ -34,17 +34,17 @@
<v-radio-group v-model="selectionMode" class="mb-4" hide-details>
<v-radio
value="all"
label="启用所有插件"
:label="tm('pluginSetSelector.enableAll')"
color="primary"
></v-radio>
<v-radio
value="none"
label="不启用任何插件"
:label="tm('pluginSetSelector.enableNone')"
color="primary"
></v-radio>
<v-radio
value="custom"
label="自定义选择"
:label="tm('pluginSetSelector.customSelect')"
color="primary"
></v-radio>
</v-radio-group>
@@ -68,21 +68,21 @@
<v-list-item-title>{{ plugin.name }}</v-list-item-title>
<v-list-item-subtitle>
{{ plugin.desc || '无描述' }}
{{ plugin.desc || tm('pluginSetSelector.noDescription') }}
<v-chip v-if="!plugin.activated" size="x-small" color="grey" class="ml-1">
未激活
{{ tm('pluginSetSelector.notActivated') }}
</v-chip>
</v-list-item-subtitle>
</v-list-item>
<div class="pl-8 pt-2">
<small>*不显示系统插件和已经在插件页禁用的插件</small>
<small>{{ tm('pluginSetSelector.note') }}</small>
</div>
</v-list>
<div v-else class="text-center py-8">
<v-icon size="64" color="grey-lighten-1">mdi-puzzle-outline</v-icon>
<p class="text-grey mt-4">暂无可用的插件</p>
<p class="text-grey mt-4">{{ tm('pluginSetSelector.noPlugins') }}</p>
</div>
</div>
</div>
@@ -90,11 +90,11 @@
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="cancelSelection">取消</v-btn>
<v-btn variant="text" @click="cancelSelection">{{ tm('pluginSetSelector.cancelSelection') }}</v-btn>
<v-btn
color="primary"
@click="confirmSelection">
确认选择
{{ tm('pluginSetSelector.confirmSelection') }}
</v-btn>
</v-card-actions>
</v-card>
@@ -104,6 +104,7 @@
<script setup>
import { ref, computed, watch } from 'vue'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
const props = defineProps({
modelValue: {
@@ -121,6 +122,7 @@ const props = defineProps({
})
const emit = defineEmits(['update:modelValue'])
const { tm } = useModuleI18n('core.shared')
const dialog = ref(false)
const pluginList = ref([])

View File

@@ -1,7 +1,7 @@
<template>
<div class="d-flex align-center justify-space-between">
<span v-if="!modelValue" style="color: rgb(var(--v-theme-primaryText));">
未选择
{{ tm('providerSelector.notSelected') }}
</span>
<span v-else>
{{ modelValue }}
@@ -15,7 +15,7 @@
<v-dialog v-model="dialog" max-width="600px">
<v-card>
<v-card-title class="text-h3 py-4" style="font-weight: normal;">
选择提供商
{{ tm('providerSelector.dialogTitle') }}
</v-card-title>
<v-card-text class="pa-0" style="max-height: 400px; overflow-y: auto;">
@@ -30,8 +30,8 @@
:active="selectedProvider === ''"
rounded="md"
class="ma-1">
<v-list-item-title>不选择</v-list-item-title>
<v-list-item-subtitle>清除当前选择</v-list-item-subtitle>
<v-list-item-title>{{ tm('providerSelector.clearSelection') }}</v-list-item-title>
<v-list-item-subtitle>{{ tm('providerSelector.clearSelectionSubtitle') }}</v-list-item-subtitle>
<template v-slot:append>
<v-icon v-if="selectedProvider === ''" color="primary">mdi-check-circle</v-icon>
@@ -50,7 +50,7 @@
class="ma-1">
<v-list-item-title>{{ provider.id }}</v-list-item-title>
<v-list-item-subtitle>
{{ provider.type || provider.provider_type || '未知类型' }}
{{ provider.type || provider.provider_type || tm('providerSelector.unknownType') }}
<span v-if="provider.model_config?.model">- {{ provider.model_config.model }}</span>
</v-list-item-subtitle>
@@ -62,7 +62,7 @@
<div v-else-if="!loading && providerList.length === 0" class="text-center py-8">
<v-icon size="64" color="grey-lighten-1">mdi-api-off</v-icon>
<p class="text-grey mt-4">暂无可用的提供商</p>
<p class="text-grey mt-4">{{ tm('providerSelector.noProviders') }}</p>
</div>
</v-card-text>
@@ -70,11 +70,11 @@
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="cancelSelection">取消</v-btn>
<v-btn variant="text" @click="cancelSelection">{{ tm('providerSelector.cancelSelection') }}</v-btn>
<v-btn
color="primary"
@click="confirmSelection">
确认选择
{{ tm('providerSelector.confirmSelection') }}
</v-btn>
</v-card-actions>
</v-card>
@@ -84,6 +84,7 @@
<script setup>
import { ref, watch } from 'vue'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
const props = defineProps({
modelValue: {
@@ -94,6 +95,10 @@ const props = defineProps({
type: String,
default: 'chat_completion'
},
providerSubtype: {
type: String,
default: ''
},
buttonText: {
type: String,
default: '选择提供商...'
@@ -101,6 +106,7 @@ const props = defineProps({
})
const emit = defineEmits(['update:modelValue'])
const { tm } = useModuleI18n('core.shared')
const dialog = ref(false)
const providerList = ref([])
@@ -127,7 +133,10 @@ async function loadProviders() {
}
})
if (response.data.status === 'ok') {
providerList.value = response.data.data || []
const providers = response.data.data || []
providerList.value = props.providerSubtype
? providers.filter((provider) => matchesProviderSubtype(provider, props.providerSubtype))
: providers
}
} catch (error) {
console.error('加载提供商列表失败:', error)
@@ -137,6 +146,17 @@ async function loadProviders() {
}
}
function matchesProviderSubtype(provider, subtype) {
if (!subtype) {
return true
}
const normalized = String(subtype).toLowerCase()
const candidates = [provider.type, provider.provider, provider.id]
.filter(Boolean)
.map((value) => String(value).toLowerCase())
return candidates.includes(normalized)
}
function selectProvider(provider) {
selectedProvider.value = provider.id
}

View File

@@ -0,0 +1,145 @@
import { ref, computed } from 'vue';
import axios from 'axios';
import { useRouter } from 'vue-router';
export interface Conversation {
cid: string;
title: string;
updated_at: number;
}
export function useConversations(chatboxMode: boolean = false) {
const router = useRouter();
const conversations = ref<Conversation[]>([]);
const selectedConversations = ref<string[]>([]);
const currCid = ref('');
const pendingCid = ref<string | null>(null);
// 编辑标题相关
const editTitleDialog = ref(false);
const editingTitle = ref('');
const editingCid = ref('');
const getCurrentConversation = computed(() => {
if (!currCid.value) return null;
return conversations.value.find(c => c.cid === currCid.value);
});
async function getConversations() {
try {
const response = await axios.get('/api/chat/conversations');
conversations.value = response.data.data;
// 处理待加载的会话
if (pendingCid.value) {
const conversation = conversations.value.find(c => c.cid === pendingCid.value);
if (conversation) {
selectedConversations.value = [pendingCid.value];
pendingCid.value = null;
}
} else if (!currCid.value && conversations.value.length > 0) {
// 默认选择第一个会话
const firstConversation = conversations.value[0];
selectedConversations.value = [firstConversation.cid];
}
} catch (err: any) {
if (err.response?.status === 401) {
router.push('/auth/login?redirect=/chatbox');
}
console.error(err);
}
}
async function newConversation() {
try {
const response = await axios.get('/api/chat/new_conversation');
const cid = response.data.data.conversation_id;
currCid.value = cid;
// 更新 URL
const basePath = chatboxMode ? '/chatbox' : '/chat';
router.push(`${basePath}/${cid}`);
await getConversations();
return cid;
} catch (err) {
console.error(err);
throw err;
}
}
async function deleteConversation(cid: string) {
try {
await axios.get('/api/chat/delete_conversation?conversation_id=' + cid);
await getConversations();
currCid.value = '';
selectedConversations.value = [];
} catch (err) {
console.error(err);
}
}
function showEditTitleDialog(cid: string, title: string) {
editingCid.value = cid;
editingTitle.value = title || '';
editTitleDialog.value = true;
}
async function saveTitle() {
if (!editingCid.value) return;
const trimmedTitle = editingTitle.value.trim();
try {
await axios.post('/api/chat/rename_conversation', {
conversation_id: editingCid.value,
title: trimmedTitle
});
// 更新本地会话标题
const conversation = conversations.value.find(c => c.cid === editingCid.value);
if (conversation) {
conversation.title = trimmedTitle;
}
editTitleDialog.value = false;
} catch (err) {
console.error('重命名对话失败:', err);
}
}
function updateConversationTitle(cid: string, title: string) {
const conversation = conversations.value.find(c => c.cid === cid);
if (conversation) {
conversation.title = title;
}
}
function newChat(closeMobileSidebar?: () => void) {
currCid.value = '';
selectedConversations.value = [];
const basePath = chatboxMode ? '/chatbox' : '/chat';
router.push(basePath);
if (closeMobileSidebar) {
closeMobileSidebar();
}
}
return {
conversations,
selectedConversations,
currCid,
pendingCid,
editTitleDialog,
editingTitle,
editingCid,
getCurrentConversation,
getConversations,
newConversation,
deleteConversation,
showEditTitleDialog,
saveTitle,
updateConversationTitle,
newChat
};
}

View File

@@ -0,0 +1,104 @@
import { ref } from 'vue';
import axios from 'axios';
export function useMediaHandling() {
const stagedImagesName = ref<string[]>([]);
const stagedImagesUrl = ref<string[]>([]);
const stagedAudioUrl = ref<string>('');
const mediaCache = ref<Record<string, string>>({});
async function getMediaFile(filename: string): Promise<string> {
if (mediaCache.value[filename]) {
return mediaCache.value[filename];
}
try {
const response = await axios.get('/api/chat/get_file', {
params: { filename },
responseType: 'blob'
});
const blobUrl = URL.createObjectURL(response.data);
mediaCache.value[filename] = blobUrl;
return blobUrl;
} catch (error) {
console.error('Error fetching media file:', error);
return '';
}
}
async function processAndUploadImage(file: File) {
const formData = new FormData();
formData.append('file', file);
try {
const response = await axios.post('/api/chat/post_image', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
const img = response.data.data.filename;
stagedImagesName.value.push(img);
stagedImagesUrl.value.push(URL.createObjectURL(file));
} catch (err) {
console.error('Error uploading image:', err);
}
}
async function handlePaste(event: ClipboardEvent) {
const items = event.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
const file = items[i].getAsFile();
if (file) {
await processAndUploadImage(file);
}
}
}
}
function removeImage(index: number) {
const urlToRevoke = stagedImagesUrl.value[index];
if (urlToRevoke && urlToRevoke.startsWith('blob:')) {
URL.revokeObjectURL(urlToRevoke);
}
stagedImagesName.value.splice(index, 1);
stagedImagesUrl.value.splice(index, 1);
}
function removeAudio() {
stagedAudioUrl.value = '';
}
function clearStaged() {
stagedImagesName.value = [];
stagedImagesUrl.value = [];
stagedAudioUrl.value = '';
}
function cleanupMediaCache() {
Object.values(mediaCache.value).forEach(url => {
if (url.startsWith('blob:')) {
URL.revokeObjectURL(url);
}
});
mediaCache.value = {};
}
return {
stagedImagesName,
stagedImagesUrl,
stagedAudioUrl,
getMediaFile,
processAndUploadImage,
handlePaste,
removeImage,
removeAudio,
clearStaged,
cleanupMediaCache
};
}

View File

@@ -0,0 +1,304 @@
import { ref, reactive, type Ref } from 'vue';
import axios from 'axios';
import { useToast } from '@/utils/toast';
export interface MessageContent {
type: string;
message: string;
reasoning?: string;
image_url?: string[];
audio_url?: string;
embedded_images?: string[];
embedded_audio?: string;
isLoading?: boolean;
}
export interface Message {
content: MessageContent;
}
export function useMessages(
currSessionId: Ref<string>,
getMediaFile: (filename: string) => Promise<string>,
updateSessionTitle: (sessionId: string, title: string) => void,
onSessionsUpdate: () => void
) {
const messages = ref<Message[]>([]);
const isStreaming = ref(false);
const isConvRunning = ref(false);
const isToastedRunningInfo = ref(false);
const activeSSECount = ref(0);
const enableStreaming = ref(true);
// 从 localStorage 读取流式响应开关状态
const savedStreamingState = localStorage.getItem('enableStreaming');
if (savedStreamingState !== null) {
enableStreaming.value = JSON.parse(savedStreamingState);
}
function toggleStreaming() {
enableStreaming.value = !enableStreaming.value;
localStorage.setItem('enableStreaming', JSON.stringify(enableStreaming.value));
}
async function getSessionMessages(sessionId: string, router: any) {
if (!sessionId) return;
try {
const response = await axios.get('/api/chat/get_session?session_id=' + sessionId);
isConvRunning.value = response.data.data.is_running || false;
let history = response.data.data.history;
if (isConvRunning.value) {
if (!isToastedRunningInfo.value) {
useToast().info("该会话正在运行中。", { timeout: 5000 });
isToastedRunningInfo.value = true;
}
// 如果会话还在运行3秒后重新获取消息
setTimeout(() => {
getSessionMessages(currSessionId.value, router);
}, 3000);
}
// 处理历史消息中的媒体文件
for (let i = 0; i < history.length; i++) {
let content = history[i].content;
if (content.message?.startsWith('[IMAGE]')) {
let img = content.message.replace('[IMAGE]', '');
const imageUrl = await getMediaFile(img);
if (!content.embedded_images) {
content.embedded_images = [];
}
content.embedded_images.push(imageUrl);
content.message = '';
}
if (content.message?.startsWith('[RECORD]')) {
let audio = content.message.replace('[RECORD]', '');
const audioUrl = await getMediaFile(audio);
content.embedded_audio = audioUrl;
content.message = '';
}
if (content.image_url && content.image_url.length > 0) {
for (let j = 0; j < content.image_url.length; j++) {
content.image_url[j] = await getMediaFile(content.image_url[j]);
}
}
if (content.audio_url) {
content.audio_url = await getMediaFile(content.audio_url);
}
}
messages.value = history;
} catch (err) {
console.error(err);
}
}
async function sendMessage(
prompt: string,
imageNames: string[],
audioName: string,
selectedProviderId: string,
selectedModelName: string
) {
// Create user message
const userMessage: MessageContent = {
type: 'user',
message: prompt,
image_url: [],
audio_url: undefined
};
// Convert image filenames to blob URLs
if (imageNames.length > 0) {
const imagePromises = imageNames.map(name => {
if (!name.startsWith('blob:')) {
return getMediaFile(name);
}
return Promise.resolve(name);
});
userMessage.image_url = await Promise.all(imagePromises);
}
// Convert audio filename to blob URL
if (audioName) {
if (!audioName.startsWith('blob:')) {
userMessage.audio_url = await getMediaFile(audioName);
} else {
userMessage.audio_url = audioName;
}
}
messages.value.push({ content: userMessage });
// 添加一个加载中的机器人消息占位符
const loadingMessage = reactive({
type: 'bot',
message: '',
reasoning: '',
isLoading: true
});
messages.value.push({ content: loadingMessage });
try {
activeSSECount.value++;
if (activeSSECount.value === 1) {
isConvRunning.value = true;
}
const response = await fetch('/api/chat/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('token')
},
body: JSON.stringify({
message: prompt,
session_id: currSessionId.value,
image_url: imageNames,
audio_url: audioName ? [audioName] : [],
selected_provider: selectedProviderId,
selected_model: selectedModelName,
enable_streaming: enableStreaming.value
})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let in_streaming = false;
let message_obj: any = null;
isStreaming.value = true;
while (true) {
try {
const { done, value } = await reader.read();
if (done) {
console.log('SSE stream completed');
break;
}
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n\n');
for (let i = 0; i < lines.length; i++) {
let line = lines[i].trim();
if (!line) continue;
let chunk_json;
try {
chunk_json = JSON.parse(line.replace('data: ', ''));
} catch (parseError) {
console.warn('JSON解析失败:', line, parseError);
continue;
}
if (!chunk_json || typeof chunk_json !== 'object' || !chunk_json.hasOwnProperty('type')) {
console.warn('无效的数据对象:', chunk_json);
continue;
}
if (chunk_json.type === 'error') {
console.error('Error received:', chunk_json.data);
continue;
}
if (chunk_json.type === 'image') {
let img = chunk_json.data.replace('[IMAGE]', '');
const imageUrl = await getMediaFile(img);
let bot_resp: MessageContent = {
type: 'bot',
message: '',
embedded_images: [imageUrl]
};
messages.value.push({ content: bot_resp });
} else if (chunk_json.type === 'record') {
let audio = chunk_json.data.replace('[RECORD]', '');
const audioUrl = await getMediaFile(audio);
let bot_resp: MessageContent = {
type: 'bot',
message: '',
embedded_audio: audioUrl
};
messages.value.push({ content: bot_resp });
} else if (chunk_json.type === 'plain') {
const chain_type = chunk_json.chain_type || 'normal';
if (!in_streaming) {
// 移除加载占位符
const lastMsg = messages.value[messages.value.length - 1];
if (lastMsg?.content?.isLoading) {
messages.value.pop();
}
message_obj = reactive({
type: 'bot',
message: chain_type === 'reasoning' ? '' : chunk_json.data,
reasoning: chain_type === 'reasoning' ? chunk_json.data : '',
});
messages.value.push({ content: message_obj });
in_streaming = true;
} else {
if (chain_type === 'reasoning') {
// 使用 reactive 对象,直接修改属性会触发响应式更新
message_obj.reasoning = (message_obj.reasoning || '') + chunk_json.data;
} else {
message_obj.message = (message_obj.message || '') + chunk_json.data;
}
}
} else if (chunk_json.type === 'update_title') {
updateSessionTitle(chunk_json.session_id, chunk_json.data);
}
if ((chunk_json.type === 'break' && chunk_json.streaming) || !chunk_json.streaming) {
in_streaming = false;
if (!chunk_json.streaming) {
isStreaming.value = false;
}
}
}
} catch (readError) {
console.error('SSE读取错误:', readError);
break;
}
}
// 获取最新的会话列表
onSessionsUpdate();
} catch (err) {
console.error('发送消息失败:', err);
// 移除加载占位符
const lastMsg = messages.value[messages.value.length - 1];
if (lastMsg?.content?.isLoading) {
messages.value.pop();
}
} finally {
isStreaming.value = false;
activeSSECount.value--;
if (activeSSECount.value === 0) {
isConvRunning.value = false;
}
}
}
return {
messages,
isStreaming,
isConvRunning,
enableStreaming,
getSessionMessages,
sendMessage,
toggleStreaming
};
}

View File

@@ -0,0 +1,74 @@
import { ref } from 'vue';
import axios from 'axios';
export function useRecording() {
const isRecording = ref(false);
const audioChunks = ref<Blob[]>([]);
const mediaRecorder = ref<MediaRecorder | null>(null);
async function startRecording(onStart?: (label: string) => void) {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder.value = new MediaRecorder(stream);
mediaRecorder.value.ondataavailable = (event) => {
audioChunks.value.push(event.data);
};
mediaRecorder.value.start();
isRecording.value = true;
if (onStart) {
onStart('录音中...');
}
} catch (error) {
console.error('Failed to start recording:', error);
}
}
async function stopRecording(onStop?: (label: string) => void): Promise<string> {
return new Promise((resolve, reject) => {
if (!mediaRecorder.value) {
reject('No media recorder');
return;
}
isRecording.value = false;
if (onStop) {
onStop('聊天输入框');
}
mediaRecorder.value.stop();
mediaRecorder.value.onstop = async () => {
const audioBlob = new Blob(audioChunks.value, { type: 'audio/wav' });
audioChunks.value = [];
mediaRecorder.value?.stream.getTracks().forEach(track => track.stop());
const formData = new FormData();
formData.append('file', audioBlob);
try {
const response = await axios.post('/api/chat/post_file', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
const audio = response.data.data.filename;
console.log('Audio uploaded:', audio);
resolve(audio);
} catch (err) {
console.error('Error uploading audio:', err);
reject(err);
}
};
});
}
return {
isRecording,
startRecording,
stopRecording
};
}

View File

@@ -0,0 +1,149 @@
import { ref, computed } from 'vue';
import axios from 'axios';
import { useRouter } from 'vue-router';
export interface Session {
session_id: string;
display_name: string | null;
updated_at: string;
platform_id: string;
creator: string;
is_group: number;
created_at: string;
}
export function useSessions(chatboxMode: boolean = false) {
const router = useRouter();
const sessions = ref<Session[]>([]);
const selectedSessions = ref<string[]>([]);
const currSessionId = ref('');
const pendingSessionId = ref<string | null>(null);
// 编辑标题相关
const editTitleDialog = ref(false);
const editingTitle = ref('');
const editingSessionId = ref('');
const getCurrentSession = computed(() => {
if (!currSessionId.value) return null;
return sessions.value.find(s => s.session_id === currSessionId.value);
});
async function getSessions() {
try {
const response = await axios.get('/api/chat/sessions');
sessions.value = response.data.data;
// 处理待加载的会话
if (pendingSessionId.value) {
const session = sessions.value.find(s => s.session_id === pendingSessionId.value);
if (session) {
selectedSessions.value = [pendingSessionId.value];
pendingSessionId.value = null;
}
} else if (!currSessionId.value && sessions.value.length > 0) {
// 默认选择第一个会话
const firstSession = sessions.value[0];
selectedSessions.value = [firstSession.session_id];
}
} catch (err: any) {
if (err.response?.status === 401) {
router.push('/auth/login?redirect=/chatbox');
}
console.error(err);
}
}
async function newSession() {
try {
const response = await axios.get('/api/chat/new_session');
const sessionId = response.data.data.session_id;
currSessionId.value = sessionId;
// 更新 URL
const basePath = chatboxMode ? '/chatbox' : '/chat';
router.push(`${basePath}/${sessionId}`);
await getSessions();
return sessionId;
} catch (err) {
console.error(err);
throw err;
}
}
async function deleteSession(sessionId: string) {
try {
await axios.get('/api/chat/delete_session?session_id=' + sessionId);
await getSessions();
currSessionId.value = '';
selectedSessions.value = [];
} catch (err) {
console.error(err);
}
}
function showEditTitleDialog(sessionId: string, title: string) {
editingSessionId.value = sessionId;
editingTitle.value = title || '';
editTitleDialog.value = true;
}
async function saveTitle() {
if (!editingSessionId.value) return;
const trimmedTitle = editingTitle.value.trim();
try {
await axios.post('/api/chat/update_session_display_name', {
session_id: editingSessionId.value,
display_name: trimmedTitle
});
// 更新本地会话标题
const session = sessions.value.find(s => s.session_id === editingSessionId.value);
if (session) {
session.display_name = trimmedTitle;
}
editTitleDialog.value = false;
} catch (err) {
console.error('重命名会话失败:', err);
}
}
function updateSessionTitle(sessionId: string, title: string) {
const session = sessions.value.find(s => s.session_id === sessionId);
if (session) {
session.display_name = title;
}
}
function newChat(closeMobileSidebar?: () => void) {
currSessionId.value = '';
selectedSessions.value = [];
const basePath = chatboxMode ? '/chatbox' : '/chat';
router.push(basePath);
if (closeMobileSidebar) {
closeMobileSidebar();
}
}
return {
sessions,
selectedSessions,
currSessionId,
pendingSessionId,
editTitleDialog,
editingTitle,
editingSessionId,
getCurrentSession,
getSessions,
newSession,
deleteSession,
showEditTitleDialog,
saveTitle,
updateSessionTitle,
newChat
};
}

View File

@@ -33,6 +33,7 @@ export class I18nLoader {
{ name: 'core/status', path: 'core/status.json' },
{ name: 'core/navigation', path: 'core/navigation.json' },
{ name: 'core/header', path: 'core/header.json' },
{ name: 'core/shared', path: 'core/shared.json' },
// 功能模块
{ name: 'features/chat', path: 'features/chat.json' },
@@ -43,6 +44,7 @@ export class I18nLoader {
{ name: 'features/provider', path: 'features/provider.json' },
{ name: 'features/platform', path: 'features/platform.json' },
{ name: 'features/config', path: 'features/config.json' },
{ name: 'features/config-metadata', path: 'features/config-metadata.json' },
{ name: 'features/console', path: 'features/console.json' },
{ name: 'features/about', path: 'features/about.json' },
{ name: 'features/settings', path: 'features/settings.json' },

View File

@@ -72,7 +72,9 @@
"enabled": "Enabled",
"disabled": "Disabled",
"delete": "Delete",
"copy": "Copy",
"edit": "Edit",
"copy": "Copy",
"noData": "No data available"
}
}
}

View File

@@ -32,7 +32,6 @@
"issueLink": "GitHub Issues"
},
"tip": "💡 TIP:",
"tipLink": "",
"tipContinue": "By default, the corresponding version of the WebUI files will be downloaded when switching versions. The WebUI code is located in the dashboard directory of the project, and you can use npm to build it yourself.",
"dockerTip": "When switching versions, it will try to update both the bot main program and the dashboard. If you are using Docker deployment, you can also re-pull the image or use",
"dockerTipLink": "watchtower",
@@ -91,4 +90,4 @@
"updateFailed": "Update failed, please try again"
}
}
}
}

View File

@@ -5,10 +5,10 @@
"persona": "Persona",
"toolUse": "MCP Tools",
"config": "Config",
"extension": "Extensions",
"chat": "Chat",
"extension": "Extensions",
"conversation": "Conversations",
"sessionManagement": "Session Management",
"sessionManagement": "Custom Rules",
"console": "Console",
"alkaid": "Alkaid Lab",
"knowledgeBase": "Knowledge Base",
@@ -20,4 +20,4 @@
"groups": {
"more": "More Features"
}
}
}

View File

@@ -0,0 +1,45 @@
{
"knowledgeBaseSelector": {
"notSelected": "Not selected",
"buttonText": "Select Knowledge Base...",
"dialogTitle": "Select Knowledge Base",
"loading": "Loading...",
"noKnowledgeBases": "No knowledge bases available",
"createKnowledgeBase": "Create Knowledge Base",
"selectedCount": "{count} knowledge base(s) selected",
"confirmSelection": "Confirm Selection",
"cancelSelection": "Cancel",
"noDescription": "No description",
"documentCount": "{count} document(s)",
"chunkCount": "{count} chunk(s)"
},
"pluginSetSelector": {
"notSelected": "No plugins enabled",
"allPlugins": "All plugins enabled (*)",
"selectedCount": "{count} plugin(s) selected",
"buttonText": "Select Plugin Set...",
"dialogTitle": "Select Plugin Set",
"loading": "Loading...",
"enableAll": "Enable all plugins",
"enableNone": "Disable all plugins",
"customSelect": "Custom selection",
"noPlugins": "No plugins available",
"confirmSelection": "Confirm Selection",
"cancelSelection": "Cancel",
"noDescription": "No description",
"notActivated": "Not activated",
"note": "*System plugins and disabled plugins are not shown."
},
"providerSelector": {
"notSelected": "Not selected",
"buttonText": "Select Provider...",
"dialogTitle": "Select Provider",
"loading": "Loading...",
"noProviders": "No providers available",
"confirmSelection": "Confirm Selection",
"cancelSelection": "Cancel",
"clearSelection": "None",
"clearSelectionSubtitle": "Clear current selection",
"unknownType": "Unknown type"
}
}

View File

@@ -47,7 +47,12 @@
"noHistory": "No conversation history",
"systemStatus": "System Status",
"llmService": "LLM Service",
"speechToText": "Speech to Text"
"speechToText": "Speech to Text",
"editDisplayName": "Edit Session Name",
"displayName": "Session Name",
"displayNameUpdated": "Session name updated",
"displayNameUpdateFailed": "Failed to update session name",
"confirmDelete": "Are you sure you want to delete \"{name}\"? This action cannot be undone."
},
"modes": {
"darkMode": "Switch to Dark Mode",
@@ -80,5 +85,9 @@
"reconnected": "Chat connection re-established",
"failed": "Connection failed, please refresh the page"
}
},
"errors": {
"sendMessageFailed": "Failed to send message, please try again",
"createSessionFailed": "Failed to create session, please refresh the page"
}
}

View File

@@ -0,0 +1,452 @@
{
"ai_group": {
"name": "AI",
"agent_runner": {
"description": "Agent Runner",
"hint": "Select the runner for AI conversations. Defaults to AstrBot's built-in Agent runner, which supports knowledge base, persona, and tool calling features. You don't need to modify this section unless you plan to integrate third-party Agent runners like Dify or Coze.",
"provider_settings": {
"enable": {
"description": "Enable",
"hint": "Master switch for AI conversations"
},
"agent_runner_type": {
"description": "Runner",
"labels": ["Built-in Agent", "Dify", "Coze", "Alibaba Cloud Bailian Application"]
},
"coze_agent_runner_provider_id": {
"description": "Coze Agent Runner Provider ID"
},
"dify_agent_runner_provider_id": {
"description": "Dify Agent Runner Provider ID"
},
"dashscope_agent_runner_provider_id": {
"description": "Alibaba Cloud Bailian Application Agent Runner Provider ID"
}
}
},
"ai": {
"description": "Model",
"hint": "When using non-built-in Agent runners, the default chat model and default image caption model may not take effect, but some plugins rely on these settings to invoke AI capabilities.",
"provider_settings": {
"default_provider_id": {
"description": "Default Chat Model",
"hint": "Uses the first model when left empty"
},
"default_image_caption_provider_id": {
"description": "Default Image Caption Model",
"hint": "Leave empty to disable; useful for non-multimodal models"
},
"image_caption_prompt": {
"description": "Image Caption Prompt"
}
},
"provider_stt_settings": {
"enable": {
"description": "Enable Speech-to-Text",
"hint": "Master switch for STT"
},
"provider_id": {
"description": "Default Speech-to-Text Model",
"hint": "Users can also select session-specific STT models using the /provider command."
}
},
"provider_tts_settings": {
"enable": {
"description": "Enable Text-to-Speech",
"hint": "Master switch for TTS"
},
"provider_id": {
"description": "Default Text-to-Speech Model"
}
}
},
"persona": {
"description": "Persona",
"provider_settings": {
"default_personality": {
"description": "Default Persona"
}
}
},
"knowledgebase": {
"description": "Knowledge Base",
"kb_names": {
"description": "Knowledge Base List",
"hint": "Supports multiple selections"
},
"kb_fusion_top_k": {
"description": "Fusion Search Results Count",
"hint": "Number of results returned after fusing search results from multiple knowledge bases"
},
"kb_final_top_k": {
"description": "Final Results Count",
"hint": "Number of results retrieved from the knowledge base. Higher values may provide more relevant information but could also introduce noise. Adjust based on actual needs"
},
"kb_agentic_mode": {
"description": "Agentic Knowledge Base Retrieval",
"hint": "When enabled, knowledge base retrieval becomes an LLM Tool, allowing the model to autonomously decide when to query the knowledge base. Requires the model to support function calling."
}
},
"websearch": {
"description": "Web Search",
"provider_settings": {
"web_search": {
"description": "Enable Web Search"
},
"websearch_provider": {
"description": "Web Search Provider"
},
"websearch_tavily_key": {
"description": "Tavily API Key",
"hint": "Multiple keys can be added for rotation."
},
"websearch_baidu_app_builder_key": {
"description": "Baidu Qianfan Smart Cloud APP Builder API Key",
"hint": "Reference: https://console.bce.baidu.com/iam/#/iam/apikey/list"
},
"web_search_link": {
"description": "Display Source Citations"
}
}
},
"others": {
"description": "Other Settings",
"provider_settings": {
"display_reasoning_text": {
"description": "Display Reasoning Content"
},
"identifier": {
"description": "User Identification",
"hint": "When enabled, user ID information will be included in the prompt."
},
"group_name_display": {
"description": "Display Group Name",
"hint": "When enabled, group name information will be included in the prompt on supported platforms (OneBot v11)."
},
"datetime_system_prompt": {
"description": "Real-world Time Awareness",
"hint": "When enabled, current time information will be appended to the system prompt."
},
"show_tool_use_status": {
"description": "Output Function Call Status"
},
"max_agent_step": {
"description": "Maximum Tool Call Rounds"
},
"tool_call_timeout": {
"description": "Tool Call Timeout (seconds)"
},
"streaming_response": {
"description": "Streaming Output"
},
"unsupported_streaming_strategy": {
"description": "Platforms Without Streaming Support",
"hint": "Select the handling method for platforms that don't support streaming responses. Real-time segmented reply sends content immediately when the system detects segment points like punctuation during streaming reception",
"labels": ["Real-time Segmented Reply", "Disable Streaming Response"]
},
"max_context_length": {
"description": "Maximum Conversation Rounds",
"hint": "Discards the oldest parts when this count is exceeded. One conversation round counts as 1, -1 means unlimited"
},
"dequeue_context_length": {
"description": "Dequeue Conversation Rounds",
"hint": "Number of conversation rounds to discard at once when maximum context length is exceeded"
},
"wake_prefix": {
"description": "Additional LLM Chat Wake Prefix",
"hint": "If the wake prefix is / and the additional chat wake prefix is chat, then /chat is required to trigger LLM requests"
},
"prompt_prefix": {
"description": "User Prompt",
"hint": "You can use {{prompt}} as a placeholder for user input. If no placeholder is provided, it will be added before the user input."
}
},
"provider_tts_settings": {
"dual_output": {
"description": "Output Both Voice and Text When TTS is Enabled"
}
}
}
},
"platform_group": {
"name": "Platform",
"general": {
"description": "General",
"admins_id": {
"description": "Administrator IDs"
},
"platform_settings": {
"unique_session": {
"description": "Isolate Sessions",
"hint": "When enabled, group members have independent contexts."
},
"friend_message_needs_wake_prefix": {
"description": "Private Messages Require Wake Word"
},
"reply_prefix": {
"description": "Reply Text Prefix"
},
"reply_with_mention": {
"description": "Mention Sender in Reply"
},
"reply_with_quote": {
"description": "Quote Sender's Message in Reply"
},
"forward_threshold": {
"description": "Forward Message Word Count Threshold"
},
"empty_mention_waiting": {
"description": "Trigger Waiting on Mention-only Messages"
}
},
"wake_prefix": {
"description": "Wake Word"
}
},
"whitelist": {
"description": "Whitelist",
"platform_settings": {
"enable_id_white_list": {
"description": "Enable Whitelist",
"hint": "When enabled, only sessions in the whitelist will be responded to."
},
"id_whitelist": {
"description": "Whitelist ID List",
"hint": "Use /sid to get IDs."
},
"id_whitelist_log": {
"description": "Output Logs",
"hint": "When enabled, INFO level logs will be output when a message doesn't pass the whitelist."
},
"wl_ignore_admin_on_group": {
"description": "Administrator Group Messages Bypass ID Whitelist"
},
"wl_ignore_admin_on_friend": {
"description": "Administrator Private Messages Bypass ID Whitelist"
}
}
},
"rate_limit": {
"description": "Rate Limiting",
"platform_settings": {
"rate_limit": {
"time": {
"description": "Message Rate Limit Time (seconds)"
},
"count": {
"description": "Message Rate Limit Count"
},
"strategy": {
"description": "Rate Limit Strategy"
}
}
}
},
"content_safety": {
"description": "Content Safety",
"content_safety": {
"also_use_in_response": {
"description": "Also Check Model Response Content"
},
"baidu_aip": {
"enable": {
"description": "Use Baidu Content Safety Moderation",
"hint": "You need to manually install the baidu-aip library."
},
"app_id": {
"description": "App ID"
},
"api_key": {
"description": "API Key"
},
"secret_key": {
"description": "Secret Key"
}
},
"internal_keywords": {
"enable": {
"description": "Keyword Check"
},
"extra_keywords": {
"description": "Additional Keywords",
"hint": "Additional keyword blocklist, supports regular expressions."
}
}
}
},
"t2i": {
"description": "Text-to-Image",
"t2i": {
"description": "Text-to-Image Output"
},
"t2i_word_threshold": {
"description": "Text-to-Image Word Count Threshold"
}
},
"others": {
"description": "Other Settings",
"platform_settings": {
"ignore_bot_self_message": {
"description": "Ignore Bot's Own Messages"
},
"ignore_at_all": {
"description": "Ignore @All Events"
},
"no_permission_reply": {
"description": "Reply When User Has Insufficient Permissions"
}
},
"platform_specific": {
"lark": {
"pre_ack_emoji": {
"enable": {
"description": "[Lark] Enable Pre-acknowledgment Emoji"
},
"emojis": {
"description": "Emoji List (Lark Emoji Enum Names)",
"hint": "Emoji enum names reference: https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce"
}
}
},
"telegram": {
"pre_ack_emoji": {
"enable": {
"description": "[Telegram] Enable Pre-acknowledgment Emoji"
},
"emojis": {
"description": "Emoji List (Unicode)",
"hint": "Telegram only supports a fixed reaction set, reference: https://gist.github.com/Soulter/3f22c8e5f9c7e152e967e8bc28c97fc9"
}
}
}
}
}
},
"plugin_group": {
"name": "Plugin",
"plugin": {
"description": "Plugins",
"plugin_set": {
"description": "Available Plugins",
"hint": "All non-disabled plugins are enabled by default. If a plugin is disabled on the plugins page, selections here will not take effect."
}
}
},
"ext_group": {
"name": "Ext.",
"segmented_reply": {
"description": "Segmented Reply",
"platform_settings": {
"segmented_reply": {
"enable": {
"description": "Enable Segmented Reply"
},
"only_llm_result": {
"description": "Segment Only LLM Results"
},
"interval_method": {
"description": "Interval Method"
},
"interval": {
"description": "Random Interval Time",
"hint": "Format: minimum,maximum (e.g., 1.5,3.5)"
},
"log_base": {
"description": "Logarithm Base",
"hint": "Base for logarithmic intervals, defaults to 2.0. Value range: 1.0-10.0."
},
"words_count_threshold": {
"description": "Segmented Reply Word Count Threshold"
},
"regex": {
"description": "Segmentation Regular Expression"
},
"content_cleanup_rule": {
"description": "Content Filtering Regular Expression",
"hint": "Remove specified content from segmented content. For example, `[。?!]` will remove all periods, question marks, and exclamation marks."
}
}
}
},
"ltm": {
"description": "Group Chat Context Awareness (formerly Chat Memory Enhancement)",
"provider_ltm_settings": {
"group_icl_enable": {
"description": "Enable Group Chat Context Awareness"
},
"group_message_max_cnt": {
"description": "Maximum Message Count"
},
"image_caption": {
"description": "Auto-understand Images",
"hint": "Requires setting a default image caption model."
},
"active_reply": {
"enable": {
"description": "Active Reply"
},
"method": {
"description": "Active Reply Method"
},
"possibility_reply": {
"description": "Reply Probability",
"hint": "Value between 0.0-1.0"
},
"whitelist": {
"description": "Active Reply Whitelist",
"hint": "Whitelist filtering is disabled when empty. Use /sid to get IDs."
}
}
}
}
},
"system_group": {
"name": "System",
"system": {
"description": "System Settings",
"t2i_strategy": {
"description": "Text-to-Image Strategy",
"hint": "Text-to-image strategy. `remote` uses a remote HTML-based rendering service, `local` uses PIL for local rendering. When using local, place a TTF font named 'font.ttf' in the data/ directory to customize the font."
},
"t2i_endpoint": {
"description": "Text-to-Image Service API Endpoint",
"hint": "Uses AstrBot API service when empty"
},
"t2i_template": {
"description": "Text-to-Image Custom Template",
"hint": "When enabled, you can customize HTML templates for text-to-image rendering."
},
"t2i_active_template": {
"description": "Currently Active Text-to-Image Rendering Template",
"hint": "This value is maintained by the text-to-image template management page."
},
"log_level": {
"description": "Console Log Level",
"hint": "Log level for console output."
},
"pip_install_arg": {
"description": "Additional pip Installation Arguments",
"hint": "When installing plugin dependencies, Python's pip tool will be used. Additional arguments can be provided here, such as `--break-system-package`."
},
"pypi_index_url": {
"description": "PyPI Repository URL",
"hint": "PyPI repository URL for installing Python dependencies. Defaults to https://mirrors.aliyun.com/pypi/simple/"
},
"callback_api_base": {
"description": "Externally Accessible Callback API Address",
"hint": "External services may access AstrBot's backend through callback links generated by AstrBot (such as file download links). Since AstrBot cannot automatically determine the externally accessible host address in the deployment environment, this configuration item is needed to explicitly specify how external services should access AstrBot's address. Examples: http://localhost:6185, https://example.com, etc."
},
"timezone": {
"description": "Timezone",
"hint": "Timezone setting. Please enter an IANA timezone name, such as Asia/Shanghai. Uses system default timezone when empty. For all timezones, see: https://data.iana.org/time-zones/tzdb-2021a/zone1970.tab"
},
"http_proxy": {
"description": "HTTP Proxy",
"hint": "When enabled, proxy will be set by adding environment variables. Format: `http://ip:port`"
},
"no_proxy": {
"description": "Direct Connection Address List"
}
}
}
}

View File

@@ -30,7 +30,11 @@
"configApplyError": "Configuration not applied, JSON format error.",
"saveSuccess": "Configuration saved successfully",
"saveError": "Failed to save configuration",
"loadError": "Failed to load configuration"
"loadError": "Failed to load configuration",
"deleteSuccess": "Deleted successfully",
"deleteError": "Failed to delete",
"updateSuccess": "Updated successfully",
"updateError": "Failed to update"
},
"sections": {
"general": "General Settings",
@@ -58,5 +62,33 @@
"allowedHosts": "Allowed Hosts",
"rateLimit": "Rate Limit",
"encryption": "Encryption Settings"
},
"configSelection": {
"selectConfig": "Select Configuration",
"normalConfig": "Basic",
"systemConfig": "System"
},
"configManagement": {
"title": "Configuration Management",
"description": "AstrBot supports separate configuration files for different bots. The `default` configuration is used by default.",
"newConfig": "New Configuration",
"editConfig": "Edit Configuration",
"manageConfigs": "Manage Configurations...",
"configName": "Name",
"fillConfigName": "Enter configuration name",
"confirmDelete": "Are you sure you want to delete the configuration \"{name}\"? This action cannot be undone.",
"pleaseEnterName": "Please enter a configuration name",
"createFailed": "Failed to create new configuration",
"deleteFailed": "Failed to delete configuration",
"updateFailed": "Failed to update configuration"
},
"buttons": {
"cancel": "Cancel",
"create": "Create",
"update": "Update"
},
"codeEditor": {
"title": "Edit Configuration File"
}
}
}

View File

@@ -4,6 +4,7 @@
"tabs": {
"overview": "Overview",
"documents": "Documents",
"retrieval": "Retrieval",
"sessions": "Sessions",
"settings": "Settings"
},
@@ -49,6 +50,10 @@
"maxSize": "Max file size: 128MB",
"chunkSettings": "Chunk Settings",
"batchSettings": "Batch Settings",
"cleaningSettings": "Cleaning Settings",
"enableCleaning": "Enable Content Cleaning",
"cleaningProvider": "Cleaning Service Provider",
"cleaningProviderHint": "Select an LLM provider to clean and summarize the extracted web page content",
"chunkSize": "Chunk Size",
"chunkSizeHint": "Number of characters per chunk (default: 512)",
"chunkOverlap": "Chunk Overlap",
@@ -61,7 +66,37 @@
"maxRetriesHint": "Number of times to retry a failed upload task (default: 3)",
"cancel": "Cancel",
"submit": "Upload",
"fileRequired": "Please select a file to upload"
"fileRequired": "Please select a file to upload",
"fileUpload": "File Upload",
"fromUrl": "From URL",
"urlPlaceholder": "Enter the URL of the web page to extract content from",
"urlRequired": "Please enter a URL",
"urlHint": "The main content will be automatically extracted from the target URL as a document. Currently supports {supported} pages. Before use, please ensure that the target web page allows crawler access.",
"beta": "Beta"
},
"retrieval": {
"title": "Retrieval",
"subtitle": "Test the knowledge base using dense and sparse retrieval methods",
"query": "Query",
"queryPlaceholder": "Enter a query...",
"search": "Search",
"searching": "Searching...",
"results": "Results",
"noResults": "No results found",
"tryDifferentQuery": "Try a different query",
"settings": "Retrieval Settings",
"topK": "Number of Results",
"topKHint": "Maximum number of results to return",
"enableRerank": "Enable Rerank",
"enableRerankHint": "Use a rerank model to improve retrieval quality",
"score": "Relevance Score",
"document": "Document",
"chunk": "Chunk #{index}",
"content": "Content",
"charCount": "{count} characters",
"searchSuccess": "Search completed, found {count} results",
"searchFailed": "Search failed",
"queryRequired": "Please enter a query"
},
"settings": {
"title": "Knowledge Base Settings",

View File

@@ -22,7 +22,10 @@
"preview": "Preview",
"search": "Search Chunks",
"searchPlaceholder": "Enter keywords to search chunks...",
"showing": "Showing"
"showing": "Showing",
"deleteConfirm": "Are you sure you want to delete this chunk?",
"deleteSuccess": "Chunk deleted successfully",
"deleteFailed": "Failed to delete chunk"
},
"edit": {
"title": "Edit Chunk",

View File

@@ -9,6 +9,7 @@
"tabs": {
"all": "All",
"chatCompletion": "Chat Completion",
"agentRunner": "Agent Runner",
"speechToText": "Speech to Text",
"textToSpeech": "Text to Speech",
"embedding": "Embedding",
@@ -44,12 +45,13 @@
"title": "Service Provider",
"tabs": {
"basic": "Basic",
"agentRunner": "Agent Runner",
"speechToText": "Speech to Text",
"textToSpeech": "Text to Speech",
"embedding": "Embedding",
"rerank": "Rerank"
},
"noTemplates": "No {type} type provider templates available"
"noTemplates": "No this type provider templates available"
},
"config": {
"addTitle": "Add",

View File

@@ -1,124 +1,99 @@
{
"title": "Session Management",
"subtitle": "Manage active sessions and configurations",
"title": "Custom Rules",
"subtitle": "Set custom rules for specific sessions, which take priority over global settings",
"buttons": {
"refresh": "Refresh",
"edit": "Edit",
"apply": "Apply Batch Settings",
"editName": "Edit Session Name",
"editRule": "Edit Rules",
"deleteAllRules": "Delete All Rules",
"addRule": "Add Rule",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete"
"delete": "Delete",
"clear": "Clear",
"next": "Next",
"editCustomName": "Edit Note",
"batchDelete": "Batch Delete"
},
"sessions": {
"activeSessions": "Active Sessions",
"sessionCount": "sessions",
"noActiveSessions": "No active sessions",
"noActiveSessionsDesc": "Sessions will appear here when users interact with the bot"
"customRules": {
"title": "Custom Rules",
"rulesCount": "rules",
"hasRules": "Configured",
"noRules": "No Custom Rules",
"noRulesDesc": "Click 'Add Rule' to configure custom rules for specific sessions",
"serviceConfig": "Service Config",
"pluginConfig": "Plugin Config",
"kbConfig": "Knowledge Base",
"providerConfig": "Provider Config",
"configured": "Configured",
"noCustomName": "No note set"
},
"quickEditName": {
"title": "Edit Note"
},
"search": {
"placeholder": "Search sessions...",
"platformFilter": "Platform Filter"
"placeholder": "Search sessions..."
},
"table": {
"headers": {
"sessionStatus": "Session Status",
"sessionInfo": "Session Info",
"persona": "Persona",
"chatProvider": "Chat Provider",
"sttProvider": "STT Provider",
"ttsProvider": "TTS Provider",
"llmStatus": "LLM Status",
"ttsStatus": "TTS Status",
"knowledgeBase": "Knowledge Base",
"pluginManagement": "Plugin Management",
"umoInfo": "Unified Message Origin",
"rulesOverview": "Rules Overview",
"actions": "Actions"
}
},
"status": {
"enabled": "Enabled",
"disabled": "Disabled"
},
"persona": {
"none": "No Persona"
"none": "Follow Config"
},
"batchOperations": {
"title": "Batch Operations",
"setPersona": "Batch Set Persona",
"setChatProvider": "Batch Set Chat Provider",
"setSttProvider": "Batch Set STT Provider",
"setTtsProvider": "Batch Set TTS Provider",
"setLlmStatus": "Batch Set LLM Status",
"setTtsStatus": "Batch Set TTS Status",
"noSttProvider": "No STT Provider Available",
"noTtsProvider": "No TTS Provider Available"
"provider": {
"followConfig": "Follow Config"
},
"pluginManagement": {
"title": "Plugin Management",
"noPlugins": "No available plugins",
"noPluginsDesc": "Currently no active plugins",
"loading": "Loading plugin list...",
"author": "Author"
"addRule": {
"title": "Add Custom Rule",
"description": "Select a session (UMO) to configure custom rules. Custom rules take priority over global settings.",
"selectUmo": "Select Session",
"noUmos": "No sessions available"
},
"nameEditor": {
"title": "Edit Session Name",
"customName": "Custom Name",
"placeholder": "Enter custom session name (leave empty to use original name)",
"originalName": "Original Name",
"fullSessionId": "Full Session ID",
"hint": "Custom names help you easily identify sessions. The small information icon (!) will show the actual UMO when hovering."
},
"knowledgeBase": {
"title": "Knowledge Base Configuration",
"configure": "Configure",
"selectKB": "Select Knowledge Bases",
"selectMultiple": "You can select multiple knowledge bases",
"noKBAvailable": "No knowledge bases available",
"noKBDesc": "No knowledge bases have been created yet",
"createKB": "Create Knowledge Base",
"advancedSettings": "Advanced Settings",
"topK": "Result Count",
"topKHint": "Number of results to retrieve from knowledge base",
"enableRerank": "Enable Reranking",
"enableRerankHint": "Use reranking model to improve retrieval quality",
"clearConfig": "Clear Configuration",
"save": "Save",
"cancel": "Cancel",
"loading": "Loading knowledge base configuration...",
"description": "Configure knowledge bases for this session. The session will use configured knowledge bases to enhance conversation context.",
"saveSuccess": "Knowledge base configuration saved successfully",
"saveFailed": "Failed to save knowledge base configuration",
"loadFailed": "Failed to load knowledge base configuration",
"clearSuccess": "Knowledge base configuration cleared",
"clearFailed": "Failed to clear knowledge base configuration",
"clearConfirm": "Are you sure you want to clear the knowledge base configuration for this session?"
},
"list": {
"documents": "documents"
"ruleEditor": {
"title": "Edit Custom Rules",
"description": "Configure custom rules for this session. These rules take priority over global settings.",
"serviceConfig": {
"title": "Service Configuration",
"sessionEnabled": "Enable Session",
"llmEnabled": "Enable LLM",
"ttsEnabled": "Enable TTS",
"customName": "Custom Name"
},
"providerConfig": {
"title": "Provider Configuration",
"chatProvider": "Chat Provider",
"sttProvider": "STT Provider",
"ttsProvider": "TTS Provider"
},
"personaConfig": {
"title": "Persona Configuration",
"selectPersona": "Select Persona",
"hint": "Persona settings affect the conversation style and behavior of the LLM"
}
},
"deleteConfirm": {
"message": "Are you sure you want to delete session {sessionName}?",
"warning": "This action will permanently delete all chat history and preference settings for this session (except for data linked via plugins), and this cannot be undone. Continue?"
"title": "Confirm Delete",
"message": "Are you sure you want to delete all custom rules for this session? Global settings will be used after deletion."
},
"batchDeleteConfirm": {
"title": "Confirm Batch Delete",
"message": "Are you sure you want to delete {count} selected rules? Global settings will be used after deletion."
},
"messages": {
"refreshSuccess": "Session list refreshed",
"personaUpdateSuccess": "Persona updated successfully",
"personaUpdateError": "Failed to update persona",
"providerUpdateSuccess": "Provider updated successfully",
"providerUpdateError": "Failed to update provider",
"sessionStatusSuccess": "Session {status}",
"llmStatusSuccess": "LLM {status}",
"ttsStatusSuccess": "TTS {status}",
"statusUpdateError": "Failed to update status",
"loadSessionsError": "Failed to load session list",
"batchUpdateSuccess": "Successfully batch updated {count} settings",
"batchUpdatePartial": "Batch update completed, {success} successful, {error} failed",
"loadPluginsError": "Failed to load plugin list",
"pluginStatusSuccess": "Plugin {name} {status}",
"pluginStatusError": "Failed to update plugin status",
"nameUpdateSuccess": "Session name updated successfully",
"nameUpdateError": "Failed to update session name",
"deleteSuccess": "Session deleted successfully",
"deleteError": "Failed to delete session"
"refreshSuccess": "Data refreshed",
"loadError": "Failed to load data",
"saveSuccess": "Saved successfully",
"saveError": "Failed to save",
"clearSuccess": "Cleared successfully",
"clearError": "Failed to clear",
"deleteSuccess": "Deleted successfully",
"deleteError": "Failed to delete",
"noChanges": "No changes to save",
"batchDeleteSuccess": "Batch delete successful",
"batchDeleteError": "Batch delete failed"
}
}

View File

@@ -96,6 +96,42 @@
},
"confirmDelete": "Are you sure you want to delete server {name}?"
},
"syncProvider": {
"title": "Sync MCP Servers",
"subtitle": "Sync MCP server configurations from providers to local",
"steps": {
"selectProvider": "Step 1: Select Provider",
"configureAuth": "Step 2: Configure Authentication",
"syncServers": "Step 3: Sync Servers"
},
"providers": {
"modelscope": "ModelScope",
"description": "ModelScope is an open model community providing MCP servers for various machine learning and AI services"
},
"fields": {
"provider": "Select Provider",
"accessToken": "Access Token",
"tokenRequired": "Access token is required",
"tokenHint": "Please enter your ModelScope access token"
},
"buttons": {
"cancel": "Cancel",
"previous": "Previous",
"next": "Next",
"sync": "Start Sync",
"getToken": "Get Token"
},
"status": {
"selectProvider": "Please select an MCP server provider",
"enterToken": "Please enter the access token to continue",
"readyToSync": "Ready to sync server configurations"
},
"messages": {
"syncSuccess": "MCP servers synced successfully!",
"syncError": "Sync failed: {error}",
"tokenHelp": "How to get a ModelScope access token? Click the button on the right for instructions"
}
},
"messages": {
"getServersError": "Failed to get MCP server list: {error}",
"getToolsError": "Failed to get function tools list: {error}",
@@ -117,4 +153,4 @@
"toggleToolError": "Failed to toggle tool status: {error}",
"testError": "Test connection failed: {error}"
}
}
}

View File

@@ -8,7 +8,7 @@
"config": "配置文件",
"chat": "聊天",
"conversation": "对话数据",
"sessionManagement": "会话管理",
"sessionManagement": "自定义规则",
"console": "控制台",
"alkaid": "Alkaid",
"knowledgeBase": "知识库",

View File

@@ -0,0 +1,45 @@
{
"knowledgeBaseSelector": {
"notSelected": "未选择",
"buttonText": "选择知识库...",
"dialogTitle": "选择知识库",
"loading": "加载中...",
"noKnowledgeBases": "暂无知识库",
"createKnowledgeBase": "创建知识库",
"selectedCount": "已选择 {count} 个知识库",
"confirmSelection": "确认选择",
"cancelSelection": "取消",
"noDescription": "无描述",
"documentCount": "{count} 个文档",
"chunkCount": "{count} 个块"
},
"pluginSetSelector": {
"notSelected": "未启用任何插件",
"allPlugins": "启用所有插件 (*)",
"selectedCount": "已选择 {count} 个插件",
"buttonText": "选择插件集合...",
"dialogTitle": "选择插件集合",
"loading": "加载中...",
"enableAll": "启用所有插件",
"enableNone": "不启用任何插件",
"customSelect": "自定义选择",
"noPlugins": "暂无可用的插件",
"confirmSelection": "确认选择",
"cancelSelection": "取消",
"noDescription": "无描述",
"notActivated": "未激活",
"note": "*不显示系统插件和已经在插件页禁用的插件。"
},
"providerSelector": {
"notSelected": "未选择",
"buttonText": "选择提供商...",
"dialogTitle": "选择提供商",
"loading": "加载中...",
"noProviders": "暂无可用的提供商",
"confirmSelection": "确认选择",
"cancelSelection": "取消",
"clearSelection": "不选择",
"clearSelectionSubtitle": "清除当前选择",
"unknownType": "未知类型"
}
}

View File

@@ -43,11 +43,16 @@
"exitFullscreen": "退出全屏"
},
"conversation": {
"newConversation": "新对话",
"newConversation": "新的聊天",
"noHistory": "暂无对话历史",
"systemStatus": "系统状态",
"llmService": "LLM 服务",
"speechToText": "语音转文本"
"speechToText": "语音转文本",
"editDisplayName": "编辑会话名称",
"displayName": "会话名称",
"displayNameUpdated": "会话名称已更新",
"displayNameUpdateFailed": "更新会话名称失败",
"confirmDelete": "确定要删除“{name}”吗?此操作无法撤销。"
},
"modes": {
"darkMode": "切换到夜间模式",
@@ -80,5 +85,9 @@
"reconnected": "聊天连接已重新建立",
"failed": "连接失败,请刷新页面重试"
}
},
"errors": {
"sendMessageFailed": "发送消息失败,请重试",
"createSessionFailed": "创建会话失败,请刷新页面重试"
}
}

View File

@@ -0,0 +1,452 @@
{
"ai_group": {
"name": "AI 配置",
"agent_runner": {
"description": "Agent 执行方式",
"hint": "选择 AI 对话的执行器,默认为 AstrBot 内置 Agent 执行器,可使用 AstrBot 内的知识库、人格、工具调用功能。如果不打算接入 Dify 或 Coze 等第三方 Agent 执行器,不需要修改此节。",
"provider_settings": {
"enable": {
"description": "启用",
"hint": "AI 对话总开关"
},
"agent_runner_type": {
"description": "执行器",
"labels": ["内置 Agent", "Dify", "Coze", "阿里云百炼应用"]
},
"coze_agent_runner_provider_id": {
"description": "Coze Agent 执行器提供商 ID"
},
"dify_agent_runner_provider_id": {
"description": "Dify Agent 执行器提供商 ID"
},
"dashscope_agent_runner_provider_id": {
"description": "阿里云百炼应用 Agent 执行器提供商 ID"
}
}
},
"ai": {
"description": "模型",
"hint": "当使用非内置 Agent 执行器时,默认聊天模型和默认图片转述模型可能会无效,但某些插件会依赖此配置项来调用 AI 能力。",
"provider_settings": {
"default_provider_id": {
"description": "默认聊天模型",
"hint": "留空时使用第一个模型"
},
"default_image_caption_provider_id": {
"description": "默认图片转述模型",
"hint": "留空代表不使用,可用于非多模态模型"
},
"image_caption_prompt": {
"description": "图片转述提示词"
}
},
"provider_stt_settings": {
"enable": {
"description": "启用语音转文本",
"hint": "STT 总开关"
},
"provider_id": {
"description": "默认语音转文本模型",
"hint": "用户也可使用 /provider 指令单独选择会话的 STT 模型。"
}
},
"provider_tts_settings": {
"enable": {
"description": "启用文本转语音",
"hint": "TTS 总开关"
},
"provider_id": {
"description": "默认文本转语音模型"
}
}
},
"persona": {
"description": "人格",
"provider_settings": {
"default_personality": {
"description": "默认采用的人格"
}
}
},
"knowledgebase": {
"description": "知识库",
"kb_names": {
"description": "知识库列表",
"hint": "支持多选"
},
"kb_fusion_top_k": {
"description": "融合检索结果数",
"hint": "多个知识库检索结果融合后的返回结果数量"
},
"kb_final_top_k": {
"description": "最终返回结果数",
"hint": "从知识库中检索到的结果数量,越大可能获得越多相关信息,但也可能引入噪音。建议根据实际需求调整"
},
"kb_agentic_mode": {
"description": "Agentic 知识库检索",
"hint": "启用后,知识库检索将作为 LLM Tool,由模型自主决定何时调用知识库进行查询。需要模型支持函数调用能力。"
}
},
"websearch": {
"description": "网页搜索",
"provider_settings": {
"web_search": {
"description": "启用网页搜索"
},
"websearch_provider": {
"description": "网页搜索提供商"
},
"websearch_tavily_key": {
"description": "Tavily API Key",
"hint": "可添加多个 Key 进行轮询。"
},
"websearch_baidu_app_builder_key": {
"description": "百度千帆智能云 APP Builder API Key",
"hint": "参考:https://console.bce.baidu.com/iam/#/iam/apikey/list"
},
"web_search_link": {
"description": "显示来源引用"
}
}
},
"others": {
"description": "其他配置",
"provider_settings": {
"display_reasoning_text": {
"description": "显示思考内容"
},
"identifier": {
"description": "用户识别",
"hint": "启用后,会在提示词前包含用户 ID 信息。"
},
"group_name_display": {
"description": "显示群名称",
"hint": "启用后,在支持的平台(OneBot v11)上会在提示词前包含群名称信息。"
},
"datetime_system_prompt": {
"description": "现实世界时间感知",
"hint": "启用后,会在系统提示词中附带当前时间信息。"
},
"show_tool_use_status": {
"description": "输出函数调用状态"
},
"max_agent_step": {
"description": "工具调用轮数上限"
},
"tool_call_timeout": {
"description": "工具调用超时时间(秒)"
},
"streaming_response": {
"description": "流式输出"
},
"unsupported_streaming_strategy": {
"description": "不支持流式回复的平台",
"hint": "选择在不支持流式回复的平台上的处理方式。实时分段回复会在系统接收流式响应检测到诸如标点符号等分段点时,立即发送当前已接收的内容",
"labels": ["实时分段回复", "关闭流式回复"]
},
"max_context_length": {
"description": "最多携带对话轮数",
"hint": "超出这个数量时丢弃最旧的部分,一轮聊天记为 1 条,-1 为不限制"
},
"dequeue_context_length": {
"description": "丢弃对话轮数",
"hint": "超出最多携带对话轮数时, 一次丢弃的聊天轮数"
},
"wake_prefix": {
"description": "LLM 聊天额外唤醒前缀",
"hint": "如果唤醒前缀为 /, 额外聊天唤醒前缀为 chat,则需要 /chat 才会触发 LLM 请求"
},
"prompt_prefix": {
"description": "用户提示词",
"hint": "可使用 {{prompt}} 作为用户输入的占位符。如果不输入占位符则代表添加在用户输入的前面。"
}
},
"provider_tts_settings": {
"dual_output": {
"description": "开启 TTS 时同时输出语音和文字内容"
}
}
}
},
"platform_group": {
"name": "平台配置",
"general": {
"description": "基本",
"admins_id": {
"description": "管理员 ID"
},
"platform_settings": {
"unique_session": {
"description": "隔离会话",
"hint": "启用后,群成员的上下文独立。"
},
"friend_message_needs_wake_prefix": {
"description": "私聊消息需要唤醒词"
},
"reply_prefix": {
"description": "回复时的文本前缀"
},
"reply_with_mention": {
"description": "回复时 @ 发送人"
},
"reply_with_quote": {
"description": "回复时引用发送人消息"
},
"forward_threshold": {
"description": "转发消息的字数阈值"
},
"empty_mention_waiting": {
"description": "只 @ 机器人是否触发等待"
}
},
"wake_prefix": {
"description": "唤醒词"
}
},
"whitelist": {
"description": "白名单",
"platform_settings": {
"enable_id_white_list": {
"description": "启用白名单",
"hint": "启用后,只有在白名单内的会话会被响应。"
},
"id_whitelist": {
"description": "白名单 ID 列表",
"hint": "使用 /sid 获取 ID。"
},
"id_whitelist_log": {
"description": "输出日志",
"hint": "启用后,当一条消息没通过白名单时,会输出 INFO 级别的日志。"
},
"wl_ignore_admin_on_group": {
"description": "管理员群组消息无视 ID 白名单"
},
"wl_ignore_admin_on_friend": {
"description": "管理员私聊消息无视 ID 白名单"
}
}
},
"rate_limit": {
"description": "速率限制",
"platform_settings": {
"rate_limit": {
"time": {
"description": "消息速率限制时间(秒)"
},
"count": {
"description": "消息速率限制计数"
},
"strategy": {
"description": "速率限制策略"
}
}
}
},
"content_safety": {
"description": "内容安全",
"content_safety": {
"also_use_in_response": {
"description": "同时检查模型的响应内容"
},
"baidu_aip": {
"enable": {
"description": "使用百度内容安全审核",
"hint": "您需要手动安装 baidu-aip 库。"
},
"app_id": {
"description": "App ID"
},
"api_key": {
"description": "API Key"
},
"secret_key": {
"description": "Secret Key"
}
},
"internal_keywords": {
"enable": {
"description": "关键词检查"
},
"extra_keywords": {
"description": "额外关键词",
"hint": "额外的屏蔽关键词列表,支持正则表达式。"
}
}
}
},
"t2i": {
"description": "文本转图像",
"t2i": {
"description": "文本转图像输出"
},
"t2i_word_threshold": {
"description": "文本转图像字数阈值"
}
},
"others": {
"description": "其他配置",
"platform_settings": {
"ignore_bot_self_message": {
"description": "是否忽略机器人自身的消息"
},
"ignore_at_all": {
"description": "是否忽略 @ 全体成员事件"
},
"no_permission_reply": {
"description": "用户权限不足时是否回复"
}
},
"platform_specific": {
"lark": {
"pre_ack_emoji": {
"enable": {
"description": "[飞书] 启用预回应表情"
},
"emojis": {
"description": "表情列表(飞书表情枚举名)",
"hint": "表情枚举名参考:https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce"
}
}
},
"telegram": {
"pre_ack_emoji": {
"enable": {
"description": "[Telegram] 启用预回应表情"
},
"emojis": {
"description": "表情列表(Unicode)",
"hint": "Telegram 仅支持固定反应集合,参考:https://gist.github.com/Soulter/3f22c8e5f9c7e152e967e8bc28c97fc9"
}
}
}
}
}
},
"plugin_group": {
"name": "插件配置",
"plugin": {
"description": "插件",
"plugin_set": {
"description": "可用插件",
"hint": "默认启用全部未被禁用的插件。若插件在插件页面被禁用,则此处的选择不会生效。"
}
}
},
"ext_group": {
"name": "扩展功能",
"segmented_reply": {
"description": "分段回复",
"platform_settings": {
"segmented_reply": {
"enable": {
"description": "启用分段回复"
},
"only_llm_result": {
"description": "仅对 LLM 结果分段"
},
"interval_method": {
"description": "间隔方法"
},
"interval": {
"description": "随机间隔时间",
"hint": "格式:最小值,最大值(如:1.5,3.5)"
},
"log_base": {
"description": "对数底数",
"hint": "对数间隔的底数,默认为 2.0。取值范围为 1.0-10.0。"
},
"words_count_threshold": {
"description": "分段回复字数阈值"
},
"regex": {
"description": "分段正则表达式"
},
"content_cleanup_rule": {
"description": "内容过滤正则表达式",
"hint": "移除分段后内容中的指定内容。如填写 `[。?!]` 将移除所有的句号、问号、感叹号。"
}
}
}
},
"ltm": {
"description": "群聊上下文感知(原聊天记忆增强)",
"provider_ltm_settings": {
"group_icl_enable": {
"description": "启用群聊上下文感知"
},
"group_message_max_cnt": {
"description": "最大消息数量"
},
"image_caption": {
"description": "自动理解图片",
"hint": "需要设置默认图片转述模型。"
},
"active_reply": {
"enable": {
"description": "主动回复"
},
"method": {
"description": "主动回复方法"
},
"possibility_reply": {
"description": "回复概率",
"hint": "0.0-1.0 之间的数值"
},
"whitelist": {
"description": "主动回复白名单",
"hint": "为空时不启用白名单过滤。使用 /sid 获取 ID。"
}
}
}
}
},
"system_group": {
"name": "系统配置",
"system": {
"description": "系统配置",
"t2i_strategy": {
"description": "文本转图像策略",
"hint": "文本转图像策略。`remote` 为使用远程基于 HTML 的渲染服务,`local` 为使用 PIL 本地渲染。当使用 local 时,将 ttf 字体命名为 'font.ttf' 放在 data/ 目录下可自定义字体。"
},
"t2i_endpoint": {
"description": "文本转图像服务 API 地址",
"hint": "为空时使用 AstrBot API 服务"
},
"t2i_template": {
"description": "文本转图像自定义模版",
"hint": "启用后可自定义 HTML 模板用于文转图渲染。"
},
"t2i_active_template": {
"description": "当前应用的文转图渲染模板",
"hint": "此处的值由文转图模板管理页面进行维护。"
},
"log_level": {
"description": "控制台日志级别",
"hint": "控制台输出日志的级别。"
},
"pip_install_arg": {
"description": "pip 安装额外参数",
"hint": "安装插件依赖时,会使用 Python 的 pip 工具。这里可以填写额外的参数,如 `--break-system-package` 等。"
},
"pypi_index_url": {
"description": "PyPI 软件仓库地址",
"hint": "安装 Python 依赖时请求的 PyPI 软件仓库地址。默认为 https://mirrors.aliyun.com/pypi/simple/"
},
"callback_api_base": {
"description": "对外可达的回调接口地址",
"hint": "外部服务可能会通过 AstrBot 生成的回调链接(如文件下载链接)访问 AstrBot 后端。由于 AstrBot 无法自动判断部署环境中对外可达的主机地址(host),因此需要通过此配置项显式指定外部服务如何访问 AstrBot 的地址。如 http://localhost:6185,https://example.com 等。"
},
"timezone": {
"description": "时区",
"hint": "时区设置。请填写 IANA 时区名称, 如 Asia/Shanghai, 为空时使用系统默认时区。所有时区请查看: https://data.iana.org/time-zones/tzdb-2021a/zone1970.tab"
},
"http_proxy": {
"description": "HTTP 代理",
"hint": "启用后,会以添加环境变量的方式设置代理。格式为 `http://ip:port`"
},
"no_proxy": {
"description": "直连地址列表"
}
}
}
}

View File

@@ -62,5 +62,32 @@
"allowedHosts": "允许的主机",
"rateLimit": "频率限制",
"encryption": "加密设置"
},
"configSelection": {
"selectConfig": "选择配置文件",
"normalConfig": "普通",
"systemConfig": "系统"
},
"configManagement": {
"title": "配置文件管理",
"description": "AstrBot 支持针对不同机器人分别设置配置文件。默认会使用 `default` 配置。",
"newConfig": "新建配置文件",
"editConfig": "编辑配置文件",
"manageConfigs": "管理配置文件...",
"configName": "名称",
"fillConfigName": "填写配置文件名称",
"confirmDelete": "确定要删除配置文件 \"{name}\" 吗?此操作不可恢复。",
"pleaseEnterName": "请填写配置名称",
"createFailed": "新配置文件创建失败",
"deleteFailed": "删除配置文件失败",
"updateFailed": "更新配置文件失败"
},
"buttons": {
"cancel": "取消",
"create": "创建",
"update": "更新"
},
"codeEditor": {
"title": "编辑配置文件"
}
}

View File

@@ -50,6 +50,10 @@
"maxSize": "最大文件大小: 128MB",
"chunkSettings": "分块设置",
"batchSettings": "批处理设置",
"cleaningSettings": "清洗设置",
"enableCleaning": "启用内容清洗",
"cleaningProvider": "清洗服务提供商",
"cleaningProviderHint": "选择一个 LLM 服务商来对提取的网页内容进行清洗和总结",
"chunkSize": "分块大小",
"chunkSizeHint": "每个文本块的字符数 (默认: 512)",
"chunkOverlap": "分块重叠",
@@ -62,7 +66,13 @@
"maxRetriesHint": "上传失败任务的重试次数 (默认: 3)",
"cancel": "取消",
"submit": "上传",
"fileRequired": "请选择要上传的文件"
"fileRequired": "请选择要上传的文件",
"fileUpload": "文件上传",
"fromUrl": "从 URL",
"urlPlaceholder": "请输入要提取内容的网页 URL",
"urlRequired": "请输入 URL",
"urlHint": "将自动从目标 URL 提取主要内容作为文档。目前支持 {supported} 页面,请确保目标网页允许爬虫访问。",
"beta": "测试版"
},
"retrieval": {
"title": "知识库检索",

View File

@@ -8,7 +8,8 @@
"providerType": "提供商类型",
"tabs": {
"all": "全部",
"chatCompletion": "基本对话",
"chatCompletion": "对话",
"agentRunner": "Agent 执行器",
"speechToText": "语音转文字",
"textToSpeech": "文字转语音",
"embedding": "嵌入(Embedding)",
@@ -44,13 +45,14 @@
"addProvider": {
"title": "模型提供商",
"tabs": {
"basic": "基本",
"basic": "对话",
"agentRunner": "Agent 执行器",
"speechToText": "语音转文字",
"textToSpeech": "文字转语音",
"embedding": "嵌入(Embedding)",
"rerank": "重排序(Rerank)"
},
"noTemplates": "暂无{type}类型的提供商模板"
"noTemplates": "暂无类型的提供商模板"
},
"config": {
"addTitle": "新增",

View File

@@ -1,124 +1,99 @@
{
"title": "会话管理",
"subtitle": "管理活跃会话和配置",
"title": "自定义规则",
"subtitle": "为特定会话设置自定义规则,优先级高于全局配置",
"buttons": {
"refresh": "刷新",
"edit": "编辑",
"apply": "应用批量设置",
"editName": "备注",
"editRule": "编辑规则",
"deleteAllRules": "删除所有规则",
"addRule": "添加规则",
"save": "保存",
"cancel": "取消",
"delete": "删除"
"delete": "删除",
"clear": "清除",
"next": "下一步",
"editCustomName": "编辑备注",
"batchDelete": "批量删除"
},
"sessions": {
"activeSessions": "活跃会话",
"sessionCount": "个会话",
"noActiveSessions": "暂无活跃会话",
"noActiveSessionsDesc": "当有用户与机器人交互时,会话将会显示在这里"
"customRules": {
"title": "自定义规则",
"rulesCount": "条规则",
"hasRules": "已配置",
"noRules": "暂无自定义规则",
"noRulesDesc": "点击「添加规则」为特定会话配置自定义规则",
"serviceConfig": "服务配置",
"pluginConfig": "插件配置",
"kbConfig": "知识库配置",
"providerConfig": "模型配置",
"configured": "已配置",
"noCustomName": "未设置备注"
},
"quickEditName": {
"title": "编辑备注名"
},
"search": {
"placeholder": "搜索会话...",
"platformFilter": "平台筛选"
"placeholder": "搜索会话..."
},
"table": {
"headers": {
"sessionStatus": "会话状态",
"sessionInfo": "消息会话来源",
"persona": "人格",
"chatProvider": "聊天模型",
"sttProvider": "语音识别模型",
"ttsProvider": "语音合成模型",
"llmStatus": "启用 LLM",
"ttsStatus": "启用 TTS",
"knowledgeBase": "知识库配置",
"pluginManagement": "插件管理",
"umoInfo": "消息会话来源",
"rulesOverview": "规则概览",
"actions": "操作"
}
},
"status": {
"enabled": "已启用",
"disabled": "已禁用"
},
"persona": {
"none": "无人格"
"none": "跟随配置文件"
},
"batchOperations": {
"title": "批量操作",
"setPersona": "批量设置人格",
"setChatProvider": "批量设置 Chat Provider",
"setSttProvider": "批量设置 STT Provider",
"setTtsProvider": "批量设置 TTS Provider",
"setLlmStatus": "批量设置 LLM 状态",
"setTtsStatus": "批量设置 TTS 状态",
"noSttProvider": "暂无可用 STT Provider",
"noTtsProvider": "暂无可用 TTS Provider"
"provider": {
"followConfig": "跟随配置文件"
},
"pluginManagement": {
"title": "插件管理",
"noPlugins": "暂无可用插件",
"noPluginsDesc": "目前没有激活的插件",
"loading": "加载插件列表中...",
"author": "作者"
"addRule": {
"title": "添加自定义规则",
"description": "选择一个消息会话来源 (UMO) 来配置自定义规则。自定义规则的优先级高于该来源所属的配置文件中的全局规则。可以使用 /sid 指令获取该来源的 UMO 信息。",
"selectUmo": "选择会话",
"noUmos": "暂无可用会话"
},
"nameEditor": {
"title": "编辑会话名称",
"customName": "自定义名称",
"placeholder": "输入自定义会话名称(留空则使用原始名称)",
"originalName": "原始名称",
"fullSessionId": "完整会话ID",
"hint": "自定义名称帮助您轻松识别会话。当设置了自定义名称时会显示一个小感叹号标识鼠标悬停时会显示实际的UMO。"
},
"knowledgeBase": {
"title": "知识库配置",
"configure": "配置",
"selectKB": "选择知识库",
"selectMultiple": "可以选择多个知识库",
"noKBAvailable": "暂无可用的知识库",
"noKBDesc": "目前没有创建任何知识库",
"createKB": "创建知识库",
"advancedSettings": "高级配置",
"topK": "返回结果数量",
"topKHint": "从知识库检索的结果数量",
"enableRerank": "启用重排序",
"enableRerankHint": "使用重排序模型提高检索质量",
"clearConfig": "清除配置",
"save": "保存",
"cancel": "取消",
"loading": "加载知识库配置中...",
"description": "为此会话配置使用的知识库。会话将使用配置的知识库来增强对话上下文。",
"saveSuccess": "知识库配置保存成功",
"saveFailed": "保存知识库配置失败",
"loadFailed": "加载知识库配置失败",
"clearSuccess": "知识库配置已清除",
"clearFailed": "清除知识库配置失败",
"clearConfirm": "确定要清除此会话的知识库配置吗?"
},
"list": {
"documents": "篇文档"
"ruleEditor": {
"title": "编辑自定义规则",
"description": "为此会话配置自定义规则,这些规则将优先于全局配置生效。",
"serviceConfig": {
"title": "服务配置",
"sessionEnabled": "启用该消息会话来源的消息处理",
"llmEnabled": "启用 LLM",
"ttsEnabled": "启用 TTS",
"customName": "消息会话来源备注名称"
},
"providerConfig": {
"title": "模型配置",
"chatProvider": "聊天模型",
"sttProvider": "语音识别模型",
"ttsProvider": "语音合成模型"
},
"personaConfig": {
"title": "人格配置",
"selectPersona": "选择人格",
"hint": "应用人格配置后,将会强制该来源的所有对话使用该人格。"
}
},
"deleteConfirm": {
"message": "确定要删除会话 {sessionName} 吗?",
"warning": "此操作将永久删除本次会话的「全部对话记录」与「偏好设置」(插件对会话的关联数据除外),且无法恢复。确认继续?"
"title": "确认删除",
"message": "确定要删除此会话的所有自定义规则吗?删除后将恢复使用全局配置。"
},
"batchDeleteConfirm": {
"title": "确认批量删除",
"message": "确定要删除选中的 {count} 条规则吗?删除后将恢复使用全局配置。"
},
"messages": {
"refreshSuccess": "会话列表已刷新",
"personaUpdateSuccess": "人格更新成功",
"personaUpdateError": "人格更新失败",
"providerUpdateSuccess": "Provider 更新成功",
"providerUpdateError": "Provider 更新失败",
"sessionStatusSuccess": "会话 {status}",
"llmStatusSuccess": "LLM {status}",
"ttsStatusSuccess": "TTS {status}",
"statusUpdateError": "状态更新失败",
"loadSessionsError": "加载会话列表失败",
"batchUpdateSuccess": "成功批量更新 {count} 项设置",
"batchUpdatePartial": "批量更新完成,{success} 项成功,{error} 项失败",
"loadPluginsError": "加载插件列表失败",
"pluginStatusSuccess": "插件 {name} {status}",
"pluginStatusError": "插件状态更新失败",
"nameUpdateSuccess": "会话名称更新成功",
"nameUpdateError": "会话名称更新失败",
"deleteSuccess": "会话删除成功",
"deleteError": "会话删除失败"
"refreshSuccess": "数据已刷新",
"loadError": "加载数据失败",
"saveSuccess": "保存成功",
"saveError": "保存失败",
"clearSuccess": "已清除",
"clearError": "清除失败",
"deleteSuccess": "删除成功",
"deleteError": "删除失败",
"noChanges": "没有需要保存的更改",
"batchDeleteSuccess": "批量删除成功",
"batchDeleteError": "批量删除失败"
}
}

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