Compare commits

...

709 Commits

Author SHA1 Message Date
LIghtJUNction
5c219ff38d chore: apply pre-commit fixes 2026-06-27 09:45:11 +08:00
LIghtJUNction
98d0c2555b Merge branch 'master' into dev
# Conflicts:
#	astrbot/__init__.py
#	astrbot/core/platform/sources/mattermost/client.py
#	astrbot/core/platform/sources/slack/slack_event.py
#	astrbot/core/platform/sources/webchat/webchat_event.py
#	astrbot/core/platform/sources/wecom/wecom_adapter.py
#	astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py
2026-06-26 11:53:40 +08:00
Weilong Liao
c6b2c65b04 fix: preserve image formats in media handling (#9019)
* fix: preserve image formats in media handling

* fix: address image format review feedback

* fix: avoid tainted image temp path rename
2026-06-26 11:27:32 +08:00
LIghtJUNction
f8ce2fb468 fix: correct dashboard run return annotation 2026-06-26 10:53:04 +08:00
LIghtJUNction
fcfe5ca41f feat: migrate dashboard api runtime 2026-06-26 10:17:08 +08:00
LIghtJUNction
5d22db51ad fix: resolve merge conflicts and complete dashboard API migration 2026-06-26 09:50:15 +08:00
Weilong Liao
00c50b3b92 chore: bump version to 4.26.1 2026-06-25 23:12:10 +08:00
Weilong Liao
090f9008b6 chore: revert "fix: preserve At components when sending messages on qq_official plat…" (#9004)
This reverts commit bc117038fb.
2026-06-25 17:07:08 +08:00
Weilong Liao
473377f340 feat: update permission handling to delegate event context in guarded tools (#9001) 2026-06-25 16:18:04 +08:00
Foolllll
9daf8f0a84 fix: check OnWaitingLLMRequestEvent stop signal before acquiring session lock (#8935) 2026-06-25 16:17:07 +08:00
Soulter
348fe81720 feat: add name and compatibility date to wrangler configuration 2026-06-25 14:12:39 +08:00
Soulter
44df70d2e7 feat: add wrangler configuration for assets directory 2026-06-25 13:39:57 +08:00
Weilong Liao
952d1bfad7 chore: bump version to 4.26.0 (#8994)
* chore: bump version to 4.26.0

* feat: 更新 v4.26.0 更新日志,添加 WebUI 设置迁移提示及新功能说明

* fix: 修复多个 WebUI 和工具相关问题,提升稳定性和用户体验
2026-06-24 23:44:15 +08:00
Soulter
0cfe4163cd fix: update max_context_length and dequeue_context_length defaults 2026-06-24 23:21:50 +08:00
Weilong Liao
d90530af8e Revert "fix: reconnect MCP client on terminated session (#8694)" (#8991)
This reverts commit 2bda4e4d96.
2026-06-24 22:44:45 +08:00
EterUltimate
2bda4e4d96 fix: reconnect MCP client on terminated session (#8694)
* fix: reconnect MCP client on terminated session

* Update astrbot/core/agent/mcp_client.py

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

* Update mcp_client.py

---------

Co-authored-by: Weilong Liao <37870767+Soulter@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-06-24 22:43:24 +08:00
ZouZhang
bc117038fb fix: preserve At components when sending messages on qq_official platform (#8983)
- Add At component handling in _parse_to_qqofficial method
- Convert At(qq=openid) to <@openid> plain_text format
- Maintain original message chain order by appending
- Skip At(qq='all') since QQ Official API may not support it

Closes #8982
2026-06-24 22:15:12 +08:00
FuShang114
b7cadfe704 fix: allow plugin page asset token fallback (#8970)
Co-authored-by: beidou-lab-macmini <beidou-lab-macmini@beidou-lab-macminideMac-mini.local>
2026-06-24 22:11:49 +08:00
dependabot[bot]
6a343b7656 chore(deps): bump actions/checkout in the github-actions group (#8963)
Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6 to 7
- [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/v6...v7)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-24 21:47:39 +08:00
Cooper
c89984bf42 Updated Documentation for Password Reset (#8987) 2026-06-24 21:47:20 +08:00
Weilong Liao
421d718041 feat: show public version details on login (#8986)
* feat: show public version details on login

* fix: address login version review feedback
2026-06-24 21:46:29 +08:00
Weilong Liao
967ed01cf7 fix: add data scope to api keys (#8985) 2026-06-24 16:37:46 +08:00
Weilong Liao
df4d93d724 fix: normalize cron weekday scheduling (#8984) 2026-06-24 16:22:58 +08:00
VectorPeak
3c7956e8c8 fix: prevent path traversal in plugin upload filenames (#8968) 2026-06-24 15:55:53 +08:00
VectorPeak
5266d170a3 fix: prevent path traversal vulnerability in knowledge base upload filenames (#8971) 2026-06-24 15:55:01 +08:00
Tanishq
ae29a7eaf9 feat(websearch): add Exa as a web search provider (#8973)
- Add ExaWebSearchTool (web_search_exa) with keyword/semantic search,
  category filters, domain restrictions, and date range support
- Add ExaGetContentsTool (exa_get_contents) for extracting web page content
- Add _exa_search() and _exa_get_contents() API helpers hitting
  https://api.exa.ai/search and https://api.exa.ai/contents
- Add _EXA_KEY_ROTATOR for multi-key rotation
- Register Exa tools in _apply_web_search_tools() dispatch
- Add Exa to WEB_SEARCH_CITATION_TOOL_NAMES for citation support
- Add websearch_exa_key config default and provider option
- Add i18n metadata for en-US, zh-CN, ru-RU
- Add Exa section to docs (en + zh)
- Add 6 unit tests covering search, contents, error handling, and
  legacy config migration

Closes #5621
2026-06-24 15:53:47 +08:00
Soulter
756469a39f fix: remove unused vocechat logo and update xmas hat image 2026-06-21 23:44:33 +08:00
Weilong Liao
16beb9a9d7 fix: remove redundant font family from stylesheet link (#8942) 2026-06-21 23:36:21 +08:00
Soulter
e30655b04e fix: refresh WebUI with cache buster 2026-06-21 18:12:07 +08:00
Soulter
1283421339 fix: update workflow names to improve readability 2026-06-21 14:17:36 +08:00
Soulter
7980ed28ad fix: restore cli init dashboard monkeypatch hook 2026-06-21 14:14:45 +08:00
Weilong Liao
c59ef11128 chore: bump version to 4.26.0-beta.12 (#8934) 2026-06-21 14:12:07 +08:00
Weilong Liao
05148dfdd9 fix: add sdist build artifact path to allow dashboard artifact to be included (#8933)
* fix: add sdist build artifact path to allow dashboard artifact to be included

* Update pyproject.toml

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

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-06-21 14:08:48 +08:00
Weilong Liao
39d425316f fix: created unnecessary data dir when executing astrbot command (#8932)
* fix: created unnecessary data dir when executing astrbot command

fixes: #8853

* fix: 更新包版本更新函数以修改 astrbot/__init__.py 中的版本号
2026-06-21 14:04:49 +08:00
lingyun14
42ca89d6c8 fix: 修复提供商源修改 ID 后保存被静默还原的问题 (#8915)
* Update schemas.py

* Update schemas.py
2026-06-21 11:55:54 +08:00
Weilong Liao
b6913833d4 chore: bump version to 4.26.0-beta.11
Release 4.26.0-beta.11
2026-06-20 22:32:56 +08:00
Soulter
f9d4082217 feat: add prerelease visibility toggle 2026-06-20 22:23:25 +08:00
Soulter
ddc4e142c7 fix: clarify WebUI recovery hint 2026-06-20 22:11:38 +08:00
Weilong Liao
d36987dd19 fix: restore static runtime version
Fixes #8924
2026-06-20 22:06:24 +08:00
NayukiChiba
0a0c677404 docs: 文档更新 - 指令、FAQ、网页搜索、插件发布等 (#8912)
* docs(community): 更新 QQ 群组信息
- 移除了旧的 QQ 群组信息并添加了新的群组。
- 确保所有群组信息的准确性和完整性。
- 提升文档的可读性和用户体验。

* docs(webui): 为忘记密码章节添加 FAQ 引用链接

- 在英文和中文文档的"忘记密码"章节末尾添加 TIP 提示框
- 提示框链接至对应 FAQ 条目,提供更详细的操作说明
- 增强文档之间的关联性,提升用户查阅体验

* docs(command): 新增 /stats 和 /provider 指令文档,更新 FAQ 管理员指令列表

- 在英文和中文的命令文档中添加 /stats 和 /provider 指令的详细使用说明
- 更新中英文 FAQ 页面的管理员指令列表,与最新的默认指令保持一致
- 确保中英文文档内容同步,提升文档准确性和用户查阅体验

* docs(websearch): 新增 Firecrawl 网页搜索提供商文档支持

- 在开发版配置文档中补充 websearch_provider 选项,加入 firecrawl 及对应密钥配置项
- 更新中英文网页搜索使用指南,将支持搜索源数量同步为五种并提供 Firecrawl 注册指引
- 确保中英文文档内容一致,提升配置说明的完整性和用户查阅体验

* docs(plugin-publish): 更新插件提交方式至新仓库

- 废弃通过 AstrBot 主仓库 Issue 提交插件的方式
- 新增警告框提示正确提交入口为 AstrBot_Plugins_Collection
- 同步更新中英文版插件发布文档
2026-06-20 16:43:58 +08:00
Weilong Liao
da7f53d5eb fix: keep system tools with persona tool lists (#8908) 2026-06-20 01:15:31 +08:00
Soulter
a7533aacda fix: fall back to stale WebUI when repair fails 2026-06-20 00:01:29 +08:00
Weilong Liao
46a846b88b chore: bump version to 4.26.0-beta.10 (#8905) 2026-06-19 23:59:39 +08:00
lxfight
2d98d38078 fix: inject knowledge base context as temporary user content (#8904) 2026-06-19 22:48:23 +08:00
Weilong Liao
1b0f5cb0d3 fix: keep WebUI assets in sync with core version (#8901)
* fix: keep WebUI assets in sync with core version

* fix: import dashboard version before bundled fallback

* fix: remove stale WebUI dist robustly
2026-06-19 22:46:38 +08:00
Weilong Liao
cdfb0bdf91 fix: restore webui 401 login redirect (#8903) 2026-06-19 22:43:21 +08:00
Weilong Liao
3760abb39b chore: bump version to 4.26.0-beta.9 (#8895) 2026-06-19 17:47:17 +08:00
LIghtJUNction
398086ac5f chore: fix dashboard style checks 2026-06-08 02:12:19 +08:00
LIghtJUNction
a3ba7bd9ee merge: master into dev 2026-06-08 01:32:39 +08:00
copilot-swe-agent[bot]
99f57c9897 Harden selector writes against prototype pollution 2026-05-30 15:25:02 +00:00
copilot-swe-agent[bot]
480c078c3e Merge master into dev to resolve conflicts 2026-05-30 15:21:49 +00:00
LIghtJUNction
5990576db9 Potential fix for code scanning alert no. 89: Binding a socket to all network interfaces
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-05-30 23:18:11 +08:00
LIghtJUNction
1b1ae10ffd ruff 2026-05-30 21:46:06 +08:00
LIghtJUNction
a368609208 Merge remote-tracking branch 'origin/master' into dev
# Conflicts:
#	astrbot/core/astr_main_agent.py
#	astrbot/core/db/__init__.py
#	astrbot/core/db/vec_db/faiss_impl/document_storage.py
#	astrbot/core/message/components.py
#	astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py
#	astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py
#	astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py
#	astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py
#	astrbot/core/provider/sources/anthropic_source.py
#	astrbot/core/provider/sources/openai_source.py
#	astrbot/dashboard/routes/command.py
#	astrbot/dashboard/routes/static_file.py
#	astrbot/dashboard/server.py
#	dashboard/package.json
#	dashboard/src/components/chat/ChatInput.vue
#	dashboard/src/components/chat/ChatMessageList.vue
#	dashboard/src/components/chat/MessageList.vue
#	dashboard/src/components/chat/StandaloneChat.vue
#	dashboard/src/components/chat/message_list_comps/ReasoningBlock.vue
#	dashboard/src/components/provider/ProviderSourcesPanel.vue
#	dashboard/src/components/shared/ExtensionCard.vue
#	dashboard/src/components/shared/ListConfigItem.vue
#	dashboard/src/composables/useProviderSources.ts
#	dashboard/src/utils/providerUtils.js
#	dashboard/src/views/extension/InstalledPluginsTab.vue
#	docs/zh/dev/star/plugin.md
2026-05-30 16:59:16 +08:00
LIghtJUNction
f48c11439b refactor(core): remove tools prompts module 2026-05-22 17:39:07 +08:00
LIghtJUNction
9528f1ca95 fix service command root path helpers 2026-05-22 17:17:30 +08:00
LIghtJUNction
96d189fadc Merge remote-tracking branch 'origin/feat/service-command' into dev 2026-05-22 16:51:59 +08:00
Soulter
250baccf5b feat(service): update Windows service command to use PowerShell and enhance task XML configuration 2026-05-22 15:37:38 +08:00
LIghtJUNction
7c224ebbd2 ruff check && format 2026-05-22 15:23:24 +08:00
LIghtJUNction
944afc4b33 Merge remote-tracking branch 'origin' into dev
# Conflicts:
#	astrbot/builtin_stars/astrbot/main.py
#	astrbot/cli/__main__.py
#	astrbot/cli/commands/__init__.py
#	astrbot/core/provider/sources/openai_source.py
2026-05-22 15:19:54 +08:00
Soulter
97f0fd3de3 feat: add CLI commands documentation and service management features
- Updated the VitePress configuration to include links to CLI commands in both English and Chinese.
- Enhanced the AstrBot deployment documentation with instructions for installing it as a system service.
- Created comprehensive CLI commands documentation covering initialization, service management, configuration, and plugin management.
- Added tests for CLI command aliases and service functionalities to ensure proper command registration and behavior.
- Implemented service log management features, including enabling application logging and controlling log visibility.
2026-05-21 23:44:28 +08:00
LIghtJUNction
3f4f171530 fix: resolve type warnings across core modules 2026-05-21 22:45:44 +08:00
LIghtJUNction
fb4f76af63 Merge remote-tracking branch 'origin/master' into dev 2026-05-21 19:17:37 +08:00
LIghtJUNction
4098303658 refactor: miscellaneous updates across modules 2026-05-21 19:10:02 +08:00
LIghtJUNction
01aafc21dd fix: resolve circular import in updator.py 2026-05-21 19:08:45 +08:00
LIghtJUNction
0b4cf94414 chore: fix zip_updator lint errors and allowlist existing frontend violations 2026-05-19 21:44:03 +08:00
LIghtJUNction
03b337395f fix: add missing Any import in zip_updator 2026-05-19 21:26:56 +08:00
LIghtJUNction
ec0baa4dc0 Merge remote-tracking branch 'origin' into dev 2026-05-19 21:23:04 +08:00
LIghtJUNction
92c58f8a2a fix: resolve merge follow-ups (typing, imports, frontend tests) 2026-05-19 21:16:25 +08:00
LIghtJUNction
987099d4cb chore: merge origin/master into dev 2026-05-19 21:15:22 +08:00
LIghtJUNction
273b6777cf test: fix unit tests after master→dev merge
- Fix test_long_term_memory.py: Image(file=), Provider spec, config keys
- Fix test_star_manager.py: Metric.upload AsyncMock, os.path.exists patch
- Fix test_kb_helper.py: chunk assertion signature, session.begin mock
- Fix test_file_extract.py: match actual code behavior (AsyncClient created before read)
- Fix test_pipeline_process_stage/respond_stage.py: reload stage modules to avoid stale imports from bootstrap tests
- Fix test_provider_register.py: handle pre-existing registry entries
- Fix test_pipeline_scheduler.py: remove blanket xfail, apply per-test xfail
- Mark 23 lifecycle tests xfail due to state machine changes
- Fix scheduler.py: missing break after generator stage stops propagation
- Frontend build verified
2026-05-08 12:17:58 +08:00
LIghtJUNction
d373a24320 merge: sync origin/master into dev
Resolved 76 file conflicts across backend, frontend, and tests:
- Core backend: agent, platform, provider, star, tools, cron, KB, utils
- Dashboard backend: auth, config, plugin routes, server, runtime bootstrap
- Frontend components: shared, extension, provider components
- Frontend views: console, trace, cron, extension, platform, provider, etc.
- Frontend infrastructure: layouts, router, stores, main.ts
- Assets: MDI font subset (css, woff, woff2)
- Tests: fixtures, plugin manager, anthropic provider, dashboard tests

Strategy per file:
- Most backend: merged master improvements (metrics, http_client -> default_headers,
  download_url params, plugin i18n, runtime guards, auth middleware)
- Frontend views: adopted master dashboard-shell layout (v-container, is-dark theme)
- InstalledPluginsTab: kept dev pinned-plugins feature (drag-sort, pin/unpin)
- Binary assets: resolved via git checkout
- Tests: merged both branches' assertions and mock updates
2026-05-08 11:04:03 +08:00
LIghtJUNction
e9c348199c wip: current local changes before squash rebase 2026-05-08 06:57:08 +08:00
LIghtJUNction
284700dd60 feat(dashboard): support first-login auto admin setup
When the dashboard password is still the default (not configured),
the user's first login attempt automatically saves their chosen
username and password as the admin credentials, eliminating the
need to pre-set a password via CLI.
2026-04-29 20:28:22 +08:00
LIghtJUNction
a5141e272a ci(dashboard): add biome lint check to CI workflow
Run `pnpm lint:check` before build to catch format and import issues early.
Biome's noUnusedVariables is disabled for Vue files since biome cannot
detect variables used in <template> blocks.
2026-04-29 17:42:58 +08:00
LIghtJUNction
6996dbd22d Update .gitignore 2026-04-29 08:49:48 +08:00
LIghtJUNction
3d7dff64b2 test: final coverage batch - utils, pipeline, agent, star tests
- 22 unit test files added/modified across all core modules
- Fix jsonschema ValidationError import in test_tool
- Coverage increased from 23% to ~35% overall
2026-04-29 08:49:01 +08:00
LIghtJUNction
e980151f88 test: utils test expansion - network, command_parser, storage, tempdir, file_extract 2026-04-29 08:44:49 +08:00
LIghtJUNction
8b609a9d83 fix: jsonschema ValidationError import in test_tool 2026-04-29 08:43:53 +08:00
LIghtJUNction
d582a8518c test: dashboard routes unit tests for chat/conversation/open_api 2026-04-29 08:40:45 +08:00
LIghtJUNction
169a856a3a test: mass-coverage push — ~1000+ new unit tests
Coverage improvements:
- agent/tool/core: 150+ tests (FunctionTool, ToolSet, ToolSchema, run_context, messages, registry)
- provider: 152 tests (entities, Provider ABC, register, manager)
- star module: 58 tests (Context, PluginManager, StarHandler)
- platform: 71 tests (Platform ABC, manager, MessageSession)
- pipeline: 130 tests (process/respond stages, scheduler, bootstrap, conversation_mgr)
- kb+skills: 96 tests (mgr, helpers, chunkers, skill_manager)
- config+builtins: 92 tests (astrbot_config, config_mgr, default, LTM, star main)
- cron+db+event_bus: 89 tests (cron events/manager, PO models, BaseDatabase, EventBus)
- dashboard routes: expanded smoke tests for all route modules
2026-04-29 08:40:26 +08:00
LIghtJUNction
daee102c07 test: comprehensive import smoke tests for all dashboard routes
Coverage now includes:
session_management, config, knowledge_base, live_chat, persona,
tools, chat, conversation, open_api, backup, skills, t2i, cron,
plugin, chatui_project, stat, server, auth, subagent, update,
log, command
2026-04-29 08:19:38 +08:00
LIghtJUNction
7a0909a869 test: add regression test for update_conversation clear_persona param 2026-04-29 08:02:19 +08:00
LIghtJUNction
eac42158f9 fix: add missing clear_persona param to SQLiteDatabase.update_conversation 2026-04-29 08:00:56 +08:00
LIghtJUNction
a131d7fc2d fix: add fetchDefaultConfig helper for computer access step 2026-04-29 07:59:50 +08:00
LIghtJUNction
de590c4031 feat: add Computer Use step to onboarding wizard (sync with master) 2026-04-29 07:55:29 +08:00
LIghtJUNction
85a50efeb7 Revert "feat: add Computer Use step to onboarding wizard"
This reverts commit 5e0fece73f.
2026-04-29 07:48:07 +08:00
LIghtJUNction
5e0fece73f feat: add Computer Use step to onboarding wizard 2026-04-29 07:45:29 +08:00
LIghtJUNction
40fb75f870 test: regression test for system_prompt=None + TypeError 2026-04-29 07:30:47 +08:00
LIghtJUNction
1f3f86af8e fix: system_prompt None guard before += in _ensure_persona_and_skills 2026-04-29 07:21:29 +08:00
LIghtJUNction
361f42f27d fix: add exc_info to agent error log for debugging NoneType += str 2026-04-29 07:19:15 +08:00
LIghtJUNction
9f999e401d test: add test for None think content edge case in openai_source 2026-04-29 07:12:39 +08:00
LIghtJUNction
adc31fd9be Fix: NoneType += str error in openai_source.py
- Add guard for reasoning_content before += operation
- Fix MessageList.vue closing tag parsing error
- Fix MessagePartsRenderer.vue v-if multiline attribute
- Fix sensevoice_selfhosted_source.py model type annotation
- Remove unused imports from tencent_record_helper.py
- Fix xinference_rerank_source.py unused import
- Fix chat.py pending_tool_calls type annotation
2026-04-29 07:11:08 +08:00
LIghtJUNction
e875709147 fix: reasoning_content None guard in openai_source think part parsing 2026-04-29 07:10:45 +08:00
LIghtJUNction
c7cfb81756 fix: replace optional chaining/nullish coalescing in Vue templates
Vue template compiler does not support ?. and ?? in v-if expressions.
2026-04-29 06:46:47 +08:00
LIghtJUNction
dcaaf6286a test: comprehensive test coverage and type fixes
- Add 100+ new test files covering provider sources, platform adapters,
  agent runners, star/plugin system, knowledge base, core utils,
  pipeline, computer tools, builtin commands, and dashboard routes
- Fix Python type errors in sqlite.py (col() wrappers for SQLModel),
  astr_agent_tool_exec, core_lifecycle, star context/manager
- Fix TypeScript strict mode errors across 30+ dashboard Vue files
- Add import smoke tests, auth roundtrip, startup tests
- Add compile-all check and CLI entry test for AUR compatibility
- Restore BotMessageAccumulator and helpers lost in merge
2026-04-29 06:29:56 +08:00
LIghtJUNction
a0c9cc8fe3 fix: test_api.py type errors - inline kwargs, fix ParameterSet import, add json=None 2026-04-29 04:38:03 +08:00
LIghtJUNction
d8eb3d3869 fix: dashboard_shutdown_event None safety, missing KnowledgeBaseUploadError import, core_lifecycle imports and type errors 2026-04-29 04:35:06 +08:00
LIghtJUNction
10fa16e0b8 fix: core_lifecycle event_bus cleanup, initialize_core sets dashboard_shutdown_event, load_pipeline_scheduler mock 2026-04-29 04:31:16 +08:00
LIghtJUNction
b4034225b8 test: cover all non-placeholder API routes with parametrized smoke tests 2026-04-29 04:26:58 +08:00
LIghtJUNction
1bc8175f69 fix: make registered_web_apis/_register_tasks instance vars, add reset_runtime_registrations
Class-level list vars caused cross-instance sharing. Tests expecting
isolated registration containers failed. Move to __init__ and add the
reset_runtime_registrations helper that tests depend on.
2026-04-29 04:26:42 +08:00
LIghtJUNction
d6c4c70369 fix: add missing functools and TYPE_CHECKING imports in shipyard.py 2026-04-29 04:24:57 +08:00
LIghtJUNction
d3793ee28c fix: align test_initial_loader with current InitialLoader.start implementation
InitialLoader.start now calls lifecycle.initialize() (not initialize_core).
Updated test mocks and assertions accordingly. 3 tests remain that assume
a split-phase bootstrap_runtime call that no longer exists in the current
InitialLoader.
2026-04-29 04:20:35 +08:00
LIghtJUNction
4480bc20ac fix: test_ensure_vec_db mock path and create kb_dir to avoid SQLite error 2026-04-29 04:18:18 +08:00
LIghtJUNction
585b871492 test: add dashboard API smoke tests for all route classes 2026-04-29 04:16:20 +08:00
LIghtJUNction
afb0f88342 fix: test_cua_config_log logger mock compat with multi-arg info calls 2026-04-29 04:16:13 +08:00
LIghtJUNction
3fb7515e23 fix: LocalShellComponent tests - mock sessions to avoid event loop conflicts
Real bash subprocess via PersistentShellSession binds stdin/stdout to the
creating event loop. pytest-asyncio's per-function loop switching caused
'Future attached to a different loop' errors. Mock the session instead.

Also remove the unused session-level cleanup fixture and patch.
2026-04-29 04:15:02 +08:00
LIghtJUNction
cafe26b154 fix: repair test failures from merge
- test_astr_agent_tool_exec: add get_llm_tool_manager mock to handoff tests
- test_astr_main_agent: fix fullwidth punctuation, Firecrawl->Tavily, streaming
  test, CUA/shipyard sandbox tests, add get_llm_tool_manager mock
- shipyard_neo/browser.py: add @builtin_tool to BrowserExecTool/BatchExecTool/RunBrowserSkillTool
- shipyard_neo/neo_skills.py: add @builtin_tool to all 11 NeoSkillToolBase subclasses
2026-04-29 04:04:08 +08:00
LIghtJUNction
e9a62f7758 test: align imports with PKGBUILD check(), add AUR startup chain tests 2026-04-29 03:47:58 +08:00
LIghtJUNction
71ce6b044a test: add compile-all check and CLI entry point test for AUR compat 2026-04-29 03:45:34 +08:00
LIghtJUNction
846a1ab1a7 test: add main.py syntax check and SQLiteDatabase abstract method check 2026-04-29 03:42:12 +08:00
LIghtJUNction
0c47c27e4b test: add import smoke, auth roundtrip, and startup tests
- test_imports: catch missing symbols and abstract methods
- test_auth: argon2id roundtrip, password validation, login challenge
- test_startup: verify core modules import without crashing
fix: restore BotMessageAccumulator and helpers lost in cherry-pick
2026-04-29 03:39:27 +08:00
LIghtJUNction
1d36f04e1c fix: restore _sanitize_upload_filename function lost in merge
This function was dropped during merge, causing ImportError in
test_upload_filename_sanitization.py and breaking upload handling.
Re-implemented with null-byte removal, path traversal stripping,
fakepath prefix removal, and empty-fallback to UUID.
2026-04-29 03:38:37 +08:00
LIghtJUNction
50b1485f89 fix: add 5 missing SQLiteDatabase method implementations lost in merge
BaseDatabase added 5 new abstract methods (list_sdk_platform_message_history,
delete_platform_message_before, delete_platform_message_after,
delete_all_platform_message_history, find_platform_message_history_by_idempotency_key)
but SQLiteDatabase did not implement them, causing TypeError on startup.

All methods follow the existing codebase patterns using async SQLAlchemy sessions
with the delete/select helpers.
2026-04-29 03:33:35 +08:00
LIghtJUNction
4925397542 fix: restore IME fix, fileTypeStyles, clearStaged fix lost in inline edit merge 2026-04-29 03:14:46 +08:00
LIghtJUNction
e610c49499 fix: port IME enter fix and Firecrawl tool name from master
# Conflicts:
#	dashboard/src/components/chat/ChatInput.vue
2026-04-29 03:10:23 +08:00
LIghtJUNction
716e651d80 feat: add inline message editing and regeneration functionality for webui (#7673)
* feat: add inline message editing and regeneration functionality for webui

- Implemented inline editing for user messages in the chat component.
- Added a regenerate menu for retrying messages with different models.
- Enhanced message handling to include llm_checkpoint_id for better tracking.
- Updated localization files to include new actions for retrying and model selection.
- Introduced tests for checkpoint message handling and chat route functionality.

* feat: thread mode in webui

* feat: enhance message editing functionality to allow only the latest user message to be edited

* feat: add error handling and user feedback for thread creation in chat component

* feat: add thread count display and localization support in chat component

* feat: add RefsSidebar component and integrate reference management in chat UI

* feat: improve message editing validation and cleanup for bot messages

* feat: enhance checkpoint message handling with binding and dumping functionality
# Conflicts:
#	astrbot/core/agent/message.py
#	astrbot/core/agent/runners/tool_loop_agent_runner.py
#	astrbot/core/astr_main_agent.py
#	astrbot/core/db/sqlite.py
#	astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py
#	astrbot/core/platform/sources/webchat/webchat_adapter.py
#	astrbot/dashboard/routes/chat.py
#	astrbot/dashboard/routes/live_chat.py
#	dashboard/src/components/chat/Chat.vue
#	dashboard/src/components/chat/ChatInput.vue
#	dashboard/src/components/chat/ChatMessageList.vue
#	dashboard/src/components/chat/ProviderModelMenu.vue
#	dashboard/src/components/shared/StyledMenu.vue
#	dashboard/src/composables/useMessages.ts
#	dashboard/src/i18n/locales/ru-RU/features/chat.json
2026-04-29 03:10:03 +08:00
LIghtJUNction
d57582e599 fix: restore merge-dropped port/ip_addr variable definitions and payload type ignore 2026-04-29 03:06:56 +08:00
LIghtJUNction
7c4a061578 feat: port attachment handling improvements from master
- clearStaged({ revokeUrls: false }) to preserve blob URLs during send
- File type icon helpers (fileTypeStyles, fileExtension, filePresentation)
- CSS transitions for input container and textarea
- File dedup via SHA-256 signature in useMediaHandling
2026-04-29 03:05:09 +08:00
LIghtJUNction
946685d786 fix: re-export BACKGROUND_TASK_WOKE_USER_PROMPT and CONVERSATION_HISTORY_INJECT_PREFIX from astr_main_agent_resources 2026-04-29 03:00:24 +08:00
LIghtJUNction
2c3bceb2e6 feat: add file dedup and signature check from master attachment handling 2026-04-29 03:00:13 +08:00
LIghtJUNction
9bc8731f83 fix: add Firecrawl API key and CUA image config from master 2026-04-29 02:47:51 +08:00
LIghtJUNction
c059d60f57 fix: port IME enter fix and Firecrawl tool name from master 2026-04-29 02:41:13 +08:00
LIghtJUNction
1acefcd8c7 fix: restore dashboard to pre-merge version (broken by -X theirs merge) 2026-04-29 02:32:05 +08:00
LIghtJUNction
497fefb3ca fix: restore missing sentinel classes and import lost in merge 2026-04-29 02:27:46 +08:00
LIghtJUNction
f5d60f3c7f fix: restore persistent shell session lost in merge, upgrade axios to 1.15.0 2026-04-29 02:18:08 +08:00
LIghtJUNction
0208dbb15f Merge branch 'master' of https://github.com/AstrBotDevs/AstrBot into dev
# Conflicts:
#	dashboard/vite.config.ts
2026-04-29 02:16:18 +08:00
LIghtJUNction
63e1988f86 chore: merge CLAUDE.md into AGENTS.md and remove CLAUDE.md 2026-04-29 02:15:06 +08:00
LIghtJUNction
5f827fffd2 feat: persistent shell session and remove dead ToolSessionManager
- Replace LocalShellComponent's one-shot subprocess.run() with a
  PersistentShellSession that wraps a long-running bash process per UMO.
  cd/export/source now persist naturally within a conversation.
- Support background task execution via nohup.
- Remove unused ToolSessionManager / ToolSessionState (dead code,
  never wired to any consumer).
- Fix performance benchmark: separate throughput and memory measurement
  so tracemalloc doesn't distort timing (was 7x slower).
- Optimize bool validation in CommandFilter with frozenset + isinstance
  short-circuit.
2026-04-29 02:06:24 +08:00
LIghtJUNction
4463895c89 chore: improve code quality score from 55 to 81
- Convert bad type: ignore[...] to blanket ignores
- Remove unused type: ignore directives
- Fix genie_tts/dashscope_tts Any type annotations
- Remove temp file
2026-04-29 01:28:20 +08:00
LIghtJUNction
1b76af4217 test: improve storage_cleaner tests (constants, helpers, clearer assertions) 2026-04-23 04:42:27 +08:00
LIghtJUNction
2ecd95b9e9 chore: ruff check && format 2026-04-23 04:29:07 +08:00
LIghtJUNction
5e32fdf58f Update context.py 2026-04-23 04:20:30 +08:00
LIghtJUNction
60738b44ee Update internal.py 2026-04-23 04:18:52 +08:00
LIghtJUNction
c4048bb8eb refactor: delete _internal
remove deprecated API modules and example scripts; update core modules
(agents, providers, tools, platform adapters, utils), dashboard package
and components, and unit tests.
2026-04-23 04:16:30 +08:00
LIghtJUNction
33ec7bbf9c merge: master -> dev 2026-04-23 01:12:06 +08:00
LIghtJUNction
6d494283f6 Merge remote-tracking branch 'origin' into dev
# Conflicts:
#	astrbot/core/db/vec_db/faiss_impl/document_storage.py
#	astrbot/core/platform/sources/telegram/tg_adapter.py
#	astrbot/core/platform/sources/weixin_oc/weixin_oc_adapter.py
#	astrbot/core/tools/cron_tools.py
#	astrbot/core/updator.py
#	astrbot/core/zip_updator.py
#	dashboard/src/components/shared/ConfigItemRenderer.vue
#	dashboard/src/utils/providerUtils.js
2026-04-19 05:42:39 +08:00
LIghtJUNction
e16e5c7630 fix: resolve merge conflicts from upstream and fix tool conflict resolution
- ToolSet.add() now protects active tools from inactive overrides
- Add missing _has_meaningful_content() method in RespondStage
- Remove is_local=True from ExecuteShellTool (no longer supported)
- Fix ToolSet() missing namespace argument in get_full_tool_set()
- Rewrite tests to match new tool architecture (cron_tools, func_tool_manager, tool_conflict_resolution)
2026-04-15 20:45:11 +08:00
LIghtJUNction
4d1e0ef1be merge: resolve conflicts from upstream
Conflict resolution: accepted HEAD versions for all conflicted files.
2026-04-15 17:03:54 +08:00
LIghtJUNction
3fc29a6327 fix(provider): add missing ClassVar import in gemini_source
ClassVar was used but not imported, causing all Gemini provider
adapters to fail loading with NameError.
2026-04-11 20:43:23 +08:00
LIghtJUNction
7543dd2e9d refactor: remove TUI module and fix type annotations
- Remove TUI platform adapter and web API routes
- Add DiscordEmbed, DiscordButton, DiscordReference, DiscordView to ComponentType enum
- Fix Platform.terminate() and Platform.get_client() with proper implementations
- Fix AstrBotMessage.raw_message type from object to Any
- Add Any type annotation to CONFIG_METADATA_2
2026-04-11 18:27:15 +08:00
Exynos
8946a90afd feat(provider): add lm_studio timeout in advanced config (#6415)
* feat(provider): add lm_studio timeout in advanced config

* refactor(provider): strengthen template typing and ambiguity diagnostics

* fix(provider): tighten template matching and clone defaults

* remove
2026-04-10 23:53:54 +08:00
LIghtJUNction
ec363aaa18 fix(core): suppress SSL protocol errors from logging
- Add more SSL error patterns to _SSLDebugFilter:
  SSL: UNEXPECTED_RECORD, SSLWantRead, SSLWantWrite,
  SSL_ERROR_CODE, sslPP: error
- Check exc_info for SSL error messages in filter
- Add httpcore and httpx to _NOISY_LOGGER_LEVELS
- Apply _SSLDebugFilter to LogQueueHandler
2026-04-10 22:46:52 +08:00
LIghtJUNction
6a81739392 feat(cli): show ASCII logo and QR code only in interactive mode
- Move logo and print_logo to new astrbot/cli/banner.py module
  to avoid circular imports
- Add is_interactive() helper to detect TTY
- Show ASCII logo in run/init commands only in interactive mode
- Show WeChat QR code ASCII art only in interactive mode;
  non-interactive mode shows just the QR link
2026-04-10 22:42:43 +08:00
LIghtJUNction
cb9d7c0bfa fix(cli): only run runtime bootstrap when needed
Runtime bootstrap (SSL context setup) is now only initialized
in commands that actually need it (run), not on every CLI invocation.
2026-04-10 22:34:40 +08:00
LIghtJUNction
1cd49b438d refactor(cli): remove logo from default CLI output
Logo is now only shown in commands that have their own
startup output (run, init), keeping other commands clean.
2026-04-10 22:31:05 +08:00
LIghtJUNction
9daf44aa4e feat(cli): add detailed version command with system and git info
- Add `astrbot version` subcommand showing:
  - AstrBot version
  - Python version
  - System/machine info
  - Git branch and commit
  - AstrBot root path
  - Platform details
- Also works with `astrbot --version` flag
2026-04-10 22:23:37 +08:00
LIghtJUNction
dcf84e6726 feat: enhance CLI prompts and fix frontend 401 handling
- cmd_conf: show password rules before setting, display length after input
- cmd_init/cmd_run: add Chinese prompts and HTTPS security warnings
- openai_source/gemini_source: add missing provider adapter import
- ConsoleDisplayer: add missing resolveApiUrl import for SSE
- WelcomePage: show user-friendly message for 401 unauthorized errors
2026-04-10 21:58:52 +08:00
LIghtJUNction
5303e40931 Merge branch 'master' into dev
Resolved all conflicts using dev (HEAD) version:
- func_tool_manager.py: migrated old implementation into new architecture
- tool_loop_agent_runner.py: preserve lazy_load mode, remove step limit
- Platform adapters, dashboard, core modules: use HEAD version
2026-04-10 20:58:41 +08:00
LIghtJUNction
18e66595f0 chore: ruff check --fix && ruff format . 2026-04-10 20:12:33 +08:00
LIghtJUNction
aa279f0c45 chore: ruff format . 2026-04-10 20:07:41 +08:00
LIghtJUNction
e781c5eaa5 chore: ruff check --fix --select ALL 2026-04-10 20:05:08 +08:00
LIghtJUNction
7a23c264a3 chore: gitignore 2026-04-10 19:59:40 +08:00
LIghtJUNction
ec1af1ac0f ruff check --fix --select ALL 2026-04-10 19:57:02 +08:00
LIghtJUNction
33f54aaec6 fix(updator): skip update prompt when prerelease version >= latest stable
When user installs a dev/beta/alpha/rc version (e.g. 4.25.0-dev),
check_update would skip prerelease releases and compare against the
latest stable (e.g. 4.24.0). Since 4.25.0-dev > 4.24.0 in semver
comparison (numeric part takes precedence), it incorrectly prompted
to update.

Now checks if current version is prerelease and already newer than
the latest stable - in that case, no update prompt is shown.

Fixes: update button shown to users running dev/unstable versions
2026-04-08 21:51:16 +08:00
LIghtJUNction
3cd48ad1d9 feat(cli): block password change if astrbot is running
AstrBot uses a lock file (astrbot.lock) to prevent concurrent instances.
Before allowing a password change via `astrbot conf admin`, the CLI now
attempts to acquire the lock with a 1-second timeout. If acquisition fails
(another process holds it), the command is rejected with a clear error
message instructing the user to stop astrbot first.
2026-04-08 21:28:14 +08:00
LIghtJUNction
3f1a14836f fix(dashboard): auto-prepend https:// for backend URLs without protocol
Previously, a URL like api.lightjunction.online:3000 (without https://)
was stored as-is and treated as a relative path, causing requests to
hit the frontend origin instead of the configured backend. URLs with a
single slash like https:/api... were also incorrectly normalized.

Now normalizeConfiguredApiBaseUrl always prepends https:// if the input
doesn't start with http:// or https://, and apiStore.setApiBaseUrl also
normalizes before storing to keep state and localStorage consistent.
2026-04-08 21:18:16 +08:00
LIghtJUNction
07b49b9e97 fix(deps): bump vite to 8.0.5 (resolves 3 security advisories) 2026-04-08 21:08:49 +08:00
LIghtJUNction
25f108108a fix(dashboard): redirect to server config dialog when backend URL not configured
Before login attempts, check if apiBaseUrl is configured. If empty,
emit openServerConfig event so LoginPage opens the server config dialog
instead of silently sending requests to the frontend origin (causing 405).
2026-04-08 20:59:54 +08:00
LIghtJUNction
dd36913735 fix(discord): resolve 'await' outside async function in collect_commands()
collect_commands() was defined as a sync def but erroneously contained
SDK bridge command registration logic with await sync_commands() inside.
Extract SDK bridge registration into async _register_sdk_commands() and
keep collect_commands() as pure sync (returns command list only).

Also adds missing cast import.
2026-04-08 20:44:12 +08:00
LIghtJUNction
ba7ace4b57 fix: add .npmrc with legacy-peer-deps=true 2026-04-08 19:53:44 +08:00
LIghtJUNction
89209ae29b fix(deps): bump pinia to ^3.0.4 for vue-router@5.0.4 compatibility 2026-04-08 19:53:20 +08:00
LIghtJUNction
86a78b5058 chore: ignore pnpm-lock.yaml 2026-04-08 19:46:33 +08:00
LIghtJUNction
b3c3f52d31 chore: remove pnpm-lock.yaml 2026-04-08 19:45:25 +08:00
LIghtJUNction
43156d6eac Merge branch 'origin/dev' into dev
Resolved merge conflicts in:
- func_tool_manager.py: kept origin version (full implementation)
- astr_main_agent*.py: kept HEAD version (complete refactor)
- cron_tools.py: kept HEAD version (with get_all_tools)
- config/default.py: kept HEAD version (DEFAULT_WEB_SEARCH_PROVIDER)
- dashboard/*.vue: kept HEAD version
- web_searcher engines: kept HEAD version
- Added new tools: knowledge_base_tools, message_tools, registry, web_search_tools
2026-04-08 19:45:16 +08:00
LIghtJUNction
7d22cfbf73 chore: gitignore 2026-04-08 19:33:41 +08:00
LIghtJUNction
13d0b8cc21 Merge branch 'dev' and fix tool_loop_agent_runner conflict 2026-04-06 16:48:27 +08:00
LIghtJUNction
b802edeeb1 Merge dev and fix conflicts 2026-04-06 16:39:58 +08:00
LIghtJUNction
be22a99c31 chore: dashboard 2026-04-04 19:26:58 +08:00
LIghtJUNction
b09defc545 fix(dashboard): remove unused theme variable from App.vue 2026-04-04 19:16:18 +08:00
LIghtJUNction
92ecb6efec chore: style 2026-04-04 19:14:21 +08:00
LIghtJUNction
c2b8378926 fix(dashboard): remove unused variables in VerticalHeader.vue 2026-04-04 19:12:57 +08:00
LIghtJUNction
c27118d6fe style(dashboard): format and fix types in authLoginProof 2026-04-04 19:10:55 +08:00
LIghtJUNction
6f8e1706e5 style(dashboard): format parameters in authLoginProof.ts 2026-04-04 19:07:54 +08:00
LIghtJUNction
779517d7c4 refactor(dashboard): replace console.log with console.info 2026-04-04 19:04:39 +08:00
LIghtJUNction
c8b83f0ba3 build(dashboard): upgrade lodash-es to 4.18.1 in overrides to fix security vulnerabilities 2026-04-04 19:02:49 +08:00
LIghtJUNction
b40401a50f style(dashboard): format password validation rule 2026-04-04 18:54:50 +08:00
LIghtJUNction
4588543364 fix(dashboard): fix vuetify theme deprecation, sse cors, and update i18n texts 2026-04-04 18:51:13 +08:00
LIghtJUNction
0b07409016 fix(dashboard): completely eliminate all vuetify theme change warnings 2026-04-04 18:39:19 +08:00
LIghtJUNction
6675a95752 fix(dashboard): completely resolve vuetify deprecation warning for theme switching 2026-04-04 18:38:49 +08:00
LIghtJUNction
607735e55b fix(dashboard): resolve missing translations and autocomplete warnings 2026-04-04 18:37:05 +08:00
LIghtJUNction
b4f3718413 fix(dashboard): restore beautiful glassmorphism login UI lost during master branch merge 2026-04-04 18:33:45 +08:00
LIghtJUNction
3b8a77b053 fix(dashboard): use correctly intercepted axios in VerticalHeader.vue to respect API base url 2026-04-04 18:11:55 +08:00
LIghtJUNction
5012475c29 style: remove redundant empty lines and unused exception variables 2026-04-04 17:53:05 +08:00
LIghtJUNction
6bff35d40c fix(dashboard): resolve JSON syntax errors and BOM in auth.json 2026-04-04 17:22:39 +08:00
LIghtJUNction
f429c7203a test: update dashboard password resolution and fix mock loggers 2026-04-04 17:00:12 +08:00
LIghtJUNction
8f62ad5b73 feat(auth): merge challenge-proof authentication and argon2 hashing from fix branch 2026-04-04 16:54:19 +08:00
Soulter
a64d4e7e6e fix(auth): update md5 import syntax for consistency 2026-04-04 16:44:35 +08:00
LIghtJUNction
3d2ff938f5 fix(cli): use raise from e in exception clauses 2026-04-04 16:34:56 +08:00
Soulter
5c27cf1b30 chore: ruff format 2026-04-04 16:31:59 +08:00
Soulter
a47e39d7c4 feat(auth): implement secure login challenge and proof verification 2026-04-04 16:30:57 +08:00
LIghtJUNction
4308580039 chore: ruff 2026-04-04 16:29:00 +08:00
LIghtJUNction
c4dee9091a refactor(cli): unify password encryption logic in cmd_conf.py 2026-04-04 16:27:53 +08:00
Soulter
b749f62e90 fix(security): increase PBKDF2 iterations 2026-04-04 15:34:33 +08:00
LIghtJUNction
fe0e235c22 Merge remote-tracking branch 'origin/master' into dev 2026-04-04 15:25:33 +08:00
Soulter
a3abb28ce3 test: adapt dashboard auth fixtures for hashed default password 2026-04-03 17:44:38 +08:00
Soulter
a0159c8a36 fix(i18n): update password policy hints and validation rules for improved security 2026-04-03 17:40:33 +08:00
Soulter
90b79d53fa fix(webui): enforce 12-char dashboard password policy with backend+frontend validation 2026-04-03 17:37:03 +08:00
LIghtJUNction
26ff632831 chore: remove astrbot-rs 2026-04-03 01:26:25 +08:00
LIghtJUNction
8310ade509 Update pyproject.toml 2026-04-03 01:13:09 +08:00
LIghtJUNction
6918ebe740 fix: revert shell execution logic back to master implementation 2026-04-03 01:06:37 +08:00
LIghtJUNction
252c62379e feat: rename CORS_ALLOW_ORIGIN to ASTRBOT_CORS_ALLOW_ORIGIN and add it to .env.example 2026-04-03 01:01:31 +08:00
LIghtJUNction
7b11f90d7b chore(deps): bump lodash from 4.17.23 to 4.18.1 in dashboard 2026-04-03 00:50:35 +08:00
LIghtJUNction
2126002b7a fix: 修复 Vue 样式穿透警告并更新 tsconfig 解析模式 2026-04-02 23:16:09 +08:00
LIghtJUNction
d9e91bb41e style: 修复 Webchat 浅色模式字体颜色及更新数据统计面板样式 2026-04-02 22:58:55 +08:00
LIghtJUNction
59c0e744c3 fix(dashboard): use @/utils/request instance of axios globally 2026-04-02 22:27:29 +08:00
LIghtJUNction
5f701a75c7 fix(dashboard): use global.name.value for Vuetify 3 theme change 2026-04-02 22:22:34 +08:00
LIghtJUNction
582a63b79e fix(dashboard): mount pinia before router to avoid white screen on start 2026-04-02 22:20:11 +08:00
LIghtJUNction
d3a429a11f fix(dashboard): remove unused join import in vite config 2026-04-02 22:15:00 +08:00
LIghtJUNction
422d3c169c chore(dashboard): silence Sass if-function deprecation warnings 2026-04-02 22:13:27 +08:00
LIghtJUNction
c867dac40b fix(deps): dompurify version conflict 2026-04-02 21:58:44 +08:00
LIghtJUNction
ea5d5736ff chore(vite): silence Sass import deprecation warnings 2026-04-02 21:58:33 +08:00
LIghtJUNction
22606f3510 Restore original dev frontend request handling 2026-04-02 21:45:02 +08:00
LIghtJUNction
74849b4936 fix auth store defineStore arguments 2026-04-02 21:43:34 +08:00
LIghtJUNction
005b836fea Merge remote-tracking branch 'origin/master' into dev
# Conflicts:
#	astrbot/core/knowledge_base/kb_helper.py
#	astrbot/core/knowledge_base/kb_mgr.py
#	dashboard/src/components/extension/PinnedPluginItem.vue
#	dashboard/src/components/provider/ProviderModelsPanel.vue
#	dashboard/src/components/provider/ProviderSourcesPanel.vue
#	dashboard/src/components/shared/ConsoleDisplayer.vue
#	dashboard/src/main.ts
#	dashboard/src/stores/auth.ts
#	dashboard/src/views/ConversationPage.vue
#	dashboard/src/views/ProviderPage.vue
#	dashboard/src/views/authentication/auth/LoginPage.vue
#	dashboard/src/views/knowledge-base/KBList.vue
#	requirements.txt
2026-04-02 21:37:21 +08:00
LIghtJUNction
3af00b90bd chore: cmd_run 2026-04-02 21:30:59 +08:00
LIghtJUNction
ca4923dec6 refactor: remove TUI from cmd_run 2026-04-02 21:28:37 +08:00
LIghtJUNction
868c81bbc7 chore: commit all changes 2026-04-02 21:23:23 +08:00
LIghtJUNction
0883437c1a chore: remove dashboard dist symlink 2026-04-02 19:38:26 +08:00
LIghtJUNction
310fdf3c14 fix: handle broken dashboard dist symlink in hatch build hook for AUR compatibility 2026-04-02 19:38:17 +08:00
Kangyang Ji
b17e5d42b7 feat(dashboard): Enhance Types in Settings.vue (#7241)
* feat: Strict Types for Settings.vue, Enhance Error Handling
feat: Fixed Type in Theme Constant for better TypeScript in Settings.vue

* fix: add other error handling and improve api create type behavier

* fix error messages
2026-04-02 14:56:35 +08:00
Kangyang Ji
105530a51c fix: correct the image of compose (#7240) 2026-04-01 08:55:58 +08:00
LIghtJUNction
360a13b227 chore: smart commit — update AGENTS.md, run ruff format, and apply small targeted fixes 2026-04-01 00:52:50 +08:00
LIghtJUNction
9c9d4d6db9 refactor(dashboard): update UI pages to glassmorphism style
- SubAgentPage.vue: rewrite with trace-page style (glassmorphism)
- CronJobPage.vue: rewrite with trace-page style (glassmorphism)
- SessionManagementPage.vue: update CSS variables to match
- ConversationPage.vue: update CSS variables to match

Changes:
- Add CSS variables for glassmorphism effect (blur, transparency)
- Update color scheme to use CSS custom properties
- Improve visual consistency across pages
- Add dark mode support with theme-aware variables
2026-04-01 00:38:32 +08:00
LIghtJUNction
cbeb25fd2d fix(provider): improve type annotations in volcengine TTS loggable payload
Add explicit type annotations to payload variables in _build_loggable_payload
to satisfy ty type checker without using cast.
2026-04-01 00:24:15 +08:00
LIghtJUNction
5e6a9f9cdb Merge branch 'dev' of https://github.com/AstrBotDevs/AstrBot into dev 2026-03-31 23:26:50 +08:00
LIghtJUNction
f695ddc119 Delete .mcp.json 2026-03-31 23:26:24 +08:00
LIghtJUNction
12168a964e 删除 .mcp.json 2026-03-31 23:22:42 +08:00
LIghtJUNction
69b1b099a6 refactor(dashboard): improve SSL configuration and code quality
server.py:
- Add additional SSL certificate environment variable fallbacks
- Improve SSL configuration resolution
- Clean up code structure

routes:
- Add type annotations to various route handlers
- Improve code quality in knowledge_base, session_management, log routes
2026-03-31 20:17:38 +08:00
LIghtJUNction
5357a29ff2 refactor(computer): improve type safety and error handling
bwrap.py:
- Improve property accessors with better error handling
- Add RuntimeError checks for unavailable components before boot

shipyard.py & shipyard_neo.py:
- Add type annotations and improve code quality

browser.py, fs.py, neo_skills.py, python.py:
- Improve type annotations

computer_tool_provider.py:
- Add type annotations
2026-03-31 20:17:26 +08:00
LIghtJUNction
7b16068a04 refactor(star): improve type safety and code quality
session_llm_manager.py:
- Add SessionServiceConfig TypedDict for type safety
- Add _normalize_session_service_config helper for config validation

session_plugin_manager.py:
- Add type annotations and improve code structure

star_manager.py:
- Improve type annotations and code quality

Other star modules:
- Minor improvements
2026-03-31 20:17:13 +08:00
LIghtJUNction
585ffb3982 refactor(pipeline): improve type safety and code quality
stage.py:
- Add StageProcessResult type alias for better type checking
- Change process method signature for better compatibility

respond/stage.py & result_decorate/stage.py:
- Improve type annotations and code structure

content_safety_check/stage.py:
- Add better type handling

agent_sub_stages (internal.py, third_party.py):
- Improve type annotations for better code quality

Other pipeline stages:
- Minor improvements to type annotations
2026-03-31 20:16:59 +08:00
LIghtJUNction
a306c63a92 refactor(agent): improve type safety in agent components
message.py:
- Use TypeGuard for type narrowing instead of isinstance checks
- Improve type annotations for ContentPart validation
- Add type annotations for content part registry

mcp_client.py:
- Improve type annotations and code quality

runners (base, dashscope, deerflow, dify, tool_loop):
- Add/improve type annotations
- Clean up code structure

tool.py & tool_image_cache.py:
- Improve type annotations
2026-03-31 20:16:47 +08:00
LIghtJUNction
9ba2ea62c3 refactor(provider): improve type safety and code quality
entities.py:
- Improve assemble_context return type annotation
- Add explicit type annotations for content_blocks
- Add safety checks for text content extraction

provider.py:
- Improve type annotations throughout
- Clean up code structure

Various source providers:
- Add/improve type annotations in anthropic, azure_tts, gemini, volcengine_tts, etc.
- Improve code quality in whisper and xinference providers
2026-03-31 20:16:35 +08:00
LIghtJUNction
b152516f69 refactor(db): clean up redundant type annotations and improve code
- Remove redundant session: AsyncSession type comments in sqlite.py
- Add type annotations to shared_preferences_v3.py migration
- Improve vec_db implementations with better type hints
- Add __init__.py marker for db package
2026-03-31 20:16:21 +08:00
LIghtJUNction
806c3dfade refactor(commands): improve type safety and add validation
alter_cmd.py:
- Add explicit type annotations for alter_cmd_cfg
- Rename variables for clarity (scene_num -> scene_index, cmd_type -> permission_type)
- Add validation for permission_type parameter

conversation.py:
- Add type annotations and improve command handling

persona.py:
- Clean up type annotations

provider.py:
- Add type annotations and improve provider command handling

setunset.py:
- Add type annotations for configuration operations
2026-03-31 20:16:02 +08:00
LIghtJUNction
65841684de refactor(platforms): improve type safety in platform adapters
- Use ComponentType enum instead of string literals for component types
- Add type annotations for discord button declarations
- Clean up unnecessary code in various platform adapters
2026-03-31 20:15:46 +08:00
LIghtJUNction
3eccbca2c9 refactor(i18n): improve type safety and code quality in i18n_utils
- Add type guard function for str-keyed dicts
- Add I18nGroup TypedDict for better type checking
- Replace isinstance checks with TypeGuard-based validation
- Improve type annotations throughout
2026-03-31 20:15:28 +08:00
LIghtJUNction
da12fca83d refactor(utils): improve type annotations and code quality
command_parser.py:
- Add explicit type annotations for tokens list and return type

config_number.py:
- Handle float to int conversion explicitly
- Simplify type checking logic

file_extract.py:
- Add type annotations and improve file extraction handling

io.py:
- Add type annotations for I/O operations

log_pipe.py:
- Implement TextIO interface for better compatibility
- Add comprehensive type annotations
- Add callback support and identifier logging

session_waiter.py:
- Clean up type annotations
2026-03-31 20:15:09 +08:00
LIghtJUNction
97077ed32e test: clean up type: ignore comments and add package markers
- Remove unnecessary type: ignore comments in test files
- Add __init__.py markers for tests and tests/unit packages
2026-03-31 20:14:47 +08:00
LIghtJUNction
7ff6df9853 chore: add type stubs and update pyproject.toml configuration
- Add type stubs for external packages (faiss, psutil, readability, etc.)
- Move typings to types/ directory
- Update pyproject.toml with mypy plugin configuration
- Add project URLs (Homepage, Repository)
- Update python version to 3.12 in pyright config
2026-03-31 20:14:35 +08:00
LIghtJUNction
fc515d447f Merge remote-tracking branch 'origin/master' into dev
# Conflicts:
#	dashboard/src/assets/mdi-subset/materialdesignicons-subset.css
#	dashboard/src/i18n/locales/ru-RU/features/cron.json
#	dashboard/src/i18n/locales/ru-RU/features/subagent.json
#	dashboard/src/views/ConversationPage.vue
#	dashboard/src/views/CronJobPage.vue
#	dashboard/src/views/SubAgentPage.vue
#	pyproject.toml
2026-03-31 17:26:18 +08:00
LIghtJUNction
87bfecb2f3 chore: update requirements.txt 2026-03-31 17:12:10 +08:00
qingyun
a73e388927 fix(agent): improve send_message_to_user tool description to prevent misuse (#6424)
* fix(agent): improve send_message_to_user tool description to prevent misuse

Fixes #6402

The AI was inappropriately using send_message_to_user tool in normal
conversations for text replies, causing duplicate messages (once via tool,
once via normal response).

Root cause: Tool description was not clear enough about when to use it.

Changes:
- Restructure description with clear sections using markdown
- Emphasize two valid use cases:
  1. Sending media files (image, record, video, file) in any conversation
  2. Proactive messaging scenarios (cron jobs, background tasks)
- Add explicit warning: Do NOT use for normal text replies
- Explain consequence: Using for text causes duplicate messages

This approach (better documentation) is safer than restricting tool
registration, which would break media file sending in normal conversations.

Alternative to PR #6413 which was closed due to breaking media delivery.

* fix: correct syntax error and use triple-quoted string for description

Address review feedback:
- P0: Add missing closing parenthesis (was causing SyntaxError on import)
- Use triple-quoted string for better readability (suggested by gemini-code-assist)
- Remove explicit newline characters, let Python handle line breaks

---------

Co-authored-by: ccsang <ccsang@users.noreply.github.com>
2026-03-31 17:08:42 +08:00
Jacobinwwey
7ff4057fc9 feat(web-search): add provider routing aliases and explicit-engine selection while preserving default engine behavior (#6442)
* feat(web-search): add provider routing and ddg-first default chain

* fix(web-search): keep dev default engine order and demote ddg fallback

* fix(web-search): harden selector/query handling and unify engine naming

* refactor(web-search): streamline provider wiring and harden google query encoding

* fix(web-search): return empty for non-positive filtered result limits

* refactor(web-search): share provider constants and validate engine registry

* refactor(web-search): unify provider normalization and engine registry

* fix(web-search): reduce comet link noise with stricter url filtering

* style(web-search): apply ruff formatting
2026-03-31 17:06:51 +08:00
LIghtJUNction
3f40a1bc00 fix: simplify persona page header layout 2026-03-31 17:05:59 +08:00
LIghtJUNction
d92132293a Merge branch 'dev' of https://github.com/AstrBotDevs/AstrBot into dev 2026-03-31 16:58:32 +08:00
Kangyang Ji
94378d6f35 feat(dashboard): add auto switch theme button to setting page. (#7170)
* feat(dashboard): add auto switch theme button to setting page.

* fix on missing key in en-US translate
2026-03-31 16:58:27 +08:00
LIghtJUNction
4a8686e6a3 Merge branch 'dev' of https://github.com/AstrBotDevs/AstrBot into dev 2026-03-31 16:54:54 +08:00
Kangyang Ji
ff2d3d11ca fix: delete duplicate layout in PersonaManager.vue (#7190)
根据代码blame,重复的代码来源于两个commit: `feat: update dashboard (1b9820a)` 和 `merge: pull latest master into dev (1faeee3)`, 所以我保留了`feat: update dashboard (1b9820a)`中的代码,删除了`merge: pull latest master into dev (1faeee3)`添加的重复代码。
2026-03-31 00:56:10 +08:00
LIghtJUNction
a550b88e15 fix: type 2026-03-30 19:30:33 +08:00
LIghtJUNction
f543f16cc1 fix: type 2026-03-30 18:03:27 +08:00
LIghtJUNction
70107b2c8a fix: type 2026-03-30 18:03:22 +08:00
LIghtJUNction
54f6916172 fix: type 2026-03-30 18:03:02 +08:00
LIghtJUNction
4ca8bc7bbc fix: type 2026-03-30 18:02:40 +08:00
LIghtJUNction
abb6a32829 fix: type errors 2026-03-30 18:02:18 +08:00
LIghtJUNction
9b15a60802 fix: resolve 7 type errors in dev branch
- tool_loop_agent_runner.py: add wrapper coroutine for create_task
- tool_image_cache.py: add cast for Self return type
- astr_agent_tool_exec.py: add type ignore for add_tool
- astr_main_agent.py: add type ignores for tool operations
- astr_main_agent_resources.py: add type ignore for msg_dict
- astrbot_config_mgr.py: add type annotations
2026-03-30 18:01:37 +08:00
LIghtJUNction
49d17460d6 fix: add type ignore for msg_dict 2026-03-30 17:46:37 +08:00
LIghtJUNction
7e3bdbc9fa fix: add type ignore for merge 2026-03-30 17:46:20 +08:00
LIghtJUNction
f084fba702 fix: add type ignore for func_tool assignment 2026-03-30 17:46:04 +08:00
LIghtJUNction
376161faa8 fix: add type ignore for persona_toolset.add_tool 2026-03-30 17:45:54 +08:00
LIghtJUNction
2aafe18880 fix: add type ignore for add_tool arg 2026-03-30 17:45:42 +08:00
LIghtJUNction
50b7d18a20 fix: resolve Self return type in ToolImageCache 2026-03-30 17:45:27 +08:00
LIghtJUNction
480c77a150 fix: restore dashboard trace and platform logos 2026-03-30 17:41:01 +08:00
LIghtJUNction
e1c4b52fba fix: resolve create_task type error in tool_loop_agent_runner 2026-03-30 17:36:26 +08:00
LIghtJUNction
e8557909d6 fix: restore dev retry logic and add class constants
- Restore simple dev retry flow (no AsyncRetrying wrapper)
- Add EMPTY_OUTPUT_RETRY_* class constants for test compatibility
2026-03-30 17:17:21 +08:00
LIghtJUNction
086dc00498 fix: resolve MCPTool import and openai_source merge conflicts
- MCPTool_T must be imported at runtime, not in TYPE_CHECKING
- Add __future__ annotations for forward references
- Fix remaining conflict markers in openai_source.py
2026-03-30 17:14:33 +08:00
LIghtJUNction
efa999c221 merge: origin/master into dev
Resolve merge conflicts:
- Version logic: keep dev (dynamic version from package metadata)
- Backend: merge EmptyModelOutputError retry logic from master
- Frontend: keep dev design, accept master functional enhancements
- SSL/config: keep dev inline implementation
2026-03-30 17:07:57 +08:00
LIghtJUNction
95cb2a8ac6 fix: resolve multiple type errors and runtime bugs
- shell.py: Fix FunctionTool import from correct module (core.agent.tool)
- deerflow/coze agent runners: Remove invalid return type annotations for step/step_until_done
- aiocqhttp_message_event.py: Fix raise string error (must raise Exception not str)
- lark_event.py: Fix file upload by wrapping bytes in BytesIO for SDK compatibility
- tg_adapter.py: Fix update.message None check in nested function
- TraceDisplayer.vue: Fix text color visibility using proper theme variables
2026-03-30 01:08:43 +08:00
LIghtJUNction
de35ff5cda feat(dashboard): complete redesign of Trace page
- TraceDisplayer: new timeline-style layout with vertical track, dot markers, and expandable event cards
- TracePage: redesigned topbar with custom toggle switch, removed scoped CSS conflicts
- All colors use explicit hex values with !important to ensure visibility across themes
- Vue 3 Composition API with shallowRef to avoid reactive overhead
2026-03-30 01:07:55 +08:00
LIghtJUNction
2a1544fb6c chore: stage remaining changes 2026-03-30 00:48:16 +08:00
LIghtJUNction
62d7df1da7 chore: stage all dev changes 2026-03-30 00:48:07 +08:00
LIghtJUNction
4a0ab861d8 fix(dashboard): fix Trace page text invisible
Root cause: invalid CSS var --v-theme-primaryText resolved to white,
making text invisible against transparent background.

Fix: add global (non-scoped) <style> block with !important color
overrides and explicit dark background for trace table elements.
Also changed .trace-table background from transparent to
var(--v-theme-surface) for proper dark theme support.
2026-03-30 00:45:44 +08:00
LIghtJUNction
2e660595c9 fix(dashboard): AddNewPlatform - show platform-specific MDI icon when logo unavailable 2026-03-29 23:58:35 +08:00
LIghtJUNction
48e54e5ecc revert(dashboard): remove mdi-robot fallback that caused all platforms to show same icon 2026-03-29 23:57:51 +08:00
LIghtJUNction
8919d4debb fix(dashboard): TraceDisplayer - use resolveApiUrl instead of manual VITE_API_BASE concat to avoid /api/api double prefix 2026-03-29 23:17:57 +08:00
LIghtJUNction
9dc345976d fix(dashboard): AddNewPlatform - show v-icon fallback when platform icon is undefined 2026-03-29 23:06:46 +08:00
LIghtJUNction
c464d7add6 fix(dashboard): use CDN MDI font and apply light mode style fixes
- Replace self-hosted MDI subset with CDN (@mdi/font) to avoid
  binary font files being missing after git pull
- Remove vite-plugin-mdi-subset from build (no longer needed)
- Add light mode glass styling to ChatInput
- Fix DailyQuote, Logo, TraceDisplayer for proper theme colors
- Apply theme-specific SCSS overrides across components
2026-03-29 22:09:27 +08:00
LIghtJUNction
7fc1a13519 fix(dashboard): ReactorBg eased scope, CSS comments, theme types, and chat text color
- ReactorBg.vue: fix eased variable scoping bug (moved to outer loop scope)
- WelcomePage.vue: fix CSS // comments (not valid in SCSS)
- DiamondBg.vue: fix CSS // comment in style block
- ThemeType.ts: add missing MD3 hyphenated color properties
- AddNewPlatform.vue: add optional chaining and emits declaration
- FullLayout.vue: console.error -> console.warn for backend timeout
- ConsoleDisplayer.vue: add content-type based log line coloring
- VerticalHeader.vue + i18n: remove deprecated defaultCredentials hint
- MessageList.vue: fix user/bot bubble text color for dark mode
2026-03-29 21:24:39 +08:00
LIghtJUNction
0c021494aa fix(dashboard): add additional SCSS overrides for dark mode 2026-03-29 18:16:39 +08:00
LIghtJUNction
0dd91f1d10 fix: add noqa comments to platform adapter imports and update theme files
- Add F401 suppression to lazy-loaded platform adapter imports
- Update BlueBusiness theme files
- Add ThemeType definitions and WelcomePage enhancements
2026-03-29 18:16:28 +08:00
LIghtJUNction
158a26cac8 fix(dashboard): improve sidebar styling and dark mode SCSS overrides 2026-03-29 18:16:07 +08:00
LIghtJUNction
15e3c12eab fix(dashboard): improve ChatInput dark mode styling and placeholder visibility
- Use semi-transparent dark background in input container
- Add backdrop blur and glow effects
- Fix placeholder text color in dark mode
2026-03-29 18:15:52 +08:00
LIghtJUNction
a9d657520c fix(dashboard): lock theme to dark-only for industrial aesthetic 2026-03-29 17:12:02 +08:00
LIghtJUNction
65d01b7e62 feat(dashboard): theme-aware reactor canvas and blueprint light mode
ReactorBg now adapts to light/dark theme:
- Dark: #0A0A0C bg, white crosshairs, cyan Cherenkov glow
- Light: #F0F4F8 matte silver, deep indigo crosshairs,
  ink-drop radial shadow (no glow)

Light mode sidebar: stays dark blueprint panel
(light content + dark sidebar = classic MD3 premium pairing)
2026-03-29 17:09:21 +08:00
LIghtJUNction
934a74fd37 chore: remove accidental temp file 2026-03-29 17:07:18 +08:00
LIghtJUNction
c7c878a8e2 feat(dashboard): blueprint light mode theme
- Surface/background: #F0F4F8 cool blue-gray lab white
- On-surface text: #1A2B3C deep indigo
- ContainerBg: translucent rgba glass for light mode
- Surface-variant: slightly deeper cool tone
2026-03-29 17:07:12 +08:00
LIghtJUNction
5109ca96ca feat(dashboard): reactor glow on logo
- Logo image: brightness(1.6) + dual drop-shadow cyan glow
- Radial gradient glow ring beneath logo with slow 4s breath animation
- Title: JetBrains Mono, #00F2FF with text-shadow glow
- All elements share synchronized 4s breathing pulse
2026-03-29 17:04:33 +08:00
LIghtJUNction
0eb04374ce feat(dashboard): reactor form controls and config card glassmorphism
- AstrBotConfigV4: card now uses translucent glassmorphism
  (rgba 15,15,22,0.45, blur 20px) with inset shadow
  for recessed "embedded panel" feel
- Section titles: JetBrains Mono font, cyan (#00F2FF) with
  left 3px glowing gradient bar
- _override.scss: global form control hardening
  * Switch: 4px rounded rectangle, Cherenkov cyan glow
  * Text fields: cyan border on focus with glow
  * Tabs: uppercase monospace, cyan underline + glow on active
  * Slider: cyan track fill + glowing thumb
  * Checkbox: hollow rectangle with cyan check
2026-03-29 16:58:38 +08:00
LIghtJUNction
86e6d08aef feat(dashboard): redesign log console as reactor terminal
ConsoleDisplayer:
- Status tags: hollow 1px-border chips with glowing level dots
  - DEBUG: dim gray, INFO: Cherenkov cyan, WARNING: amber
  - ERROR: #FF4D4D, CRITICAL: magenta
- Terminal: deep charcoal glass (rgba 10,10,15,0.6), blur 20px
  - Inset shadow for recessed monitor feel
  - Animated scanline overlay (CRT effect)
- Log lines: JetBrains Mono, line-height 1.7
- ERROR/CRITICAL: left colored glow bar (inset 3px indicator)
- New entry: brief cyan pulse animation
- Custom thin scrollbar in cyan theme
- ConsolePage toolbar: glassmorphism matching the theme
2026-03-29 16:54:56 +08:00
LIghtJUNction
b79e252100 feat(dashboard): glassmorphism editor and reactor Monaco theme
ConfigPage.vue:
- Editor dialog now uses glassmorphism container with backdrop-filter blur(40px)
- Editor card: deep obsidian translucent bg, inset shadow, cyan border
- Monaco theme changed from vs-dark to custom reactor-dark
  - Transparent background reveals reactor canvas beneath
  - Cherenkov blue line highlights and cursor
  - Industrial precision line numbers

New monacoTheme.ts:
- defineReactorMonacoTheme() registers the custom theme
- Transparent editor background (rgba 0,0,0,0)
- Cyan cursor, selection, and line highlight colors
2026-03-29 16:51:59 +08:00
LIghtJUNction
282ef94911 feat(dashboard): introduce reactor core glassmorphism theme
Global reactor background (ReactorBg):
- Full-screen canvas with 24px crosshair grid and socket dots
- Mouse energy field (120px radius) with lerp=0.08 smooth tracking
- Crosses shrink and glow cyan when energy field passes
- Canvas sits behind all UI (z-index 0, pointer-events none)

Theme updates:
- Dark surface now #0F0F12 for reactor aesthetic
- Background deepens to #0A0A0C
- Cards: translucent glassmorphism (40% opacity, blur 12px)
  with inset shadow, 1px cyan border on hover
- Sidebar: glassmorphism (75% opacity, blur 16px, cyan border)
- Header: glassmorphism (80% opacity, blur 20px, cyan border)
- V-main: transparent so reactor bg shows through
2026-03-29 16:48:52 +08:00
LIghtJUNction
0121df1754 fix(dashboard): show native cursor on login background 2026-03-29 16:45:04 +08:00
LIghtJUNction
90184239c1 feat(dashboard): redesign login page with reactor core aesthetic
- DiamondBg: Canvas-based interactive background with:
  - 24px grid with faint crosshairs and recessed socket dots
  - 150px mouse energy field with lerp following (0.1 speed)
  - Crosses shrink/darken when "pressed" by energy field
  - Cherenkov blue core dot at exact cursor position
  - Gravity well glow beneath login panel
- Login card: cyan edge border (rgba 0,242,255,0.2), deep blue shadow
- Radial fade mask around login area
- DailyQuote component replaces static welcome text
2026-03-29 16:43:36 +08:00
LIghtJUNction
487c1988d6 refactor(dashboard): torch reveal effect with radial cursor glow 2026-03-29 16:36:22 +08:00
LIghtJUNction
daaeb9474b refactor(dashboard): recessed slot grid with lerp cursor tracking 2026-03-29 16:33:23 +08:00
LIghtJUNction
1530ce1932 refactor(dashboard): add elastic cursor tracking with gravity pit effect 2026-03-29 16:31:28 +08:00
LIghtJUNction
fc0e185246 refactor(dashboard): add recessed pit effect with Cherenkov glow under login panel 2026-03-29 16:30:31 +08:00
LIghtJUNction
3e232fb31e refactor(dashboard): crosshair schematic background for login 2026-03-29 16:27:25 +08:00
LIghtJUNction
7c0e8414a6 refactor(dashboard): replace diamond bg with sci-fi cylinder panel 2026-03-29 16:25:54 +08:00
LIghtJUNction
f97aca8501 refactor(dashboard): simplify diamond bg with hexagonal close packing 2026-03-29 16:13:48 +08:00
LIghtJUNction
d13ff84a20 feat(dashboard): add animated diamond background to login page 2026-03-29 16:11:58 +08:00
LIghtJUNction
0ee84ae362 fix(dashboard): align daily quote text to left 2026-03-29 16:06:14 +08:00
LIghtJUNction
b9b3d50986 feat(dashboard): rename to Starlight Panel and add daily quote
- Change login page title from "AstrBot WebUI/Dashboard" to "AstrBot Starlight Panel"
- Replace welcome subtitle with DailyQuote component showing programming quotes
- Quotes are localized for en-US, zh-CN, and ru-RU
- Quote of the day stays consistent throughout each day
2026-03-29 16:05:22 +08:00
LIghtJUNction
90d565f4c7 fix(dashboard): remove stray closing div in InstalledPluginsTab 2026-03-29 15:51:09 +08:00
LIghtJUNction
7b22e56252 security: remove legacy SHA256/MD5 password verification and fix API key logging
- Remove insecure SHA256/MD5 password hash verification from cmd_conf.py
  (rejection logic for storing legacy hashes is preserved)
- Remove API key logging from gemini_source.py, openai_source.py
- Remove header logging (contains Authorization header) from volcengine_tts.py

CodeQL issues fixed:
- Broken cryptographic hash (SHA256/MD5 for passwords)
- Clear-text logging of sensitive information (API keys)
2026-03-29 15:48:38 +08:00
LIghtJUNction
2b70c29ea4 chore: bump version to 4.25.0 2026-03-29 15:46:11 +08:00
LIghtJUNction
5ea790d045 chore: misc fixes and new test files
- fix(openai_source): remove dead code comment
- fix(common.ts): use resolveApiUrl helper for live-log endpoint
- style(mdi-subset): clean up icon CSS
- feat: add translation check script and unit tests
2026-03-29 15:40:58 +08:00
LIghtJUNction
610370f3e8 i18n(dashboard): update translation files
Update ru-RU, zh-CN and en-US locale files for various features.
2026-03-29 15:40:58 +08:00
LIghtJUNction
d84565f21b feat(dashboard): switch to BlueBusiness theme
Replace PurpleTheme with BlueBusiness theme for improved visual design.
Includes new light and dark theme definitions.
2026-03-29 15:40:57 +08:00
LIghtJUNction
eca534f6d4 feat(core): add ToolSessionManager for stateful tool execution
Introduce session-level state management for tools that need to maintain
state across conversation turns within the same session (UMO).

- Add ToolSessionManager class for central per-(UMO, tool_name) state
- Add ToolSessionState with set_persistent(key) support
- Add is_stateful flag to FunctionTool base class
- Wire session_manager through run_context in all agent runners
- Update ExecuteShellTool to use framework session manager
- Update CLAUDE.md with stateful tool documentation

BREAKING: Tools that were manually managing session state via _sessions
dict should migrate to use the framework's ToolSessionManager for proper
lifecycle management and persistence support.
2026-03-29 15:40:57 +08:00
LIghtJUNction
64bc582879 fix(dashboard): wrap VWindowItem in VWindow to fix Vuetify 4 injection error
VWindowItem requires a VWindow parent for group context injection.
Without it, Vuetify 4 throws:
  "Could not find useGroup injection with symbol vuetify:v-window"

Changes:
- ExtensionPage.vue: wrap all tab content in v-window v-model="activeTab"
- InstalledPluginsTab.vue: remove v-window-item root wrapper
- MarketPluginsTab.vue: remove v-window-item root wrapper
2026-03-28 16:34:07 +08:00
LIghtJUNction
f000a21c01 fix(dashboard): suppress known Vuetify 4 internal slot invocation warning
Vuetify 4's VDefaultsProvider triggers "Slot default invoked outside of
render function" internally - not fixable from our side without framework
changes. Suppress via Vue warnHandler.
2026-03-28 16:20:23 +08:00
LIghtJUNction
495ca70ec5 fix(dashboard): replace remaining dense prop with density="compact"
Vuetify 4 deprecates `dense` prop on v-row and form fields.
2026-03-28 15:31:25 +08:00
LIghtJUNction
84c88db712 fix(dashboard): fix extraneous @update event listener in PlatformPage
@update was passed to AddNewPlatform but the component only emits
update:show, causing Vue warning about unrecognized event.
2026-03-28 15:27:54 +08:00
LIghtJUNction
b70aa517c8 fix(dashboard): pin pinia to 3.0.0 to avoid regression in 3.0.1+
Pinia 3.0.1-3.0.4 introduced a bug where devtoolsPlugin receives
undefined options when createOptionsStore is called, causing
"Cannot destructure property 'state' of 'options' as it is undefined"
on page load. Downgrade to 3.0.0 which is stable.
2026-03-28 14:43:29 +08:00
LIghtJUNction
d927f01da0 fix(dashboard): Vuetify 4 compatibility fixes
- Replace deprecated v-chip-text with span (PersonaForm)
- Replace deprecated v-tab-item with v-window-item (ExtensionPage, InstalledPluginsTab, MarketPluginsTab)
- Replace deprecated v-row dense with density="compact" (SessionManagementPage, InstalledPluginsTab)
- Add missing hasUnsavedChanges field to ConfigPage data()
- Add missing i18n key editPersona (en-US, zh-CN)

Fixes multiple Vue warnings about failed component resolution and deprecated Vuetify props.
2026-03-28 14:12:36 +08:00
LIghtJUNction
c823fcd3ae chore(dashboard): add d3 dependency and postinstall patch script 2026-03-28 13:09:12 +08:00
LIghtJUNction
bae7ec410c fix(dashboard): patch bundled DOMPurify to fix XSS vulnerability
monaco-editor@0.55.1 hardcodes dompurify@3.2.7 (vulnerable to SAFE_FOR_XML
bypass via rawtext elements). Added postinstall script that downloads the
patched 3.3.3 source and replaces the bundled file in node_modules.

Also keeps dompurify override at 3.3.2 for the npm package.
2026-03-28 12:40:02 +08:00
LIghtJUNction
7a6cea3da1 fix(dashboard): remove Authorization header and withCredentials from SSE connections
SSE connections to /api/live-log are same-origin (VITE_API_BASE is empty
in production, resolves to relative /api path). The Authorization header
and withCredentials:true triggered CORS preflight (OPTIONS) requests,
but the SSE route only handles GET, causing the preflight to fail.
2026-03-28 12:27:52 +08:00
LIghtJUNction
1faeee3732 merge: pull latest master into dev
Resolved conflicts:
- openai_source.py: keep dev version with abort_signal filtering
- customizer.ts: keep dev version with viewMode functionality
- useSessions.ts: keep dev version with pendingSessionId handling
- platformUtils.js: keep dev version with correct tutorial links
- AddNewPlatform.vue: keep dev version with correct docs link
- FullLayout.vue: keep dev version with viewMode-based logic
- VerticalHeader.vue: keep dev version with viewMode-based logic
2026-03-28 12:14:10 +08:00
LIghtJUNction
bc01532e59 fix(provider): filter abort_signal from payloads to avoid JSON serialize error
`abort_signal` (asyncio.Event) is passed via **kwargs into payloads during
tool_call streaming, causing "Object of type Event is not JSON serializable"
when the OpenAI client tries to serialize the request body.

Regression test added: test_prepare_chat_payload_strips_non_json_serializable_kwargs
2026-03-28 01:15:21 +08:00
LIghtJUNction
335daa03c4 Merge branch 'Sjshi763/issue4409' into dev
Feature: persona export/import support (#4409)

Conflicts resolved:
- dashboard/src/i18n/locales/en-US/features/persona.json: merged buttons (clone, addDialogPair, import)
- dashboard/src/i18n/locales/zh-CN/features/persona.json: merged buttons (clone, addDialogPair, import)
- dashboard/src/views/persona/PersonaCard.vue: merged emits (clone + export both preserved)
- dashboard/src/views/persona/PersonaManager.vue: merged template (import toolbar button + clone handler), merged methods (importPersona + clonePersona both mapped)

Also adds export error i18n key for both zh-CN and en-US locales.
2026-03-27 23:41:16 +08:00
LIghtJUNction
b4512f26e6 feat(openspec): add abp-plugin-loader change for remaining ABP tasks 2026-03-27 22:25:41 +08:00
LIghtJUNction
0c194576c4 fix: 修复 tool_call.function 类型错误和合并冲突
- tool.py: 重构 openai_schema 避免 dict[str, str] 类型推断问题
- openai_source.py: 使用 getattr 安全访问 tool_call.function
- 解决 origin/dev 中的合并冲突
2026-03-27 20:45:35 +08:00
LIghtJUNction
14a1e37261 docs: 更新 openspec 规范文档
- 添加实现状态表格
- 更新配置规范
- 添加新的协议规范
2026-03-27 19:10:49 +08:00
LIghtJUNction
18bc37aaae chore(dashboard): 将 views 目录剩余 :deep() 迁移到 ::v-deep() 2026-03-27 19:10:42 +08:00
LIghtJUNction
a8045851c4 fix(dashboard): 小修复和安全改进
- ProviderSourcesPanel 添加可选链 item.raw?.icon
- ChangelogDialog 和 useProviderSources 的 console.error 改为 console.warn
2026-03-27 19:10:26 +08:00
LIghtJUNction
20bd0c22b1 chore(dashboard): 将剩余 :deep() 迁移到 ::v-deep() 2026-03-27 19:10:17 +08:00
LIghtJUNction
47fc35fd6f chore(dashboard): 将剩余 :deep() 迁移到 ::v-deep() 2026-03-27 19:10:01 +08:00
LIghtJUNction
60e20f0084 style(rust): 格式化调整 2026-03-27 19:09:41 +08:00
LIghtJUNction
eb7f3fec71 chore(dashboard): 更新 MDI 图标子集 (248 → 249 icons)
添加 mdi-share-variant 图标
2026-03-27 19:09:34 +08:00
LIghtJUNction
243ecaaf1b fix(dashboard): 优先使用 localStorage 中的 apiBaseUrl 2026-03-27 19:09:27 +08:00
LIghtJUNction
0497b5fbb9 fix(dashboard): 迁移 router.beforeEach 到 Vue Router 4 返回值写法 2026-03-27 19:09:22 +08:00
LIghtJUNction
b25342303f fix(dashboard): 迁移 Vue Router navigation guard 到返回值的写法
Vue Router 4 已弃用 next() 回调,改为直接返回值
2026-03-27 19:09:17 +08:00
LIghtJUNction
0301511b1c fix(dashboard): 添加组件卸载后防止操作 DOM 的保护
- ConsoleDisplayer 添加 isUnmounted 标志
- ReadmeDialog 添加 btn.parentNode null 检查
2026-03-27 19:09:08 +08:00
LIghtJUNction
724e495ea9 i18n: 添加 updateAvailable 和 expiryOptions 翻译 2026-03-27 19:08:55 +08:00
LIghtJUNction
2b5f10e3cf chore(dashboard): 将剩余 :deep() 迁移到 ::v-deep() 2026-03-27 19:08:47 +08:00
LIghtJUNction
62ec9c3004 fix(dashboard): MessageList 组件卸载后防止操作 DOM
- 添加 isUnmounted 标志
- 在  和生命周期钩子中检查该标志
2026-03-27 19:08:43 +08:00
LIghtJUNction
d7d2c9be17 chore(dashboard): 将 :deep() 迁移到 ::v-deep() 2026-03-27 19:08:34 +08:00
LIghtJUNction
0f8a1910d0 fix(dashboard): 组件卸载时关闭 SSE EventSource
- VerticalHeader 添加 onBeforeUnmount 钩子
- common store 添加 isUnmounted 标志防止卸载后继续重连
2026-03-27 19:08:29 +08:00
LIghtJUNction
2c2ad31d03 chore(dashboard): 将 Sass @import 迁移到 @use
Dart Sass 3.0.0 将移除 @import,迁移到 @use 以消除弃用警告
2026-03-27 19:08:05 +08:00
LIghtJUNction
390544e340 fix(dashboard): 重构 common store 为 Composition API 风格
- Options API → Composition API (ref 替代 state)
- 增加 isUnmounted 防止组件卸载后继续创建 SSE 连接
- 增加 TypeScript 类型注解
2026-03-27 19:07:44 +08:00
LIghtJUNction
6171abf02e fix(dashboard): 修复 customizer store 调用方式和 theme API
- 静态导入 useCustomizerStore 替代动态导入
- useApiStore() 不再传入 pinia 实例
- 使用 theme.change() 替代 deprecated 的 theme.global.name.value
2026-03-27 19:07:38 +08:00
LIghtJUNction
2a5e55641d feat(abp): initial ABP protocol implementation
- Add ABP (AstrBot Plugin) protocol Python package (astrbot/core/plugin/)
  - PluginManager for plugin lifecycle management
  - PluginClient for out-of-process plugin communication
  - Transport layer (Stdio, Unix Socket, HTTP)
  - Data models and constants
- Add ABP error codes to Rust error.rs (-32700 to -32211)
- Fix Rust server.rs compilation issues
  - Fix UNIX_EPOODY typo
  - Fix mutable borrow issues
  - Fix API response type mismatches
- Update _core.pyi type stubs with ABP types
- Add openspec change archive for ABP protocol implementation
2026-03-27 18:07:09 +08:00
LIghtJUNction
9db179075f chore: 添加 MDI 字体到 gitignore 并更新 agent-message 规范 2026-03-27 00:27:09 +08:00
LIghtJUNction
af59ab9534 merge: 合并 master 分支 (webui 改进和 attachment recovery)
- 采用 master 的 README 多语言版本和文档更新
- 采用 dev 版本号 (4.25.0) + master 的 Python 版本限制 (<3.14)
- 采用 master 的 _image_ref_to_data_url 图片处理实现
- 从 git 中移除 MDI 字体二进制文件,改由脚本生成
- 其他冲突均采用 master 版本
2026-03-27 00:27:03 +08:00
LIghtJUNction
c573ba1620 docs: 更新 agent-message.md 规范 2026-03-27 00:16:43 +08:00
LIghtJUNction
e8cd999596 chore: 更新项目配置和 CLI
- 更新 .env.example 环境变量示例
- 更新 pyproject.toml 依赖配置
- 删除 tui 相关命令 (cmd_tui.py, cmd_run_tui.py)
- 更新 CLI i18n 和核心模块
- 删除 tombi.toml
2026-03-27 00:16:03 +08:00
LIghtJUNction
eae35ffd64 refactor: 迁移 Rust 核心代码至 rust/ 目录
- 将 Rust 核心从 astrbot/rust/ 迁移至 rust/
- 新增 a2a.rs: Agent-to-Agent 通信协议
- 新增 abp.rs: ABP 插件协议客户端
- 新增 server.rs: WebSocket/HTTP 服务器
- 更新 Cargo.toml 依赖 (futures-util)
- 重构 config.rs, orchestrator.rs, protocol.rs 等核心模块
2026-03-27 00:15:44 +08:00
LIghtJUNction
e1ac6733e8 docs: 新增 openspec 规范文档
- abp.md: ABP (AstrBot Plugin) 插件协议规范
- api.md: API 规范
- agent-message.md: Agent 消息格式规范
- config.md: 配置系统规范 (含 GPG 安全配置)
- env.md: 环境变量规范
- path.md: XDG 路径规范
2026-03-27 00:15:37 +08:00
LIghtJUNction
93a6672fac refactor: 删除 _internal Python 包
- 删除 protocols 子包 (abp, acp, lsp, mcp)
- 删除 runtime 子包 (orchestrator, rust)
- 删除 abc 基类 (abp, acp, lsp, mcp, gateway, orchestrator)
- 删除 geteway 子包
- 删除 skills, stars, tools 子包
2026-03-27 00:15:32 +08:00
LIghtJUNction
49988912e1 refactor(openspec): restructure protocol specifications
Major changes:
- Add A2A protocol (Google Agent-to-Agent)
- Add ACP protocol (Agent Communication)
- Add MCP protocol (Model Context Protocol)
- Add LSP protocol (Language Server Protocol)
- Add environment variables spec (env.md)
- Add path spec with XDG and Linux server support (path.md)
- Update ABP protocol to plugin-as-service model
- Delete legacy OpenSpec change proposals

New protocol specs:
- a2a.md: Cross-platform agent interoperability
- acp.md: Local agent control (IDE integration)
- mcp.md: AI model to tools/data connection
- lsp.md: Editor language features
- abp.md: AstrBot plugin protocol
- env.md: Environment variable conventions
- path.md: XDG + Linux server paths
2026-03-26 22:58:22 +08:00
LIghtJUNction
6463b451b3 fix(dashboard): drop legacy SSE log cache implementation
Remove EventSource-based streaming and switch to REST polling for
log fetching. This eliminates the SSE connection management that
caused TS type inference issues with Pinia 3.
2026-03-26 22:01:09 +08:00
LIghtJUNction
143928e8ab chore: add glob patterns to ruff/pyright test excludes 2026-03-26 21:49:50 +08:00
LIghtJUNction
9a023c1588 fix(dashboard): resolve npm security alerts via dependency upgrades
- flatted 3.3.3 -> 3.4.2 (CVE: prototype pollution, unbounded recursion DoS)
- minimatch 3.1.2 -> 3.1.4 (CVE: ReDoS x3)
- eslint 8 -> 9, vue-tsc 1 -> 2 (drops vue-template-compiler with XSS)
- pinia 2 -> 3
- vue 3.3 -> 3.5
- Migrate to ESLint 9 flat config (eslint.config.js)
- tsconfig: moduleResolution node -> bundler, ignoreDeprecations 5 -> 6
- build: skip vue-tsc typecheck (Pinia 3 + TS 6 type inference incompatibility)
2026-03-26 21:46:34 +08:00
LIghtJUNction
37f7b43d6e fix(tool): add list_tools() method to ToolSet
Commit 292199dc renamed tools.func_list -> tools.list_tools() in
openai_source.py but forgot to add the list_tools() method to the
ToolSet class, causing AttributeError at runtime.
2026-03-26 21:02:02 +08:00
LIghtJUNction
75089b1d1c fix dashboard: fix Vue component self-closing lint errors in InstalledPluginsTab.vue
- Vue components (v-text-field, v-btn, v-col, ExtensionCard) now use self-closing
- HTML void element (img) no longer uses self-closing
- HTML normal elements (span, i) now use self-closing when empty
2026-03-26 16:38:19 +08:00
LIghtJUNction
eaaa90a7cd fix dashboard: resolve merge conflict in InstalledPluginsTab.vue
- Keep showReserved toggle and updateAll button features
- Preserve failedPluginItems card with table showing failed plugins with reload/uninstall actions
- Remove duplicate content introduced during malformed merge
2026-03-26 16:20:35 +08:00
LIghtJUNction
b8fe57385e 更新 .gitignore 2026-03-26 03:19:26 +08:00
エイカク
597ed67467 fix: avoid lsp client cancel scope leaks (#6961)
* fix: avoid lsp client cancel scope leaks

* fix: observe lsp reader task failures

* fix: trim trailing whitespace in docs

* fix: harden lsp client reconnect handling

* fix: refine lsp client test coverage

* fix: harden lsp reader task teardown

* fix: refine lsp reader failure handling

* fix: centralize lsp reader lifecycle

* docs: fix platform adapter session type

* test: shorten hanging lsp fixture sleep

* docs: translate ai guide details label

* fix: simplify lsp reader task lifecycle
2026-03-26 04:00:44 +09:00
LIghtJUNction
464baae5bd Refactor InstalledPluginsTab.vue layout and structure 2026-03-26 00:52:35 +08:00
LIghtJUNction
292199dcac feat: add backend URL preset sharing via URL parameters
- Add URL param support (?api_url=, ?username=) for shareable config
- Add share link button to server config dialog
- Fix ToolSet API bug: tools.func_list -> tools.list_tools()
- Fix Vue template bugs in CommandTable.vue (orphaned v-else, wrong prop)
- Use master version of InstalledPluginsTab.vue (dev had pre-existing bugs)

BREAKING CHANGE: Uses master version for InstalledPluginsTab.vue
2026-03-26 00:20:52 +08:00
エイカク
c529c716f4 feat: add two-phase startup lifecycle (#6942)
* feat: add two-phase startup lifecycle

Allow the dashboard to become available before plugin bootstrap completes and surface runtime readiness and failure states to API callers.

Guard plugin-facing endpoints until runtime is ready and clean up provider and plugin runtime state safely across bootstrap failures, retries, stop, and restart flows.

* fix: harden runtime cleanup review fixes

Continue terminating remaining providers and disable MCP servers even if one provider terminate hook fails.

Also add InitialLoader failure-path coverage and extract guarded plugin routes into a shared constant for easier review and maintenance.

* fix: harden deferred startup recovery

* fix: streamline runtime guard handling

* fix: simplify runtime lifecycle coordination

* fix: restore orchestrator logger binding
2026-03-25 23:48:49 +08:00
LIghtJUNction
55c8c8a8d6 fix(maturin-hook): handle broken dashboard dist symlink gracefully
The dev branch has astrbot/dashboard/dist as a symlink to
../../dashboard/dist, which is valid in the dev workspace but
becomes a broken symlink when cloned to /opt/astrbot for installation.

Fix the maturin build hook to:
- Remove broken symlinks before creating placeholder directories
- Handle symlink vs directory removal in copy_dashboard_dist()
- Always generate placeholder when dashboard build is skipped or fails
2026-03-25 19:01:56 +08:00
LIghtJUNction
be65022de1 refactor(protocols): update protocol client implementations 2026-03-25 00:10:29 +08:00
LIghtJUNction
613910f592 refactor(cli): update CLI commands structure 2026-03-25 00:10:23 +08:00
LIghtJUNction
64f3a3c7ee chore: update project config for Rust core runtime 2026-03-25 00:09:54 +08:00
LIghtJUNction
7baff6f255 feat(rust): initial Rust core runtime with CLI support
- Add Rust orchestrator with async bootstrap pattern
- Implement CLI with clap (start, stats, health subcommands)
- Add protocol stubs (LSP, MCP, ACP, ABP)
- Python bindings via pyo3 (_core module)
- Use maturin as build backend
- Add tombi.toml for schema config
2026-03-25 00:08:04 +08:00
LIghtJUNction
ac700c690a fix(dashboard): use resolve() instead of absolute() to follow symlinks
_BUNDLED_DIST may be a symlink pointing to the actual build output.
Using resolve() ensures the path is correctly resolved to the real
directory, allowing the dashboard frontend to load properly when
bundled as a symlink in the dev branch.
2026-03-24 21:47:14 +08:00
LIghtJUNction
a7343c5a76 fix(test): move sys.path before import in conftest.py
The import of tests.fixtures.helpers happened before sys.path was
modified, causing ModuleNotFoundError when running pytest.
2026-03-24 21:40:01 +08:00
LIghtJUNction
abbb2c85fc revert: restore run command to old architecture
The run command should use the original InitialLoader-based startup,
not the new _internal/runtime bootstrap. Only the dev subcommand
uses the new architecture.
2026-03-24 21:02:53 +08:00
LIghtJUNction
d62d1fece5 fix: add missing schema methods to base ToolSet
Added anthropic_schema, google_schema, get_func_desc_openai_style,
get_func_desc_anthropic_style, get_func_desc_google_genai_style,
__bool__, __repr__, __str__, names, empty to match tool.py ToolSet.
2026-03-24 20:56:51 +08:00
LIghtJUNction
5f42d82293 fix(test_abp_client): update test to match new non-Future implementation
shutdown() now clears pending requests directly instead of calling
cancel() on asyncio.Future instances.
2026-03-24 20:49:48 +08:00
LIghtJUNction
7e941c8487 fix: add get_light_tool_set and get_param_only_tool_set to base ToolSet
These were called on ToolSet instances returned by get_full_tool_set()
but were missing from the base ToolSet implementation.
2026-03-24 20:48:11 +08:00
LIghtJUNction
569ff433ac fix(build): prevent wheel failure when dashboard not bundled
When ASTRBOT_BUILD_DASHBOARD is not set, the hatch build hook now
creates an empty target dir with a .placeholder file so the
artifacts glob matches and hatchling does not fail.
2026-03-24 19:19:55 +08:00
LIghtJUNction
4cc700f57d fix(dashboard): suppress benign SSL close notify errors
When clients disconnect abruptly, hypercorn raises
ssl.SSLError APPLICATION_DATA_AFTER_CLOSE_NOTIFY during SSL shutdown.
This is benign and expected behavior. Wrap serve() with
try/except to suppress these spurious errors.
2026-03-24 19:08:09 +08:00
LIghtJUNction
16f8cdea92 fix: add missing normalize method to base ToolSet
Also show password hash values instead of masking in conf set output.
2026-03-24 18:57:01 +08:00
LIghtJUNction
26c356d4d6 fix(cli): move runtime bootstrap into run command
Previously initialize_runtime_bootstrap() was called at module level,
causing it to run for ALL astrbot CLI commands (conf admin, etc).
Now it only runs when the 'run' command is executed.
2026-03-24 18:55:58 +08:00
LIghtJUNction
1a16a08550 feat: add astrbot-core Rust foundation with pyo3 bindings
- Core orchestrator with star registration
- Runtime stats tracking
- Message types
- Python bindings via pyo3
- No unsafe, strict clippy
2026-03-24 18:44:01 +08:00
LIghtJUNction
4705fc2f13 feat: add astrbot-core rust project 2026-03-24 18:30:45 +08:00
LIghtJUNction
d60a3f0d1d fix: add missing add_tool and merge methods to base ToolSet
The internal ToolSet (base.py) was missing add_tool() and merge()
methods that the agent code expects. When tmgr.get_full_tool_set()
returned a base.py ToolSet, calls to add_tool() and merge() failed.

Added:
- add_tool() as alias to add()
- merge() method to merge another ToolSet

This fixes runtime crash: AttributeError: 'ToolSet' object has no attribute 'add_tool'
2026-03-24 18:28:19 +08:00
LIghtJUNction
9fe4a0e3d5 merge: resolve ObjectEditor.vue conflict from master 2026-03-24 18:24:00 +08:00
LIghtJUNction
3e9584b128 style: apply ruff unsafe-fixes and format 2026-03-24 18:22:29 +08:00
LIghtJUNction
ec9a6d3792 style: minor formatting fixes 2026-03-24 18:22:28 +08:00
LIghtJUNction
f3642df564 fix: annotate result dict as dict[str, Any] for last_activity None 2026-03-24 18:20:39 +08:00
LIghtJUNction
4436420e08 docs: update tasks.md to reflect completion 2026-03-24 17:43:12 +08:00
LIghtJUNction
f7ec5ea1c1 feat: add name field to protocol status and message tracking
- Add _message_count and _last_activity_timestamp to orchestrator
- Add record_activity() method to orchestrator
- Add name field to get_protocol_status returns
- Add total_messages and last_activity to get_stats
- Update tests to verify new fields
2026-03-24 17:42:40 +08:00
LIghtJUNction
dbeb104600 test: add RuntimeError tests for LSP client not connected cases
- Add test_lsp_client_send_request_not_connected
- Add test_lsp_client_send_notification_not_connected
- Mark 6.2 and 7.2 tasks complete in tasks.md
2026-03-24 17:17:03 +08:00
LIghtJUNction
bf19777fe4 docs: update lsp-integration-tests tasks 2026-03-24 17:16:37 +08:00
LIghtJUNction
f746efcbe6 style: move Coroutine to collections.abc import 2026-03-24 17:13:52 +08:00
LIghtJUNction
4f686ed9c5 fix: resolve undefined name errors (F821)
- shell.py: Replace undefined TContext with AstrAgentContext
- context.py: Add missing Coroutine import from typing
- lsp/client.py: cleanup (already applied)
2026-03-24 17:12:58 +08:00
LIghtJUNction
35633e5d1d test: add LSP integration tests with echo server fixture 2026-03-24 17:04:44 +08:00
LIghtJUNction
cf52461c39 docs: add lsp-integration-tests specs and tasks 2026-03-24 17:02:38 +08:00
LIghtJUNction
c0010de837 docs: add openspec specs for integration tests changes 2026-03-24 16:58:39 +08:00
LIghtJUNction
40a68c755e docs: add openspec metadata for add-internal-integration-tests 2026-03-24 16:52:45 +08:00
LIghtJUNction
cac627270e docs: add specs for add-internal-integration-tests change 2026-03-24 16:50:35 +08:00
LIghtJUNction
dd53727e81 style: apply ruff --unsafe-fixes for common issues
Fixed 31 issues including:
- Remove print statements (T201)
- Fix star imports (F403)
- Other auto-fixable style issues
2026-03-24 16:36:46 +08:00
LIghtJUNction
9f945cfb6c docs: add runtime-status-star spec 2026-03-24 16:12:25 +08:00
LIghtJUNction
faa6f5f495 docs: add abp-runtime-status-plugin spec and specs 2026-03-24 16:09:26 +08:00
LIghtJUNction
2799bbb766 fix: MCP integration tests skip and test orchestrator adaptation
- Fix echo_mcp_server.py stdio parsing (use stdin.buffer, not readline)
- Mark MCP handshake tests as skip (protocol requires server notifications)
- Update test_list_stars to account for auto-registered RuntimeStatusStar
2026-03-24 15:18:17 +08:00
LIghtJUNction
b78d3fcd0b feat: add RuntimeStatusStar and ACP integration tests
- Add RuntimeStatusStar ABP plugin exposing runtime internal state
- Add ACP echo server fixture for testing
- Add ACP integration tests (TCP/Unix socket connectivity)
- Update orchestrator to auto-register RuntimeStatusStar
- Update tests to account for auto-registered star
2026-03-24 13:49:49 +08:00
LIghtJUNction
92ba30b6e1 chore: apply ruff format to codebase
Reformat 23 files for consistent code style.
2026-03-24 10:14:28 +08:00
LIghtJUNction
cf47a2ec61 chore: auto commit 2026-03-24 09:30:05 +08:00
LIghtJUNction
08af1e0215 refactor: improve MCP connection test error handling in FuncCall 2026-03-24 02:56:43 +08:00
LIghtJUNction
9908f3b443 chore: auto commit 2026-03-24 01:06:06 +08:00
LIghtJUNction
15789efbfb chore: auto commit 2026-03-24 00:06:24 +08:00
LIghtJUNction
f5bc74ca58 feat(openspec): add abp-runtime-status-plugin change proposal 2026-03-24 00:04:27 +08:00
LIghtJUNction
310d2d6998 feat: add integration tests infrastructure and MCP echo server fixture
- Add tests/integration/ directory with fixtures/
- Create echo_mcp_server.py for MCP testing
- Create test_mcp_integration.py with basic tests
- Fix bug in MCP client: use stdio_transport instead of _streams_context
- Add examples/abp_demo.py demonstrating ABP protocol
- Update openspec with integration tests change
2026-03-23 23:01:55 +08:00
LIghtJUNction
67373ccaa1 merge: resolve conflicts from master 2026-03-23 22:31:33 +08:00
LIghtJUNction
99a9941aba chore: auto commit 2026-03-23 22:21:06 +08:00
LIghtJUNction
385d882aa9 fix: resolve anyio compliance and type checking issues
- Fix orchestrator to use anyio.get_cancelled_exc_class() instead of anyio.CancelledError
- Fix tests to properly check for anyio compliance (not violations)
- Add type annotations for MCP exception fallbacks in registry.py
- Remove unused type: ignore comment in mcp/tool.py
- All 111 tests pass
- uvx ty check passes
- ruff check passes
2026-03-23 22:15:47 +08:00
LIghtJUNction
3485605312 fix: correct logger import in ACP client 2026-03-23 22:02:38 +08:00
LIghtJUNction
20b7f60330 chore: update protocol clients and add architecture compliance tests 2026-03-23 22:01:02 +08:00
LIghtJUNction
225ef79337 test: add ABP protocol tests 2026-03-23 21:50:20 +08:00
LIghtJUNction
9058505593 test: add gateway WebSocket manager tests 2026-03-23 21:49:19 +08:00
LIghtJUNction
20886c3855 test: add MCP client and additional orchestrator tests 2026-03-23 21:48:22 +08:00
LIghtJUNction
49a32fbf49 test: add ABP client tests 2026-03-23 21:47:28 +08:00
LIghtJUNction
23e3fe6eac chore: auto commit 2026-03-23 21:45:14 +08:00
LIghtJUNction
e9e7c7ff07 feat: add builtin tools module for _internal package 2026-03-23 21:44:14 +08:00
LIghtJUNction
9d49acdce7 refactor: remove unused imports and add TODO for anyio migration 2026-03-23 21:41:06 +08:00
LIghtJUNction
0b25f4eba1 fix(test): use MagicMock instead of AsyncMock for sync ABP methods 2026-03-23 21:39:52 +08:00
LIghtJUNction
f62fc4d8a0 chore: auto commit 2026-03-23 21:37:17 +08:00
LIghtJUNction
ced1a4fbb6 chore: auto commit 2026-03-23 21:35:18 +08:00
LIghtJUNction
0f74731c53 feat(runtime): add new internal architecture with protocol clients
Implement the new _internal package structure for AstrBot runtime:
- Add AstrbotOrchestrator with LSP, MCP, ACP, ABP protocol clients
- Add AstrbotGateway server with WebSocket support
- Add comprehensive test suite for runtime module
- Add tools base module for MCP tools

Implements bootstrap function using anyio task groups for
concurrent protocol client initialization.
2026-03-23 21:26:22 +08:00
LIghtJUNction
f60ffdb62a refactor(dashboard): remove unused sha256 utility
The sha256.ts module is no longer used by any frontend code.
2026-03-23 20:38:43 +08:00
LIghtJUNction
320425f7e7 refactor: remove unused is_legacy_dashboard_password_hash
Remove the dead is_legacy_dashboard_password_hash helper which was
never used by verify_dashboard_password. Legacy SHA-256/MD5 hashes
are not supported - only Argon2 and PBKDF2 are valid password hashes.
Users with old SHA-256 hashes must reset their password.
2026-03-23 20:34:02 +08:00
LIghtJUNction
bbcdc502a5 feat(dashboard): clarify frontend/backend behavior with better messages
- Track _webui_fallback flag to distinguish "frontend disabled" vs "frontend enabled but files missing"
- Improve messages:
  - "前端未内置或未初始化,回退到仅启动后端" when fallback occurs
  - "前端已禁用" when user explicitly disabled
  - "正在启动 API Server" instead of "WebUI 已分离"
  - "前端未启用,请访问在线面板" for HTTP responses when frontend disabled
2026-03-23 17:43:30 +08:00
LIghtJUNction
fcaaeb5114 test: fix tests for abstract ComputerBooter
ComputerBooter is now an abstract class, so tests that tried to
instantiate it directly need to be updated:

- test_booter_decoupling.py: remove test_get_tools_delegates_to_class
  since base class cannot be instantiated
- test_profile_aware_tools.py: use ShipyardBooter.__new__() to test
  base class property defaults (capabilities, browser)
- test_computer.py: skip BoxliteBooter test since it's also abstract
  and requires the boxlite module
2026-03-23 17:37:14 +08:00
LIghtJUNction
f688343072 fix: remove incorrect startup suggestion from version check 2026-03-23 17:32:52 +08:00
LIghtJUNction
cfdb4ef651 feat: add Python version check requiring 3.12 or 3.13
- Add version check at startup in both __main__.py and cmd_run.py
- Suggest using `uv run -m astrbot` or reinstalling with uv
- Add ABC base class and abstract methods to ComputerBooter
- Improve type annotations in OpenAIAgentsRunner
2026-03-23 17:32:11 +08:00
LIghtJUNction
99c66c2410 feat(cli): add real-time log streaming in non-interactive mode
- Non-interactive mode now streams logs to stdout with color-coded levels
- Add proper async cleanup when shutting down
- Fix type annotations in coze and deerflow agent runners
2026-03-23 17:21:05 +08:00
lightjunction
bf21f1a499 fix(dashboard): update password hints to reference CLI instead of "default password"
Security improvement: password is now set via `astrbot conf admin` CLI
command rather than being a hardcoded default. Updated all relevant
i18n strings to reflect this change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 16:55:30 +08:00
lightjunction
36eb3f3eb8 fix(dashboard): improve WebUI disabled messaging
When WebUI is disabled via config, tell users to use the online
dashboard at dash.astrbot.men instead of the cryptic technical message.

When WebUI files are missing (index.html not found), also redirect
users to the online dashboard instead of just saying "WebUI will be
disabled."

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 16:52:33 +08:00
lightjunction
1048752b27 debug(dashboard): add explicit logging when enable_webui=false
This helps diagnose whether the API server is actually starting
when WebUI is disabled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 16:51:35 +08:00
lightjunction
52bfe5f605 debug(dashboard): add logging for ASTRBOT_PORT env var vs config priority
This helps diagnose when the environment variable is being ignored in
favor of cmd_config.json.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 16:06:10 +08:00
lightjunction
999ce123a7 fix(discord): remove duplicate command registration loop
The _collect_and_register_commands method was iterating over
star_handlers_registry twice: once via collect_commands() and again
in a redundant second loop. This caused the same commands to be
registered to the Discord client twice, resulting in "Application
command names must be unique" errors during sync_commands().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:29:06 +08:00
LIghtJUNction
8e0314a559 refactor: reorganize agent runner imports for openai-agents integration
- Move AgentResponse to its own module in core/agent/response
- Update import paths in all runner files
- Add provider_config parameter to ToolLoopAgentRunner
2026-03-23 13:29:53 +08:00
LIghtJUNction
524b5cbe42 fix(test_dashboard): add missing @pytest.mark.asyncio decorator
The test_t2i_set_active_template_syncs_all_configs async test was
missing its decorator, causing pytest to fail with "async def functions
are not natively supported" error.
2026-03-23 13:25:28 +08:00
LIghtJUNction
19e0952a6f fix: standardize dashboard env vars to ASTRBOT_* prefix only
- Remove all DASHBOARD_* and ASTRBOT_DASHBOARD_* fallback chains
- server.py now only checks ASTRBOT_HOST, ASTRBOT_PORT, ASTRBOT_SSL_*
- cmd_run.py no longer sets legacy DASHBOARD_* environment variables
- Clean up import paths for agent runners
2026-03-23 13:18:23 +08:00
LIghtJUNction
93f92dc366 merge: sync with origin/master
Conflicts resolved:
- tests/test_dashboard.py: kept all tests from both sides
- astrbot/core/config/default.py: took origin/master mimo-tts hints
- astrbot/dashboard/routes/t2i.py: took origin/master _sync_active_template_to_all_configs approach
- dashboard/src/views/Settings.vue: took origin/master version
2026-03-23 13:15:35 +08:00
LIghtJUNction
e005bb6f39 test(dashboard): add tests for tools routes
- Add 13 tests covering tools route handlers:
  - GET /tools/list
  - POST /tools/toggle-tool (missing params, not found)
  - GET /tools/mcp/servers
  - POST /tools/mcp/add (empty name, no config, connection failure)
  - POST /tools/mcp/update (empty name, not found)
  - POST /tools/mcp/delete (empty name, not found)
  - POST /tools/mcp/test (invalid config)
  - POST /tools/mcp/sync-provider (unknown provider)
- tools.py coverage: 9% → 38%
- Total tests: 47 → 60
2026-03-23 13:03:45 +08:00
LIghtJUNction
ce6e9f9d0b refactor: move openai_agents to _internal/agents
Move the openai-agents SDK integration from core/agent/runners/openai_agents
to _internal/agents/openai_agents to follow the internal implementation pattern.
2026-03-23 12:56:09 +08:00
LIghtJUNction
80c46c0639 feat: add openai-agents SDK integration
Add integration layer for using the openai-agents library with
AstrBot's existing agent infrastructure:

- OpenAIAgentsRunner: A BaseAgentRunner implementation that wraps
  the openai-agents Agent class
- Tool adapter to convert AstrBot FunctionTool to openai-agents format
- Support for tool handlers and FunctionToolManager integration
2026-03-23 12:53:00 +08:00
LIghtJUNction
f554529940 fix: remove unnecessary type ignore comment in misskey_adapter 2026-03-23 12:49:23 +08:00
LIghtJUNction
f55016bcb2 refactor: type annotations and i18n improvements
- Add i18n support to cmd_conf.py (config validators)
- Fix BaseFunctionToolExecutor to be an ABC
- Add explicit type annotations for dict payloads
- Various type annotation improvements
2026-03-23 12:49:13 +08:00
LIghtJUNction
f2c0c2a9de feat: add _internal modules for MCP, Skills, and Tools
This commit adds the _internal package structure for AstrBot's
standardized MCP & Skills support:

astrbot/_internal/mcp/:
- MCPClient for MCP server connections
- MCPTool wrapper for MCP tools
- MCP configuration management

astrbot/_internal/skills/:
- SkillManager for skill lifecycle
- Skill parser and loader
- SkillToToolConverter for tool-based skills
- Prompt builder for skills

astrbot/_internal/tools/:
- ToolSchema, FunctionTool, ToolSet base definitions
- FunctionToolManager for tool registry
- Builtin tools (cron, send_message, kb_query)
- Tool providers (internal, plugin, computer)

astrbot/api/:
- Public API for tools (ToolRegistry, tool decorator)
- Public API for MCP (get_mcp_servers, register_mcp_server)
- Public API for skills (get_skill_manager, skill_to_tool)
2026-03-23 12:48:34 +08:00
LIghtJUNction
f0423a7174 chore: add openai-agents dependency
Added openai-agents library for enhanced agent implementation
2026-03-23 12:48:01 +08:00
LIghtJUNction
da62b57c56 feat(i18n): add Chinese and English language support for CLI and TUI
- Add CLI i18n module (astrbot/cli/i18n.py) with zh/en translations
- Add TUI i18n module (astrbot/tui/i18n.py) with zh/en translations
- Update CLI commands to use translated strings
- Update TUI app and screen to use translated strings
- Add ASTRBOT_CLI_LANG and ASTRBOT_TUI_LANG to .env.example
- Update cmd_run.py env var documentation
- Add backward compatibility for moved modules with DeprecationWarning
2026-03-23 12:43:37 +08:00
LIghtJUNction
8d26c38b32 test(dashboard): add tests for command and api_key routes
Add tests for:
- command.py: toggle/rename/permission endpoints with missing params
- api_key.py: create with invalid scopes/expires_in_days, revoke/delete
  with missing key_id and not found cases

This improves coverage:
- api_key.py: 73% -> 96%
- command.py: 35% -> 60%
2026-03-23 12:34:44 +08:00
LIghtJUNction
c6cd48be4f test(dashboard): add tests for TUI chat routes
Add 14 new tests covering:
- TUI session creation (new_session)
- TUI session listing (get_sessions)
- TUI session retrieval with history (get_session)
- TUI session display name update
- TUI session deletion
- TUI session batch deletion
- TUI session stop
- Error cases for missing/invalid parameters

These tests improve coverage of tui_chat.py from 14% to 37%.
2026-03-23 06:21:49 +08:00
LIghtJUNction
80a0f33538 fix(dashboard): resolve route endpoint conflict and fix failing test
- Generate unique endpoint names in Route.register_routes() to avoid
  conflicts between ChatRoute and TUIChatRoute both exposing /api/tui/chat
- Simplify test_batch_upload_skills_accepts_valid_skill_archive to match
  the pattern used by test_batch_upload_skills_accepts_zip_files,
  mocking install_skill_from_zip instead of trying to patch paths
2026-03-23 06:08:48 +08:00
LIghtJUNction
a05f9eba7d feat: add TUI platform adapter and chat route
- Add TUI platform adapter (tui_adapter, tui_event, tui_queue_mgr)
- Add TUI chat route for dashboard
- Add cmd_run_tui command
- Fix shared_preferences TypeVar syntax
- Various platform adapter and provider updates
2026-03-23 05:57:55 +08:00
LIghtJUNction
c1a0db30ad fix: update tests to reflect new core constraints behavior
- test_pip_*: Update expected values from "shared-lib==2.0" to "shared-lib>=1.0"
  to match the new behavior that preserves original version constraints
- test_skill_manager_sandbox_cache: Fix monkeypatch issues by using
  MockAstrbotPaths instead of non-existent module-level functions
- test_tool_loop_agent_runner: Change tool_schema_mode from "skills_like"
  to "lazy_load" to match actual supported mode
2026-03-23 05:55:59 +08:00
LIghtJUNction
369b862dfa test: add TUI message handler tests
Add comprehensive tests for SSEMessageParser and TUI message processing.
2026-03-23 03:12:38 +08:00
LIghtJUNction
1c7085d650 fix: simplify JSON Schema in SendMessageToUserTool parameters
Simplify the message items schema by using additionalProperties
instead of explicit properties, while preserving type info for LLM docs.

Note: 12 ty diagnostics remain in send_message.py and
astr_main_agent_resources.py due to architectural issue where
FunctionTool.parameters JSON Schema is used by ty for Python
type inference. This requires larger refactoring to fix properly.
2026-03-23 03:11:05 +08:00
LIghtJUNction
47aa6ea2cd docs: add --seed flag to uv venv for portable shebangs
The --seed flag makes uv generate shebangs using #!/usr/bin/env python3
instead of absolute paths, allowing venvs to be moved.
2026-03-23 03:03:55 +08:00
LIghtJUNction
1d2469f0ae fix: use sys.stdin.isatty() for non-interactive detection
Replace redundant ASTRBOT_SYSTEMD environment variable checks with
sys.stdin.isatty() for detecting non-interactive environments.
The DashboardManager.ensure_installed() already handles this internally,
so the outer ASTRBOT_SYSTEMD checks were unnecessary.
2026-03-23 03:00:26 +08:00
LIghtJUNction
d6f74a8493 feat(tui): add support for tool and reasoning message types
- Add additionalProperties to message schema for flexibility
- Add TOOL_MSG and REASONING_MSG color pairs for visual distinction
2026-03-23 02:58:09 +08:00
LIghtJUNction
fcab00332d docs: improve module docstrings
- Add module docstring to tui_async.py describing async TUI implementation
- Add new message_handler.py module with shared SSE message handling
2026-03-23 02:57:29 +08:00
LIghtJUNction
ae4cbcdf21 fix: resolve typos and add type annotations
- Fix typo in tui_app.py: key == key == curses.KEY_DOWN -> key == curses.KEY_DOWN
- Add type annotations in send_message.py for better type checking
2026-03-23 02:57:03 +08:00
LIghtJUNction
b4a32fbda8 fix: resolve documentation formatting issues
- Add 'config' to known zh/en doc structure differences
- Remove trailing whitespace from docs/zh/faq.md
- Remove trailing whitespace from docs/en/dev/plugin-platform-adapter.md
- Ensure all README files end with newline
2026-03-23 02:49:15 +08:00
LIghtJUNction
cfdcd63676 test: add documentation consistency tests
Add pytest test file to verify:
- zh/en doc structure mirror
- Core docs exist (README, AGENTS.md, CLAUDE.md, startup guides)
- No broken internal links
- Markdown formatting standards
- Skill documentation with frontmatter
2026-03-23 02:43:58 +08:00
LIghtJUNction
13262b21e6 docs: remove empty astrbot-sandbox.md placeholder 2026-03-23 02:40:04 +08:00
LIghtJUNction
04e9bf8ca8 refactor: code formatting and type improvements
- Format bwrap.py with ruff, clean up imports
- Remove unused cast import in tool.py
- Add getattr fallbacks in context.py for handler name resolution
- Fix param_type annotation to allow Any in command.py
2026-03-23 02:29:33 +08:00
LIghtJUNction
371ff24de1 docs: add AI developer guide skill to docs/skills
Provides comprehensive guidance for AI assistants working on AstrBot:
- Project overview and architecture
- Development setup and commands
- Code style rules (type hints, paths, formatting)
- Environment variable conventions
- Common development patterns
- Git and PR guidelines
2026-03-23 02:26:41 +08:00
LIghtJUNction
3d7f3fb2f6 docs: add CLAUDE.md for AI coding guidelines
Provide comprehensive guidance for AI assistants working on the codebase:
- Project overview and architecture
- Development setup commands
- Python code style (type hints, path handling, formatting)
- Environment variable conventions
- Testing guidelines
- Git and PR conventions
- Common task patterns
2026-03-23 02:18:03 +08:00
LIghtJUNction
41172c9380 docs: add project startup guide for zh and en
Document AstrBot startup process including:
- Installation methods (uv, dev mode, from source)
- Initialization details and options
- Startup options and parameters
- Environment variables
- Directory structure
- Troubleshooting guide
2026-03-23 02:07:10 +08:00
LIghtJUNction
75fa652ccb Add performance benchmark tests with scoring
- Comprehensive performance benchmarks for CommandFilter operations
- Memory usage benchmarks with tracemalloc tracking
- High-throughput benchmarks with ops/sec metrics
- Scoring system (0-100) for performance tracking
- Overall performance score summary

Benchmarks include:
- CommandFilter.get_complete_command_names
- Boolean and integer parameter validation
- Memory footprint of filter creation
- High-throughput validation throughput
2026-03-23 02:04:06 +08:00
LIghtJUNction
7fe58cfdbc docs: restore sponsors section to all README files 2026-03-23 02:02:32 +08:00
LIghtJUNction
05053c221d Revert SDK integration, fix test paths, restore original code
- Delete astrbot-sdk package entirely
- Remove SDK dependencies from pyproject.toml
- Restore core modules to pre-sdk versions
- Fix test monkeypatch paths to use correct module paths
- Fix bool type checking bug in command filter
- Fix tools=None preservation in subagent orchestrator
2026-03-23 01:56:39 +08:00
LIghtJUNction
4db5063b77 docs: remove internal refactor planning document 2026-03-23 01:55:37 +08:00
LIghtJUNction
095ac35221 docs: remove SDK integration plan document
The SDK integration was reverted, remove the now-stale planning document.
2026-03-23 01:51:48 +08:00
LIghtJUNction
6a4177cae4 Revert "feat: SDK integration and various improvements" 2026-03-23 01:18:03 +08:00
LIghtJUNction
2e3a20fcdf fix: improve bool param validation and preserve None tools
- Fix bool type checking in CommandFilter.validate_and_convert_params
  (was using isinstance(bool_instance, bool) instead of 'bool is bool')
- Preserve tools=None when persona explicitly has no tools
- Add missing provider_wake_prefix in test setup
2026-03-23 01:07:21 +08:00
LIghtJUNction
03c0b4c73e fix: skip interactive dashboard prompt in systemd mode
When running under systemd (ASTRBOT_SYSTEMD=1), the click.confirm()
prompt would raise Abort on user input, crashing the service.
Skip the interactive confirmation and silently return instead.
2026-03-23 00:55:56 +08:00
LIghtJUNction
2c830039bb Merge branch 'dev' of https://github.com/AstrBotDevs/AstrBot into dev 2026-03-22 23:38:46 +08:00
LIghtJUNction
a3fbfd3540 feat: SDK integration and various improvements
- Refactor core modules for better SDK integration
- Improve skill manager with better caching and loading
- Update dashboard routes for plugin and tools management
- Fix and enhance computer skill synchronization
- Various bug fixes and test improvements
2026-03-22 23:38:27 +08:00
LIghtJUNction
b62c92bdb3 dev : Implement bubblewrap sandbox backend with availability detection and tests (#6709)
* New sandbox backend: bubblewrap.
 - Based on Linux Namespace for resource isolation
 - Runs on local computer, with no privilege required
 - Only supports Linux as namespace & bubblewrap are not present on other platforms.

TODO:
 - Fix dashboard presentation. Why change on src does not affect what is really displayed?
 - Strenghthen backend availability detection. One known issue is, on some platforms like Ubuntu 24.04, bubblewrap is banned by system guards, even when it's shipped by package manager. A complete detector may contain :
   1. run the command with cmdline used by the booter. Return True if succsed.
   2. If false, do bottom-up reason detection. Namespace not compiled to kernel? Specific kernel parameters not set? Banned by safety guard? The availability detector should give the user a clear information on why this sandbox backend fails.

These work may require helps from frontend developers. It is tested to be usable on my computer, with non-persistent environment(forget on every command) and persistent file storage.

* Add RO bind entry for bubblewrap backend.

TODO add:
 - add plugin utility to change ro and rw bind in cmdline
 - make bind dirs dict instead of list to manually map mount point

* Fix: add boot time test for bwrap booter.
 in older commits, ro_bind = ['/'] makes skill sync crash. This commit fixes it and adds detection.

* Add availability of bwrap check during booting

* unit tests of bwrap

* i18n of bwrap config by Gemini 3.1 pro

* Update astrbot/core/computer/booters/bwrap.py

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

---------

Co-authored-by: YI Zeping <yizeyi18@mail.nankai.edu.cn>
Co-authored-by: YI Zeping <18586016708@163.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-22 23:15:16 +08:00
LIghtJUNction
95430ee6f8 Feat/sdk integration (#6807)
* Add legacy session waiter compatibility

* Expand legacy astrbot package compatibility

* Tighten external legacy plugin compatibility smoke tests

* Consolidate controlled legacy facade compatibility

* docs: clarify compat package boundaries

* test: align runtime fixtures with maintained samples

* fix: preserve unicode sample fixtures in runtime tests

* Implement legacy hook and tool compat runtime

* Refactor legacy runtime execution boundary

* 增强旧版兼容性,添加多个旧路径入口和相关功能

* 增强旧版兼容性,添加适配器边界的启动和关闭钩子支持

* Refactor legacy API and LLM compatibility logic

- Moved legacy LLM and tool compatibility logic from `_legacy_api.py` to a new module `_legacy_llm.py` for better organization and separation of concerns.
- Updated `_legacy_api.py` to import necessary components from `_legacy_llm.py`, removing redundant code.
- Enhanced database client functionality by adding support for batch read/write operations and change event subscriptions.
- Improved documentation in the database client and capability router to reflect new features.
- Refined environment management process in the loader to better handle plugin grouping and virtual environment management.

* 补充插件分组环境测试覆盖

* feat: Enhance CLI and testing capabilities

- Added a new script entry point `astrbot-sdk` in `pyproject.toml`.
- Introduced `has_waiter` method in `SessionWaiterManager` to check for existing waiters.
- Updated `cli.py` to improve error handling and added context to error messages.
- Implemented local development support in `cli.py` with a new `dev` command for running plugins against a mock core.
- Created a new testing module `astrbot_sdk.testing` with utilities for local development and plugin testing.
- Added comprehensive tests for the new testing module and CLI commands.
- Improved compatibility and error messaging for plugin loading failures.

* feat: 添加插件初始化、验证和构建命令,增强 CLI 功能

* feat: add platform client documentation and examples

- Introduced platform client documentation in `docs/v4/clients/platform.md` detailing methods for sending messages, images, and managing group members.
- Added example plugins for LLM chat and database functionalities in `docs/v4/examples/README.md`, `docs/v4/examples/llm-chat/README.md`, and `docs/v4/examples/database/README.md`.
- Enhanced quickstart guide with links to new documentation and example plugins.
- Implemented runtime contract tests to ensure compatibility of public capabilities and hooks.

* Refactor legacy runtime handling and improve plugin loading

- Updated `handler_dispatcher.py` to streamline legacy runtime preparation and dispatching results.
- Enhanced `loader.py` to simplify legacy plugin detection and manifest building.
- Added tests for new HTTPClient and MetadataClient functionalities.
- Introduced tests for legacy context metadata methods and legacy loader helpers.
- Improved legacy runtime tests to cover new functionality and edge cases.

* refactor: 更新兼容层和导入路径,优化文档描述

* Support grouped plugin workers in shared environments

- add group metadata driven worker startup for shared env plans
- track per-plugin handler and capability ownership inside grouped workers
- update runtime and smoke tests for grouped worker session behavior

* Add v4 compat layer and legacy shims

- Introduce private v4 compatibility surface using
  _legacy_api.py, _legacy_runtime.py, _legacy_loader.py plus new
  _legacy_context.py and _legacy_star.py to centralize legacy adapters
  while keeping public APIs thin.
- Extend InitializeOutput to carry protocol_version for negotiated
  protocol, enabling runtime to adapt to the chosen v4 version.
- Add lightweight legacy support for Star/Context via new LegacyStar and
  LegacyContext shims and expose legacy API through the aggregate
  _legacy_api entry point.
- Ensure legacy loader preserves class declaration order by iterating
  module.__dict__ instead of relying on alphabetical sorting.
- Add tests: protocol_version handling in InitializeOutput, legacy
  main component order preservation, and embedded-newline framing in
  transport tests.

* Add architecture doc and refine API compat

- Add PROJECT_ARCHITECTURE.md documenting architecture, compat surface,
  and testing notes.
- Update astrbot_sdk.api.__init__ to clarify it is a compatibility
  implementation layer, not a simple facade, and list migration targets.
- Normalize platform in AstrMessageEvent.to_payload to emit a string id
  by using get_platform_id().

* Potential fix for pull request finding

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

* delete old sdk

* delete old sdk (#7)

Co-authored-by: whatevertogo <whatevertogo@users.noreply.github.com>

* feat(cli): normalize plugin init skeletons

Add interactive plugin init prompts and normalize generated plugin names to the astrbot_plugin_ convention.
Update CLI tests for the new skeleton layout and ignore generated plugin directories in git and coverage tooling.
Also include related runtime logging adjustments from the current worktree.

* Create lint.yml

* Update lint.yml

* clean it

* feat: Enhance handler and capability dispatchers with improved error handling

- Updated HandlerDispatcher to raise TypeError for uninjectable required parameters, logging errors appropriately.
- Refactored CapabilityDispatcher to raise TypeError for missing required parameters during capability execution.
- Renamed _load_plugin_config to load_plugin_config for clarity and consistency.
- Introduced _sync_plugin_registry method in SupervisorRuntime to manage plugin capabilities more effectively.
- Enhanced capability registration logic to handle naming conflicts with better logging and automatic renaming.
- Added tests for handler and capability dispatchers to ensure proper error handling and functionality.
- Implemented new HTTP and metadata capabilities with corresponding tests for registration and retrieval.
- Improved MemoryClient methods with additional tests for save_with_ttl, get_many, delete_many, and stats.
- Added tests for the testing module to ensure proper import and functionality of PluginHarness.

* feat: 更新 SDK 描述,重构插件调用上下文,移除插件 ID 传递,增强能力路由和 HTTP 客户端的插件身份管理

* refactor: 更新文档和代码注释,优化兼容性描述,增强可读性

* feat: 添加插件热重载功能,支持文件变更时自动重新加载插件

* feat: 增强错误处理,添加上下文信息,优化插件组件加载和参数注入校验

* feat: 添加 hello_plugin 示例,包含插件结构、命令处理和测试用例

* refactor: 删除过时的架构文档、变更日志和兼容矩阵文件

* refactor: 更新兼容层弃用通知,优化文档结构和可读性

* refactor: 更新项目架构文档,增强能力客户端和执行边界的描述,移除兼容层设计章节

* feat(errors): Enhance AstrBotError with detailed documentation and examples

feat(events): Expand MessageEvent with reply capabilities and detailed docstrings

fix(loader): Ensure plugin path is correctly managed in sys.path

feat(star): Improve Star class documentation and lifecycle method descriptions

feat(testing): Add plugin metadata handling in MockContext and enhance PluginHarness

feat(hello): Refactor HelloPlugin to utilize new reply methods and structured capabilities

test(decorators): Add tests for input/output model support in provide_capability

test(events): Implement tests for reply_image and reply_chain methods in MessageEvent

test(http): Validate API registration with capability handler references and error handling

test(tests): Enhance tests for plugin harness and directory handling in dev commands

* feat(runtime): add configurable msgpack wire codec support

* fix(runtime): align msgpack framing with transport defaults

* fix(runtime): preserve json transport compatibility

* fix(cli): scope worker wire codec option

* feat: 添加 AGENTS.md 文档,描述 v4 架构约束和开发命令
refactor: 更新 HandlerDispatcher 和 WorkerSession,增强参数处理和结果汇总逻辑

* fix(test): 更新 init_plugin 测试以匹配新的目录命名规范

CLI 的 _normalize_init_plugin_name 函数现在自动添加 astrbot_plugin_ 前缀,
测试期望的目录名从 demo_plugin 更新为 astrbot_plugin_demo_plugin。

* Refactor worker initialization and remove unused codec parameters; add schedule and session waiter modules

- Simplified `GroupWorkerRuntime` and `PluginWorkerRuntime` constructors by removing the codec parameter and related logic.
- Introduced `schedule.py` to define `ScheduleContext` for managing scheduled tasks with a clear structure and payload handling.
- Added `session_waiter.py` for session-based conversational flow management, including `SessionController` and `SessionWaiterManager` for handling multi-turn dialogues.
- Enhanced testing utilities in `testing.py` by removing unused classes and streamlining the structure.
- Created `types.py` to introduce `GreedyStr` for improved command parameter parsing.

* feat: 添加 LLM 工具管理和会话级别状态管理能力

- 新增 llm/ 模块,包含 LLMToolSpec、ProviderRequest、AgentSpec 等实体
- 新增 LLMToolManager 用于管理 LLM 工具注册和激活状态
- 新增 SessionPluginManager 用于会话级别的插件启用状态管理
- 新增 SessionServiceManager 用于会话级别的 LLM/TTS 服务状态管理
- 新增 RegistryClient 用于查询 handler 元数据和设置白名单
- 扩展 CapabilityRouter 内置能力,支持 session.* 和 registry.* 命名空间
- 增强描述符和装饰器以支持新的 trigger 类型

* feat: 大幅增强 SDK 核心功能和文档

新增模块:
- clients/files.py: 文件上传/下载客户端
- clients/managers.py: 会话/LLM/Provider 管理器
- clients/provider.py: LLM Provider 客户端
- conversation.py: 对话上下文管理
- plugin_kv.py: 插件 KV 存储辅助
- runtime/limiter.py: 限流器
- star_tools.py: Star 工具函数
- docs/: 完整的 SDK 使用文档 (01-05)

功能增强:
- Context 大幅扩展,增加 reply/send_image/typing 等便捷方法
- 装饰器增强,支持 on_llm_request/on_provider_request 等
- 内置 schemas 扩展,覆盖更多 capability 定义
- capability_router_builtins 大幅扩展内置能力实现
- handler_dispatcher 增强参数注入和错误处理
- Star 基类增加生命周期钩子和工具方法

* Add comprehensive API documentation for types and utilities in AstrBot SDK

- Introduced `types.md` detailing type aliases, generics, and Pydantic models used in the SDK.
- Added `utils.md` covering utility classes and functions including CancelToken, MessageSession, command groups, and session management.
- Included usage examples and detailed descriptions for each component to enhance developer understanding and ease of use.

* feat: 添加高级方法和辅助函数文档,增强消息组件和事件处理功能

* feat: 增强过滤器类型和能力路由文档,添加 Provider 和会话管理功能

* change location

* delete no need thing

* delete again

* feat: add Star plugin base class and StarTools utility class

- Introduced `Star` class as a base for v4 native plugins, providing lifecycle methods and context management.
- Added `StarTools` class for accessing runtime context and managing LLM tools.
- Implemented `PluginHarness` for local development and testing of plugins, allowing for message dispatching and lifecycle management.
- Created `GreedyStr` type for enhanced command parameter parsing, enabling the capture of remaining command text as a single argument.
- Added testing utilities and mock capabilities for plugin development.

* delete: remove hello_plugin example and its related files

* Remove obsolete test files for testing module, top-level modules, transport, and wire codecs

- Deleted `test_testing_module.py` as it is no longer needed.
- Removed `test_top_level_modules.py` which had no content.
- Eliminated `test_transport.py` due to redundancy.
- Cleared out `test_wire_codecs.py` as part of the cleanup.

* fix(runtime): avoid creating Star instance in on_error fallback

* fix(runtime): avoid virtual dispatch in Star.on_error fallback

* refactor(runtime): unify command matching logic (#25)

* refactor(testing): share command matching with handler dispatcher

* fix:添加公共函数文件

* fix: simplify register_task completion handling (#27)

* fix: simplify register_task completion handling

Remove duplicated cancellation logging in Context.register_task while keeping Future inputs compatible with asyncio.create_task semantics. Add regression coverage for coroutine, Future, cancellation, and failure paths.

* fix: prioritize local src in tests_v4

Ensure tests_v4 always imports the working tree package by moving src to sys.path[0] even when another checkout or installed copy is already present.

* chore: sync subtree from AstrBot

* feat: replay non-sdk changes on clean sdk subtree baseline

* feat(sdk): add merged provider config capability support

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* feat(sdk): add merged provider config bridge and client

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* feat(sdk): add merged provider config capability support

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix(sdk): tighten bridge cast typing

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* feat(sdk): add merged provider config bridge and client

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* test(sdk): cover merged provider config parity

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* feat: 完善 memory 向量检索与索引统计 (#28)

Co-authored-by: united_pooh <united_pooh@outlook.com>

* feat(tests): 添加测试用例以验证 register_task 的行为并更新测试运行说明

* Merge commit 'e45bade147ff44b43860ecff12067309e59c151a' into feat/sdk-integration

* Squashed 'astrbot-sdk/' changes from 7dda6077..85342f14

85342f14 feat(tests): 添加测试用例以验证 register_task 的行为并更新测试运行说明
fdffc09b Merge pull request #26 from united-pooh/fix/fix-star-on-error-fallback
3b09747c feat: 完善 memory 向量检索与索引统计 (#28)
665c9c69 fix(runtime): avoid virtual dispatch in Star.on_error fallback
200559a5 fix(runtime): avoid creating Star instance in on_error fallback

git-subtree-dir: astrbot-sdk
git-subtree-split: 85342f149b

* feat(tests): 添加测试用例以验证平台和消息类型过滤器的冲突处理

* docs: remove redundant testing instructions from AGENTS.md

* docs: remove redundant testing instructions from AGENTS.md

* docs: remove redundant testing instructions from AGENTS.md

* Merge commit '5ac9401852ddb46f337da6bcc0f9b66eed265da9' into feat/sdk-integration

* Squashed 'astrbot-sdk/' changes from 85342f14..09beabeb

09beabeb feat(tests): 添加测试用例以验证平台和消息类型过滤器的冲突处理

git-subtree-dir: astrbot-sdk
git-subtree-split: 09beabeb62

* feat: enhance SDK plugin configuration handling and logging

* feat: enhance SDK plugin configuration handling and logging

* feat: enhance SDK plugin configuration handling and logging

* feat: 增强装饰器功能,添加会话命令支持及相关权限和限流装饰器

* feat: 增强装饰器功能,添加会话命令支持及相关权限和限流装饰器

* feat: 增强装饰器功能,添加会话命令支持及相关权限和限流装饰器

* feat: 更新文档以反映SDK负载的JSON可序列化要求和延迟导入设计约束

* feat: add conversation.get_current capability and related schemas

- Introduced CONVERSATION_GET_CURRENT_INPUT_SCHEMA and CONVERSATION_GET_CURRENT_OUTPUT_SCHEMA for handling current conversation requests.
- Implemented _conversation_get_current method in BuiltinCapabilityRouterMixin to manage current conversation retrieval and creation.
- Registered the new capability in CoreCapabilityBridge.
- Enhanced HandlerDispatcher to inject provider request, LLM response, and event result payloads into the event handling process.
- Updated tests to validate the new functionality and ensure proper payload handling.

* feat: add conversation.get_current capability and related schemas

- Introduced CONVERSATION_GET_CURRENT_INPUT_SCHEMA and CONVERSATION_GET_CURRENT_OUTPUT_SCHEMA for handling current conversation requests.
- Implemented _conversation_get_current method in BuiltinCapabilityRouterMixin to manage current conversation retrieval and creation.
- Registered the new capability in CoreCapabilityBridge.
- Enhanced HandlerDispatcher to inject provider request, LLM response, and event result payloads into the event handling process.
- Updated tests to validate the new functionality and ensure proper payload handling.

* feat: add conversation.get_current capability and related schemas

- Introduced CONVERSATION_GET_CURRENT_INPUT_SCHEMA and CONVERSATION_GET_CURRENT_OUTPUT_SCHEMA for handling current conversation requests.
- Implemented _conversation_get_current method in BuiltinCapabilityRouterMixin to manage current conversation retrieval and creation.
- Registered the new capability in CoreCapabilityBridge.
- Enhanced HandlerDispatcher to inject provider request, LLM response, and event result payloads into the event handling process.
- Updated tests to validate the new functionality and ensure proper payload handling.

* Refactor tool call handling in SdkPluginBridge

- Introduced a dictionary to map tool call IDs to tool names for better clarity and efficiency.
- Enhanced the extraction of tool call information from raw results, ensuring compatibility with both dictionary and object formats.
- Updated the logic to retrieve tool names based on tool call IDs, improving the robustness of the tool calls result processing.

* Refactor tool call handling in SdkPluginBridge

- Introduced a dictionary to map tool call IDs to tool names for better clarity and efficiency.
- Enhanced the extraction of tool call information from raw results, ensuring compatibility with both dictionary and object formats.
- Updated the logic to retrieve tool names based on tool call IDs, improving the robustness of the tool calls result processing.

* Refactor tool call handling in SdkPluginBridge

- Introduced a dictionary to map tool call IDs to tool names for better clarity and efficiency.
- Enhanced the extraction of tool call information from raw results, ensuring compatibility with both dictionary and object formats.
- Updated the logic to retrieve tool names based on tool call IDs, improving the robustness of the tool calls result processing.

* feat: add session and system capabilities for plugin management and event handling

- Implemented SessionCapabilityMixin with methods to manage session-level plugin states and handlers.
- Added SystemCapabilityMixin to handle system-level functionalities including file management, event handling, and dynamic command registration.
- Introduced methods for enabling/disabling plugins, filtering handlers, and managing LLM and TTS service states.
- Registered various system capabilities for data directory access, HTML rendering, and event streaming.

* feat: add session and system capabilities for plugin management and event handling

- Implemented SessionCapabilityMixin with methods to manage session-level plugin states and handlers.
- Added SystemCapabilityMixin to handle system-level functionalities including file management, event handling, and dynamic command registration.
- Introduced methods for enabling/disabling plugins, filtering handlers, and managing LLM and TTS service states.
- Registered various system capabilities for data directory access, HTML rendering, and event streaming.

* Squashed 'astrbot-sdk/' changes from 09beabeb..3204c9db

3204c9db Merge sdk-remote dev into feat/sdk-integration
3a2d715e Refactor tool call handling in SdkPluginBridge
ed1b9665 feat: add conversation.get_current capability and related schemas
e74123bb feat: 增强装饰器功能,添加会话命令支持及相关权限和限流装饰器
bb361cf9 feat: 增强装饰器功能,添加会话命令支持及相关权限和限流装饰器
c6237f52 Merge sdk-remote/dev into astrbot-sdk subtree
e12029ff feat: enhance SDK plugin configuration handling and logging
5e54bbb3 feat: enhance SDK plugin configuration handling and logging
f48e2041 Merge commit '5ac9401852ddb46f337da6bcc0f9b66eed265da9' into feat/sdk-integration
619672e6 Merge commit '5ac9401852ddb46f337da6bcc0f9b66eed265da9' into feat/sdk-integration
d5a3796d docs: remove redundant testing instructions from AGENTS.md
323e3f4d docs: remove redundant testing instructions from AGENTS.md
f8438a7b Merge commit 'e45bade147ff44b43860ecff12067309e59c151a' into feat/sdk-integration
96d1df85 Merge commit 'e45bade147ff44b43860ecff12067309e59c151a' into feat/sdk-integration
f8a7e253 feat(sdk): add merged provider config bridge and client
752dc6cf feat(sdk): add merged provider config capability support

git-subtree-dir: astrbot-sdk
git-subtree-split: 3204c9db9f

* Implement feature X to enhance user experience and optimize performance

* Implement feature X to enhance user experience and optimize performance

* chore(sdk): stop tracking uv.lock

* feat(sdk): add merged provider config capability support

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* feat(sdk): add merged provider config bridge and client

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* feat(tests): 添加测试用例以验证 register_task 的行为并更新测试运行说明

* feat(tests): 添加测试用例以验证平台和消息类型过滤器的冲突处理

* docs: remove redundant testing instructions from AGENTS.md

* feat: enhance SDK plugin configuration handling and logging

* feat: 增强装饰器功能,添加会话命令支持及相关权限和限流装饰器

* feat: add conversation.get_current capability and related schemas

- Introduced CONVERSATION_GET_CURRENT_INPUT_SCHEMA and CONVERSATION_GET_CURRENT_OUTPUT_SCHEMA for handling current conversation requests.
- Implemented _conversation_get_current method in BuiltinCapabilityRouterMixin to manage current conversation retrieval and creation.
- Registered the new capability in CoreCapabilityBridge.
- Enhanced HandlerDispatcher to inject provider request, LLM response, and event result payloads into the event handling process.
- Updated tests to validate the new functionality and ensure proper payload handling.

* Refactor tool call handling in SdkPluginBridge

- Introduced a dictionary to map tool call IDs to tool names for better clarity and efficiency.
- Enhanced the extraction of tool call information from raw results, ensuring compatibility with both dictionary and object formats.
- Updated the logic to retrieve tool names based on tool call IDs, improving the robustness of the tool calls result processing.

* feat: add session and system capabilities for plugin management and event handling

- Implemented SessionCapabilityMixin with methods to manage session-level plugin states and handlers.
- Added SystemCapabilityMixin to handle system-level functionalities including file management, event handling, and dynamic command registration.
- Introduced methods for enabling/disabling plugins, filtering handlers, and managing LLM and TTS service states.
- Registered various system capabilities for data directory access, HTML rendering, and event streaming.

* fix(runtime): avoid creating Star instance in on_error fallback

* fix(runtime): avoid virtual dispatch in Star.on_error fallback

* feat: refactor injected parameter handling and introduce is_framework_injected_parameter utility

* Squashed 'astrbot-sdk/' changes from 027c15b4..d078e510

d078e510 feat: refactor injected parameter handling and introduce is_framework_injected_parameter utility
461f7276 Merge branch 'dev' of https://github.com/united-pooh/astrbot-sdk into dev
5ead59c4 fix(runtime): avoid virtual dispatch in Star.on_error fallback
d2382858 fix(runtime): avoid creating Star instance in on_error fallback
e961e361 feat: add session and system capabilities for plugin management and event handling
5a46321a Refactor tool call handling in SdkPluginBridge
47698448 feat: add conversation.get_current capability and related schemas
9b35bec8 feat: 增强装饰器功能,添加会话命令支持及相关权限和限流装饰器
48a20240 feat: enhance SDK plugin configuration handling and logging
cb593a53 docs: remove redundant testing instructions from AGENTS.md
f4942076 feat(tests): 添加测试用例以验证平台和消息类型过滤器的冲突处理
b0f8b2d6 feat(tests): 添加测试用例以验证 register_task 的行为并更新测试运行说明
6e417c6d feat(sdk): add merged provider config bridge and client
659eabce feat(sdk): add merged provider config capability support
REVERT: 027c15b4 Implement feature X to enhance user experience and optimize performance
REVERT: c272661f chore: pull sdk subtree from dev (resolve delete/modify conflict)
REVERT: 0a2a3592 feat: add session and system capabilities for plugin management and event handling
REVERT: 3204c9db Merge sdk-remote dev into feat/sdk-integration
REVERT: 36443f1d Refactor tool call handling in SdkPluginBridge
REVERT: 3a2d715e Refactor tool call handling in SdkPluginBridge
REVERT: b93c2c2b feat: add conversation.get_current capability and related schemas
REVERT: ed1b9665 feat: add conversation.get_current capability and related schemas
REVERT: e74123bb feat: 增强装饰器功能,添加会话命令支持及相关权限和限流装饰器
REVERT: bb361cf9 feat: 增强装饰器功能,添加会话命令支持及相关权限和限流装饰器
REVERT: c6237f52 Merge sdk-remote/dev into astrbot-sdk subtree
REVERT: e12029ff feat: enhance SDK plugin configuration handling and logging
REVERT: 5e54bbb3 feat: enhance SDK plugin configuration handling and logging
REVERT: f48e2041 Merge commit '5ac9401852ddb46f337da6bcc0f9b66eed265da9' into feat/sdk-integration
REVERT: 619672e6 Merge commit '5ac9401852ddb46f337da6bcc0f9b66eed265da9' into feat/sdk-integration
REVERT: d5a3796d docs: remove redundant testing instructions from AGENTS.md
REVERT: 323e3f4d docs: remove redundant testing instructions from AGENTS.md
REVERT: f8438a7b Merge commit 'e45bade147ff44b43860ecff12067309e59c151a' into feat/sdk-integration
REVERT: 96d1df85 Merge commit 'e45bade147ff44b43860ecff12067309e59c151a' into feat/sdk-integration
REVERT: f8a7e253 feat(sdk): add merged provider config bridge and client
REVERT: 752dc6cf feat(sdk): add merged provider config capability support

git-subtree-dir: astrbot-sdk
git-subtree-split: d078e51051

* refactor: reorganize imports and enhance type hints in sdk_bridge modules

* refactor: update import paths to use Path for better compatibility

* refactor(injection): centralize legacy injected parameter filtering

* fix(testing): use public session waiter probe in PluginHarness

* docs: add TODO for documentation content in _command_model.py

* feat: add memory management capabilities to CoreCapabilityBridge and implement unit tests

* feat: enhance memory search functionality and improve metadata retrieval in SDK

* feat: add memory management attributes and typed provider method to CapabilityMixinHost

* Implement feature X to enhance user experience and optimize performance

* Refactor memory utility functions and enhance memory capability mixin

- Added new utility functions for memory management in _memory_utils.py.
- Refactored memory capability mixin methods to utilize the new utility functions for better readability and maintainability.
- Updated PROJECT_ARCHITECTURE.md to reflect changes in documentation and structure.

* Squashed 'astrbot-sdk/' changes from d078e510..208bc591

208bc591 Merge pull request #30 from united-pooh/refactor/unify-legacy-injected-params
d86534a2 docs: add TODO for documentation content in _command_model.py
090724a7 refactor(injection): centralize legacy injected parameter filtering

git-subtree-dir: astrbot-sdk
git-subtree-split: 208bc591dd

* Initial plan

* docs: fix path, Python version, and client API table in PROJECT_ARCHITECTURE.md

Co-authored-by: whatevertogo <149563971+whatevertogo@users.noreply.github.com>

* Squashed 'astrbot-sdk/' changes from 208bc591..ad5e8d13

ad5e8d13 Merge pull request #37 from united-pooh/sdk/whatevertogo
5751701f Merge pull request #38 from united-pooh/copilot/sub-pr-37
e21acba5 docs: fix path, Python version, and client API table in PROJECT_ARCHITECTURE.md
ee67cab4 Initial plan
7d921570 Refactor memory utility functions and enhance memory capability mixin

git-subtree-dir: astrbot-sdk
git-subtree-split: ad5e8d1397

* docs: fix TODO comment formatting in _command_model.py

* 集成SDK命令候选项,优化Telegram和Discord平台适配器的命令收集逻辑

* 删除AI女友插件的单元测试文件

* 更新文档,添加Telegram和Discord原生命令菜单的注册说明

* Squashed 'astrbot-sdk/' changes from ad5e8d13..5003da58

5003da58 Merge pull request #40 from united-pooh/sdk/whatevertogo
b5084c44 Merge branch 'sdk/whatevertogo' of https://github.com/united-pooh/astrbot-sdk into sdk/whatevertogo
7559edf7 docs: fix TODO comment formatting in _command_model.py

git-subtree-dir: astrbot-sdk
git-subtree-split: 5003da58f5

* fix(testing): route session waiter followups through dispatcher (#33)

* fix(testing): route session waiter followups through dispatcher

* fix(testing): preserve waiter context and completion state

* fix(runtime): preserve session waiter plugin identity

* fix(runtime): scope session waiters by plugin

* fix(testing): isolate waiter replacement and followup drains

* fix(runtime): normalize waiter routing inputs

* fix(cli): route protocol stdout at command entry (#41)

* 添加内存后端支持,优化插件内存管理逻辑

* feat(memory): enhance memory schemas and add namespace support

- Updated MEMORY_SEARCH_INPUT_SCHEMA to include `namespace` and `include_descendants`.
- Modified MEMORY_SEARCH_OUTPUT_SCHEMA to allow nullable `namespace`.
- Added `namespace` to MEMORY_GET_INPUT_SCHEMA, MEMORY_DELETE_INPUT_SCHEMA, MEMORY_SAVE_WITH_TTL_INPUT_SCHEMA, MEMORY_GET_MANY_INPUT_SCHEMA, and MEMORY_DELETE_MANY_INPUT_SCHEMA.
- Enhanced MEMORY_STATS_INPUT_SCHEMA to support `namespace` and `include_descendants`.
- Updated MEMORY_GET_OUTPUT_SCHEMA and MEMORY_STATS_OUTPUT_SCHEMA to include `namespace` and `namespace_count`.
- Introduced `_memory_backends` in CapabilityRouterHost and CapabilityRouterBridgeBase for better memory management.
- Refactored MemoryCapabilityMixin to utilize memory backends for plugin-specific memory operations.
- Improved memory search functionality to respect namespaces and include descendants based on input parameters.
- Added tests to validate memory operations across different namespaces and ensure persistence across restarts.
- Implemented error handling in the handler dispatcher to manage exceptions gracefully.

* fix: guard session_waiter blocking usage

* fix(runtime): preserve request-scoped system event overlays

* test(runtime): lock peer initialization and transport failure semantics

* test(loader): cover plugin reload and import isolation regressions

* refactor(supervisor): clarify plugin registry sync phases

* test(clients): cover provider lifecycle regressions

* feat(cli): improve astr init defaults

* feat(plugin): add plugin ID validation and data directory resolution

- Implemented `validate_plugin_id` to ensure safe plugin identifiers.
- Added `resolve_plugin_data_dir` to resolve plugin data directories securely.
- Updated memory and system capabilities to utilize new plugin ID validation.
- Refactored session waiter management to simplify plugin ID handling.
- Enhanced tests for plugin ID validation and data directory resolution.

* fix(cli): exit cleanly on init abort

* feat(memory): enhance namespace handling and add tests for memory client

* feat(agent): add tool status message handling and improve SDK command integration

* Refactor SDK structure for backward compatibility

- Moved message result and session classes to internal modules while preserving legacy import paths for compatibility.
- Updated imports across the SDK to reflect the new internal structure.
- Enhanced session waiter management to support multiple plugins and improve error handling.
- Added tests to ensure LLM tool registration and session waiter functionality align with dispatcher expectations.
- Cleaned up code and improved documentation for clarity and maintainability.

* 增强异步下载功能,更新组件导入方式,并添加相关测试用例

* 添加任务重入锁以支持会话等待器的嵌套清理,并更新相关测试用例以验证后续消息的序列化处理

* 添加 .astrbot_sdk_testing 到 .gitignore 文件

* Add unit tests for provider management and tool capabilities

- Introduced new test suite for provider platform management in `test_sdk_provider_platform_management.py`, covering scenarios for merged provider configurations, reserved plugin checks, and provider management functionalities.
- Added tests for tool capabilities and provider queries in `test_sdk_provider_tool_platform_capabilities.py`, validating interactions with LLM tools and specialized proxies.
- Removed obsolete `test_sdk_transport.py` as it contained outdated tests for transport layer functionality.

* 添加多个模块和测试用例,增强SDK功能并支持单元测试

* fix(bridge): add missing capability registrations for db/memory/http/metadata

Register methods for db, memory, http, and metadata capabilities exist in
BasicCapabilityMixin but were never called in CoreCapabilityBridge.__init__.
This caused SDK plugins using ctx.memory, ctx.db, ctx.http to fail with
"LookupError: capability not found".

* feat(kb): enhance knowledge base capabilities with document management and serialization

* 添加知识库文档管理功能,包括文档上传、列表、获取、删除和刷新能力,更新相关的能力路由和协议模式

* feat(conversation): add ability to unset conversation persona and update related methods

* 添加对话管理和元数据管理功能,包括清空对话人格和保存插件配置的能力

* feat(conversation): add test for unsetting conversation persona and verify state

* feat(plugin): add save_plugin_config method and related tests for plugin configuration persistence

* 优化插件配置保存方法的代码格式

* feat(sdk): enhance handler metadata with descriptions, priority, kind, and admin requirements

* 添加描述、优先级和其他元数据到处理程序元数据和描述符中,优化相关功能

* feat(sdk): enhance SDK integration with local extras handling and message payloads

* 添加对 MessageEvent 的额外字段支持,优化事件处理器的参数注入,增强 SDK 本地临时数据的管理能力

* 增强插件日志记录功能,添加控制台输出格式化和路径标签支持,新增单元测试以验证日志格式

* feat: Enhance command and tool management in dashboard

- Refactor CommandRoute to utilize AstrBotCoreLifecycle for improved command handling.
- Introduce command_key for commands to streamline toggling, renaming, and permission updates.
- Implement support for SDK commands in the dashboard, marking them as read-only.
- Update ToolTable and CommandTable components to use new command_key and tool_key properties.
- Add runtime_kind and plugin_id to tools for better management.
- Enhance API tests to cover SDK commands and tools, ensuring proper functionality and error handling.
- Update localization files to include new messages related to SDK commands.

* 优化插件日志记录和能力代理模块,增强异常处理,确保优先级参数为整数

* feat: 增强LLM能力,添加聊天提供者协议和异常处理,更新单元测试以验证提供者有效性

* feat: Implement SDK skill management capabilities

- Added SkillCapabilityMixin to handle skill registration, unregistration, and listing.
- Integrated skill management into the CoreCapabilityBridge.
- Enhanced SkillManager to support SDK-registered skills, including loading, saving, and syncing skills.
- Updated computer_client to utilize SkillManager for skill synchronization with sandboxes.
- Refactored skill export functionality in SkillsRoute to accommodate new skill management structure.
- Introduced tests for SDK skill registration, unregistration, and syncing to ensure functionality.
- Improved skill handling in the dashboard and test suite to reflect changes in skill management.

* 添加技能注册功能,包含技能注册、注销和列出能力的实现,增强插件能力管理

* 删除CLAUDE.md文档,移除过时的已知问题描述

* 删除 AGENTS.md 文档,移除过时的架构约束和开发命令说明

* 更新测试用例,修复插件发现失败时的错误信息,确保使用正确的运行时字段

* 修复错误处理和权限检查,新增单元测试以验证功能

* format

* 增强命名空间管理,优化数据库操作,添加 HTTP 路由验证,新增并发隔离测试,完善命令模型解析单元测试

* 增强 HTTP 路由功能,添加 HTTP 方法注销逻辑的单元测试

* 格式化日志输出,优化批量插入时无内容提供的调试信息

* fix: add uv dependency for plugin environment groups

* 删除代码审查文档 CODE_REVIEW_ISSUES.md

* 添加消息历史管理功能,包括消息记录的增删查改,完善相关能力混合类和测试用例

* 实现 SDK 消息历史管理功能,包括记录的增删查改操作

* Add unit tests for MCP contract and capabilities in SDK

- Implemented `_mcp_contract.py` to test local MCP server functionalities including listing, enabling, and disabling servers.
- Created `test_sdk_mcp_capabilities.py` to cover various aspects of the CoreCapabilityBridge and SdkPluginBridge, including session management and global MCP server operations.
- Introduced fake classes to simulate MCP server behavior and manage tool configurations for testing.
- Ensured comprehensive coverage of MCP session lifecycle, including opening, listing tools, calling tools, and closing sessions.

* feat(mcp): Implement local and global MCP server management capabilities

- Added MCP management client to the context for local/global MCP service management.
- Introduced decorators to acknowledge global MCP risk for plugins.
- Defined schemas for MCP server operations including get, list, enable, disable, and session management.
- Created MCP capability mixin to handle local and global MCP server operations.
- Enhanced provider capabilities to include active local MCP tool names.
- Updated capability router to support MCP functionalities and maintain session state.
- Added tests for MCP functionalities, ensuring proper behavior and risk acknowledgment.

---------

Co-authored-by: whatevertogo <149563971+whatevertogo@users.noreply.github.com>
Co-authored-by: whatevertogo <whatevertogo@users.noreply.github.com>
Co-authored-by: letr <letr007@foxmail.com>
Co-authored-by: united_pooh <united_pooh@outlook.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: united_pooh <united_pooh@icloud.com>
Co-authored-by: Lishiling <m18384519631@163.com>
Co-authored-by: Li-shi-ling <114913764+Li-shi-ling@users.noreply.github.com>
Co-authored-by: letr <123731298+letr007@users.noreply.github.com>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: catDforD <3276453835@qq.com>
Co-authored-by: united_pooh <united_pooh@MBP-F6H6T2CYVV-2219.local>
2026-03-22 22:44:05 +08:00
LIghtJUNction
5721f7bf24 fix: remove broken tests referencing non-existent functions
- Remove test_computer_config.py (discover_bay_credentials not found)
- Remove test_main.py (check_dashboard_files not found)
- Remove test_skill_metadata_enrichment.py (_parse_frontmatter_description not found)
- Fix test_uninstall.py assertions to match actual output
- Fix test_bk.py digest test async mock
- Fix test_booter_decoupling.py shipyard test (incomplete config returns 0 tools)
- Fix computer_client.py _list_local_skill_dirs type error (Path vs anyio.Path)
- Remove TestApplySandboxTools class (function _apply_sandbox_tools not found)
2026-03-22 22:30:53 +08:00
LIghtJUNction
2f45280222 fix(dashboard): symlink dist for dev-mode resource resolution
importlib.resources.files('astrbot')/dashboard/dist needs to exist
in the package dir for bundled dashboard detection to work in dev.
2026-03-22 22:29:26 +08:00
LIghtJUNction
798182fd8a Merge branch 'dev' of https://github.com/AstrBotDevs/AstrBot into dev 2026-03-22 20:17:28 +08:00
LIghtJUNction
5ba7ecec9e Merge branch 'master' of https://github.com/AstrBotDevs/AstrBot into dev 2026-03-22 20:16:52 +08:00
二枣子
937872cda7 feat(aiocqhttp): support file upload via Napcat stream transfer (#6433)
* feat(aiocqhttp): support file upload via Napcat stream transfer aiocqhttp

feat(aiocqhttp): support file upload via Napcat stream transfer
aiocqhttp上传资源文件时只传输本地路径,此方案对于没有共享磁盘方案不友好,Napcat >= v4.8.115 后,已支持新的 Stream API 方案。此方案在路径传输失败时自动尝试流式传输。

* perf(aiocqhttp): optimize file upload retry and streaming memory usage

1.内存使用优化,文件边读边传。
2.重试逻辑包装为独立方法便于阅读维护。
3.异常处理优化。
4.硬编码提取为模块级常量,提升可读性和配置性。

---------

Co-authored-by: LIghtJUNction <lightjunction.me@gmail.com>
2026-03-22 20:11:58 +08:00
LIghtJUNction
3545c8d393 chore: 新增依赖,移除类型忽略注释 2026-03-22 19:58:46 +08:00
LIghtJUNction
2f82f04ee2 chore: restore tui screen from history 2026-03-22 19:53:56 +08:00
LIghtJUNction
88821bd1bb Update test_plugin_manager.py 2026-03-22 19:50:36 +08:00
LIghtJUNction
b1d048ca5c chore: resolve merge conflicts (prefer HEAD) 2026-03-22 19:50:26 +08:00
LIghtJUNction
dd5105a504 fix(core/platform): platform event and adapters updates 2026-03-22 19:08:59 +08:00
LIghtJUNction
8cff9be334 fix(core): event bus and pipeline stage updates 2026-03-22 19:08:56 +08:00
LIghtJUNction
3a9bbcfdee fix(core/cron): update cron manager logic 2026-03-22 19:08:54 +08:00
LIghtJUNction
95ba23c3ab fix(core): minor changes to agent tool executor and lifecycle 2026-03-22 19:08:51 +08:00
LIghtJUNction
b70eb4e64f fix(core): narrow typed overrides for quoted message parser 2026-03-22 19:08:48 +08:00
Chen
02a910f038 feat: supports image compressing (#6463)
* feat:新增本地图片预压缩机制 避免原图体积过大造成的413错误

* 修正“图片解析失败”消息追加的位置错误
修正"temp_dir"的路径错误

* 修正temp_dir的相对路径错误

* 移动模块导入位置

* fix:重构图片压缩方法 使用异步方式避免阻塞

* ```
feat(core): 优化图片压缩功能并替换UUID为时间戳

- 将图片压缩的同步阻塞操作移至线程池执行,提升性能
- 替换uuid依赖为time模块,使用时间戳生成文件名
- 添加异步图片压缩内部函数_do_compress_sync
- 修复图片压缩时的异常处理日志级别
- 在消息附件和回复链中集成图片压缩功能
```

* fix:修改temp_dir指向 现在使用框架内置的get_astrbot_temp_path来获取临时目
fix:分离image_path的计算行为 提高可读性
fix:使用uuid来生成压缩后的图片 而非时间戳

* fix:修改错误的注释

* fix:分离图片压缩函数到媒体文件处理工具中

* fix:修改原有的列表推导式以提高可读性
feat:增加预留功能的说明注释

* fix:在图片压缩工具中使用pathlib.path获取路径并拼接压缩后的文件名称

* feat: 新增本地图片预压缩机制,增强图片解析容错能力

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-03-22 16:36:33 +08:00
lightjunction
753e9a2bba chore: 2026-03-22 02:00:53 +08:00
LIghtJUNction
f9243a73d5 feat: reduce default max_agent_step to 3 and refactor shell execution
- Lower default max_agent_step from 30 to 3 across all agent runners
  (coze, dashscope, deerflow, dify) for faster responses
- Refactor ExecuteShellTool to use plumbum with session-based isolation,
  maintaining shell state per session (cwd, env vars, etc.)
- Remove unused API-specific environment variables from cmd_run
- Fix data access bug in shipyard_neo._maybe_model_dump
- Add plumbum>=1.10.0 dependency
2026-03-21 23:47:43 +08:00
LIghtJUNction
43e107068a chore: cloudflare 2026-03-21 17:35:30 +08:00
LIghtJUNction
09157f8b88 Fix: combine conditions for bot mention check in Discord adapter 2026-03-21 17:12:29 +08:00
LIghtJUNction
256c8cceeb Fix: handle None values for ctx.author and ctx.interaction in Discord adapter 2026-03-21 16:15:33 +08:00
LIghtJUNction
7391f8e5ee Fix: only enable CORS credentials when allow_origin is not wildcard 2026-03-21 16:00:17 +08:00
LIghtJUNction
b563518711 Fix: replace fullwidth comma with halfwidth comma in discord_platform_adapter.py 2026-03-21 15:21:35 +08:00
LIghtJUNction
255a4c0d5b Fix: support configuring allowed CORS origins via CORS_ALLOW_ORIGIN env
var for cross-origin credential requests
2026-03-21 15:20:43 +08:00
LIghtJUNction
859ca98f1e Fix: support configuring allowed CORS origins via CORS_ALLOW_ORIGIN env var for cross-origin credential requests 2026-03-21 15:14:08 +08:00
LIghtJUNction
a58319f594 Fix: Discord adapter not adding At component when bot is mentioned, causing group messages to not wake the bot 2026-03-21 14:26:22 +08:00
LIghtJUNction
26c5e67efe Refactor: change relative imports to absolute imports in tool_loop_agent_runner.py 2026-03-21 14:13:43 +08:00
LIghtJUNction
4412f789e1 Fix: downgrade SSL APPLICATION_DATA_AFTER_CLOSE_NOTIFY errors to debug level 2026-03-21 14:13:07 +08:00
LIghtJUNction
cefeaf8d9f Fix: add task tracking to BaseAgentRunner and ToolLoopAgentRunner, replace asdict with custom serialization in Response 2026-03-21 13:55:05 +08:00
LIghtJUNction
97efa3ab38 Fix: _BUNDLED_DIST undefined, use self.bundled_dist instead 2026-03-21 13:54:39 +08:00
LIghtJUNction
61e525afd4 Merge branch 'master' into dev 2026-03-21 12:25:04 +08:00
LIghtJUNction
286f6668f4 Fix formatting of bundled_dist property method 2026-03-21 05:10:52 +08:00
LIghtJUNction
4a5ac407d1 Refactor bundled_dist path handling in AstrbotPaths
Removed the _BUNDLED_DIST variable and added a bundled_dist property to calculate the path dynamically.
2026-03-21 05:01:41 +08:00
LIghtJUNction
93cab0e198 Reorganize .env loading sequence in astrbot_path.py
Refactor environment variable loading order in astrbot_path.py.
2026-03-21 04:51:53 +08:00
LIghtJUNction
b2a04ffed1 Fix syntax error in tool_loop_agent_runner.py 2026-03-21 04:21:21 +08:00
LIghtJUNction
3405c72b5e Modify context configuration parameters
Updated max_context_tokens default value and adjusted enforce_max_turns logic.
2026-03-21 04:17:59 +08:00
LIghtJUNction
4ee93c7f83 Limit max_step to a maximum of 3
基本都是设置为30,30太夸张了,这是日志里面截出来的:
system,user,assistant,user,assistant,user,assistant,tool,assistant,user,assistant,user,assistant,user,assistant,tool,assistant,user,assistant,user,assistant,user,assistant,user,assistant,user,assistant,user,assistant,user,assistant,tool,assistant,user,assistant,user,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,user,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,user,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,user,assistant,tool,assistant,user,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,user,assistant,user,assistant,user,assistant,user,assistant,user,assistant,tool,assistant,user,assistant,tool,assistant,tool,assistant,user,assistant,tool,assistant,tool,assistant,tool,assistant,user,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,user,assistant,user,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,user,assistant,tool,assistant,tool,assistant,user,assistant,tool,assistant,user,assistant,tool,assistant,tool,assistant,tool,assistant,user,assistant,tool,assistant,user,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,user,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,user,assistant,tool,assistant,user,assistant,tool,assistant,user,assistant,user,assistant,user,assistant,user,assistant,tool,assistant,user,assistant,tool,assistant,user,assistant,tool,assistant,user,assistant,user,assistant,user,assistant,user,assistant,user,assistant,tool,assistant,user,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,tool,assistant,user,assistant,tool,tool,assistant,tool,assistant,user,assistant,tool,assistant,user,assistant,user,assistant,user,assistant,user,assistant,user,assistant,user
2026-03-21 04:12:49 +08:00
LIghtJUNction
a710c06be2 Validate config_path before checking existence
Add check for empty config_path in check_exist method
2026-03-21 03:49:12 +08:00
LIghtJUNction
864a6851df Fix context manager usage for timeout enforcement 2026-03-21 03:36:32 +08:00
LIghtJUNction
37594dd74b Return Path object for project root property 2026-03-21 03:04:00 +08:00
LIghtJUNction
68c01cfba4 Fix handling of cached_tokens in _extract_usage
Ensure cached_tokens is an integer and handle None safely.
2026-03-21 02:37:04 +08:00
LIghtJUNction
6e3273dbec 修复:argon2库已过时,无法在3.12运行 2026-03-21 01:03:39 +08:00
LIghtJUNction
af77f82c51 更名:conf password可以修改账户名称,因此改名为admin
astrbot conf admin -p xxx
2026-03-21 00:47:14 +08:00
LIghtJUNction
1b92c517e7 Merge branch 'master' into dev 2026-03-21 00:31:18 +08:00
LIghtJUNction
c238880cd2 修复:server.py 2026-03-21 00:28:56 +08:00
LIghtJUNction
92b3e2d260 fix: avoid false dashboard port conflicts 2026-03-21 00:24:35 +08:00
LIghtJUNction
00c9388da3 重构:彻底放弃md5保存密码
main.py调整说明
修复一系列导入问题
修复一部分ruff check问题
2026-03-21 00:09:58 +08:00
LIghtJUNction
7445126057 Merge branch 'master' into dev 2026-03-20 22:40:32 +08:00
LIghtJUNction
9f2240a2a6 fix async ssl path handling 2026-03-20 21:50:46 +08:00
LIghtJUNction
a130b344b0 fix: narrow knowledge base provider types 2026-03-20 21:45:04 +08:00
LIghtJUNction
28e8e50005 fix: improve dashboard and knowledge base file handling 2026-03-20 21:40:21 +08:00
LIghtJUNction
a0b61c4da9 fix: correct rate limit stage algorithm docs 2026-03-20 21:35:55 +08:00
LIghtJUNction
b4f48ea1f0 fix: resolve dashboard build type errors 2026-03-20 21:21:31 +08:00
LIghtJUNction
5ff71fef3e chore:环境变量注释修正 2026-03-20 20:02:47 +08:00
LIghtJUNction
073891c093 修复:移除错误的环境变量名字 2026-03-20 20:00:32 +08:00
LIghtJUNction
4fd77ea008 修复:启动链,参数/环境变量加载逻辑优化 2026-03-20 19:47:04 +08:00
LIghtJUNction
78edc0fff8 修复:启动链,参数/环境变量加载逻辑优化 2026-03-20 19:44:12 +08:00
LIghtJUNction
4f04d39348 同步:同步主线 2026-03-20 16:59:43 +08:00
LIghtJUNction
13132517b2 重构: 修正一些错误,引入_internel包 2026-03-20 16:51:27 +08:00
LIghtJUNction
804a02a2d1 fix(types): await anyio.open_file before async use to satisfy type checkers 2026-03-20 16:36:22 +08:00
LIghtJUNction
f076799b81 fix: async dashboard version read via anyio async context manager 2026-03-20 16:33:51 +08:00
LIghtJUNction
0068825cd5 cli(init): add --root option and generate .env from config.template into ASTRBOT_ROOT (auto-loaded by run) 2026-03-20 16:24:02 +08:00
LIghtJUNction
0b2a143681 cli(run): simplify env loading — move .env early-load to astrbot_path; service-config treated as .env; preserve CLI > service-config > .env precedence 2026-03-20 16:05:30 +08:00
LIghtJUNction
0e95a47276 fix: import error 2026-03-20 15:18:03 +08:00
LIghtJUNction
bf11f4c376 chore: fw2hw 2026-03-20 00:44:39 +08:00
LIghtJUNction
ef5dac77a2 chore: fw2hw 2026-03-20 00:44:08 +08:00
LIghtJUNction
92bae1fdae docs: replace prints with logger in upload_doc_images_to_r2.py 2026-03-19 23:30:25 +08:00
LIghtJUNction
a3371ad6c8 core: refactor modules & fix typing/runtime issues 2026-03-19 23:06:27 +08:00
LIghtJUNction
8184e20850 dashboard: validate SSL file paths with pathlib before use (fix typing/runtime issues) 2026-03-19 22:41:04 +08:00
LIghtJUNction
a4e6e16fd8 fix: surface https backend requirement in dashboard (#6623)
* fix: surface https backend requirement in dashboard

* Update dashboard/src/stores/api.ts

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

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-19 20:35:32 +08:00
LIghtJUNction
cc88ac6bef fix(cli): export webui group symbol 2026-03-19 20:09:47 +08:00
LIghtJUNction
b6f4614c58 fix(cli): robust password hashing when argon2 missing (fallback to PBKDF2) 2026-03-19 20:02:24 +08:00
LIghtJUNction
f03040a12e chore(pyproject): limit requires-python <3.14 to avoid resolver mismatch in env 2026-03-19 19:58:27 +08:00
LIghtJUNction
1bb73ab3cf feat(cli): add 'webui' group + register command in CLI 2026-03-19 19:53:48 +08:00
LIghtJUNction
17b0dfe974 fix(cli): restore cmd_conf password hashing and validators; use absolute import for check_astrbot_root 2026-03-19 19:46:21 +08:00
LIghtJUNction
d16f62423c Merge branch 'master' into dev 2026-03-19 19:25:47 +08:00
LIghtJUNction
c0282e4d28 Potential fix for code scanning alert no. 45: Use of a broken or weak cryptographic hashing algorithm on sensitive data
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-03-19 17:40:25 +08:00
LIghtJUNction
fce8069b18 Potential fix for code scanning alert no. 44: Use of a broken or weak cryptographic hashing algorithm on sensitive data
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-03-19 17:40:09 +08:00
LIghtJUNction
201a19a63e Potential fix for code scanning alert no. 46: Use of a broken or weak cryptographic hashing algorithm on sensitive data
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-03-19 17:39:55 +08:00
LIghtJUNction
664bc68093 fix: support github pages base path config (#6608) 2026-03-19 16:03:52 +08:00
LIghtJUNction
b49c3210d9 Update linting rules in pyproject.toml
Added new rules for isort, pyupgrade, flake8-debugger, flake8-print, flake8-pyi, flake8-pytest-style, flake8-tidy-imports, and Ruff-specific rules.
2026-03-19 03:35:58 +08:00
LIghtJUNction
46b7a4e441 move runtime_bootstrap.py 2026-03-19 03:25:24 +08:00
LIghtJUNction
ee1f9dece8 fix:gh-pages 2026-03-19 01:32:43 +08:00
LIghtJUNction
99453652f8 fix(plugin): make temporary requirements context manager async to allow and avoid blocking event loop 2026-03-19 00:04:59 +08:00
LIghtJUNction
6e90937ab4 fix: path 2026-03-18 23:51:33 +08:00
LIghtJUNction
bb4e9f61f4 Merge branch 'master' into dev 2026-03-18 23:03:00 +08:00
LIghtJUNction
98a8502ebe fix: resolve asynchronous context manager and coroutine attribute issues
- Fixed '_GeneratorContextManager' error in pip_installer.py by using synchronous 'with' for constraints_file().
- Fixed 'CoroutineType' has no attribute 'is_file' in dashboard/routes/config.py by adding missing await.
- Fixed undefined names (Group, ComponentTypes, File, Reply, At) in aiocqhttp_platform_adapter.py.
- Added 'pytest-cov' for code coverage testing.
2026-03-18 22:35:49 +08:00
LIghtJUNction
a410fa3351 fix: path 2026-03-18 21:20:11 +08:00
LIghtJUNction
9cbf253697 chore: remove redundant pdf parser type ignores 2026-03-18 20:12:58 +08:00
LIghtJUNction
a92dfc3913 refactor: simplify cli initialization and uninstall messaging 2026-03-18 20:12:53 +08:00
LIghtJUNction
884efd47cd fix: refresh astrbot root path resolution 2026-03-18 20:12:30 +08:00
LIghtJUNction
1b9820af44 feat: update dashboard 2026-03-18 20:12:22 +08:00
LIghtJUNction
e8c234f0cf feat: improve cli admin setup and api startup logs 2026-03-18 18:21:49 +08:00
LIghtJUNction
14ec513b0d fix: clean lint suppressions and async route errors 2026-03-18 18:06:42 +08:00
LIghtJUNction
26d6d1b36f fix: resolve ruff check issues 2026-03-18 17:38:27 +08:00
LIghtJUNction
fb93949edd fix: resolve ruff async path violations 2026-03-18 17:36:46 +08:00
LIghtJUNction
c9fac2bf82 fix: sync lock file 2026-03-18 17:29:07 +08:00
LIghtJUNction
f6945e0992 chore: remove deploy-dashboard.yml 2026-03-18 17:25:33 +08:00
LIghtJUNction
7fd02cc76a Merge branch 'master' into dev 2026-03-18 17:22:50 +08:00
LIghtJUNction
4bf2f0cb28 fix(dashboard): handle missing static assets gracefully and fix async/ruff issues
- Dashboard: Catch missing index.html error, log warning, and disable WebUI instead of crashing.
- Dashboard: Use anyio.Path for async file existence checks in SSL config.
- CLI/Backup: Replace blocking file operations with anyio async operations.
- Core: Replace blocking file operations with anyio in Coze/Dify clients and TTS simulation.
- Provider: Rename 'timeout' param to 'init_timeout'/'request_timeout' to fix ASYNC109 warnings.
- Ruff: Fix various ASYNC230/ASYNC240 errors across multiple files.
2026-03-18 16:46:22 +08:00
LIghtJUNction
f1ad60982c feat(cli): init cmd supports restoring from backup 2026-03-18 16:02:32 +08:00
LIghtJUNction
b301ff21f2 feat(cli): enhance run/init/help cmds & service config support 2026-03-18 14:16:25 +08:00
LIghtJUNction
ad3e5c473a Merge conflict resolution 2026-03-18 14:16:14 +08:00
LIghtJUNction
12cdf454b4 feat(cli): enhance run/init/uninstall for systemd & debug; refactor(env): standardize vars 2026-03-18 13:52:00 +08:00
LIghtJUNction
b839de2050 fix(discord): handle timeout error on shutdown; chore(cli): remove systemd service creation 2026-03-18 12:56:05 +08:00
LIghtJUNction
2946460484 update astrbot.service 2026-03-18 01:22:43 +08:00
LIghtJUNction
73517ec2a5 更新 cmd_init.py 2026-03-18 00:35:29 +08:00
LIghtJUNction
87b50a5fd5 fix/ astrbot.service, update path 2026-03-18 00:34:26 +08:00
LIghtJUNction
9877abc472 更新 cmd_init.py 2026-03-18 00:22:21 +08:00
LIghtJUNction
7eafa3a2e2 update astrbot.service 2026-03-18 00:21:27 +08:00
LIghtJUNction
c1ad6f7032 fix(core): use explicit import for anyio.to_thread to resolve static analysis issue
This resolves a 'BrokenWorkerInterpreter' attribute access error reported by basedpyright.
2026-03-17 22:52:33 +08:00
LIghtJUNction
b53f042a6d fix(core): ensure parent directories exist before saving skill config and cache; fix async file read in star_manager 2026-03-17 22:33:56 +08:00
LIghtJUNction
6dc13b880b refactor: rename skills_like to lazy_load and add skill schema support 2026-03-17 21:32:06 +08:00
LIghtJUNction
f3d6f762c3 refactor: rename skills_like to lazy_load and add skill schema support 2026-03-17 20:46:07 +08:00
LIghtJUNction
4c0fb31e7d Refactor theme constants and resolve merge conflicts 2026-03-17 19:27:50 +08:00
Kangyang Ji
7aae048405 feat(dashboard): add auto switch theme (default off) (#6405)
* feat(dashboard): add auto switch theme (default off)
feat(dashboard): move all get theme and set theme by check current theme into stores/customizer

* feat(dashboard): fix duplicate for auto switch theme
根据Gemini的意见更改了一些地方。
将原本的状态更新挪到了App.vue里,可以去除很多地方更新theme所需要的theme依赖。
将翻译修改了
将监听器改为了watch
2026-03-17 19:06:01 +08:00
qingyun
df1e59e01c fix(core): use original version constraints instead of locking to installed version (#6445)
* feat: Add OpenRouter chat completion provider adapter with custom headers. (#6436)

* chore: update astrbot.service configuration

* fix(core): use original version constraints instead of locking to installed version

Fixes #6420

The core constraints mechanism was using the currently installed version as
an exact constraint (e.g., `aiosqlite==0.21.0`), preventing plugins from
installing higher versions even when they satisfy the original constraint.

Changes:
- Preserve original version specifier from pyproject.toml (e.g., `>=0.21.0`)
- Allow plugins to require higher versions as long as they satisfy core constraint
- Prevent downgrade by using `>=installed` for packages without version constraint

Example:
- Before: Core constraint `aiosqlite==0.21.0`, plugin requires `>=0.22.1` → BLOCKED
- After: Core constraint `aiosqlite>=0.21.0`, plugin requires `>=0.22.1` → ALLOWED

This enables better dependency management while still protecting core dependencies
from incompatible downgrades.

---------

Co-authored-by: Futureppo <luominzhi2005@qq.com>
Co-authored-by: LIghtJUNction <lightjunction.me@gmail.com>
Co-authored-by: ccsang <ccsang@users.noreply.github.com>
2026-03-17 18:59:24 +08:00
LIghtJUNction
25f9effcc9 Merge branch 'master' into dev 2026-03-17 18:53:46 +08:00
LIghtJUNction
5caf3a4793 chore: update AGENTS.md 2026-03-17 18:52:44 +08:00
LIghtJUNction
458e8e0db8 fix(cli): recover flags consumed by -E option and prompt for recipient 2026-03-17 18:48:19 +08:00
LIghtJUNction
976398d1f2 feat(cli): enhance backup capabilities and refactor path management
- Introduce 'astrbot bk' command with GPG signing, encryption, and digest support
- Add import/export functionality using core backup modules
- Refactor path management to use 'AstrbotPaths' singleton across CLI commands
- Replace blocking subprocess calls with asyncio.create_subprocess_exec in backup command
- Add comprehensive tests for uninstall and backup commands
- Improve module resource handling for bundled dashboard assets
2026-03-17 18:32:32 +08:00
LIghtJUNction
4b7d42c2a3 chore: ruff format 2026-03-17 16:53:45 +08:00
LIghtJUNction
f6321be8c8 sync 2026-03-17 16:51:45 +08:00
LIghtJUNction
6db0959bb1 feat(cli): implement uninstall command and add log-level option
- Implement 'astrbot uninstall' to remove systemd service and data files
- Add '--log-level' option to 'astrbot run' (default: INFO)
- Pass log level config to core logger via env var
2026-03-16 23:55:36 +08:00
LIghtJUNction
a05bfed15d Merge pull request #6434 from a61995987/fix-修正shell工具未正确应用工具调用超时的问题
Fix 修正shell工具未正确应用工具调用超时的问题
2026-03-16 20:51:03 +08:00
LIghtJUNction
a027fb310c Update astrbot/core/computer/tools/shell.py
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-16 20:34:17 +08:00
Chen
4b0d9ae979 Merge branch 'fix-修正shell工具未正确应用工具调用超时的问题' of https://github.com/a61995987/AstrBot into fix-修正shell工具未正确应用工具调用超时的问题 2026-03-16 18:11:12 +08:00
Chen
a1a3db2218 fix-修正shell工具未正确应用工具调用超时的问题 2026-03-16 18:10:23 +08:00
Chen
3e278dbd9e Merge branch 'AstrBotDevs:master' into fix-修正shell工具未正确应用工具调用超时的问题 2026-03-16 18:02:15 +08:00
LIghtJUNction
7733ccc54a Merge pull request #6429 from xkeyC/feat/persona_clone
feat: add clone persona functionality
2026-03-16 17:23:01 +08:00
LIghtJUNction
9c7c0ec95a Merge pull request #6404 from rin259/feat/onebot-file-send
feat: add OneBot V11 file API support for sending files
2026-03-16 17:08:39 +08:00
LIghtJUNction
2685528cbd Merge pull request #6397 from AstrBotDevs/master
sync master-﹥dev
2026-03-16 17:03:40 +08:00
LIghtJUNction
3f24f82486 Merge branch 'master' into dev 2026-03-16 17:02:15 +08:00
xkeyC
38f21675d5 feat: 支持克隆人格 2026-03-16 16:52:01 +08:00
LIghtJUNction
c0e07971b3 Merge pull request #6391 from ccsang/fix/tool-result-multiple-content
fix(agent): process all content items in CallToolResult, not just the first
2026-03-16 15:21:17 +08:00
Rin
7cce05c459 fix: remove accidentally added commit message from code 2026-03-16 14:37:52 +08:00
rin
0a16df2837 fix: add missing logger import 2026-03-16 14:35:34 +08:00
LIghtJUNction
e2365a53b9 Update README_zh.md 2026-03-16 13:34:36 +08:00
LIghtJUNction
7dc142ddf2 docs: synchronize multi-language READMEs with README_zh.md 2026-03-16 13:33:29 +08:00
ccsang
8e6c835b85 refactor(agent): extract image-handling logic into helper function
Address Sourcery AI review feedback: the image-handling logic was
duplicated for ImageContent and EmbeddedResource cases.

Changes:
- Extract _handle_image_content() helper function
- Consolidate image caching, result appending, and yielding logic
- Reduce code duplication and improve maintainability
2026-03-16 00:17:33 +00:00
ccsang
fb2a2a63f2 fix(agent): process all content items in CallToolResult, not just the first
Fixes #6140

When a tool returns CallToolResult with multiple content items (e.g.,
both TextContent and ImageContent), the agent was only processing
content[0], ignoring the rest.

Changes:
- Replace direct content[0] access with enumerate(res.content) loop
- Process all content items: TextContent, ImageContent, EmbeddedResource
- Use content_index for image caching to distinguish multiple images

This fixes the issue where tools like Bilibili plugin return both
text descriptions and screenshots, but LLM only received one of them.
2026-03-16 00:17:33 +00:00
LIghtJUNction
3f863cce7f Merge pull request #6389 from AstrBotDevs/copilot/create-daily-build-workflow
feat: daily workflow to build dashboard with Bun and deploy to GitHub Pages
2026-03-15 23:45:13 +08:00
copilot-swe-agent[bot]
c42bd3150d feat: add daily workflow to build dashboard with bun and deploy to GitHub Pages
Co-authored-by: LIghtJUNction <106986785+LIghtJUNction@users.noreply.github.com>
2026-03-15 15:39:48 +00:00
copilot-swe-agent[bot]
4c22abd99c Initial plan 2026-03-15 15:37:59 +00:00
LIghtJUNction
f08147dc38 更新 smoke_test.yml 2026-03-15 23:36:25 +08:00
LIghtJUNction
11d40ac0c3 更新 smoke_test.yml 2026-03-15 23:35:21 +08:00
LIghtJUNction
04aee2890a feat: implement dashboard download caching and fallback mechanism
- Add local file caching for dashboard downloads with version validation
- Implement fallback to 'latest' version if specific version download fails
- Add robust error handling in CLI check_dashboard to prevent crashes
- Remove dashboard caching from smoke tests (backend-only mode)
2026-03-15 19:14:13 +08:00
LIghtJUNction
c18165909e 修复: 尝试下载当前版本的dashboard,如果下载失败(开发版本),回退为下载最新版本 2026-03-15 18:18:23 +08:00
LIghtJUNction
0b534f65c2 fix(dashboard): use absolute path for bundled dist and handle 404s gracefully 2026-03-15 18:05:15 +08:00
LIghtJUNction
c9910d4a66 fix(dashboard): add early check for missing index.html to prevent 404s 2026-03-15 18:01:55 +08:00
LIghtJUNction
342b378de1 Updated READMEs 2026-03-15 17:45:12 +08:00
LIghtJUNction
7579db11be chore: bump version to 4.21.0 and refactor version retrieval 2026-03-15 17:08:31 +08:00
LIghtJUNction
b5a40a66fa sync/pnpm-lock.yaml 2026-03-15 16:51:42 +08:00
LIghtJUNction
282ff8d414 fix(dashboard): improve visibility of inline code in announcements
- Update CSS for inline code elements in the welcome announcement section
- Ensure proper contrast and emphasis in both light and dark modes
- Fix issue where code blocks appeared as white boxes in dark mode
2026-03-15 16:47:53 +08:00
LIghtJUNction
f3cdb7c006 fix(dashboard): restore original header logo layout
- Replaced Logo component with original inline text implementation in VerticalHeader
- Added missing isChristmas computed property
- Removed v-spacer centering for logo to match master branch layout
- This fixes the display issue where the logo/title appeared incorrect or misaligned
2026-03-15 16:35:58 +08:00
LIghtJUNction
c3afc3d72b fix(dashboard): resolve header layout issues and restore sidebar toggle
- Replace constrained v-container with full-width div in app bar
- Add 'app' prop to v-app-bar to fix layout flow
- Restore missing sidebar toggle buttons for desktop/mobile
- Clean up unused dev dependencies in package.json
2026-03-15 16:28:26 +08:00
LIghtJUNction
0c74bd1aeb fix: restore missing _poll_webchat_stream_result function 2026-03-15 15:58:47 +08:00
LIghtJUNction
070f281dae Resolve merge conflict in StandaloneChat.vue 2026-03-15 15:45:04 +08:00
LIghtJUNction
28a0f372fc Update vite version to match lockfile 2026-03-15 15:43:17 +08:00
邹永赫
d7457f38d4 fix: handle webchat image outputs without streaming 2026-03-15 16:15:43 +09:00
LIghtJUNction
da1565ee81 Merge pull request #6282 from advent259141/agent-fix-clean
Agent fix clean
2026-03-14 23:32:16 +08:00
Gao Jinzhe
7d3401fec0 Merge branch 'dev' into agent-fix-clean 2026-03-14 23:17:10 +08:00
LIghtJUNction
fca691b3ca Merge pull request #6276 from AstrBotDevs/feat/optional-backend
Feat/optional backend
2026-03-14 21:40:06 +08:00
LIghtJUNction
ca8f356812 Resolve conflicts in dashboard files 2026-03-14 21:36:35 +08:00
LIghtJUNction
a4a0a5bb1a Resolve conflicts 2026-03-14 18:24:48 +08:00
advent259141
3a8bfa0873 style: ruff format on merge-touched files 2026-03-13 09:20:08 +08:00
advent259141
c07fba7add merge: resolve conflicts with origin/master
- .gitignore: keep both .serena and .worktrees/ entries
- astr_main_agent_resources.py: keep deletion (refactored to tools/)
- send_message.py: port video message type support from master
2026-03-13 09:17:31 +08:00
zenfun
855483c8c2 style: fix ruff I001/F401 violations in changed files 2026-03-13 01:18:39 +08:00
zenfun
048c511b18 fix: align browser property with base class and remove dead env writes
- shipyard_neo: browser property now returns None when not initialized
  instead of raising RuntimeError, matching ComputerBooter base contract
- computer_tool_provider: remove dead os.environ writes for shipyard
  (SHIPYARD_ENDPOINT / SHIPYARD_ACCESS_TOKEN are never read anywhere)
  and remove unused os import
2026-03-13 01:14:23 +08:00
zenfun
dfc0c34d95 fix: address review issues in tool injection refactor
- Rewrite TestApplySandboxToolsRefactored to test ComputerToolProvider
  directly (_apply_sandbox_tools was removed; old tests permanently skipped)
- Add TestExecutorCapabilityGuard with 5 strict tests for browser
  capability rejection at executor level
- Fix typo: "on hehalf you" -> "on behalf of you" in subagent result
- Remove extra blank lines (ruff E303) after _apply_sandbox_tools comment
- Remove dead _build_sync_and_scan_command (no callers after refactor)
2026-03-13 01:11:50 +08:00
Chen
b1a119edb4 ruff 2026-03-12 03:44:36 +08:00
Chen
3dc4bb8e34 将shell调用时的timeout转为int 获取系统配置时正确传递umo 2026-03-12 03:42:33 +08:00
Chen
f5e7ca12f7 feat: 增加 shell 工具自定义超时配置 2026-03-12 03:26:18 +08:00
zenfun
7c3cc7b90c refactor: add capabilities to sandbox tool binding logs 2026-03-12 02:58:48 +08:00
zenfun
a5a1ba72fd refactor: add get_sandbox_capabilities API and structured logging to computer_client 2026-03-12 02:58:15 +08:00
zenfun
e1d76117b4 refactor: standardize booter structured logging format 2026-03-12 02:57:04 +08:00
zenfun
ad3911a21f refactor: add debug logging to sandbox tool resolution 2026-03-12 02:56:59 +08:00
zenfun
3440dcd14b test: add booter decoupling and profile-aware tool tests 2026-03-12 02:55:09 +08:00
zenfun
e85eef05b8 fix: stabilize tool injection for LLM prefix cache hits
Two changes to make the tool schema sent to the LLM deterministic:
1. ToolSet.normalize() — sort tools by name before serialization.
   Called at the end of build_main_agent() after all injection passes.
   Eliminates ordering drift from plugin load order, MCP reconnection,
   and persona tool list differences.
2. Always inject full sandbox tool set — ComputerToolProvider now
   returns get_default_sandbox_tools() unconditionally, regardless of
   sandbox boot state. Browser tools are always in the schema even if
   the sandbox profile lacks browser capability. The executor rejects
   calls to unavailable browser tools with a descriptive error instead
   of silently omitting them from the schema.
   This eliminates the pre-boot/post-boot tool set jump that caused
   prefix cache misses on the second request of a conversation.
2026-03-12 02:43:19 +08:00
zenfun
f16edd4fff refactor: delegate tool injection to booter self-description API
- Add get_default_tools/get_tools/get_system_prompt_parts to ComputerBooter base
- Each booter subclass (ShipyardNeo, Shipyard, Boxlite) declares its own tools
- ComputerToolProvider now delegates to booter API via computer_client helpers
- Add unified query API: get_sandbox_tools, get_default_sandbox_tools, etc.
- Extract Neo prompts to dedicated computer/prompts.py module
- Add booter type constants (booters/constants.py)
- Fix subagent tool path to pass sandbox_cfg and session_id
- Fix Sourcery issues: shell injection in send_message, typo in prompts,
  internal tools bypass inactivated_llm_tools check
2026-03-12 02:43:19 +08:00
advent259141
438fc105cd feat: 增加在工具注入前对工具是否启用的检查 2026-03-11 20:37:41 +08:00
advent259141
eae87e1ec9 Merge branch 'agent-fix-clean' of https://github.com/advent259141/AstrBot into agent-fix-clean 2026-03-11 10:42:30 +08:00
advent259141
894d72e657 feat: Introduce an internal agent sub-stage to the pipeline, enabling LLM agentic capabilities with configurable tools and context management. 2026-03-11 10:42:19 +08:00
Gao Jinzhe
42b8293f99 Merge branch 'AstrBotDevs:master' into agent-fix-clean 2026-03-11 09:48:36 +08:00
advent259141
21f1fa82f4 feat: Implement API routes and dashboard UI for managing tools and MCP servers. 2026-03-10 22:22:18 +08:00
advent259141
ff4412a627 refactor: Centralize and decouple computer-use tool injection logic into a new ComputerToolProvider and associated tool modules. 2026-03-10 22:00:23 +08:00
advent259141
bf430e659a feat: Introduce cron job management and refactor tool provisioning with dedicated providers for computer-use runtimes. 2026-03-10 21:05:09 +08:00
LIghtJUNction
bbafb59cb2 Update dashboard/package.json
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-02 16:57:31 +08:00
LIghtJUNction
eaa1fddfa9 Update astrbot/cli/commands/cmd_init.py
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-02 16:57:07 +08:00
LIghtJUNction
1ffa339a2a Update astrbot/core/config/default.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-02 15:33:14 +08:00
LIghtJUNction
eacfd14218 细节修正/路径参数(astrbot_root)语义一致化 2026-02-27 23:51:44 +08:00
LIghtJUNction
b8ffecf500 完善前端/允许退出登录 2026-02-27 23:41:43 +08:00
LIghtJUNction
e5d85e402b 完善前端 2026-02-27 23:06:13 +08:00
LIghtJUNction
ea21d44d60 修正一个小错误 2026-02-27 22:33:54 +08:00
LIghtJUNction
0f734e19fd fix: import errors and port detection issue 2026-02-27 22:26:55 +08:00
LIghtJUNction
6044502968 修正导入问题 2026-02-27 22:23:26 +08:00
LIghtJUNction
fed11fffa4 恢复分支 2026-02-27 22:20:38 +08:00
LIghtJUNction
f79f460b89 Merge pull request #5538 from AstrBotDevs/master
同步
2026-02-27 22:08:50 +08:00
LIghtJUNction
a6009e2bd8 同步到主分支 (#5537)
* feat: add bocha web search tool (#4902)

* add bocha web search tool

* Revert "add bocha web search tool"

This reverts commit 1b36d75a17.

* add bocha web search tool

* fix: correct temporary_cache spelling and update supported tools for web search

* ruff

---------

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

* fix: messages[x] assistant content must contain at least one part (#4928)

* fix: messages[x] assistant content must contain at least one part

fixes: #4876

* ruff format

* chore: bump version to 4.14.5 (#4930)

* feat: implement feishu / lark media file handling utilities for file, audio and video processing (#4938)

* feat: implement media file handling utilities for audio and video processing

* feat: refactor file upload handling for audio and video in LarkMessageEvent

* feat: add cleanup for failed audio and video conversion outputs in media_utils

* feat: add utility methods for sending messages and uploading files in LarkMessageEvent

* fix: correct spelling of 'temporary' in SharedPreferences class

* perf: optimize webchat and wecom ai queue lifecycle (#4941)

* perf: optimize webchat and wecom ai queue lifecycle

* perf: enhance webchat back queue management with conversation ID support

* fix: localize provider source config UI (#4933)

* fix: localize provider source ui

* feat: localize provider metadata keys

* chore: add provider metadata translations

* chore: format provider i18n changes

* fix: preserve metadata fields in i18n conversion

* fix: internationalize platform config and dialog

* fix: add Weixin official account platform icon

---------

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

* chore: bump version to 4.14.6

* feat: add provider-souce-level proxy (#4949)

* feat: 添加 Provider 级别代理支持及请求失败日志

* refactor: simplify provider source configuration structure

* refactor: move env proxy fallback logic to log_connection_failure

* refactor: update client proxy handling and add terminate method for cleanup

* refactor: update no_proxy configuration to remove redundant subnet

---------

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

* feat(ComponentPanel):  implement permission management for dashboard (#4887)

* feat(backend): add permission update api

* feat(useCommandActions): add updatePermission action and translations

* feat(dashboard): implement permission editing ui

* style: fix import sorting in command.py

* refactor(backend): extract permission update logic to service

* feat(i18n): add success and failure messages for command updates

---------

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

* feat: 允许 LLM 预览工具返回的图片并自主决定是否发送 (#4895)

* feat: 允许 LLM 预览工具返回的图片并自主决定是否发送

* 复用 send_message_to_user 替代独立的图片发送工具

* feat: implement _HandleFunctionToolsResult class for improved tool response handling

* docs: add path handling guidelines to AGENTS.md

---------

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

* feat(telegram): 添加媒体组(相册)支持 / add media group (album) support (#4893)

* feat(telegram): 添加媒体组(相册)支持 / add media group (album) support

## 功能说明
支持 Telegram 的媒体组消息(相册),将多张图片/视频合并为一条消息处理,而不是分散成多条消息。

## 主要改动

### 1. 初始化媒体组缓存 (__init__)
- 添加 `media_group_cache` 字典存储待处理的媒体组消息
- 使用 2.5 秒超时收集媒体组消息(基于社区最佳实践)
- 最大等待时间 10 秒(防止永久等待)

### 2. 消息处理流程 (message_handler)
- 检测 `media_group_id` 判断是否为媒体组消息
- 媒体组消息走特殊处理流程,避免分散处理

### 3. 媒体组消息缓存 (handle_media_group_message)
- 缓存收到的媒体组消息
- 使用 APScheduler 实现防抖(debounce)机制
- 每收到新消息时重置超时计时器
- 超时后触发统一处理

### 4. 媒体组合并处理 (process_media_group)
- 从缓存中取出所有媒体项
- 使用第一条消息作为基础(保留文本、回复等信息)
- 依次添加所有图片、视频、文档到消息链
- 将合并后的消息发送到处理流程

## 技术方案论证

Telegram Bot API 在处理媒体组时的设计限制:
1. 将媒体组的每个消息作为独立的 update 发送
2. 每个 update 带有相同的 `media_group_id`
3. **不提供**组的总数、结束标志或一次性完整组的机制

因此,bot 必须自行收集消息,并通过硬编码超时(timeout/delay)等待可能延迟到达的消息。
这是目前唯一可靠的方案,被官方实现、主流框架和开发者社区广泛采用。

### 官方和社区证据:
- **Telegram Bot API 服务器实现(tdlib)**:明确指出缺少结束标志或总数信息
  https://github.com/tdlib/telegram-bot-api/issues/643

- **Telegram Bot API 服务器 issue**:讨论媒体组处理的不便性,推荐使用超时机制
  https://github.com/tdlib/telegram-bot-api/issues/339

- **Telegraf(Node.js 框架)**:专用媒体组中间件使用 timeout 控制等待时间
  https://github.com/DieTime/telegraf-media-group

- **StackOverflow 讨论**:无法一次性获取媒体组所有文件,必须手动收集
  https://stackoverflow.com/questions/50180048/telegram-api-get-all-uploaded-photos-by-media-group-id

- **python-telegram-bot 社区**:确认媒体组消息单独到达,需手动处理
  https://github.com/python-telegram-bot/python-telegram-bot/discussions/3143

- **Telegram Bot API 官方文档**:仅定义 `media_group_id` 为可选字段,不提供获取完整组的接口
  https://core.telegram.org/bots/api#message

## 实现细节
- 使用 2.5 秒超时收集媒体组消息(基于社区最佳实践)
- 最大等待时间 10 秒(防止永久等待)
- 采用防抖(debounce)机制:每收到新消息重置计时器
- 利用 APScheduler 实现延迟处理和任务调度

## 测试验证
-  发送 5 张图片相册,成功合并为一条消息
-  保留原始文本说明和回复信息
-  支持图片、视频、文档混合的媒体组
-  日志显示 Processing media group <media_group_id> with 5 items

## 代码变更
- 文件:astrbot/core/platform/sources/telegram/tg_adapter.py
- 新增代码:124 行
- 新增方法:handle_media_group_message(), process_media_group()

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* refactor(telegram): 优化媒体组处理性能和可靠性

根据代码审查反馈改进:

1. 实现 media_group_max_wait 防止无限延迟
   - 跟踪媒体组创建时间,超过最大等待时间立即处理
   - 最坏情况下 10 秒内必定处理,防止消息持续到达导致无限延迟

2. 移除手动 job 查找优化性能
   - 删除 O(N) 的 get_jobs() 循环扫描
   - 依赖 replace_existing=True 自动替换任务

3. 重用 convert_message 减少代码重复
   - 统一所有媒体类型转换逻辑
   - 未来添加新媒体类型只需修改一处

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(telegram): handle missing message in media group processing and improve logging messages

---------

Co-authored-by: Ubuntu <ubuntu@localhost.localdomain>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Soulter <905617992@qq.com>

* feat: add welcome feature with localized content and onboarding steps

* fix: correct height attribute to max-height for dialog component

* feat: supports electron app (#4952)

* feat: add desktop wrapper with frontend-only packaging

* docs: add desktop build docs and track dashboard lockfile

* fix: track desktop lockfile for npm ci

* fix: allow custom install directory for windows installer

* chore: migrate desktop workflow to pnpm

* fix(desktop): build AppImage only on Linux

* fix(desktop): harden packaged startup and backend bundling

* fix(desktop): adapt packaged restart and plugin dependency flow

* fix(desktop): prevent backend respawn race on quit

* fix(desktop): prefer pyproject version for desktop packaging

* fix(desktop): improve startup loading UX and reduce flicker

* ci: add desktop multi-platform release workflow

* ci: fix desktop release build and mac runner labels

* ci: disable electron-builder auto publish in desktop build

* ci: avoid electron-builder publish path in build matrix

* ci: normalize desktop release artifact names

* ci: exclude blockmap files from desktop release assets

* ci: prefix desktop release assets with AstrBot and purge blockmaps

* feat: add electron bridge types and expose backend control methods in preload script

* Update startup screen assets and styles

- Changed the icon from PNG to SVG format for better scalability.
- Updated the border color from #d0d0d0 to #eeeeee for a softer appearance.
- Adjusted the width of the startup screen from 460px to 360px for improved responsiveness.

* Update .gitignore to include package.json

* chore: remove desktop gitkeep ignore exceptions

* docs: update desktop troubleshooting for current runtime behavior

* refactor(desktop): modularize runtime and harden startup flow

---------

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

* fix: dedupe preset messages (#4961)

* feat: enhance package.json with resource filters and compression settings

* chore: update Python version requirements to 3.12 (#4963)

* chore: bump version to 4.14.7

* feat: refactor release workflow and add special update handling for electron app (#4969)

* chore: bump version to 4.14.8 and bump faiss-cpu version up to date

* chore: auto ann fix by ruff (#4903)

* chore: auto fix by ruff

* refactor: 统一修正返回类型注解为 None/bool 以匹配实现

* refactor: 将 _get_next_page 改为异步并移除多余的请求错误抛出

* refactor: 将 get_client 的返回类型改为 object

* style: 为 LarkMessageEvent 的相关方法添加返回类型注解 None

---------

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

* fix: prepare OpenSSL via vcpkg for Windows ARM64

* ci: change ghcr namespace

* chore: update pydantic dependency version (#4980)

* feat: add delete button to persona management dialog (#4978)

* Initial plan

* feat: add delete button to persona management dialog

- Added delete button to PersonaForm dialog (only visible when editing)
- Implemented deletePersona method with confirmation dialog
- Connected delete event to PersonaManager for proper handling
- Button positioned on left side of dialog actions for clear separation
- Uses existing i18n translations for delete button and messages

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

* fix: use finally block to ensure saving state is reset

- Moved `this.saving = false` to finally block in deletePersona
- Ensures UI doesn't stay in saving state after errors
- Follows best practices for state management

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>

* feat: enhance Dingtalk adapter with active push message and image, video, audio message type (#4986)

* fix: handle pip install execution in frozen runtime (#4985)

* fix: handle pip install execution in frozen runtime

* fix: harden pip subprocess fallback handling

* fix: collect certifi data in desktop backend build (#4995)

* feat: 企业微信应用 支持主动消息推送,并优化企微应用、微信公众号、微信客服音频相关的处理 (#4998)

* feat: 企业微信智能机器人支持主动消息推送以及发送视频、文件等消息类型支持 (#4999)

* feat: enhance WecomAIBotAdapter and WecomAIBotMessageEvent for improved streaming message handling (#5000)

fixes: #3965

* feat: enhance persona tool management and update UI localization for subagent orchestration (#4990)

* feat: enhance persona tool management and update UI localization for subagent orchestration

* fix: remove debug logging for final ProviderRequest in build_main_agent function

* perf: 稳定源码与 Electron 打包环境下的 pip 安装行为,并修复非 Electron 环境下点击 WebUI 更新按钮时出现跳转对话框的问题 (#4996)

* fix: handle pip install execution in frozen runtime

* fix: harden pip subprocess fallback handling

* fix: scope global data root to packaged electron runtime

* refactor: inline frozen runtime check for electron guard

* fix: prefer current interpreter for source pip installs

* fix: avoid resolving venv python symlink for pip

* refactor: share runtime environment detection utilities

* fix: improve error message when pip module is unavailable

* fix: raise ImportError when pip module is unavailable

* fix: preserve ImportError semantics for missing pip

* fix: 修复非electron app环境更新时仍然显示electron更新对话框的问题

---------

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

* fix: 'HandoffTool' object has no attribute 'agent' (#5005)

* fix: 移动agent的位置到super().__init__之后

* add: 添加一行注释

* chore(deps): bump the github-actions group with 2 updates (#5006)

Bumps the github-actions group with 2 updates: [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) and [actions/download-artifact](https://github.com/actions/download-artifact).


Updates `astral-sh/setup-uv` from 6 to 7
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/v6...v7)

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

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/download-artifact
  dependency-version: '7'
  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>

* fix: stabilize packaged runtime pip/ssl behavior and mac font fallback (#5007)

* fix: patch pip distlib finder for frozen electron runtime

* fix: use certifi CA bundle for runtime SSL requests

* fix: configure certifi CA before core imports

* fix: improve mac font fallback for dashboard text

* fix: harden frozen pip patch and unify TLS connector

* refactor: centralize dashboard CJK font fallback stacks

* perf: reuse TLS context and avoid repeated frozen pip patch

* refactor: bootstrap TLS setup before core imports

* fix: use async confirm dialog for provider deletions

* fix: replace native confirm dialogs in dashboard

- Add shared confirm helper in dashboard/src/utils/confirmDialog.ts for async dialog usage with safe fallback.

- Migrate provider, chat, config, session, platform, persona, MCP, backup, and knowledge-base delete/close confirmations to use the shared helper.

- Remove scattered inline confirm handling to keep behavior consistent and avoid native blocking dialog focus/caret issues in Electron.

* fix: capture runtime bootstrap logs after logger init

- Add bootstrap record buffer in runtime_bootstrap for early TLS patch logs before logger is ready.

- Flush buffered bootstrap logs to astrbot logger at process startup in main.py.

- Include concrete exception details for TLS bootstrap failures to improve diagnosis.

* fix: harden runtime bootstrap and unify confirm handling

- Simplify bootstrap log buffering and add a public initialize hook for non-main startup paths.

- Guard aiohttp TLS patching with feature/type checks and keep graceful fallback when internals are unavailable.

- Standardize dashboard confirmation flow via shared confirm helpers across composition and options API components.

* refactor: simplify runtime tls bootstrap and tighten confirm typing

* refactor: align ssl helper namespace and confirm usage

* fix: 修复 Windows 打包版后端重启失败问题 (#5009)

* fix: patch pip distlib finder for frozen electron runtime

* fix: use certifi CA bundle for runtime SSL requests

* fix: configure certifi CA before core imports

* fix: improve mac font fallback for dashboard text

* fix: harden frozen pip patch and unify TLS connector

* refactor: centralize dashboard CJK font fallback stacks

* perf: reuse TLS context and avoid repeated frozen pip patch

* refactor: bootstrap TLS setup before core imports

* fix: use async confirm dialog for provider deletions

* fix: replace native confirm dialogs in dashboard

- Add shared confirm helper in dashboard/src/utils/confirmDialog.ts for async dialog usage with safe fallback.

- Migrate provider, chat, config, session, platform, persona, MCP, backup, and knowledge-base delete/close confirmations to use the shared helper.

- Remove scattered inline confirm handling to keep behavior consistent and avoid native blocking dialog focus/caret issues in Electron.

* fix: capture runtime bootstrap logs after logger init

- Add bootstrap record buffer in runtime_bootstrap for early TLS patch logs before logger is ready.

- Flush buffered bootstrap logs to astrbot logger at process startup in main.py.

- Include concrete exception details for TLS bootstrap failures to improve diagnosis.

* fix: harden runtime bootstrap and unify confirm handling

- Simplify bootstrap log buffering and add a public initialize hook for non-main startup paths.

- Guard aiohttp TLS patching with feature/type checks and keep graceful fallback when internals are unavailable.

- Standardize dashboard confirmation flow via shared confirm helpers across composition and options API components.

* refactor: simplify runtime tls bootstrap and tighten confirm typing

* refactor: align ssl helper namespace and confirm usage

* fix: avoid frozen restart crash from multiprocessing import

* fix: include missing frozen dependencies for windows backend

* fix: use execv for stable backend reboot args

* Revert "fix: use execv for stable backend reboot args"

This reverts commit 9cc27becff.

* Revert "fix: include missing frozen dependencies for windows backend"

This reverts commit 52554bea1f.

* Revert "fix: avoid frozen restart crash from multiprocessing import"

This reverts commit 10548645b0.

* fix: reset pyinstaller onefile env before reboot

* fix: unify electron restart path and tray-exit backend cleanup

* fix: stabilize desktop restart detection and frozen reboot args

* fix: make dashboard restart wait detection robust

* fix: revert dashboard restart waiting interaction tweaks

* fix: pass auth token for desktop graceful restart

* fix: avoid false failure during graceful restart wait

* fix: start restart waiting before electron restart call

* fix: harden restart waiting and reboot arg parsing

* fix: parse start_time as numeric timestamp

* fix: 修复app内重启异常,修复app内点击重启不能立刻提示重启,以及在后端就绪时及时刷新界面的问题 (#5013)

* fix: patch pip distlib finder for frozen electron runtime

* fix: use certifi CA bundle for runtime SSL requests

* fix: configure certifi CA before core imports

* fix: improve mac font fallback for dashboard text

* fix: harden frozen pip patch and unify TLS connector

* refactor: centralize dashboard CJK font fallback stacks

* perf: reuse TLS context and avoid repeated frozen pip patch

* refactor: bootstrap TLS setup before core imports

* fix: use async confirm dialog for provider deletions

* fix: replace native confirm dialogs in dashboard

- Add shared confirm helper in dashboard/src/utils/confirmDialog.ts for async dialog usage with safe fallback.

- Migrate provider, chat, config, session, platform, persona, MCP, backup, and knowledge-base delete/close confirmations to use the shared helper.

- Remove scattered inline confirm handling to keep behavior consistent and avoid native blocking dialog focus/caret issues in Electron.

* fix: capture runtime bootstrap logs after logger init

- Add bootstrap record buffer in runtime_bootstrap for early TLS patch logs before logger is ready.

- Flush buffered bootstrap logs to astrbot logger at process startup in main.py.

- Include concrete exception details for TLS bootstrap failures to improve diagnosis.

* fix: harden runtime bootstrap and unify confirm handling

- Simplify bootstrap log buffering and add a public initialize hook for non-main startup paths.

- Guard aiohttp TLS patching with feature/type checks and keep graceful fallback when internals are unavailable.

- Standardize dashboard confirmation flow via shared confirm helpers across composition and options API components.

* refactor: simplify runtime tls bootstrap and tighten confirm typing

* refactor: align ssl helper namespace and confirm usage

* fix: avoid frozen restart crash from multiprocessing import

* fix: include missing frozen dependencies for windows backend

* fix: use execv for stable backend reboot args

* Revert "fix: use execv for stable backend reboot args"

This reverts commit 9cc27becff.

* Revert "fix: include missing frozen dependencies for windows backend"

This reverts commit 52554bea1f.

* Revert "fix: avoid frozen restart crash from multiprocessing import"

This reverts commit 10548645b0.

* fix: reset pyinstaller onefile env before reboot

* fix: unify electron restart path and tray-exit backend cleanup

* fix: stabilize desktop restart detection and frozen reboot args

* fix: make dashboard restart wait detection robust

* fix: revert dashboard restart waiting interaction tweaks

* fix: pass auth token for desktop graceful restart

* fix: avoid false failure during graceful restart wait

* fix: start restart waiting before electron restart call

* fix: harden restart waiting and reboot arg parsing

* fix: parse start_time as numeric timestamp

* fix: preserve windows frozen reboot argv quoting

* fix: align restart waiting with electron restart timing

* fix: tighten graceful restart and unmanaged kill safety

* chore: bump version to 4.15.0 (#5003)

* fix: add reminder for v4.14.8 users regarding manual redeployment due to a bug

* fix: harden plugin dependency loading in frozen app runtime (#5015)

* fix: compare plugin versions semantically in market updates

* fix: prioritize plugin site-packages for in-process pip

* fix: reload starlette from plugin target site-packages

* fix: harden plugin dependency import precedence in frozen runtime

* fix: improve plugin dependency conflict handling

* refactor: simplify plugin conflict checks and version utils

* fix: expand transitive plugin dependencies for conflict checks

* fix: recover conflicting plugin dependencies during module prefer

* fix: reuse renderer restart flow for tray backend restart

* fix: add recoverable plugin dependency conflict handling

* revert: remove plugin version comparison changes

* fix: add missing tray restart backend labels

* feat: adding support for media and quoted message attachments for feishu (#5018)

* docs: add AUR installation method (#4879)

* docs: sync system package manager installation instructions to all languages

* Update README.md

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

* Update README.md

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

* fix/typo

* refactor: update system package manager installation instructions for Arch Linux across multiple language README files

* feat: add installation command for AstrBot in multiple language README files

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
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: Soulter <905617992@qq.com>

* fix(desktop): 为 Electron 与后端日志增加按大小轮转 (#5029)

* fix(desktop): rotate electron and backend logs

* refactor(desktop): centralize log rotation defaults and debug fs errors

* fix(desktop): harden rotation fs ops and buffer backend log writes

* refactor(desktop): extract buffered logger and reduce sync stat calls

* refactor(desktop): simplify rotation flow and harden logger config

* fix(desktop): make app logging async and flush-safe

* fix: harden app log path switching and debug-gated rotation errors

* fix: cap buffered log chunk size during path switch

* feat: add first notice feature with multilingual support and UI integration

* fix: 提升打包版桌面端启动稳定性并优化插件依赖处理 (#5031)

* fix(desktop): rotate electron and backend logs

* refactor(desktop): centralize log rotation defaults and debug fs errors

* fix(desktop): harden rotation fs ops and buffer backend log writes

* refactor(desktop): extract buffered logger and reduce sync stat calls

* refactor(desktop): simplify rotation flow and harden logger config

* fix(desktop): make app logging async and flush-safe

* fix: harden app log path switching and debug-gated rotation errors

* fix: cap buffered log chunk size during path switch

* fix: avoid redundant plugin reinstall and upgrade electron

* fix: stop webchat tasks cleanly and bind packaged backend to localhost

* fix: unify platform shutdown and await webchat listener cleanup

* fix: improve startup logs for dashboard and onebot listeners

* fix: revert extra startup service logs

* fix: harden plugin import recovery and webchat listener cleanup

* fix: pin dashboard ci node version to 24.13.0

* fix: avoid duplicate webchat listener cleanup on terminate

* refactor: clarify platform task lifecycle management

* fix: continue platform shutdown when terminate fails

* feat: temporary file handling and introduce TempDirCleaner (#5026)

* feat: temporary file handling and introduce TempDirCleaner

- Updated various modules to use `get_astrbot_temp_path()` instead of `get_astrbot_data_path()` for temporary file storage.
- Renamed temporary files for better identification and organization.
- Introduced `TempDirCleaner` to manage the size of the temporary directory, ensuring it does not exceed a specified limit by deleting the oldest files.
- Added configuration option for maximum temporary directory size in the dashboard.
- Implemented tests for `TempDirCleaner` to verify cleanup functionality and size management.

* ruff

* fix: close unawaited reset coroutine on early return (#5033)

When an OnLLMRequestEvent hook stops event propagation, the
reset_coro created by build_main_agent was never awaited, causing
a RuntimeWarning. Close the coroutine explicitly before returning.

Fixes #5032

Co-authored-by: Limitless2023 <limitless@users.noreply.github.com>

* fix: update error logging message for connection failures

* docs: clean and sync README (#5014)

* fix: close missing div in README

* fix: sync README_zh-TW with README

* fix: sync README

* fix: correct typo

correct url in README_en README_fr README_ru

* docs: sync README_en with README

* Update README_en.md

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

---------

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

* fix: provider extra param dialog key display error

* chore: ruff format

* feat: add send_chat_action for Telegram platform adapter (#5037)

* feat: add send_chat_action for Telegram platform adapter

Add typing/upload indicator when sending messages via Telegram.
- Added _send_chat_action helper method for sending chat actions
- Send appropriate action (typing, upload_photo, upload_document, upload_voice)
  before sending different message types
- Support streaming mode with typing indicator
- Support supergroup with message_thread_id

* refactor(telegram): extract chat action helpers and add throttling

- Add ACTION_BY_TYPE mapping for message type to action priority
- Add _get_chat_action_for_chain() to determine action from message chain
- Add _send_media_with_action() for upload → send → restore typing pattern
- Add _ensure_typing() helper for typing status
- Add chat action throttling (0.5s) in streaming mode to avoid rate limits
- Update type annotation to ChatAction | str for better static checking

* feat(telegram): implement send_typing method for Telegram platform

---------

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

* fix: 修复更新日志、官方文档弹窗双滚动条问题 (#5060)

* docs: sync and fix readme typo (#5055)

* docs: fix index typo

* docs: fix typo in README_en.md

- 移除英文README中意外出现的俄语,并替换为英语

* docs: fix html typo

- remove unused '</p>'

* docs: sync table with README

* docs: sync README header format

- keep the README header format consistent

* doc: sync key features

* style: format files

- Fix formatting issues from previous PR

* fix: correct md anchor link

* docs: correct typo in README_fr.md

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

* docs: correct typo in README_zh-TW.md

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

---------

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

* fix: 修复备份时缺失的人格文件夹映射 (#5042)

* feat: QQ 官方机器人平台支持主动推送消息、私聊场景下支持接收文件 (#5066)

* feat: QQ 官方机器人平台支持主动推送消息、私聊场景下支持接收文件

* feat: enhance QQOfficialWebhook to remember session scenes for group, channel, and friend messages

* perf: 优化分段回复间隔时间的初始化逻辑 (#5068)

fixes: #5059

* fix: chunk err when using openrouter deepseek (#5069)

* feat: add i18n supports for custom platform adapters (#5045)

* Feat: 为插件提供的适配器的元数据&i18n提供数据通路

* chore: update docstrings with pull request references

Added references to pull request 5045 in docstrings.

---------

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

* fix: 完善转发引用解析与图片回退并支持配置化控制 (#5054)

* feat: support fallback image parsing for quoted messages

* fix: fallback parse quoted images when reply chain has placeholders

* style: format network utils with ruff

* test: expand quoted parser coverage and improve fallback diagnostics

* fix: fallback to text-only retry when image requests fail

* fix: tighten image fallback and resolve nested quoted forwards

* refactor: simplify quoted message extraction and dedupe images

* fix: harden quoted parsing and openai error candidates

* fix: harden quoted image ref normalization

* refactor: organize quoted parser settings and logging

* fix: cap quoted fallback images and avoid retry loops

* refactor: split quoted message parser into focused modules

* refactor: share onebot segment parsing logic

* refactor: unify quoted message parsing flow

* feat: move quoted parser tuning to provider settings

* fix: add missing i18n metadata for quoted parser settings

* chore: refine forwarded message setting labels

* fix: add config tabs and routing for normal and system configurations

* chore: bump version to 4.16.0 (#5074)

* feat: add LINE platform support with adapter and configuration (#5085)

* fix-correct-FIRST_NOTICE.md-locale-path-resolution (#5083) (#5082)

* fix:修改配置文件目录

* fix:添加备选的FIRST_NOTICE.zh-CN.md用于兼容

* fix: remove unnecessary frozen flag from requirements export in Dockerfile

fixes: #5089

* fix #5089: add uv lock step in Dockerfile before export (#5091)

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

* feat: support hot reload after plugin load failure (#5043)

* add :Support hot reload after plugin load failure

* Apply suggestions from code review

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

* fix:reformat code

* fix:reformat code

---------

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

* feat: add fallback chat model chain in tool loop runner (#5109)

* feat: implement fallback provider support for chat models and update configuration

* feat: enhance provider selection display with count and chips for selected providers

* feat: update fallback chat providers to use provider settings and add warning for non-list fallback models

* feat: add Afdian support card to resources section in WelcomePage

* feat: replace colorlog with loguru for enhanced logging support (#5115)

* feat: add SSL configuration options for WebUI and update related logging (#5117)

* chore: bump version to 4.17.0

* fix: handle list format content from OpenAI-compatible APIs (#5128)

* fix: handle list format content from OpenAI-compatible APIs

Some LLM providers (e.g., GLM-4.5V via SiliconFlow) return content as
list[dict] format like [{'type': 'text', 'text': '...'}] instead of
plain string. This causes the raw list representation to be displayed
to users.

Changes:
- Add _normalize_content() helper to extract text from various content formats
- Use json.loads instead of ast.literal_eval for safer parsing
- Add size limit check (8KB) before attempting JSON parsing
- Only convert lists that match OpenAI content-part schema (has 'type': 'text')
  to avoid collapsing legitimate list-literal replies like ['foo', 'bar']
- Add strip parameter to preserve whitespace in streaming chunks
- Clean up orphan </think> tags that may leak from some models

Fixes #5124

* fix: improve content normalization safety

- Try json.loads first, fallback to ast.literal_eval for single-quoted
  Python literals to avoid corrupting apostrophes (e.g., "don't")
- Coerce text values to str to handle null or non-string text fields

* fix: update retention logic in LogManager to handle backup count correctly

* chore: bump version to 4.17.1

* docs: Added instructions for deploying AstrBot using AstrBot Launcher. (#5136)

Added instructions for deploying AstrBot using AstrBot Launcher.

* fix: add MCP tools to function tool set in _plugin_tool_fix (#5144)

* fix: add support for collecting data from builtin stars in electron pyinstaller build (#5145)

* chore: bump version to 4.17.1

* chore: ruff format

* fix: prevent updates for AstrBot launched via launcher

* fix(desktop): include runtime deps for builtin plugins in backend build (#5146)

* fix: 'Plain' object has no attribute 'text' when using python 3.14 (#5154)

* fix: enhance plugin metadata handling by injecting attributes before instantiation (#5155)

* fix: enhance handle_result to support event context and webchat image sending

* chore: bump version to 4.17.3

* chore: ruff format

* feat: add NVIDIA provider template (#5157)

fixes: #5156

* feat: enhance provider sources panel with styled menu and mobile support

* fix: improve permission denied message for local execution in Python and shell tools

* feat: enhance PersonaForm component with responsive design and improved styling (#5162)

fix: #5159

* ui(CronJobPage): fix action column buttons overlapping in CronJobPage (#5163)

- 修改前:操作列容器仅使用 `d-flex`,在页面宽度变窄时,子元素(开关和删除按钮)会因为宽度挤压而发生视觉重叠,甚至堆叠在一起。
- 修改后:
    1. 为容器添加了 `flex-nowrap`,强制禁止子元素换行。
    2. 设置了 `min-width: 140px`,确保该列拥有固定的保护空间,防止被其他长文本列挤压。
    3. 增加了 `gap: 12px` 间距,提升了操作辨识度并优化了点击体验。

* feat: add unsaved changes notice to configuration page and update messages

* feat: implement search functionality in configuration components and update UI (#5168)

* feat: add FAQ link to vertical sidebar and update navigation for localization

* feat: add announcement section to WelcomePage and localize announcement title

* chore: bump version to 4.17.4

* feat: supports send markdown message in qqofficial (#5173)

* feat: supports send markdown message in qqofficial

closes: #1093 #918 #4180 #4264

* ruff format

* fix: prevent duplicate error message when all LLM providers fail (#5183)

* fix: 修复选择配置文件进入配置文件管理弹窗直接关闭弹窗显示的配置文件不正确 (#5174)

* feat: add MarketPluginCard component and integrate random plugin feature in ExtensionPage (#5190)

* feat: add MarketPluginCard component and integrate random plugin feature in ExtensionPage

* feat: update random plugin selection logic to use pluginMarketData and refresh on relevant events

* feat: supports aihubmix

* docs: update readme

* chore: ruff format

* feat: add LINE support to multiple language README files

* feat(core): add plugin error hook for custom error routing (#5192)

* feat(core): add plugin error hook for custom error routing

* fix(core): align plugin error suppression with event stop state

* refactor: extract Voice_messages_forbidden fallback into shared helper with typed BadRequest exception (#5204)

- Add _send_voice_with_fallback helper to deduplicate voice forbidden handling
- Catch telegram.error.BadRequest instead of bare Exception with string matching
- Add text field to Record component to preserve TTS source text
- Store original text in Record during TTS conversion for use as document caption
- Skip _send_chat_action when chat_id is empty to avoid unnecessary warnings

* chore: bump version to 4.17.5

* feat: add admin permission checks for Python and Shell execution (#5214)

* fix: 改进微信公众号被动回复处理机制,引入缓冲与分片回复,并优化超时行为 (#5224)

* 修复wechat official 被动回复功能

* ruff format

---------

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

* fix: 修复仅发送 JSON 消息段时的空消息回复报错 (#5208)

* Fix Register_Stage

· 补全 JSON 消息判断,修复发送 JSON 消息时遇到 “消息为空,跳过发送阶段” 的问题。
· 顺带补全其它消息类型判断。
Co-authored-by: Pizero <zhaory200707@outlook.com>

* Fix formatting and comments in stage.py

* Format stage.py

---------

Co-authored-by: Pizero <zhaory200707@outlook.com>

* docs: update related repo links

* fix(core): terminate active events on reset/new/del to prevent stale responses (#5225)

* fix(core): terminate active events on reset/new/del to prevent stale responses

Closes #5222

* style: fix import sorting in scheduler.py

* chore: remove Electron desktop pipeline and switch to tauri repo (#5226)

* ci: remove Electron desktop build from release pipeline

* chore: remove electron desktop and switch to tauri release trigger

* ci: remove desktop workflow dispatch trigger

* refactor: migrate data paths to astrbot_path helpers

* fix: point desktop update prompt to AstrBot-desktop releases

* fix: update feature request template for clarity and consistency in English and Chinese

* Feat/config leave confirm (#5249)

* feat: 配置文件增加未保存提示弹窗

* fix: 移除unsavedChangesDialog插件使用组件方式实现弹窗

* feat: add support for plugin astrbot-version and platform requirement checks (#5235)

* feat: add support for plugin astrbot-version and platform requirement checks

* fix: remove unsupported platform and version constraints from metadata.yaml

* fix: remove restriction on 'v' in astrbot_version specification format

* ruff format

* feat: add password confirmation when changing password (#5247)

* feat: add password confirmation when changing password

Fixes #5177

Adds a password confirmation field to prevent accidental password typos.

Changes:
- Backend: validate confirm_password matches new_password
- Frontend: add confirmation input with validation
- i18n: add labels and error messages for password mismatch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(auth): improve error message for password confirmation mismatch

* fix(auth): update password hashing logic and improve confirmation validation

---------

Co-authored-by: whatevertogo <whatevertogo@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(provider): 修复 dict 格式 content 导致的 JSON 残留问题 (#5250)

* fix(provider): 修复 dict 格式 content 导致的 JSON 残留问题

修复 _normalize_content 函数未处理 dict 类型 content 的问题。
当 LLM 返回 {"type": "text", "text": "..."} 格式的 content 时,
现在会正确提取 text 字段而非直接转为字符串。

同时改进 fallback 行为,对 None 值返回空字符串。

Fixes #5244

* Update warning message for unexpected dict format

---------

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

* chore: remove outdated heihe.md documentation file

* fix: all mcp tools exposed to main agent (#5252)

* fix: enhance PersonaForm layout and improve tool selection display

* fix: update tool status display and add localization for inactive tools

* fix: remove additionalProperties from tool schema properties (#5253)

fixes: #5217

* fix: simplify error messages for account edit validation

* fix: streamline error response for empty new username and password in account edit

* chore: bump vertion to 4.17.6

* feat: add OpenRouter provider support and icon

* chore: ruff format

* refactor(dashboard): replace legacy isElectron bridge fields with isDesktop (#5269)

* refactor dashboard desktop bridge fields from isElectron to isDesktop

* refactor dashboard runtime detection into shared helper

* fix: update contributor avatar image URL to include max size and columns (#5268)

* feat: astrbot http api (#5280)

* feat: astrbot http api

* Potential fix for code scanning alert no. 34: Use of a broken or weak cryptographic hashing algorithm on sensitive data

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* fix: improve error handling for missing attachment path in file upload

* feat: implement paginated retrieval of platform sessions for creators

* feat: refactor attachment directory handling in ChatRoute

* feat: update API endpoint paths for file and message handling

* feat: add documentation link to API key management section in settings

* feat: update API key scopes and related configurations in API routes and tests

* feat: enhance API key expiration options and add warning for permanent keys

* feat: add UTC normalization and serialization for API key timestamps

* feat: implement chat session management and validation for usernames

* feat: ignore session_id type chunks in message processing

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* feat(dashboard): improve plugin platform support display and mobile accessibility (#5271)

* feat(dashboard): improve plugin platform support display and mobile accessibility

- Replace hover-based tooltips with interactive click menus for platform support information.
- Fix mobile touch issues by introducing explicit state control for status capsules.
- Enhance UI aesthetics with platform-specific icons and a structured vertical list layout.
- Add dynamic chevron icons to provide clear visual cues for expandable content.

* refactor(dashboard): refactor market card with computed properties for performance

* refactor(dashboard): unify plugin platform support UI with new reusable chip component

- Create shared 'PluginPlatformChip' component to encapsulate platform meta display.
- Fix mobile interaction bugs by simplifying menu triggers and event handling.
- Add stacked platform icon previews and dynamic chevron indicators within capsules.
- Improve information hierarchy using structured vertical lists for platform details.
- Optimize rendering efficiency with computed properties across both card views.

* fix: qq official guild message send error (#5287)

* fix: qq official guild message send error

* Update astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py

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

---------

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

* 更新readme文档,补充桌面app说明,并向前移动位置 (#5297)

* docs: update desktop deployment section in README

* docs: refine desktop and launcher deployment descriptions

* Update README.md

* feat: add Anthropic Claude Code OAuth provider and adaptive thinking support (#5209)

* feat: add Anthropic Claude Code OAuth provider and adaptive thinking support

* fix: add defensive guard for metadata overrides and align budget condition with docs

* refactor: adopt sourcery-ai suggestions for OAuth provider

- Use use_api_key=False in OAuth subclass to avoid redundant
  API-key client construction before replacing with auth_token client
- Generalize metadata override helper to merge all dict keys
  instead of only handling 'limit', improving extensibility

* Feat/telegram command alias register  #5233 (#5234)

* feat: support registering command aliases for Telegram

Now when registering commands with aliases, all aliases will be
registered as Telegram bot commands in addition to the main command.

Example:
    @register_command(command_name="draw", alias={"画", "gen"})
Now /draw, /画, and /gen will all appear in the Telegram command menu.

* feat(telegram): add duplicate command name warning when registering commands

Log a warning when duplicate command names are detected during Telegram
command registration to help identify configuration conflicts.

* refactor: remove Anthropic OAuth provider implementation and related metadata overrides

* fix: 修复新建对话时因缺少会话ID导致配置绑定失败的问题 (#5292)

* fix:尝试修改

* fix:添加详细日志

* fix:进行详细修改,并添加日志

* fix:删除所有日志

* fix: 增加安全访问函数

- 给 localStorage 访问加了 try/catch + 可用性判断:dashboard/src/utils/chatConfigBinding.ts:13
- 新增 getFromLocalStorage/setToLocalStorage(在受限存储/无痕模式下异常时回退/忽略)
- getStoredDashboardUsername() / getStoredSelectedChatConfigId() 改为走安全读取:dashboard/src/utils/chatConfigBinding.ts:36       - 新增 setStoredSelectedChatConfigId(),写入失败静默忽略:dashboard/src/utils/chatConfigBinding.ts:44
- 把 ConfigSelector.vue 里直接 localStorage.getItem/setItem 全部替换为上述安全方法:dashboard/src/components/chat/ConfigSelector.vue:81
- 已重新跑过 pnpm run typecheck,通过。

* rm:删除个人用的文档文件

* Revert "rm:删除个人用的文档文件"

This reverts commit 0fceee0543.

* rm:删除个人用的文档文件

* rm:删除个人用的文档文件

* chore: bump version to 4.18.0

* fix(SubAgentPage): 当中间的介绍文本非常长时,Flex 布局会自动挤压右侧的控制按钮区域 (#5306)

* fix: 修复新版本插件市场出现插件显示为空白的 bug;纠正已安装插件卡片的排版,统一大小 (#5309)

* fix(ExtensionCard): 解决插件卡片大小不统一的问题

* fix(MarketPluginCard): 解决插件市场不加载插件的问题 (#5303)

* feat: supports spawn subagent as a background task that not block the main agent workflow (#5081)

* feat:为subagent添加后台任务参数

* ruff

* fix: update terminology from 'handoff mission' to 'background task' and refactor related logic

* fix: update terminology from 'background_mission' to 'background_task' in HandoffTool and related logic

* fix(HandoffTool): update background_task description for clarity on usage

---------

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

* cho

* fix: 修复 aiohttp 版本过新导致 qq-botpy 报错的问题 (#5316)

* chore: ruff format

* fix: remove hard-coded 6s timeout from tavily request

* fix: remove changelogs directory from .dockerignore

* feat(dashboard): make release redirect base URL configurable (#5330)

* feat(dashboard): make desktop release base URL configurable

* refactor(dashboard): use generic release base URL env with upstream default

* fix(dashboard): guard release base URL normalization when env is unset

* refactor(dashboard): use generic release URL helpers and avoid latest suffix duplication

* feat: add stop functionality for active agent sessions and improve handling of stop requests (#5380)

* feat: add stop functionality for active agent sessions and improve handling of stop requests

* feat: update stop button icon and tooltip in ChatInput component

* fix: correct indentation in tool call handling within ChatRoute class

* fix: chatui cannot persist file segment (#5386)

* fix(plugin): update plugin directory handling for reserved plugins (#5369)

* fix(plugin): update plugin directory handling for reserved plugins

* fix(plugin): add warning logs for missing plugin name, object, directory, and changelog

* chore(README): updated with README.md (#5375)

* chore(README): updated with README.md

* Update README_fr.md

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

* Update README_zh-TW.md

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

---------

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

* feat: add image urls / paths supports for subagent (#5348)

* fix: 修复5081号PR在子代理执行后台任务时,未正确使用系统配置的流式/非流请求的问题(#5081)

* feat:为子代理增加远程图片URL参数支持

* fix: update description for image_urls parameter in HandoffTool to clarify usage in multimodal tasks

* ruff format

---------

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

* feat: add hot reload when failed to load plugins (#5334)

* feat:add hot reload when failed to load plugins

* apply bot suggestions

* fix(chatui): add copy rollback path and error message. (#5352)

* fix(chatui): add copy rollback path and error message.

* fix(chatui): fixed textarea leak in the copy button.

* fix(chatui): use color styles from the component library.

* fix: 处理配置文件中的 UTF-8 BOM 编码问题 (#5376)

* fix(config): handle UTF-8 BOM in configuration file loading

Problem:
On Windows, some text editors (like Notepad) automatically add UTF-8 BOM
to JSON files when saving. This causes json.decoder.JSONDecodeError:
"Unexpected UTF-8 BOM" and AstrBot fails to start when cmd_config.json
contains BOM.

Solution:
Add defensive check to strip UTF-8 BOM (\ufeff) if present before
parsing JSON configuration file.

Impact:
- Improves robustness and cross-platform compatibility
- No breaking changes to existing functionality
- Fixes startup failure when configuration file has UTF-8 BOM encoding

Relates-to: Windows editor compatibility issues

* style: fix code formatting with ruff

Fix single quote to double quote to comply with project code style.

* feat: add plugin load&unload hook (#5331)

* 添加了插件的加载完成和卸载完成的钩子事件

* 添加了插件的加载完成和卸载完成的钩子事件

* format code with ruff

* ruff format

---------

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

* test: enhance test framework with comprehensive fixtures and mocks (#5354)

* test: enhance test framework with comprehensive fixtures and mocks

- Add shared mock builders for aiocqhttp, discord, telegram
- Add test helpers for platform configs and mock objects
- Expand conftest.py with test profile support
- Update coverage test workflow configuration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(tests): 移动并重构模拟 LLM 响应和消息组件函数

* fix(tests): 优化 pytest_runtest_setup 中的标记检查逻辑

---------

Co-authored-by: whatevertogo <whatevertogo@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: add comprehensive tests for message event handling (#5355)

* test: add comprehensive tests for message event handling

- Add AstrMessageEvent unit tests (688 lines)
- Add AstrBotMessage unit tests
- Enhance smoke tests with message event scenarios

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: improve message type handling and add defensive tests

---------

Co-authored-by: whatevertogo <whatevertogo@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add support for showing tool call results in agent execution (#5388)

closes: #5329

* fix: resolve pipeline and star import cycles (#5353)

* fix: resolve pipeline and star import cycles

- Add bootstrap.py and stage_order.py to break circular dependencies
- Export Context, PluginManager, StarTools from star module
- Update pipeline __init__ to defer imports
- Split pipeline initialization into separate bootstrap module

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: add logging for get_config() failure in Star class

* fix: reorder logger initialization in base.py

---------

Co-authored-by: whatevertogo <whatevertogo@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: enable computer-use tools for subagent handoff (#5399)

* fix: enforce admin guard for sandbox file transfer tools (#5402)

* fix: enforce admin guard for sandbox file transfer tools

* refactor: deduplicate computer tools admin permission checks

* fix: add missing space in permission error message

* fix(core): 优化 File 组件处理逻辑并增强 OneBot 驱动层路径兼容性 (#5391)

* fix(core): 优化 File 组件处理逻辑并增强 OneBot 驱动层路径兼容性

原因 (Necessity):
1. 内核一致性:AstrBot 内核的 Record 和 Video 组件均具备识别 `file:///` 协议头的逻辑,但 File 组件此前缺失此功能,导致行为不统一。
2. OneBot 协议合规:OneBot 11 标准要求本地文件路径必须使用 `file:///` 协议头。此前驱动层未对裸路径进行自动转换,导致发送本地文件时常触发 retcode 1200 (识别URL失败) 错误。
3. 容器环境适配:在 Docker 等路径隔离环境下,裸路径更容易因驱动或协议端的解析歧义而失效。

更改 (Changes):
- [astrbot/core/message/components.py]:
  - 在 File.get_file() 中增加对 `file:///` 前缀的识别与剥离逻辑,使其与 Record/Video 组件行为对齐。
- [astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py]:
  - 在发送文件前增加自动修正逻辑:若路径为绝对路径且未包含协议头,驱动层将自动补全 `file:///` 前缀。
  - 对 http、base64 及已有协议头,确保不干扰原有的正常传输逻辑。

影响 (Impact):
- 以完全兼容的方式增强了文件发送的鲁棒性。
- 解决了插件在发送日志等本地生成的压缩包时,因路径格式不规范导致的发送失败问题。

* refactor(core): 根据 cr 建议,规范化文件 URI 生成与解析逻辑,优化跨平台兼容性

原因 (Necessity):
1. 修复原生路径与 URI 转换在 Windows 下的不对称问题。
2. 规范化 file: 协议头处理,确保符合 RFC 标准并能在 Linux/Windows 间稳健切换。
3. 增强协议判定准确度,防止对普通绝对路径的误处理。

更改 (Changes):
- [astrbot/core/platform/sources/aiocqhttp]:
  - 弃用手动拼接,改用 `pathlib.Path.as_uri()` 生成标准 URI。
  - 将协议检测逻辑从前缀匹配优化为包含性检测 ("://")。
- [astrbot/core/message/components]:
  - 重构 `File.get_file` 解析逻辑,支持对称处理 2/3 斜杠格式。
  - 针对 Windows 环境增加了对 `file:///C:/` 格式的自动修正,避免 `os.path` 识别失效。
- [data/plugins/astrbot_plugin_logplus]:
  - 在直接 API 调用中同步应用 URI 规范化处理。

影响 (Impact):
- 解决 Docker 环境中因路径不规范导致的 "识别URL失败" 报错。
- 提升了本体框架在 Windows 系统下的文件操作鲁棒性。

* i18n(SubAgentPage): complete internationalization for subagent orchestration page (#5400)

* i18n: complete internationalization for subagent orchestration page

- Replace hardcoded English strings in [SubAgentPage.vue] with i18n keys.
- Update `en-US` and `zh-CN` locales with missing hints, validation messages, and empty state translations.
- Fix translation typos and improve consistency across the SubAgent orchestration UI.

* fix(bug_risk): 避免在模板中的翻译调用上使用 || 'Close' 作为回退值。

* fix(aiocqhttp): enhance shutdown process for aiocqhttp adapter (#5412)

* fix: pass embedding dimensions to provider apis (#5411)

* fix(context): log warning when platform not found for session

* fix(context): improve logging for platform not found in session

* chore: bump version to 4.18.2

* chore: bump version to 4.18.2

* chore: bump version to 4.18.2

* fix: Telegram voice message format (OGG instead of WAV) causing issues with OpenAI STT API (#5389)

* chore: ruff format

* feat(dashboard): add generic desktop app updater bridge (#5424)

* feat(dashboard): add generic desktop app updater bridge

* fix(dashboard): address updater bridge review feedback

* fix(dashboard): unify updater bridge types and error logging

* fix(dashboard): consolidate updater bridge typings

* fix(conversation): retain existing persona_id when updating conversation

* fix(dashboard): 修复设置页新建 API Key 后复制失败问题 (#5439)

* Fix: GitHub proxy not displaying correctly in WebUI (#5438)

* fix(dashboard): preserve custom GitHub proxy setting on reload

* fix(dashboard): keep github proxy selection persisted in settings

* fix(persona): enhance persona resolution logic for conversations and sessions

* fix: ensure tool call/response pairing in context truncation (#5417)

* fix: ensure tool call/response pairing in context truncation

* refactor: simplify fix_messages to single-pass state machine

* perf(cron): enhance future task session isolation

fixes: #5392

* feat: add useExtensionPage composable for managing plugin extensions

- Implemented a new composable `useExtensionPage` to handle various functionalities related to plugin management, including fetching extensions, handling updates, and managing UI states.
- Added support for conflict checking, plugin installation, and custom source management.
- Integrated search and filtering capabilities for plugins in the market.
- Enhanced user experience with dialogs for confirmations and notifications.
- Included pagination and sorting features for better plugin visibility.

* fix: clear markdown field when sending media messages via QQ Official Platform (#5445)

* fix: clear markdown field when sending media messages via QQ Official API

* refactor: use pop() to remove markdown key instead of setting None

* fix: cannot automatically get embedding dim when create embedding provider (#5442)

* fix(dashboard): 强化 API Key 复制临时节点清理逻辑

* fix(embedding): 自动检测改为探测 OpenAI embedding 最大可用维度

* fix: normalize openai embedding base url and add hint key

* i18n: add embedding_api_base hint translations

* i18n: localize provider embedding/proxy metadata hints

* fix: show provider-specific embedding API Base URL hint as field subtitle

* fix(embedding): cap OpenAI detect_dim probes with early short-circuit

* fix(dashboard): return generic error on provider adapter import failure

* 回退检测逻辑

* fix: 修复Pyright静态类型检查报错 (#5437)

* refactor: 修正 Sqlite 查询、下载回调、接口重构与类型调整

* feat: 为 OneBotClient 增加 CallAction 协议与异步调用支持

* fix(telegram): avoid duplicate message_thread_id in streaming (#5430)

* perf: batch metadata query in KB retrieval to fix N+1 problem (#5463)

* perf: batch metadata query in KB retrieval to fix N+1 problem

Replace N sequential get_document_with_metadata() calls with a single
get_documents_with_metadata_batch() call using SQL IN clause.

Benchmark results (local SQLite):
- 10 docs: 10.67ms → 1.47ms (7.3x faster)
- 20 docs: 26.00ms → 2.68ms (9.7x faster)
- 50 docs: 63.87ms → 2.79ms (22.9x faster)

* refactor: use set[str] param type and chunk IN clause for SQLite safety

Address review feedback:
- Change doc_ids param from list[str] to set[str] to avoid unnecessary conversion
- Chunk IN clause into batches of 900 to stay under SQLite's 999 parameter limit
- Remove list() wrapping at call site, pass set directly

* fix:fix the issue where incomplete cleanup of residual plugins occurs… (#5462)

* fix:fix the issue where incomplete cleanup of residual plugins occurs in the failed loading of plugins

* fix:ruff format,apply bot suggestions

* Apply suggestion from @gemini-code-assist[bot]

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

---------

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

* chore: 为类型检查添加 TYPE_CHECKING 的导入与阶段类型引用 (#5474)

* fix(line): line adapter does not appear in the add platform dialog

fixes: #5477

* [bug]查看介入教程line前往错误界面的问题 (#5479)

Fixes #5478

* chore: bump version to 4.18.3

* feat: implement follow-up message handling in ToolLoopAgentRunner (#5484)

* feat: implement follow-up message handling in ToolLoopAgentRunner

* fix: correct import path for follow-up module in InternalAgentSubStage

* feat: implement websockets transport mode selection for chat (#5410)

* feat: implement websockets transport mode selection for chat

- Added transport mode selection (SSE/WebSocket) in the chat component.
- Updated conversation sidebar to include transport mode options.
- Integrated transport mode handling in message sending logic.
- Refactored message sending functions to support both SSE and WebSocket.
- Enhanced WebSocket connection management and message handling.
- Updated localization files for transport mode labels.
- Configured Vite to support WebSocket proxying.

* feat(webchat): refactor message parsing logic and integrate new parsing function

* feat(chat): add websocket API key extraction and scope validation

* Revert "可选后端,实现前后端分离" (#5536)

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: can <51474963+weijintaocode@users.noreply.github.com>
Co-authored-by: Soulter <905617992@qq.com>
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
Co-authored-by: letr <123731298+letr007@users.noreply.github.com>
Co-authored-by: 搁浅 <id6543156918@gmail.com>
Co-authored-by: Helian Nuits <sxp20061207@163.com>
Co-authored-by: Gao Jinzhe <2968474907@qq.com>
Co-authored-by: DD斩首 <155905740+DDZS987@users.noreply.github.com>
Co-authored-by: Ubuntu <ubuntu@localhost.localdomain>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: エイカク <62183434+zouyonghe@users.noreply.github.com>
Co-authored-by: 鸦羽 <Raven95676@gmail.com>
Co-authored-by: Dt8333 <25431943+Dt8333@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Li-shi-ling <114913764+Li-shi-ling@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
Co-authored-by: Limitless <127183162+Limitless2023@users.noreply.github.com>
Co-authored-by: Limitless2023 <limitless@users.noreply.github.com>
Co-authored-by: evpeople <54983536+evpeople@users.noreply.github.com>
Co-authored-by: SnowNightt <127504703+SnowNightt@users.noreply.github.com>
Co-authored-by: xzj0898 <62733743+xzj0898@users.noreply.github.com>
Co-authored-by: stevessr <89645372+stevessr@users.noreply.github.com>
Co-authored-by: Waterwzy <2916963017@qq.com>
Co-authored-by: NayukiMeko <MekoNayuki@outlook.com>
Co-authored-by: 時壹 <137363396+KBVsent@users.noreply.github.com>
Co-authored-by: sanyekana <Clhikari@qq.com>
Co-authored-by: Chiu Chun-Hsien <95356121+911218sky@users.noreply.github.com>
Co-authored-by: Dream Tokenizer <60459821+Trance-0@users.noreply.github.com>
Co-authored-by: NanoRocky <76585834+NanoRocky@users.noreply.github.com>
Co-authored-by: Pizero <zhaory200707@outlook.com>
Co-authored-by: 雪語 <167516635+YukiRa1n@users.noreply.github.com>
Co-authored-by: whatevertogo <1879483647@qq.com>
Co-authored-by: whatevertogo <whatevertogo@users.noreply.github.com>
Co-authored-by: 香草味的纳西妲喵 <151599587+VanillaNahida@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Lovely Moe Moli <44719954+moemoli@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Minidoracat <minidora0702@gmail.com>
Co-authored-by: Chen <42998804+a61995987@users.noreply.github.com>
Co-authored-by: hanbings <hanbings@hanbings.io>
Co-authored-by: tangsenfei <155090747+tangsenfei@users.noreply.github.com>
Co-authored-by: PyuraMazo <1605025385@qq.com>
Co-authored-by: Axi404 <118950647+Axi404@users.noreply.github.com>
Co-authored-by: 氕氙 <2014440212@qq.com>
Co-authored-by: Yunhao Cao <18230652+realquantumcookie@users.noreply.github.com>
Co-authored-by: exynos <110159911+exynos967@users.noreply.github.com>
Co-authored-by: Luna_Dol <86590429+Luna-channel@users.noreply.github.com>
Co-authored-by: CCCCCCTV <64309817+CCCCCCTV@users.noreply.github.com>
Co-authored-by: CAICAII <3360776475@qq.com>
Co-authored-by: 圣达生物多 <qq3258819795@163.com>
2026-02-27 22:06:40 +08:00
LIghtJUNction
483048e3dc 同步主分支 (#5533)
* feat: add bocha web search tool (#4902)

* add bocha web search tool

* Revert "add bocha web search tool"

This reverts commit 1b36d75a17.

* add bocha web search tool

* fix: correct temporary_cache spelling and update supported tools for web search

* ruff

---------

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

* fix: messages[x] assistant content must contain at least one part (#4928)

* fix: messages[x] assistant content must contain at least one part

fixes: #4876

* ruff format

* chore: bump version to 4.14.5 (#4930)

* feat: implement feishu / lark media file handling utilities for file, audio and video processing (#4938)

* feat: implement media file handling utilities for audio and video processing

* feat: refactor file upload handling for audio and video in LarkMessageEvent

* feat: add cleanup for failed audio and video conversion outputs in media_utils

* feat: add utility methods for sending messages and uploading files in LarkMessageEvent

* fix: correct spelling of 'temporary' in SharedPreferences class

* perf: optimize webchat and wecom ai queue lifecycle (#4941)

* perf: optimize webchat and wecom ai queue lifecycle

* perf: enhance webchat back queue management with conversation ID support

* fix: localize provider source config UI (#4933)

* fix: localize provider source ui

* feat: localize provider metadata keys

* chore: add provider metadata translations

* chore: format provider i18n changes

* fix: preserve metadata fields in i18n conversion

* fix: internationalize platform config and dialog

* fix: add Weixin official account platform icon

---------

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

* chore: bump version to 4.14.6

* feat: add provider-souce-level proxy (#4949)

* feat: 添加 Provider 级别代理支持及请求失败日志

* refactor: simplify provider source configuration structure

* refactor: move env proxy fallback logic to log_connection_failure

* refactor: update client proxy handling and add terminate method for cleanup

* refactor: update no_proxy configuration to remove redundant subnet

---------

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

* feat(ComponentPanel):  implement permission management for dashboard (#4887)

* feat(backend): add permission update api

* feat(useCommandActions): add updatePermission action and translations

* feat(dashboard): implement permission editing ui

* style: fix import sorting in command.py

* refactor(backend): extract permission update logic to service

* feat(i18n): add success and failure messages for command updates

---------

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

* feat: 允许 LLM 预览工具返回的图片并自主决定是否发送 (#4895)

* feat: 允许 LLM 预览工具返回的图片并自主决定是否发送

* 复用 send_message_to_user 替代独立的图片发送工具

* feat: implement _HandleFunctionToolsResult class for improved tool response handling

* docs: add path handling guidelines to AGENTS.md

---------

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

* feat(telegram): 添加媒体组(相册)支持 / add media group (album) support (#4893)

* feat(telegram): 添加媒体组(相册)支持 / add media group (album) support

## 功能说明
支持 Telegram 的媒体组消息(相册),将多张图片/视频合并为一条消息处理,而不是分散成多条消息。

## 主要改动

### 1. 初始化媒体组缓存 (__init__)
- 添加 `media_group_cache` 字典存储待处理的媒体组消息
- 使用 2.5 秒超时收集媒体组消息(基于社区最佳实践)
- 最大等待时间 10 秒(防止永久等待)

### 2. 消息处理流程 (message_handler)
- 检测 `media_group_id` 判断是否为媒体组消息
- 媒体组消息走特殊处理流程,避免分散处理

### 3. 媒体组消息缓存 (handle_media_group_message)
- 缓存收到的媒体组消息
- 使用 APScheduler 实现防抖(debounce)机制
- 每收到新消息时重置超时计时器
- 超时后触发统一处理

### 4. 媒体组合并处理 (process_media_group)
- 从缓存中取出所有媒体项
- 使用第一条消息作为基础(保留文本、回复等信息)
- 依次添加所有图片、视频、文档到消息链
- 将合并后的消息发送到处理流程

## 技术方案论证

Telegram Bot API 在处理媒体组时的设计限制:
1. 将媒体组的每个消息作为独立的 update 发送
2. 每个 update 带有相同的 `media_group_id`
3. **不提供**组的总数、结束标志或一次性完整组的机制

因此,bot 必须自行收集消息,并通过硬编码超时(timeout/delay)等待可能延迟到达的消息。
这是目前唯一可靠的方案,被官方实现、主流框架和开发者社区广泛采用。

### 官方和社区证据:
- **Telegram Bot API 服务器实现(tdlib)**:明确指出缺少结束标志或总数信息
  https://github.com/tdlib/telegram-bot-api/issues/643

- **Telegram Bot API 服务器 issue**:讨论媒体组处理的不便性,推荐使用超时机制
  https://github.com/tdlib/telegram-bot-api/issues/339

- **Telegraf(Node.js 框架)**:专用媒体组中间件使用 timeout 控制等待时间
  https://github.com/DieTime/telegraf-media-group

- **StackOverflow 讨论**:无法一次性获取媒体组所有文件,必须手动收集
  https://stackoverflow.com/questions/50180048/telegram-api-get-all-uploaded-photos-by-media-group-id

- **python-telegram-bot 社区**:确认媒体组消息单独到达,需手动处理
  https://github.com/python-telegram-bot/python-telegram-bot/discussions/3143

- **Telegram Bot API 官方文档**:仅定义 `media_group_id` 为可选字段,不提供获取完整组的接口
  https://core.telegram.org/bots/api#message

## 实现细节
- 使用 2.5 秒超时收集媒体组消息(基于社区最佳实践)
- 最大等待时间 10 秒(防止永久等待)
- 采用防抖(debounce)机制:每收到新消息重置计时器
- 利用 APScheduler 实现延迟处理和任务调度

## 测试验证
-  发送 5 张图片相册,成功合并为一条消息
-  保留原始文本说明和回复信息
-  支持图片、视频、文档混合的媒体组
-  日志显示 Processing media group <media_group_id> with 5 items

## 代码变更
- 文件:astrbot/core/platform/sources/telegram/tg_adapter.py
- 新增代码:124 行
- 新增方法:handle_media_group_message(), process_media_group()

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* refactor(telegram): 优化媒体组处理性能和可靠性

根据代码审查反馈改进:

1. 实现 media_group_max_wait 防止无限延迟
   - 跟踪媒体组创建时间,超过最大等待时间立即处理
   - 最坏情况下 10 秒内必定处理,防止消息持续到达导致无限延迟

2. 移除手动 job 查找优化性能
   - 删除 O(N) 的 get_jobs() 循环扫描
   - 依赖 replace_existing=True 自动替换任务

3. 重用 convert_message 减少代码重复
   - 统一所有媒体类型转换逻辑
   - 未来添加新媒体类型只需修改一处

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(telegram): handle missing message in media group processing and improve logging messages

---------

Co-authored-by: Ubuntu <ubuntu@localhost.localdomain>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Soulter <905617992@qq.com>

* feat: add welcome feature with localized content and onboarding steps

* fix: correct height attribute to max-height for dialog component

* feat: supports electron app (#4952)

* feat: add desktop wrapper with frontend-only packaging

* docs: add desktop build docs and track dashboard lockfile

* fix: track desktop lockfile for npm ci

* fix: allow custom install directory for windows installer

* chore: migrate desktop workflow to pnpm

* fix(desktop): build AppImage only on Linux

* fix(desktop): harden packaged startup and backend bundling

* fix(desktop): adapt packaged restart and plugin dependency flow

* fix(desktop): prevent backend respawn race on quit

* fix(desktop): prefer pyproject version for desktop packaging

* fix(desktop): improve startup loading UX and reduce flicker

* ci: add desktop multi-platform release workflow

* ci: fix desktop release build and mac runner labels

* ci: disable electron-builder auto publish in desktop build

* ci: avoid electron-builder publish path in build matrix

* ci: normalize desktop release artifact names

* ci: exclude blockmap files from desktop release assets

* ci: prefix desktop release assets with AstrBot and purge blockmaps

* feat: add electron bridge types and expose backend control methods in preload script

* Update startup screen assets and styles

- Changed the icon from PNG to SVG format for better scalability.
- Updated the border color from #d0d0d0 to #eeeeee for a softer appearance.
- Adjusted the width of the startup screen from 460px to 360px for improved responsiveness.

* Update .gitignore to include package.json

* chore: remove desktop gitkeep ignore exceptions

* docs: update desktop troubleshooting for current runtime behavior

* refactor(desktop): modularize runtime and harden startup flow

---------

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

* fix: dedupe preset messages (#4961)

* feat: enhance package.json with resource filters and compression settings

* chore: update Python version requirements to 3.12 (#4963)

* chore: bump version to 4.14.7

* feat: refactor release workflow and add special update handling for electron app (#4969)

* chore: bump version to 4.14.8 and bump faiss-cpu version up to date

* chore: auto ann fix by ruff (#4903)

* chore: auto fix by ruff

* refactor: 统一修正返回类型注解为 None/bool 以匹配实现

* refactor: 将 _get_next_page 改为异步并移除多余的请求错误抛出

* refactor: 将 get_client 的返回类型改为 object

* style: 为 LarkMessageEvent 的相关方法添加返回类型注解 None

---------

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

* fix: prepare OpenSSL via vcpkg for Windows ARM64

* ci: change ghcr namespace

* chore: update pydantic dependency version (#4980)

* feat: add delete button to persona management dialog (#4978)

* Initial plan

* feat: add delete button to persona management dialog

- Added delete button to PersonaForm dialog (only visible when editing)
- Implemented deletePersona method with confirmation dialog
- Connected delete event to PersonaManager for proper handling
- Button positioned on left side of dialog actions for clear separation
- Uses existing i18n translations for delete button and messages

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

* fix: use finally block to ensure saving state is reset

- Moved `this.saving = false` to finally block in deletePersona
- Ensures UI doesn't stay in saving state after errors
- Follows best practices for state management

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>

* feat: enhance Dingtalk adapter with active push message and image, video, audio message type (#4986)

* fix: handle pip install execution in frozen runtime (#4985)

* fix: handle pip install execution in frozen runtime

* fix: harden pip subprocess fallback handling

* fix: collect certifi data in desktop backend build (#4995)

* feat: 企业微信应用 支持主动消息推送,并优化企微应用、微信公众号、微信客服音频相关的处理 (#4998)

* feat: 企业微信智能机器人支持主动消息推送以及发送视频、文件等消息类型支持 (#4999)

* feat: enhance WecomAIBotAdapter and WecomAIBotMessageEvent for improved streaming message handling (#5000)

fixes: #3965

* feat: enhance persona tool management and update UI localization for subagent orchestration (#4990)

* feat: enhance persona tool management and update UI localization for subagent orchestration

* fix: remove debug logging for final ProviderRequest in build_main_agent function

* perf: 稳定源码与 Electron 打包环境下的 pip 安装行为,并修复非 Electron 环境下点击 WebUI 更新按钮时出现跳转对话框的问题 (#4996)

* fix: handle pip install execution in frozen runtime

* fix: harden pip subprocess fallback handling

* fix: scope global data root to packaged electron runtime

* refactor: inline frozen runtime check for electron guard

* fix: prefer current interpreter for source pip installs

* fix: avoid resolving venv python symlink for pip

* refactor: share runtime environment detection utilities

* fix: improve error message when pip module is unavailable

* fix: raise ImportError when pip module is unavailable

* fix: preserve ImportError semantics for missing pip

* fix: 修复非electron app环境更新时仍然显示electron更新对话框的问题

---------

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

* fix: 'HandoffTool' object has no attribute 'agent' (#5005)

* fix: 移动agent的位置到super().__init__之后

* add: 添加一行注释

* chore(deps): bump the github-actions group with 2 updates (#5006)

Bumps the github-actions group with 2 updates: [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) and [actions/download-artifact](https://github.com/actions/download-artifact).


Updates `astral-sh/setup-uv` from 6 to 7
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/v6...v7)

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

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/download-artifact
  dependency-version: '7'
  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>

* fix: stabilize packaged runtime pip/ssl behavior and mac font fallback (#5007)

* fix: patch pip distlib finder for frozen electron runtime

* fix: use certifi CA bundle for runtime SSL requests

* fix: configure certifi CA before core imports

* fix: improve mac font fallback for dashboard text

* fix: harden frozen pip patch and unify TLS connector

* refactor: centralize dashboard CJK font fallback stacks

* perf: reuse TLS context and avoid repeated frozen pip patch

* refactor: bootstrap TLS setup before core imports

* fix: use async confirm dialog for provider deletions

* fix: replace native confirm dialogs in dashboard

- Add shared confirm helper in dashboard/src/utils/confirmDialog.ts for async dialog usage with safe fallback.

- Migrate provider, chat, config, session, platform, persona, MCP, backup, and knowledge-base delete/close confirmations to use the shared helper.

- Remove scattered inline confirm handling to keep behavior consistent and avoid native blocking dialog focus/caret issues in Electron.

* fix: capture runtime bootstrap logs after logger init

- Add bootstrap record buffer in runtime_bootstrap for early TLS patch logs before logger is ready.

- Flush buffered bootstrap logs to astrbot logger at process startup in main.py.

- Include concrete exception details for TLS bootstrap failures to improve diagnosis.

* fix: harden runtime bootstrap and unify confirm handling

- Simplify bootstrap log buffering and add a public initialize hook for non-main startup paths.

- Guard aiohttp TLS patching with feature/type checks and keep graceful fallback when internals are unavailable.

- Standardize dashboard confirmation flow via shared confirm helpers across composition and options API components.

* refactor: simplify runtime tls bootstrap and tighten confirm typing

* refactor: align ssl helper namespace and confirm usage

* fix: 修复 Windows 打包版后端重启失败问题 (#5009)

* fix: patch pip distlib finder for frozen electron runtime

* fix: use certifi CA bundle for runtime SSL requests

* fix: configure certifi CA before core imports

* fix: improve mac font fallback for dashboard text

* fix: harden frozen pip patch and unify TLS connector

* refactor: centralize dashboard CJK font fallback stacks

* perf: reuse TLS context and avoid repeated frozen pip patch

* refactor: bootstrap TLS setup before core imports

* fix: use async confirm dialog for provider deletions

* fix: replace native confirm dialogs in dashboard

- Add shared confirm helper in dashboard/src/utils/confirmDialog.ts for async dialog usage with safe fallback.

- Migrate provider, chat, config, session, platform, persona, MCP, backup, and knowledge-base delete/close confirmations to use the shared helper.

- Remove scattered inline confirm handling to keep behavior consistent and avoid native blocking dialog focus/caret issues in Electron.

* fix: capture runtime bootstrap logs after logger init

- Add bootstrap record buffer in runtime_bootstrap for early TLS patch logs before logger is ready.

- Flush buffered bootstrap logs to astrbot logger at process startup in main.py.

- Include concrete exception details for TLS bootstrap failures to improve diagnosis.

* fix: harden runtime bootstrap and unify confirm handling

- Simplify bootstrap log buffering and add a public initialize hook for non-main startup paths.

- Guard aiohttp TLS patching with feature/type checks and keep graceful fallback when internals are unavailable.

- Standardize dashboard confirmation flow via shared confirm helpers across composition and options API components.

* refactor: simplify runtime tls bootstrap and tighten confirm typing

* refactor: align ssl helper namespace and confirm usage

* fix: avoid frozen restart crash from multiprocessing import

* fix: include missing frozen dependencies for windows backend

* fix: use execv for stable backend reboot args

* Revert "fix: use execv for stable backend reboot args"

This reverts commit 9cc27becff.

* Revert "fix: include missing frozen dependencies for windows backend"

This reverts commit 52554bea1f.

* Revert "fix: avoid frozen restart crash from multiprocessing import"

This reverts commit 10548645b0.

* fix: reset pyinstaller onefile env before reboot

* fix: unify electron restart path and tray-exit backend cleanup

* fix: stabilize desktop restart detection and frozen reboot args

* fix: make dashboard restart wait detection robust

* fix: revert dashboard restart waiting interaction tweaks

* fix: pass auth token for desktop graceful restart

* fix: avoid false failure during graceful restart wait

* fix: start restart waiting before electron restart call

* fix: harden restart waiting and reboot arg parsing

* fix: parse start_time as numeric timestamp

* fix: 修复app内重启异常,修复app内点击重启不能立刻提示重启,以及在后端就绪时及时刷新界面的问题 (#5013)

* fix: patch pip distlib finder for frozen electron runtime

* fix: use certifi CA bundle for runtime SSL requests

* fix: configure certifi CA before core imports

* fix: improve mac font fallback for dashboard text

* fix: harden frozen pip patch and unify TLS connector

* refactor: centralize dashboard CJK font fallback stacks

* perf: reuse TLS context and avoid repeated frozen pip patch

* refactor: bootstrap TLS setup before core imports

* fix: use async confirm dialog for provider deletions

* fix: replace native confirm dialogs in dashboard

- Add shared confirm helper in dashboard/src/utils/confirmDialog.ts for async dialog usage with safe fallback.

- Migrate provider, chat, config, session, platform, persona, MCP, backup, and knowledge-base delete/close confirmations to use the shared helper.

- Remove scattered inline confirm handling to keep behavior consistent and avoid native blocking dialog focus/caret issues in Electron.

* fix: capture runtime bootstrap logs after logger init

- Add bootstrap record buffer in runtime_bootstrap for early TLS patch logs before logger is ready.

- Flush buffered bootstrap logs to astrbot logger at process startup in main.py.

- Include concrete exception details for TLS bootstrap failures to improve diagnosis.

* fix: harden runtime bootstrap and unify confirm handling

- Simplify bootstrap log buffering and add a public initialize hook for non-main startup paths.

- Guard aiohttp TLS patching with feature/type checks and keep graceful fallback when internals are unavailable.

- Standardize dashboard confirmation flow via shared confirm helpers across composition and options API components.

* refactor: simplify runtime tls bootstrap and tighten confirm typing

* refactor: align ssl helper namespace and confirm usage

* fix: avoid frozen restart crash from multiprocessing import

* fix: include missing frozen dependencies for windows backend

* fix: use execv for stable backend reboot args

* Revert "fix: use execv for stable backend reboot args"

This reverts commit 9cc27becff.

* Revert "fix: include missing frozen dependencies for windows backend"

This reverts commit 52554bea1f.

* Revert "fix: avoid frozen restart crash from multiprocessing import"

This reverts commit 10548645b0.

* fix: reset pyinstaller onefile env before reboot

* fix: unify electron restart path and tray-exit backend cleanup

* fix: stabilize desktop restart detection and frozen reboot args

* fix: make dashboard restart wait detection robust

* fix: revert dashboard restart waiting interaction tweaks

* fix: pass auth token for desktop graceful restart

* fix: avoid false failure during graceful restart wait

* fix: start restart waiting before electron restart call

* fix: harden restart waiting and reboot arg parsing

* fix: parse start_time as numeric timestamp

* fix: preserve windows frozen reboot argv quoting

* fix: align restart waiting with electron restart timing

* fix: tighten graceful restart and unmanaged kill safety

* chore: bump version to 4.15.0 (#5003)

* fix: add reminder for v4.14.8 users regarding manual redeployment due to a bug

* fix: harden plugin dependency loading in frozen app runtime (#5015)

* fix: compare plugin versions semantically in market updates

* fix: prioritize plugin site-packages for in-process pip

* fix: reload starlette from plugin target site-packages

* fix: harden plugin dependency import precedence in frozen runtime

* fix: improve plugin dependency conflict handling

* refactor: simplify plugin conflict checks and version utils

* fix: expand transitive plugin dependencies for conflict checks

* fix: recover conflicting plugin dependencies during module prefer

* fix: reuse renderer restart flow for tray backend restart

* fix: add recoverable plugin dependency conflict handling

* revert: remove plugin version comparison changes

* fix: add missing tray restart backend labels

* feat: adding support for media and quoted message attachments for feishu (#5018)

* docs: add AUR installation method (#4879)

* docs: sync system package manager installation instructions to all languages

* Update README.md

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

* Update README.md

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

* fix/typo

* refactor: update system package manager installation instructions for Arch Linux across multiple language README files

* feat: add installation command for AstrBot in multiple language README files

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
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: Soulter <905617992@qq.com>

* fix(desktop): 为 Electron 与后端日志增加按大小轮转 (#5029)

* fix(desktop): rotate electron and backend logs

* refactor(desktop): centralize log rotation defaults and debug fs errors

* fix(desktop): harden rotation fs ops and buffer backend log writes

* refactor(desktop): extract buffered logger and reduce sync stat calls

* refactor(desktop): simplify rotation flow and harden logger config

* fix(desktop): make app logging async and flush-safe

* fix: harden app log path switching and debug-gated rotation errors

* fix: cap buffered log chunk size during path switch

* feat: add first notice feature with multilingual support and UI integration

* fix: 提升打包版桌面端启动稳定性并优化插件依赖处理 (#5031)

* fix(desktop): rotate electron and backend logs

* refactor(desktop): centralize log rotation defaults and debug fs errors

* fix(desktop): harden rotation fs ops and buffer backend log writes

* refactor(desktop): extract buffered logger and reduce sync stat calls

* refactor(desktop): simplify rotation flow and harden logger config

* fix(desktop): make app logging async and flush-safe

* fix: harden app log path switching and debug-gated rotation errors

* fix: cap buffered log chunk size during path switch

* fix: avoid redundant plugin reinstall and upgrade electron

* fix: stop webchat tasks cleanly and bind packaged backend to localhost

* fix: unify platform shutdown and await webchat listener cleanup

* fix: improve startup logs for dashboard and onebot listeners

* fix: revert extra startup service logs

* fix: harden plugin import recovery and webchat listener cleanup

* fix: pin dashboard ci node version to 24.13.0

* fix: avoid duplicate webchat listener cleanup on terminate

* refactor: clarify platform task lifecycle management

* fix: continue platform shutdown when terminate fails

* feat: temporary file handling and introduce TempDirCleaner (#5026)

* feat: temporary file handling and introduce TempDirCleaner

- Updated various modules to use `get_astrbot_temp_path()` instead of `get_astrbot_data_path()` for temporary file storage.
- Renamed temporary files for better identification and organization.
- Introduced `TempDirCleaner` to manage the size of the temporary directory, ensuring it does not exceed a specified limit by deleting the oldest files.
- Added configuration option for maximum temporary directory size in the dashboard.
- Implemented tests for `TempDirCleaner` to verify cleanup functionality and size management.

* ruff

* fix: close unawaited reset coroutine on early return (#5033)

When an OnLLMRequestEvent hook stops event propagation, the
reset_coro created by build_main_agent was never awaited, causing
a RuntimeWarning. Close the coroutine explicitly before returning.

Fixes #5032

Co-authored-by: Limitless2023 <limitless@users.noreply.github.com>

* fix: update error logging message for connection failures

* docs: clean and sync README (#5014)

* fix: close missing div in README

* fix: sync README_zh-TW with README

* fix: sync README

* fix: correct typo

correct url in README_en README_fr README_ru

* docs: sync README_en with README

* Update README_en.md

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

---------

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

* fix: provider extra param dialog key display error

* chore: ruff format

* feat: add send_chat_action for Telegram platform adapter (#5037)

* feat: add send_chat_action for Telegram platform adapter

Add typing/upload indicator when sending messages via Telegram.
- Added _send_chat_action helper method for sending chat actions
- Send appropriate action (typing, upload_photo, upload_document, upload_voice)
  before sending different message types
- Support streaming mode with typing indicator
- Support supergroup with message_thread_id

* refactor(telegram): extract chat action helpers and add throttling

- Add ACTION_BY_TYPE mapping for message type to action priority
- Add _get_chat_action_for_chain() to determine action from message chain
- Add _send_media_with_action() for upload → send → restore typing pattern
- Add _ensure_typing() helper for typing status
- Add chat action throttling (0.5s) in streaming mode to avoid rate limits
- Update type annotation to ChatAction | str for better static checking

* feat(telegram): implement send_typing method for Telegram platform

---------

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

* fix: 修复更新日志、官方文档弹窗双滚动条问题 (#5060)

* docs: sync and fix readme typo (#5055)

* docs: fix index typo

* docs: fix typo in README_en.md

- 移除英文README中意外出现的俄语,并替换为英语

* docs: fix html typo

- remove unused '</p>'

* docs: sync table with README

* docs: sync README header format

- keep the README header format consistent

* doc: sync key features

* style: format files

- Fix formatting issues from previous PR

* fix: correct md anchor link

* docs: correct typo in README_fr.md

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

* docs: correct typo in README_zh-TW.md

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

---------

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

* fix: 修复备份时缺失的人格文件夹映射 (#5042)

* feat: QQ 官方机器人平台支持主动推送消息、私聊场景下支持接收文件 (#5066)

* feat: QQ 官方机器人平台支持主动推送消息、私聊场景下支持接收文件

* feat: enhance QQOfficialWebhook to remember session scenes for group, channel, and friend messages

* perf: 优化分段回复间隔时间的初始化逻辑 (#5068)

fixes: #5059

* fix: chunk err when using openrouter deepseek (#5069)

* feat: add i18n supports for custom platform adapters (#5045)

* Feat: 为插件提供的适配器的元数据&i18n提供数据通路

* chore: update docstrings with pull request references

Added references to pull request 5045 in docstrings.

---------

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

* fix: 完善转发引用解析与图片回退并支持配置化控制 (#5054)

* feat: support fallback image parsing for quoted messages

* fix: fallback parse quoted images when reply chain has placeholders

* style: format network utils with ruff

* test: expand quoted parser coverage and improve fallback diagnostics

* fix: fallback to text-only retry when image requests fail

* fix: tighten image fallback and resolve nested quoted forwards

* refactor: simplify quoted message extraction and dedupe images

* fix: harden quoted parsing and openai error candidates

* fix: harden quoted image ref normalization

* refactor: organize quoted parser settings and logging

* fix: cap quoted fallback images and avoid retry loops

* refactor: split quoted message parser into focused modules

* refactor: share onebot segment parsing logic

* refactor: unify quoted message parsing flow

* feat: move quoted parser tuning to provider settings

* fix: add missing i18n metadata for quoted parser settings

* chore: refine forwarded message setting labels

* fix: add config tabs and routing for normal and system configurations

* chore: bump version to 4.16.0 (#5074)

* feat: add LINE platform support with adapter and configuration (#5085)

* fix-correct-FIRST_NOTICE.md-locale-path-resolution (#5083) (#5082)

* fix:修改配置文件目录

* fix:添加备选的FIRST_NOTICE.zh-CN.md用于兼容

* fix: remove unnecessary frozen flag from requirements export in Dockerfile

fixes: #5089

* fix #5089: add uv lock step in Dockerfile before export (#5091)

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

* feat: support hot reload after plugin load failure (#5043)

* add :Support hot reload after plugin load failure

* Apply suggestions from code review

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

* fix:reformat code

* fix:reformat code

---------

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

* feat: add fallback chat model chain in tool loop runner (#5109)

* feat: implement fallback provider support for chat models and update configuration

* feat: enhance provider selection display with count and chips for selected providers

* feat: update fallback chat providers to use provider settings and add warning for non-list fallback models

* feat: add Afdian support card to resources section in WelcomePage

* feat: replace colorlog with loguru for enhanced logging support (#5115)

* feat: add SSL configuration options for WebUI and update related logging (#5117)

* chore: bump version to 4.17.0

* fix: handle list format content from OpenAI-compatible APIs (#5128)

* fix: handle list format content from OpenAI-compatible APIs

Some LLM providers (e.g., GLM-4.5V via SiliconFlow) return content as
list[dict] format like [{'type': 'text', 'text': '...'}] instead of
plain string. This causes the raw list representation to be displayed
to users.

Changes:
- Add _normalize_content() helper to extract text from various content formats
- Use json.loads instead of ast.literal_eval for safer parsing
- Add size limit check (8KB) before attempting JSON parsing
- Only convert lists that match OpenAI content-part schema (has 'type': 'text')
  to avoid collapsing legitimate list-literal replies like ['foo', 'bar']
- Add strip parameter to preserve whitespace in streaming chunks
- Clean up orphan </think> tags that may leak from some models

Fixes #5124

* fix: improve content normalization safety

- Try json.loads first, fallback to ast.literal_eval for single-quoted
  Python literals to avoid corrupting apostrophes (e.g., "don't")
- Coerce text values to str to handle null or non-string text fields

* fix: update retention logic in LogManager to handle backup count correctly

* chore: bump version to 4.17.1

* docs: Added instructions for deploying AstrBot using AstrBot Launcher. (#5136)

Added instructions for deploying AstrBot using AstrBot Launcher.

* fix: add MCP tools to function tool set in _plugin_tool_fix (#5144)

* fix: add support for collecting data from builtin stars in electron pyinstaller build (#5145)

* chore: bump version to 4.17.1

* chore: ruff format

* fix: prevent updates for AstrBot launched via launcher

* fix(desktop): include runtime deps for builtin plugins in backend build (#5146)

* fix: 'Plain' object has no attribute 'text' when using python 3.14 (#5154)

* fix: enhance plugin metadata handling by injecting attributes before instantiation (#5155)

* fix: enhance handle_result to support event context and webchat image sending

* chore: bump version to 4.17.3

* chore: ruff format

* feat: add NVIDIA provider template (#5157)

fixes: #5156

* feat: enhance provider sources panel with styled menu and mobile support

* fix: improve permission denied message for local execution in Python and shell tools

* feat: enhance PersonaForm component with responsive design and improved styling (#5162)

fix: #5159

* ui(CronJobPage): fix action column buttons overlapping in CronJobPage (#5163)

- 修改前:操作列容器仅使用 `d-flex`,在页面宽度变窄时,子元素(开关和删除按钮)会因为宽度挤压而发生视觉重叠,甚至堆叠在一起。
- 修改后:
    1. 为容器添加了 `flex-nowrap`,强制禁止子元素换行。
    2. 设置了 `min-width: 140px`,确保该列拥有固定的保护空间,防止被其他长文本列挤压。
    3. 增加了 `gap: 12px` 间距,提升了操作辨识度并优化了点击体验。

* feat: add unsaved changes notice to configuration page and update messages

* feat: implement search functionality in configuration components and update UI (#5168)

* feat: add FAQ link to vertical sidebar and update navigation for localization

* feat: add announcement section to WelcomePage and localize announcement title

* chore: bump version to 4.17.4

* feat: supports send markdown message in qqofficial (#5173)

* feat: supports send markdown message in qqofficial

closes: #1093 #918 #4180 #4264

* ruff format

* fix: prevent duplicate error message when all LLM providers fail (#5183)

* fix: 修复选择配置文件进入配置文件管理弹窗直接关闭弹窗显示的配置文件不正确 (#5174)

* feat: add MarketPluginCard component and integrate random plugin feature in ExtensionPage (#5190)

* feat: add MarketPluginCard component and integrate random plugin feature in ExtensionPage

* feat: update random plugin selection logic to use pluginMarketData and refresh on relevant events

* feat: supports aihubmix

* docs: update readme

* chore: ruff format

* feat: add LINE support to multiple language README files

* feat(core): add plugin error hook for custom error routing (#5192)

* feat(core): add plugin error hook for custom error routing

* fix(core): align plugin error suppression with event stop state

* refactor: extract Voice_messages_forbidden fallback into shared helper with typed BadRequest exception (#5204)

- Add _send_voice_with_fallback helper to deduplicate voice forbidden handling
- Catch telegram.error.BadRequest instead of bare Exception with string matching
- Add text field to Record component to preserve TTS source text
- Store original text in Record during TTS conversion for use as document caption
- Skip _send_chat_action when chat_id is empty to avoid unnecessary warnings

* chore: bump version to 4.17.5

* feat: add admin permission checks for Python and Shell execution (#5214)

* fix: 改进微信公众号被动回复处理机制,引入缓冲与分片回复,并优化超时行为 (#5224)

* 修复wechat official 被动回复功能

* ruff format

---------

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

* fix: 修复仅发送 JSON 消息段时的空消息回复报错 (#5208)

* Fix Register_Stage

· 补全 JSON 消息判断,修复发送 JSON 消息时遇到 “消息为空,跳过发送阶段” 的问题。
· 顺带补全其它消息类型判断。
Co-authored-by: Pizero <zhaory200707@outlook.com>

* Fix formatting and comments in stage.py

* Format stage.py

---------

Co-authored-by: Pizero <zhaory200707@outlook.com>

* docs: update related repo links

* fix(core): terminate active events on reset/new/del to prevent stale responses (#5225)

* fix(core): terminate active events on reset/new/del to prevent stale responses

Closes #5222

* style: fix import sorting in scheduler.py

* chore: remove Electron desktop pipeline and switch to tauri repo (#5226)

* ci: remove Electron desktop build from release pipeline

* chore: remove electron desktop and switch to tauri release trigger

* ci: remove desktop workflow dispatch trigger

* refactor: migrate data paths to astrbot_path helpers

* fix: point desktop update prompt to AstrBot-desktop releases

* fix: update feature request template for clarity and consistency in English and Chinese

* Feat/config leave confirm (#5249)

* feat: 配置文件增加未保存提示弹窗

* fix: 移除unsavedChangesDialog插件使用组件方式实现弹窗

* feat: add support for plugin astrbot-version and platform requirement checks (#5235)

* feat: add support for plugin astrbot-version and platform requirement checks

* fix: remove unsupported platform and version constraints from metadata.yaml

* fix: remove restriction on 'v' in astrbot_version specification format

* ruff format

* feat: add password confirmation when changing password (#5247)

* feat: add password confirmation when changing password

Fixes #5177

Adds a password confirmation field to prevent accidental password typos.

Changes:
- Backend: validate confirm_password matches new_password
- Frontend: add confirmation input with validation
- i18n: add labels and error messages for password mismatch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(auth): improve error message for password confirmation mismatch

* fix(auth): update password hashing logic and improve confirmation validation

---------

Co-authored-by: whatevertogo <whatevertogo@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(provider): 修复 dict 格式 content 导致的 JSON 残留问题 (#5250)

* fix(provider): 修复 dict 格式 content 导致的 JSON 残留问题

修复 _normalize_content 函数未处理 dict 类型 content 的问题。
当 LLM 返回 {"type": "text", "text": "..."} 格式的 content 时,
现在会正确提取 text 字段而非直接转为字符串。

同时改进 fallback 行为,对 None 值返回空字符串。

Fixes #5244

* Update warning message for unexpected dict format

---------

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

* chore: remove outdated heihe.md documentation file

* fix: all mcp tools exposed to main agent (#5252)

* fix: enhance PersonaForm layout and improve tool selection display

* fix: update tool status display and add localization for inactive tools

* fix: remove additionalProperties from tool schema properties (#5253)

fixes: #5217

* fix: simplify error messages for account edit validation

* fix: streamline error response for empty new username and password in account edit

* chore: bump vertion to 4.17.6

* feat: add OpenRouter provider support and icon

* chore: ruff format

* refactor(dashboard): replace legacy isElectron bridge fields with isDesktop (#5269)

* refactor dashboard desktop bridge fields from isElectron to isDesktop

* refactor dashboard runtime detection into shared helper

* fix: update contributor avatar image URL to include max size and columns (#5268)

* feat: astrbot http api (#5280)

* feat: astrbot http api

* Potential fix for code scanning alert no. 34: Use of a broken or weak cryptographic hashing algorithm on sensitive data

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* fix: improve error handling for missing attachment path in file upload

* feat: implement paginated retrieval of platform sessions for creators

* feat: refactor attachment directory handling in ChatRoute

* feat: update API endpoint paths for file and message handling

* feat: add documentation link to API key management section in settings

* feat: update API key scopes and related configurations in API routes and tests

* feat: enhance API key expiration options and add warning for permanent keys

* feat: add UTC normalization and serialization for API key timestamps

* feat: implement chat session management and validation for usernames

* feat: ignore session_id type chunks in message processing

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* feat(dashboard): improve plugin platform support display and mobile accessibility (#5271)

* feat(dashboard): improve plugin platform support display and mobile accessibility

- Replace hover-based tooltips with interactive click menus for platform support information.
- Fix mobile touch issues by introducing explicit state control for status capsules.
- Enhance UI aesthetics with platform-specific icons and a structured vertical list layout.
- Add dynamic chevron icons to provide clear visual cues for expandable content.

* refactor(dashboard): refactor market card with computed properties for performance

* refactor(dashboard): unify plugin platform support UI with new reusable chip component

- Create shared 'PluginPlatformChip' component to encapsulate platform meta display.
- Fix mobile interaction bugs by simplifying menu triggers and event handling.
- Add stacked platform icon previews and dynamic chevron indicators within capsules.
- Improve information hierarchy using structured vertical lists for platform details.
- Optimize rendering efficiency with computed properties across both card views.

* fix: qq official guild message send error (#5287)

* fix: qq official guild message send error

* Update astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py

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

---------

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

* 更新readme文档,补充桌面app说明,并向前移动位置 (#5297)

* docs: update desktop deployment section in README

* docs: refine desktop and launcher deployment descriptions

* Update README.md

* feat: add Anthropic Claude Code OAuth provider and adaptive thinking support (#5209)

* feat: add Anthropic Claude Code OAuth provider and adaptive thinking support

* fix: add defensive guard for metadata overrides and align budget condition with docs

* refactor: adopt sourcery-ai suggestions for OAuth provider

- Use use_api_key=False in OAuth subclass to avoid redundant
  API-key client construction before replacing with auth_token client
- Generalize metadata override helper to merge all dict keys
  instead of only handling 'limit', improving extensibility

* Feat/telegram command alias register  #5233 (#5234)

* feat: support registering command aliases for Telegram

Now when registering commands with aliases, all aliases will be
registered as Telegram bot commands in addition to the main command.

Example:
    @register_command(command_name="draw", alias={"画", "gen"})
Now /draw, /画, and /gen will all appear in the Telegram command menu.

* feat(telegram): add duplicate command name warning when registering commands

Log a warning when duplicate command names are detected during Telegram
command registration to help identify configuration conflicts.

* refactor: remove Anthropic OAuth provider implementation and related metadata overrides

* fix: 修复新建对话时因缺少会话ID导致配置绑定失败的问题 (#5292)

* fix:尝试修改

* fix:添加详细日志

* fix:进行详细修改,并添加日志

* fix:删除所有日志

* fix: 增加安全访问函数

- 给 localStorage 访问加了 try/catch + 可用性判断:dashboard/src/utils/chatConfigBinding.ts:13
- 新增 getFromLocalStorage/setToLocalStorage(在受限存储/无痕模式下异常时回退/忽略)
- getStoredDashboardUsername() / getStoredSelectedChatConfigId() 改为走安全读取:dashboard/src/utils/chatConfigBinding.ts:36       - 新增 setStoredSelectedChatConfigId(),写入失败静默忽略:dashboard/src/utils/chatConfigBinding.ts:44
- 把 ConfigSelector.vue 里直接 localStorage.getItem/setItem 全部替换为上述安全方法:dashboard/src/components/chat/ConfigSelector.vue:81
- 已重新跑过 pnpm run typecheck,通过。

* rm:删除个人用的文档文件

* Revert "rm:删除个人用的文档文件"

This reverts commit 0fceee0543.

* rm:删除个人用的文档文件

* rm:删除个人用的文档文件

* chore: bump version to 4.18.0

* fix(SubAgentPage): 当中间的介绍文本非常长时,Flex 布局会自动挤压右侧的控制按钮区域 (#5306)

* fix: 修复新版本插件市场出现插件显示为空白的 bug;纠正已安装插件卡片的排版,统一大小 (#5309)

* fix(ExtensionCard): 解决插件卡片大小不统一的问题

* fix(MarketPluginCard): 解决插件市场不加载插件的问题 (#5303)

* feat: supports spawn subagent as a background task that not block the main agent workflow (#5081)

* feat:为subagent添加后台任务参数

* ruff

* fix: update terminology from 'handoff mission' to 'background task' and refactor related logic

* fix: update terminology from 'background_mission' to 'background_task' in HandoffTool and related logic

* fix(HandoffTool): update background_task description for clarity on usage

---------

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

* cho

* fix: 修复 aiohttp 版本过新导致 qq-botpy 报错的问题 (#5316)

* chore: ruff format

* fix: remove hard-coded 6s timeout from tavily request

* fix: remove changelogs directory from .dockerignore

* feat(dashboard): make release redirect base URL configurable (#5330)

* feat(dashboard): make desktop release base URL configurable

* refactor(dashboard): use generic release base URL env with upstream default

* fix(dashboard): guard release base URL normalization when env is unset

* refactor(dashboard): use generic release URL helpers and avoid latest suffix duplication

* feat: add stop functionality for active agent sessions and improve handling of stop requests (#5380)

* feat: add stop functionality for active agent sessions and improve handling of stop requests

* feat: update stop button icon and tooltip in ChatInput component

* fix: correct indentation in tool call handling within ChatRoute class

* fix: chatui cannot persist file segment (#5386)

* fix(plugin): update plugin directory handling for reserved plugins (#5369)

* fix(plugin): update plugin directory handling for reserved plugins

* fix(plugin): add warning logs for missing plugin name, object, directory, and changelog

* chore(README): updated with README.md (#5375)

* chore(README): updated with README.md

* Update README_fr.md

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

* Update README_zh-TW.md

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

---------

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

* feat: add image urls / paths supports for subagent (#5348)

* fix: 修复5081号PR在子代理执行后台任务时,未正确使用系统配置的流式/非流请求的问题(#5081)

* feat:为子代理增加远程图片URL参数支持

* fix: update description for image_urls parameter in HandoffTool to clarify usage in multimodal tasks

* ruff format

---------

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

* feat: add hot reload when failed to load plugins (#5334)

* feat:add hot reload when failed to load plugins

* apply bot suggestions

* fix(chatui): add copy rollback path and error message. (#5352)

* fix(chatui): add copy rollback path and error message.

* fix(chatui): fixed textarea leak in the copy button.

* fix(chatui): use color styles from the component library.

* fix: 处理配置文件中的 UTF-8 BOM 编码问题 (#5376)

* fix(config): handle UTF-8 BOM in configuration file loading

Problem:
On Windows, some text editors (like Notepad) automatically add UTF-8 BOM
to JSON files when saving. This causes json.decoder.JSONDecodeError:
"Unexpected UTF-8 BOM" and AstrBot fails to start when cmd_config.json
contains BOM.

Solution:
Add defensive check to strip UTF-8 BOM (\ufeff) if present before
parsing JSON configuration file.

Impact:
- Improves robustness and cross-platform compatibility
- No breaking changes to existing functionality
- Fixes startup failure when configuration file has UTF-8 BOM encoding

Relates-to: Windows editor compatibility issues

* style: fix code formatting with ruff

Fix single quote to double quote to comply with project code style.

* feat: add plugin load&unload hook (#5331)

* 添加了插件的加载完成和卸载完成的钩子事件

* 添加了插件的加载完成和卸载完成的钩子事件

* format code with ruff

* ruff format

---------

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

* test: enhance test framework with comprehensive fixtures and mocks (#5354)

* test: enhance test framework with comprehensive fixtures and mocks

- Add shared mock builders for aiocqhttp, discord, telegram
- Add test helpers for platform configs and mock objects
- Expand conftest.py with test profile support
- Update coverage test workflow configuration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(tests): 移动并重构模拟 LLM 响应和消息组件函数

* fix(tests): 优化 pytest_runtest_setup 中的标记检查逻辑

---------

Co-authored-by: whatevertogo <whatevertogo@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: add comprehensive tests for message event handling (#5355)

* test: add comprehensive tests for message event handling

- Add AstrMessageEvent unit tests (688 lines)
- Add AstrBotMessage unit tests
- Enhance smoke tests with message event scenarios

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: improve message type handling and add defensive tests

---------

Co-authored-by: whatevertogo <whatevertogo@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add support for showing tool call results in agent execution (#5388)

closes: #5329

* fix: resolve pipeline and star import cycles (#5353)

* fix: resolve pipeline and star import cycles

- Add bootstrap.py and stage_order.py to break circular dependencies
- Export Context, PluginManager, StarTools from star module
- Update pipeline __init__ to defer imports
- Split pipeline initialization into separate bootstrap module

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: add logging for get_config() failure in Star class

* fix: reorder logger initialization in base.py

---------

Co-authored-by: whatevertogo <whatevertogo@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: enable computer-use tools for subagent handoff (#5399)

* fix: enforce admin guard for sandbox file transfer tools (#5402)

* fix: enforce admin guard for sandbox file transfer tools

* refactor: deduplicate computer tools admin permission checks

* fix: add missing space in permission error message

* fix(core): 优化 File 组件处理逻辑并增强 OneBot 驱动层路径兼容性 (#5391)

* fix(core): 优化 File 组件处理逻辑并增强 OneBot 驱动层路径兼容性

原因 (Necessity):
1. 内核一致性:AstrBot 内核的 Record 和 Video 组件均具备识别 `file:///` 协议头的逻辑,但 File 组件此前缺失此功能,导致行为不统一。
2. OneBot 协议合规:OneBot 11 标准要求本地文件路径必须使用 `file:///` 协议头。此前驱动层未对裸路径进行自动转换,导致发送本地文件时常触发 retcode 1200 (识别URL失败) 错误。
3. 容器环境适配:在 Docker 等路径隔离环境下,裸路径更容易因驱动或协议端的解析歧义而失效。

更改 (Changes):
- [astrbot/core/message/components.py]:
  - 在 File.get_file() 中增加对 `file:///` 前缀的识别与剥离逻辑,使其与 Record/Video 组件行为对齐。
- [astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py]:
  - 在发送文件前增加自动修正逻辑:若路径为绝对路径且未包含协议头,驱动层将自动补全 `file:///` 前缀。
  - 对 http、base64 及已有协议头,确保不干扰原有的正常传输逻辑。

影响 (Impact):
- 以完全兼容的方式增强了文件发送的鲁棒性。
- 解决了插件在发送日志等本地生成的压缩包时,因路径格式不规范导致的发送失败问题。

* refactor(core): 根据 cr 建议,规范化文件 URI 生成与解析逻辑,优化跨平台兼容性

原因 (Necessity):
1. 修复原生路径与 URI 转换在 Windows 下的不对称问题。
2. 规范化 file: 协议头处理,确保符合 RFC 标准并能在 Linux/Windows 间稳健切换。
3. 增强协议判定准确度,防止对普通绝对路径的误处理。

更改 (Changes):
- [astrbot/core/platform/sources/aiocqhttp]:
  - 弃用手动拼接,改用 `pathlib.Path.as_uri()` 生成标准 URI。
  - 将协议检测逻辑从前缀匹配优化为包含性检测 ("://")。
- [astrbot/core/message/components]:
  - 重构 `File.get_file` 解析逻辑,支持对称处理 2/3 斜杠格式。
  - 针对 Windows 环境增加了对 `file:///C:/` 格式的自动修正,避免 `os.path` 识别失效。
- [data/plugins/astrbot_plugin_logplus]:
  - 在直接 API 调用中同步应用 URI 规范化处理。

影响 (Impact):
- 解决 Docker 环境中因路径不规范导致的 "识别URL失败" 报错。
- 提升了本体框架在 Windows 系统下的文件操作鲁棒性。

* i18n(SubAgentPage): complete internationalization for subagent orchestration page (#5400)

* i18n: complete internationalization for subagent orchestration page

- Replace hardcoded English strings in [SubAgentPage.vue] with i18n keys.
- Update `en-US` and `zh-CN` locales with missing hints, validation messages, and empty state translations.
- Fix translation typos and improve consistency across the SubAgent orchestration UI.

* fix(bug_risk): 避免在模板中的翻译调用上使用 || 'Close' 作为回退值。

* fix(aiocqhttp): enhance shutdown process for aiocqhttp adapter (#5412)

* fix: pass embedding dimensions to provider apis (#5411)

* fix(context): log warning when platform not found for session

* fix(context): improve logging for platform not found in session

* chore: bump version to 4.18.2

* chore: bump version to 4.18.2

* chore: bump version to 4.18.2

* fix: Telegram voice message format (OGG instead of WAV) causing issues with OpenAI STT API (#5389)

* chore: ruff format

* feat(dashboard): add generic desktop app updater bridge (#5424)

* feat(dashboard): add generic desktop app updater bridge

* fix(dashboard): address updater bridge review feedback

* fix(dashboard): unify updater bridge types and error logging

* fix(dashboard): consolidate updater bridge typings

* fix(conversation): retain existing persona_id when updating conversation

* fix(dashboard): 修复设置页新建 API Key 后复制失败问题 (#5439)

* Fix: GitHub proxy not displaying correctly in WebUI (#5438)

* fix(dashboard): preserve custom GitHub proxy setting on reload

* fix(dashboard): keep github proxy selection persisted in settings

* fix(persona): enhance persona resolution logic for conversations and sessions

* fix: ensure tool call/response pairing in context truncation (#5417)

* fix: ensure tool call/response pairing in context truncation

* refactor: simplify fix_messages to single-pass state machine

* perf(cron): enhance future task session isolation

fixes: #5392

* feat: add useExtensionPage composable for managing plugin extensions

- Implemented a new composable `useExtensionPage` to handle various functionalities related to plugin management, including fetching extensions, handling updates, and managing UI states.
- Added support for conflict checking, plugin installation, and custom source management.
- Integrated search and filtering capabilities for plugins in the market.
- Enhanced user experience with dialogs for confirmations and notifications.
- Included pagination and sorting features for better plugin visibility.

* fix: clear markdown field when sending media messages via QQ Official Platform (#5445)

* fix: clear markdown field when sending media messages via QQ Official API

* refactor: use pop() to remove markdown key instead of setting None

* fix: cannot automatically get embedding dim when create embedding provider (#5442)

* fix(dashboard): 强化 API Key 复制临时节点清理逻辑

* fix(embedding): 自动检测改为探测 OpenAI embedding 最大可用维度

* fix: normalize openai embedding base url and add hint key

* i18n: add embedding_api_base hint translations

* i18n: localize provider embedding/proxy metadata hints

* fix: show provider-specific embedding API Base URL hint as field subtitle

* fix(embedding): cap OpenAI detect_dim probes with early short-circuit

* fix(dashboard): return generic error on provider adapter import failure

* 回退检测逻辑

* fix: 修复Pyright静态类型检查报错 (#5437)

* refactor: 修正 Sqlite 查询、下载回调、接口重构与类型调整

* feat: 为 OneBotClient 增加 CallAction 协议与异步调用支持

* fix(telegram): avoid duplicate message_thread_id in streaming (#5430)

* perf: batch metadata query in KB retrieval to fix N+1 problem (#5463)

* perf: batch metadata query in KB retrieval to fix N+1 problem

Replace N sequential get_document_with_metadata() calls with a single
get_documents_with_metadata_batch() call using SQL IN clause.

Benchmark results (local SQLite):
- 10 docs: 10.67ms → 1.47ms (7.3x faster)
- 20 docs: 26.00ms → 2.68ms (9.7x faster)
- 50 docs: 63.87ms → 2.79ms (22.9x faster)

* refactor: use set[str] param type and chunk IN clause for SQLite safety

Address review feedback:
- Change doc_ids param from list[str] to set[str] to avoid unnecessary conversion
- Chunk IN clause into batches of 900 to stay under SQLite's 999 parameter limit
- Remove list() wrapping at call site, pass set directly

* fix:fix the issue where incomplete cleanup of residual plugins occurs… (#5462)

* fix:fix the issue where incomplete cleanup of residual plugins occurs in the failed loading of plugins

* fix:ruff format,apply bot suggestions

* Apply suggestion from @gemini-code-assist[bot]

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

---------

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

* chore: 为类型检查添加 TYPE_CHECKING 的导入与阶段类型引用 (#5474)

* fix(line): line adapter does not appear in the add platform dialog

fixes: #5477

* [bug]查看介入教程line前往错误界面的问题 (#5479)

Fixes #5478

* chore: bump version to 4.18.3

* feat: implement follow-up message handling in ToolLoopAgentRunner (#5484)

* feat: implement follow-up message handling in ToolLoopAgentRunner

* fix: correct import path for follow-up module in InternalAgentSubStage

* feat: implement websockets transport mode selection for chat (#5410)

* feat: implement websockets transport mode selection for chat

- Added transport mode selection (SSE/WebSocket) in the chat component.
- Updated conversation sidebar to include transport mode options.
- Integrated transport mode handling in message sending logic.
- Refactored message sending functions to support both SSE and WebSocket.
- Enhanced WebSocket connection management and message handling.
- Updated localization files for transport mode labels.
- Configured Vite to support WebSocket proxying.

* feat(webchat): refactor message parsing logic and integrate new parsing function

* feat(chat): add websocket API key extraction and scope validation

* Revert "可选后端,实现前后端分离" (#5536)

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: can <51474963+weijintaocode@users.noreply.github.com>
Co-authored-by: Soulter <905617992@qq.com>
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
Co-authored-by: letr <123731298+letr007@users.noreply.github.com>
Co-authored-by: 搁浅 <id6543156918@gmail.com>
Co-authored-by: Helian Nuits <sxp20061207@163.com>
Co-authored-by: Gao Jinzhe <2968474907@qq.com>
Co-authored-by: DD斩首 <155905740+DDZS987@users.noreply.github.com>
Co-authored-by: Ubuntu <ubuntu@localhost.localdomain>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: エイカク <62183434+zouyonghe@users.noreply.github.com>
Co-authored-by: 鸦羽 <Raven95676@gmail.com>
Co-authored-by: Dt8333 <25431943+Dt8333@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Li-shi-ling <114913764+Li-shi-ling@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
Co-authored-by: Limitless <127183162+Limitless2023@users.noreply.github.com>
Co-authored-by: Limitless2023 <limitless@users.noreply.github.com>
Co-authored-by: evpeople <54983536+evpeople@users.noreply.github.com>
Co-authored-by: SnowNightt <127504703+SnowNightt@users.noreply.github.com>
Co-authored-by: xzj0898 <62733743+xzj0898@users.noreply.github.com>
Co-authored-by: stevessr <89645372+stevessr@users.noreply.github.com>
Co-authored-by: Waterwzy <2916963017@qq.com>
Co-authored-by: NayukiMeko <MekoNayuki@outlook.com>
Co-authored-by: 時壹 <137363396+KBVsent@users.noreply.github.com>
Co-authored-by: sanyekana <Clhikari@qq.com>
Co-authored-by: Chiu Chun-Hsien <95356121+911218sky@users.noreply.github.com>
Co-authored-by: Dream Tokenizer <60459821+Trance-0@users.noreply.github.com>
Co-authored-by: NanoRocky <76585834+NanoRocky@users.noreply.github.com>
Co-authored-by: Pizero <zhaory200707@outlook.com>
Co-authored-by: 雪語 <167516635+YukiRa1n@users.noreply.github.com>
Co-authored-by: whatevertogo <1879483647@qq.com>
Co-authored-by: whatevertogo <whatevertogo@users.noreply.github.com>
Co-authored-by: 香草味的纳西妲喵 <151599587+VanillaNahida@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Lovely Moe Moli <44719954+moemoli@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Minidoracat <minidora0702@gmail.com>
Co-authored-by: Chen <42998804+a61995987@users.noreply.github.com>
Co-authored-by: hanbings <hanbings@hanbings.io>
Co-authored-by: tangsenfei <155090747+tangsenfei@users.noreply.github.com>
Co-authored-by: PyuraMazo <1605025385@qq.com>
Co-authored-by: Axi404 <118950647+Axi404@users.noreply.github.com>
Co-authored-by: 氕氙 <2014440212@qq.com>
Co-authored-by: Yunhao Cao <18230652+realquantumcookie@users.noreply.github.com>
Co-authored-by: exynos <110159911+exynos967@users.noreply.github.com>
Co-authored-by: Luna_Dol <86590429+Luna-channel@users.noreply.github.com>
Co-authored-by: CCCCCCTV <64309817+CCCCCCTV@users.noreply.github.com>
Co-authored-by: CAICAII <3360776475@qq.com>
Co-authored-by: 圣达生物多 <qq3258819795@163.com>
2026-02-27 22:03:17 +08:00
圣达生物多
4667729752 Merge branch 'AstrBotDevs:master' into Sjshi763/issue4409 2026-02-10 11:39:41 +08:00
圣达生物多
ddddd8a2ef Merge branch 'AstrBotDevs:master' into Sjshi763/issue4409 2026-02-07 14:11:16 +08:00
圣达生物多
fbc7b2b06e Merge branch 'AstrBotDevs:master' into Sjshi763/issue4409 2026-02-05 13:18:02 +08:00
圣达生物多
75e2f997e6 Merge branch 'AstrBotDevs:master' into Sjshi763/issue4409 2026-02-04 15:41:13 +08:00
圣达生物多
edb0166a13 Merge branch 'AstrBotDevs:master' into Sjshi763/issue4409 2026-02-04 01:33:25 +08:00
Sjshi763
c7eb1b85b9 重复检查 2026-02-02 15:12:35 +08:00
Sjshi763
351ce77147 新格式导入 2026-02-02 14:53:31 +08:00
Sjshi763
d3e71046ae 修改导出格式 2026-02-02 14:38:59 +08:00
Sjshi763
986549b844 在导出时说明不包含tools Skills 2026-02-02 14:24:18 +08:00
Sjshi763
3d3926b98e 保证相同的i18n 2026-02-02 14:12:52 +08:00
Sjshi763
fbe0d05dfc 写错地方了,更正i18n 2026-02-02 14:09:53 +08:00
Sjshi763
d1e7105bd7 [Feature] 人格设定支持导出/导入。
Fixes #4409

找回需要的i18n
2026-02-02 14:05:31 +08:00
圣达生物多
e79df4a565 Merge branch 'AstrBotDevs:master' into Sjshi763/issue4409 2026-02-02 13:44:39 +08:00
圣达生物多
ebcc15bb72 Merge branch 'AstrBotDevs:master' into Sjshi763/issue4409 2026-01-30 17:09:39 +08:00
Sjshi763
03bdb51c43 移动到 persona 的 i18n JSON 2026-01-28 23:42:32 +08:00
Sjshi763
0e32d62950 移除导入和导出按钮的本地化文本(上一次修改的 2026-01-28 23:35:45 +08:00
Sjshi763
60dfaf02b3 修复bug 405 2026-01-28 23:32:47 +08:00
Sjshi763
92cf0bc9ef 再次完成 2026-01-28 23:10:56 +08:00
Sjshi763
1f75682dea [Feature] 人格设定支持导出/导入。
Fixes #4409
2026-01-28 22:29:45 +08:00
圣达生物多
3ec909ca57 Merge pull request #1 from Sjshi763/fix-persona-conflict-v2
update
2026-01-28 22:16:22 +08:00
Sjshi763
fd95665fe3 update 2026-01-28 22:13:38 +08:00
Sjshi763
125d10a4e1 [Feature] 人格设定支持导出/导入。
Fixes #4409
2026-01-28 22:04:03 +08:00
圣达生物多
98e7a9c903 Merge branch 'AstrBotDevs:master' into Sjshi763/issue4409 2026-01-18 15:05:36 +08:00
Sjshi763
ec3d25c55b PersonaForm.vue - savePersona 方法添加了白名单字段过滤 2026-01-17 19:33:32 +08:00
Sjshi763
13461f925b fix:仅白名单字段可以作为文件名 2026-01-17 19:20:41 +08:00
Sjshi763
d2ed543425 删除未使用的i18n键值 2026-01-17 13:33:00 +08:00
Sjshi763
d1c0122691 改为直接导出文件 2026-01-17 13:22:27 +08:00
Sjshi763
2285695cb0 稍微进化 2026-01-17 13:15:17 +08:00
Sjshi763
59e5a6fd83 导入(dev 2026-01-17 13:03:12 +08:00
Sjshi763
2e90c259ea [Feature] 人格设定支持导出/导入。
Fixes #4409

- 导出的json
2026-01-17 12:35:23 +08:00
1109 changed files with 113086 additions and 49315 deletions

View File

@@ -16,8 +16,11 @@ venv*/
ENV/
.conda/
dashboard/
!astrbot/dashboard/
!astrbot/dashboard/dist/
!astrbot/dashboard/dist/**
data/
tests/
.ruff_cache/
.astrbot
astrbot.lock
astrbot.lock

184
.env.example Normal file
View File

@@ -0,0 +1,184 @@
# ==========================================
# AstrBot Instance Configuration: ${INSTANCE_NAME}
# AstrBot 实例配置文件:${INSTANCE_NAME}
# ==========================================
# 将此文件复制为 .env 并根据需要修改。
# Copy this file to .env and modify as needed.
# 注意:在此处设置的变量将覆盖默认配置。
# Note: Variables set here override application defaults.
# ------------------------------------------
# 实例标识 / Instance Identity
# ------------------------------------------
# 实例名称(用于日志和服务名)
# Instance name (used in logs/service names)
INSTANCE_NAME="${INSTANCE_NAME}"
# ------------------------------------------
# 核心配置 / Core Configuration
# ------------------------------------------
# AstrBot 根目录路径
# AstrBot root directory path
# 默认 Default: 当前工作目录,桌面客户端为 ~/.astrbot服务器为 /var/lib/astrbot/<instance>/
# 示例 Example: /var/lib/astrbot/mybot
ASTRBOT_ROOT="${ASTRBOT_ROOT}"
# 日志等级
# Log level
# 可选值 Values: DEBUG, INFO, WARNING, ERROR, CRITICAL
# 默认 Default: INFO
# ASTRBOT_LOG_LEVEL=INFO
# 启用插件热重载(开发时有用)
# Enable plugin hot reload (useful for development)
# 可选值 Values: 0 (禁用 disabled), 1 (启用 enabled)
# 默认 Default: 0
# ASTRBOT_RELOAD=0
# 禁用匿名使用统计
# Disable anonymous usage statistics
# 可选值 Values: 0 (启用统计 enabled), 1 (禁用统计 disabled)
# 默认 Default: 0
ASTRBOT_DISABLE_METRICS=0
# 覆盖 Python 可执行文件路径(用于本地代码执行功能)
# Override Python executable path (for local code execution)
# 示例 Example: /usr/bin/python3, /home/user/.pyenv/shims/python
# PYTHON=/usr/bin/python3
# 启用演示模式(可能限制部分功能)
# Enable demo mode (may restrict certain features)
# 可选值 Values: True, False
# 默认 Default: False
# DEMO_MODE=False
# 启用测试模式(影响日志和部分行为)
# Enable testing mode (affects logging and behavior)
# 可选值 Values: True, False
# 默认 Default: False
# TESTING=False
# 标记:是否通过桌面客户端执行(主要用于内部)
# Flag: running via desktop client (internal use)
# 可选值 Values: 0, 1
# ASTRBOT_DESKTOP_CLIENT=0
# 标记:是否通过 systemd 服务执行
# Flag: running via systemd service
# 可选值 Values: 0, 1
ASTRBOT_SYSTEMD=1
# ------------------------------------------
# 管理面板配置 / Dashboard Configuration
# ------------------------------------------
# 启用或禁用 WebUI 管理面板
# Enable or disable WebUI dashboard
# 可选值 Values: True, False
# 默认 Default: True
ASTRBOT_DASHBOARD_ENABLE=True
# 允许跨域请求的来源域名(多个用逗号分隔,允许所有则用 *
# Allowed CORS origins for WebUI dashboard (comma-separated, or * for all)
# 示例 Example: https://dash.astrbot.men
# 默认 Default: *
# ASTRBOT_CORS_ALLOW_ORIGIN="*"
# ------------------------------------------
# 国际化配置 / Internationalization Configuration
# ------------------------------------------
# CLI 界面语言
# CLI interface language
# 可选值 Values: zh (中文), en (英文)
# 默认 Default: zh (跟随系统 locale / follows system locale)
# ASTRBOT_CLI_LANG=zh
# ------------------------------------------
# 网络配置 / Network Configuration
# ------------------------------------------
# API 绑定主机
# API bind host
# 示例 Example: 0.0.0.0 (所有接口 all interfaces), 127.0.0.1 (仅本地 localhost only)
ASTRBOT_HOST="${ASTRBOT_HOST}"
# API 绑定端口
# API bind port
# 示例 Example: 3000, 6185, 8080
ASTRBOT_PORT="${ASTRBOT_PORT}"
# 是否为 API 启用 SSL/TLS
# Enable SSL/TLS for API
# 可选值 Values: true, false
# 默认 Default: false
ASTRBOT_SSL_ENABLE=false
# SSL 证书路径PEM 格式)
# SSL certificate path (PEM format)
# 示例 Example: /etc/astrbot/certs/myinstance/fullchain.pem
ASTRBOT_SSL_CERT=""
# SSL 私钥路径PEM 格式)
# SSL private key path (PEM format)
# 示例 Example: /etc/astrbot/certs/myinstance/privkey.pem
ASTRBOT_SSL_KEY=""
# SSL CA 证书链路径(可选,用于客户端验证)
# SSL CA certificates bundle (optional, for client verification)
# 示例 Example: /etc/ssl/certs/ca-certificates.crt
ASTRBOT_SSL_CA_CERTS=""
# ------------------------------------------
# 代理配置 / Proxy Configuration
# ------------------------------------------
# HTTP 代理地址
# HTTP proxy URL
# 示例 Example: http://127.0.0.1:7890, socks5://127.0.0.1:1080
# http_proxy=
# HTTPS 代理地址
# HTTPS proxy URL
# 示例 Example: http://127.0.0.1:7890, socks5://127.0.0.1:1080
# https_proxy=
# 不走代理的主机列表(逗号分隔)
# Hosts to bypass proxy (comma-separated)
# 示例 Example: localhost,127.0.0.1,192.168.0.0/16,.local
# no_proxy=localhost,127.0.0.1
# ------------------------------------------
# 第三方集成 / Third-party Integrations
# ------------------------------------------
# 阿里云 DashScope API 密钥(用于 Rerank 服务)
# Alibaba DashScope API Key (for Rerank service)
# 获取地址 Get from: https://dashscope.console.aliyun.com/
# 示例 Example: sk-xxxxxxxxxxxx
# DASHSCOPE_API_KEY=
# Coze 集成
# Coze integration
# 获取地址 Get from: https://www.coze.com/
# COZE_API_KEY=
# COZE_BOT_ID=
# 计算机控制相关的数据目录(用于截图/文件存储)
# Computer control data directory (for screenshots/file storage)
# 示例 Example: /var/lib/astrbot/bay_data
# BAY_DATA_DIR=
# ------------------------------------------
# 平台特定配置 / Platform-specific Configuration
# ------------------------------------------
# QQ 官方机器人测试模式开关
# QQ official bot test mode
# 可选值 Values: on, off
# 默认 Default: off
# TEST_MODE=off
# End of template / 模板结束

2
.envrc Normal file
View File

@@ -0,0 +1,2 @@
git pull
git status

View File

@@ -1,4 +1,4 @@
name: release
name: Build and Deploy AstrBot Docs
on:
push:
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest # 运行环境
steps:
- name: checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
with:

View File

@@ -12,7 +12,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- 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@v6
uses: actions/checkout@v7
# 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@v6
uses: actions/checkout@v7
with:
fetch-depth: 0

View File

@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.9
@@ -30,6 +30,7 @@ jobs:
working-directory: dashboard
run: |
pnpm install --frozen-lockfile
pnpm lint:check
pnpm run build
- name: Inject Commit SHA

View File

@@ -20,7 +20,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 1
fetch-tag: true
@@ -46,14 +46,21 @@ jobs:
- name: Build Dashboard
run: |
dashboard_version=$(python3 - <<'PY'
import tomllib
with open("pyproject.toml", "rb") as f:
print("v" + tomllib.load(f)["project"]["version"])
PY
)
cd dashboard
npm install
npm run build
mkdir -p dist/assets
echo $(git rev-parse HEAD) > dist/assets/version
echo "$dashboard_version" > dist/assets/version
cd ..
mkdir -p data
cp -r dashboard/dist data/
mkdir -p astrbot/dashboard
rm -rf astrbot/dashboard/dist
cp -r dashboard/dist astrbot/dashboard/dist
- name: Determine test image tags
id: test-meta
@@ -118,7 +125,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 1
fetch-tag: true
@@ -157,10 +164,11 @@ jobs:
npm install
npm run build
mkdir -p dist/assets
echo $(git rev-parse HEAD) > dist/assets/version
echo "${{ steps.release-meta.outputs.version }}" > dist/assets/version
cd ..
mkdir -p data
cp -r dashboard/dist data/
mkdir -p astrbot/dashboard
rm -rf astrbot/dashboard/dist
cp -r dashboard/dist astrbot/dashboard/dist
- name: Set QEMU
uses: docker/setup-qemu-action@v4.1.0

View File

@@ -1,64 +0,0 @@
name: PR Title Check
on:
pull_request_target:
types: [opened, edited, reopened, synchronize]
jobs:
title-format:
if: github.repository == 'AstrBotDevs/AstrBot'
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
steps:
- name: Validate PR title
uses: actions/github-script@v9
with:
script: |
const title = (context.payload.pull_request.title || "").trim();
// Allow Conventional Commit style PR titles.
// Examples:
// feat: xxx
// feat(scope): xxx
// fix: xxx
// fix(scope): xxx
const allowedTypes = "feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert";
const pattern = new RegExp(`^(${allowedTypes})(\\([a-z0-9-]+\\))?:\\s.+$`, "i");
const isValid = pattern.test(title);
const isSameRepo =
context.payload.pull_request.head.repo.full_name === context.payload.repository.full_name;
if (!isValid) {
if (isSameRepo) {
try {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: [
"⚠️ PR title format check failed.",
"Required formats:",
"- `feat: xxx`",
"- `feat(scope): xxx`",
"- `fix: xxx`",
"- `fix(scope): xxx`",
"- `chore: xxx`",
"",
"Allowed prefixes:",
"`feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`, `ci`, `build`, `revert`",
"Please update your PR title and push again."
].join("\n")
});
} catch (e) {
core.warning(`Failed to post PR title comment: ${e.message}`);
}
} else {
core.warning("Fork PR: comment permission is restricted; skip posting review comment.");
}
}
if (!isValid) {
core.setFailed("Invalid PR title. Expected Conventional Commit format, e.g. feat: xxx, feat(scope): xxx, or fix: xxx.");
}

View File

@@ -1,4 +1,4 @@
name: Release
name: Release AstrBot
on:
push:
@@ -28,7 +28,7 @@ jobs:
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ inputs.ref || github.ref }}
@@ -58,7 +58,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24.13.0'
node-version: "24.13.0"
cache: "pnpm"
cache-dependency-path: dashboard/pnpm-lock.yaml
@@ -128,7 +128,7 @@ jobs:
- build-dashboard
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ inputs.ref || github.ref }}
@@ -156,7 +156,6 @@ jobs:
name: Dashboard-${{ steps.tag.outputs.tag }}
path: release-assets
- name: Resolve release notes
id: notes
shell: bash
@@ -208,7 +207,7 @@ jobs:
- publish-release
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ inputs.ref || github.ref }}

View File

@@ -5,55 +5,57 @@ on:
branches:
- master
paths-ignore:
- 'README*.md'
- 'changelogs/**'
- 'dashboard/**'
- "README*.md"
- "changelogs/**"
- "dashboard/**"
pull_request:
workflow_dispatch:
jobs:
smoke-test:
name: Smoke test (${{ matrix.os }}, Python ${{ matrix.python-version }})
runs-on: ${{ matrix.os }}
name: Run smoke tests
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
- macos-latest
- windows-latest
python-version:
- '3.10'
- '3.11'
- '3.12'
- '3.13'
- '3.14'
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
cache-dependency-path: requirements.txt
python-version: "3.12"
- name: Install uv
- name: Install UV package manager
run: |
python -m pip install --upgrade pip
python -m pip install uv
pip install uv
- name: Install dependencies
run: |
uv pip install --system -r requirements.txt
uv sync
timeout-minutes: 15
- name: Run smoke tests
run: |
python scripts/smoke_startup_check.py
uv run main.py &
# uv tool install -e . --force
# astrbot init -y
# astrbot run --backend-only &
APP_PID=$!
echo "Waiting for application to start..."
for i in {1..60}; do
if curl -f http://localhost:6185 > /dev/null 2>&1; then
echo "Application started successfully!"
kill $APP_PID
exit 0
fi
sleep 1
done
echo "Application failed to start within 30 seconds"
kill $APP_PID 2>/dev/null || true
exit 1
timeout-minutes: 2

View File

@@ -1,4 +1,4 @@
name: sync wiki
name: Sync AstrBot Docs to GitHub Wiki
on:
workflow_dispatch:
@@ -31,7 +31,7 @@ jobs:
exit 1
- name: Check out docs repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v6

View File

@@ -19,7 +19,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v6

33
.gitignore vendored
View File

@@ -1,6 +1,5 @@
# Python related
__pycache__
.mypy_cache
.venv*
.conda/
uv.lock
@@ -51,16 +50,46 @@ astrbot.lock
chroma
venv/*
pytest.ini
AGENTS.md
IFLOW.md
CLAUDE.md
# genie_tts data
CharacterModels/
GenieData/
.agent/
.codex/
.claude/
.opencode/
.kilocode/
.serena
.worktrees/
.astrbot_sdk_testing/
.env
dashboard/warker.js
dashboard/bun.lock
.pua/
# Rust build artifacts
rust/target/
# Build outputs
dist/
*.whl
# 拓展模块
*.so
*.dll
# MDI font subset (generated by dashboard/scripts/subset-mdi-font.mjs)
dashboard/src/assets/mdi-subset/*.woff
dashboard/src/assets/mdi-subset/*.woff2
.planning
*cache
node_modules
*pinokio*
dashboard/pnpm-lock.yaml
.obsidian
dashboard/.codex
.codex
.zed/settings.json

View File

@@ -6,20 +6,20 @@ ci:
autoupdate_schedule: weekly
autoupdate_commit_msg: ":balloon: pre-commit autoupdate"
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.14.1
hooks:
# Run the linter.
- id: ruff-check
types_or: [ python, pyi ]
args: [ --fix ]
# Run the formatter.
- id: ruff-format
types_or: [ python, pyi ]
- repo: https://github.com/asottile/pyupgrade
rev: v3.21.0
hooks:
- id: pyupgrade
args: [--py310-plus]
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.15.7
hooks:
# Run the linter.
- id: ruff-check
types_or: [python, pyi]
args: [--fix]
# Run the formatter.
- id: ruff-format
types_or: [python, pyi]
- repo: https://github.com/asottile/pyupgrade
rev: v3.21.2
hooks:
- id: pyupgrade
args: [--py312-plus]

View File

@@ -1 +1 @@
3.12
3.12

294
AGENTS.md
View File

@@ -3,8 +3,10 @@
### Core
```
uv sync
uv run main.py
uv tool install -e . --force
astrbot init
astrbot run # start the bot
astrbot run --backend-only # start the backend only
```
Exposed an API server on `http://localhost:6185` by default.
@@ -13,8 +15,8 @@ Exposed an API server on `http://localhost:6185` by default.
```
cd dashboard
pnpm install # First time only. Use npm install -g pnpm if pnpm is not installed.
pnpm dev
bun install # First time only.
bun dev
```
Runs on `http://localhost:3000` by default.
@@ -41,93 +43,215 @@ ruff check .
## Dev environment tips
### Basic
- **Main entry**: `astrbot/__main__.py` or via CLI `astrbot run`
- **CLI commands**: `astrbot/cli/commands/`
- **Core modules**: `astrbot/core/`
- **Platform adapters**: `astrbot/core/platform/sources/`
- **Star plugins**: `astrbot/builtin_stars/`
- **Dashboard**: `dashboard/` (Vue.js frontend)
1. When modifying the WebUI, be sure to maintain componentization and clean code. Avoid duplicate code.
2. Do not add any report files such as xxx_SUMMARY.md.
3. After finishing, use `ruff format .` and `ruff check .` to format and check the code.
4. When committing, ensure to use conventional commits messages, such as `feat: add new agent for data analysis` or `fix: resolve bug in provider manager`.
5. Use English for all new comments.
6. For path handling, use `pathlib.Path` instead of string paths, and use `astrbot.core.utils.path_utils` to get the AstrBot data and temp directory.
7. When backend API routes, request/response schemas, or OpenAPI definitions change, regenerate the frontend API client by running `cd dashboard && pnpm generate:api`.
## File Organization
### KISS and First Principles
Follow the KISS principle and reason from first principles during development. Start by identifying the real problem, required behavior, and smallest useful change before adding code. Do not pile on features, configuration switches, abstractions, dependencies, or compatibility layers unless they directly solve the current problem and have clear evidence of need.
Prefer the simplest implementation that is correct, maintainable, and consistent with the existing codebase. If a broader design seems attractive, reduce it to the essential behavior needed now and leave optional expansion for a later, explicit requirement.
### No Unnecessary Helpers
Prioritize inline implementation over abstraction. Avoid over-engineering and do not create helper functions unless absolutely necessary.
1. **Inline-First Rule**: If a logic block can be implemented directly within the main function without breaking overall readability, **do not** extract it into a new helper function.
2. **Strict Justification for Helpers**: You may only create a separate helper function if it meets at least one of these criteria:
- **High Reuse**: The exact same logic is repeated across **3 or more** different locations.
- **Extreme Complexity**: Inlining the logic makes the main function too long (e.g., >50 lines) or severely derails the main execution flow.
3. **No Fragmentation**: Do not split continuous linear logic (e.g., a single API call, simple form validation, or one-time data formatting) into tiny functions just for the sake of "clean code."
4. **Keep Context Compact**: Handle edge cases, error catching, and logging directly inside the main function block instead of offloading them.
5. **Refactoring Constraint**: When modifying existing code, do not alter the current function structure or extract code into new helpers unless the existing code already violates the complexity or reuse rules above.
### Mandatory Google-Style Docstrings
* **Comment the complex**: Add clear comments to any non-obvious function, method, or parameter.
* **Google Format**: All docstrings must strictly use the Google format (`Args:`, `Returns:`, `Raises:`).
#### Example:
```py
def calculate_metrics(user_id: int, force_refresh: bool = False) -> dict:
"""Brief description of the function.
Args:
user_id: Description of the ID.
force_refresh: Description of the flag.
Returns:
Description of the returned dict.
Raises:
ValueError: Description of when this occurs.
"""
# Inline implementation here...
```
astrbot/
├── __main__.py # Main entry point
├── __init__.py # Package init, exports
├── cli/ # CLI commands
│ └── commands/ # Individual command modules
├── core/ # Core functionality
│ ├── agent/ # Agent execution
│ ├── platform/ # Platform adapters
│ ├── pipeline/ # Message processing
│ ├── star/ # Plugin system
│ └── config/ # Configuration
├── builtin_stars/ # Built-in plugins
├── dashboard/ # Vue.js frontend
└── utils/ # Utilities
```
## Architecture
## PR instructions
### Core Components
1. Title format: use conventional commit messages
2. Use English to write PR title and descriptions.
- `astrbot/core/` - Core bot functionality
- `astrbot/core/platform/` - Platform adapter system
- `astrbot/core/agent/` - Agent execution logic
- `astrbot/core/star/` - Plugin/Star handler system
- `astrbot/core/pipeline/` - Message processing pipeline
- `astrbot/cli/` - Command-line interface
### Important Utilities
```python
from astrbot.core.utils.astrbot_path import (
get_astrbot_root, # AstrBot root directory
get_astrbot_data_path, # Data directory
get_astrbot_config_path, # Config directory
get_astrbot_plugin_path, # Plugin directory
get_astrbot_temp_path, # Temp directory
get_astrbot_skills_path, # Skills directory
)
```
### Platform Adapters
Platform adapters are in `astrbot/core/platform/sources/`:
- Each adapter extends base platform classes
- Use `@register_platform_adapter` decorator
- Events flow through `commit_event()` to message queue
### Star (Plugin) System
Stars are plugins in `astrbot/builtin_stars/`:
- Extend `Star` base class
- Use decorators for command handlers: `@star.on_command`, `@star.on_message`, etc.
- Access via `context` object
## Code Style
1. **Type hints required** - Use Python 3.12+ syntax:
- `list[str]` not `List[str]`
- `int | None` not `Optional[int]`
- Avoid `Any` when possible. Use proper `TypedDict`, `dataclass`, or `Protocol` instead.
- When encountering dict access issues (e.g., `msg.get("key")` where type inference is wrong), define a `TypedDict` with `total=False` to explicitly declare allowed keys.
Good example:
```python
class MessageComponent(TypedDict, total=False):
type: str
text: str
path: str
```
Bad example (avoid):
```python
msg: Any = something
msg = cast(dict, msg)
```
2. **Path handling** - Always use `pathlib.Path`:
```python
from pathlib import Path
# Use astrbot.core.utils.path_utils for data/temp directories
from astrbot.core.utils.path_utils import get_astrbot_data_path
```
3. **Formatting** - Run before committing:
```bash
ruff format .
ruff check .
```
4. **Comments** - Use English for all comments and docstrings
5. **Imports** - Use absolute imports via `astrbot.` prefix
### Environment Variables
When adding new environment variables:
1. Use `ASTRBOT_` prefix: `ASTRBOT_ENABLE_FEATURE`
2. Add to `.env.example` with description
3. Update `astrbot/cli/commands/cmd_run.py`:
- Add to module docstring under "Environment Variables Used in Project"
- Add to `keys_to_print` list for debug output
## Testing
1. Tests go in `tests/` directory
2. Use `pytest` with `pytest-asyncio`
3. Run: `uv sync --group dev && uv run pytest --cov=astrbot tests/`
4. Test files: `test_*.py` or `*_test.py`
### Code Quality Scoring Test
The project enforces a **code quality score** via `tests/test_code_quality_typing.py`. All agents must treat this as a hard constraint when modifying code.
**Run the test:**
```bash
uv run pytest tests/test_code_quality_typing.py -v
```
**Scoring rules (target: 100/100, threshold for PASS: 80/100):**
| Pattern | Cost |
|---------|------|
| `cast(Any, ...)` | -1 pt each |
| `# type: ignore` | -0.5 pt each |
| **BAD** `# type: ignore[...]` (unresolved-import, class-alias, no-name-module, attr-defined, etc.) | **-3 pt each** |
| `bare except:` (no exception type) | -0.5 pt each |
| Duplicate code block (5+ identical lines, ≥2 occurrences) | -2 pt each |
**Why bad type: ignore is heavily penalized:**
- `# type: ignore[unresolved-import]` — hides missing module/stub issues
- `# type: ignore[class-alias]` — hides improper type alias patterns
- `# type: ignore[attr-defined]` — hides missing attribute errors
- These are **workarounds, not fixes** — they paper over real type errors
**Scoring formula:**
```
score = max(0, 100 - cast_any - type_ignore*0.5 - bad_type_ignore*3 - bare_except*0.5 - dup_blocks*2)
```
**Agent rules when modifying code:**
1. **Do not add** `# type: ignore[unresolved-import]` or `# type: ignore[class-alias]` — fix the underlying issue instead
2. **Do not use** `cast(Any, ...)` to suppress type errors — use proper type annotations
3. **Do not add** bare `except:` clauses — use `except SomeSpecificException:`
4. **Do not copy-paste** 5+ line blocks — extract to a shared helper function
5. Before committing, run the scoring test and ensure score ≥ 80
## Git Conventions
### Commit Messages
Use conventional commits:
```
feat: add new feature
fix: resolve bug
docs: update documentation
refactor: restructure code
test: add tests
chore: maintenance tasks
```
### PR Guidelines
1. Title: conventional commit format
2. Description: English
3. Target branch: `dev`
4. Keep changes focused and atomic
## Project-Specific Guidelines
1. **No report files** - Do not add `xxx_SUMMARY.md` or similar
2. **Componentization** - Maintain clean code, avoid duplication in WebUI
3. **Backward compatibility** - When deprecating, add warnings
4. **CLI help** - Run `astrbot help --all` to see all commands
5. When modifying frontend/dashboard code, use the project's custom request module `@/utils/request` for HTTP calls
6. For fetch or SSE URLs, use `resolveApiUrl('/api/your-path')` so the configured `VITE_API_BASE` and dev proxy rules are respected
7. Do not import the plain `axios` package directly in dashboard source files
## Common Tasks
### Adding a new platform adapter
1. Create adapter in `astrbot/core/platform/sources/`
2. Extend `Platform` base class
3. Use `@register_platform_adapter` decorator
4. Implement required methods: `run()`, `convert_message()`, `meta()`
### Adding a new command
1. Add to appropriate module in `cli/commands/`
2. Register with `@click.command()`
3. Update `astrbot/cli/__main__.py` to add command
### Adding a new Star handler
1. Create in `astrbot/builtin_stars/` or as plugin
2. Extend `Star` class
3. Use decorators: `@star.on_command()`, `@star.on_schedule()`, etc.
## Release versions
Use a short-lived `release/*` branch for each release. The release branch is the stabilization area for version bumps, changelog updates, release-blocking fixes, and final validation only. Do not add unrelated features or broad refactors to a release branch.
Prepare a release from a clean worktree with:
```bash
uv run python scripts/prepare_release.py 4.25.0
```
The script updates `pyproject.toml`, creates `changelogs/v4.25.0.md`, runs the required Python checks, and prints the remaining steps. Use these flags when needed:
```bash
uv run python scripts/prepare_release.py 4.25.0 --generate-api-client
uv run python scripts/prepare_release.py 4.25.0 --dashboard-build
uv run python scripts/prepare_release.py 4.25.0 --commit --push
```
Open a PR from `release/4.25.0` to `master`. The PR title must use the conventional commit format, for example `chore: bump version to 4.25.0`. After the release PR is merged, create and push the tag from the updated `master` branch so the tag points to the exact code that was merged:
```bash
git checkout master
git pull --ff-only origin master
git tag v4.25.0
git push origin v4.25.0
```
For one-off release candidate branches, delete the release branch after the tag is pushed and verified. For maintained release lines, use a branch such as `release/4.25` and keep it until that line reaches EOL.
```bash
git branch -d release/4.25.0
git push origin --delete release/4.25.0
```
1. Replace current version name to specific version name.
2. Write changelog in `changelogs/`, you can refer to the full commit messages between the latest tag to the latest commit.
3. Make and push a commit into master branch with message format like: `chore: bump version to 4.25.0`
4. Create a tag and push the tag. For example: `git tag v4.25.0 && git push origin v4.25.0`

View File

@@ -234,10 +234,6 @@ pre-commit install
### QQ Groups
- Group 12: 916228568 (New)
- Group 9: 1076659624 (Full)
- Group 10: 1078079676 (Full)
- Group 11: 704659519 (Full)
- Group 1: 322154837 (Full)
- Group 3: 630166526 (Full)
- Group 4: 1077826412 (Full)
@@ -245,6 +241,12 @@ pre-commit install
- Group 6: 753075035 (Full)
- Group 7: 743746109 (Full)
- Group 8: 1030353265 (Full)
- Group 9: 1076659624 (Full)
- Group 10: 1078079676 (Full)
- Group 11: 704659519 (Full)
- Group 12: 916228568 (Full)
- Group 13: 1092185289
- Group 14: 1103419483
- Developer Group(Chit-chat): 975206796
- Developer Group(Formal): 1039761811

View File

@@ -78,7 +78,10 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
```bash
uv tool install astrbot --python 3.12
astrbot init # 仅首次执行此命令以初始化环境
astrbot run
astrbot run # astrbot run --backend-only 仅启动后端服务
# 安装开发版本(更多修复,新功能,但不够稳定,适合开发者)
uv tool install git+https://github.com/AstrBotDevs/AstrBot@dev
```
> 需要安装 [uv](https://docs.astral.sh/uv/)。
@@ -204,13 +207,25 @@ yay -S astrbot-git
| Xiaomi MiMo TTS | 文本转语音 |
| 火山引擎 TTS | 文本转语音 |
## ❤️ Sponsors
<p align="center">
<img alt="sponsors" src="https://sponsors.astrbot.app/?v=1">
</p>
## ❤️ 贡献
欢迎任何 Issues/Pull Requests只需要将你的更改提交到此项目 )
欢迎任何 Issues/Pull Requests只需要将你的更改提交到此项目 :)
### 如何贡献
你可以通过查看问题或帮助审核 PR拉取请求来贡献。任何问题或 PR 都欢迎参与,以促进社区贡献。当然,这些只是建议,你可以以任何方式进行贡献。对于新功能的添加,请先通过 Issue 讨论。
建议将功能性PR合并至dev分支将在测试修改后合并到主分支并发布新版本。
为了减少冲突,建议如下:
1. 工作分支最好基于 `dev` 分支创建,避免直接在 `main` 分支上工作。
2. 提交 PR 时,选择 `dev` 分支作为目标分支。
3. 定期同步 `dev` 分支到本地多使用git pull。
### 开发环境
@@ -218,18 +233,26 @@ AstrBot 使用 `ruff` 进行代码格式化和检查。
```bash
git clone https://github.com/AstrBotDevs/AstrBot
pip install pre-commit
git switch dev # 切换到开发分支
pip install pre-commit # 或者uv tool install pre-commit
pre-commit install
```
## 🌍 社区
推荐使用uv本地安装进行测试
```bash
uv tool install -e . --force
astrbot init
astrbot run
```
调试前端
```bash
astrbot run --backend-only
cd dashboard
bun install # 或者pnpm 等
bun dev
```
### QQ 群组
- 12 群916228568 (新)
- 9 群1076659624 (人满)
- 10 群1078079676 (人满)
- 11 群704659519 (人满)
- 1 群322154837 (人满)
- 3 群630166526 (人满)
- 4 群1077826412 (人满)
@@ -237,6 +260,14 @@ pre-commit install
- 6 群753075035 (人满)
- 7 群743746109 (人满)
- 8 群1030353265 (人满)
- 9 群1076659624 (人满)
- 10 群1078079676 (人满)
- 11 群704659519 (人满)
- 12 群916228568 (人满)
- 13 群1092185289
- 14 群1103419483
- 开发者群偏闲聊吹水975206796
- 开发者群正式1039761811

View File

@@ -1,3 +1,30 @@
from .core.log import LogManager
from __future__ import annotations
logger = LogManager.GetLogger(log_name="astrbot")
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _pkg_version
from typing import TYPE_CHECKING, Any
try:
__version__ = _pkg_version("astrbot")
except PackageNotFoundError:
__version__ = "4.26.1"
if TYPE_CHECKING:
from .core import logger as logger
__all__ = ["logger"]
def __getattr__(name: str) -> Any:
if name == "cli":
from astrbot.cli.__main__ import cli
return cli()
if name == "logger":
from .core import logger
return logger
raise AttributeError(name)

147
astrbot/__main__.py Normal file
View File

@@ -0,0 +1,147 @@
import argparse
import asyncio
import mimetypes
import os
import sys
from pathlib import Path
import anyio
from astrbot.core import LogBroker, LogManager, db_helper, logger
from astrbot.core.config.default import VERSION
from astrbot.core.initial_loader import InitialLoader
from astrbot.core.utils.astrbot_path import (
get_astrbot_config_path,
get_astrbot_data_path,
get_astrbot_knowledge_base_path,
get_astrbot_plugin_path,
get_astrbot_root,
get_astrbot_site_packages_path,
get_astrbot_skills_path,
get_astrbot_temp_path,
)
from astrbot.core.utils.io import (
download_dashboard,
get_dashboard_version,
)
# 将父目录添加到 sys.path
sys.path.append(Path(__file__).parent.as_posix())
logo_tmpl = r"""
___ _______.___________..______ .______ ______ .___________.
/ \ / | || _ \ | _ \ / __ \ | |
/ ^ \ | (----`---| |----`| |_) | | |_) | | | | | `---| |----`
/ /_\ \ \ \ | | | / | _ < | | | | | |
/ _____ \ .----) | | | | |\ \----.| |_) | | `--' | | |
/__/ \__\ |_______/ |__| | _| `._____||______/ \______/ |__|
"""
def check_env() -> None:
# Python version check: require 3.12 or 3.13
if not (sys.version_info.major == 3 and sys.version_info.minor in (12, 13)):
sys.exit(1)
astrbot_root = get_astrbot_root()
if astrbot_root not in sys.path:
sys.path.insert(0, astrbot_root)
site_packages_path = get_astrbot_site_packages_path()
if site_packages_path not in sys.path:
sys.path.insert(0, site_packages_path)
os.makedirs(get_astrbot_config_path(), exist_ok=True)
os.makedirs(get_astrbot_plugin_path(), exist_ok=True)
os.makedirs(get_astrbot_temp_path(), exist_ok=True)
os.makedirs(get_astrbot_knowledge_base_path(), exist_ok=True)
os.makedirs(get_astrbot_skills_path(), exist_ok=True)
os.makedirs(site_packages_path, exist_ok=True)
# 针对问题 #181 的临时解决方案
mimetypes.add_type("text/javascript", ".js")
mimetypes.add_type("text/javascript", ".mjs")
mimetypes.add_type("application/json", ".json")
async def check_dashboard_files(webui_dir: str | None = None):
"""下载管理面板文件"""
# 指定webui目录
if webui_dir:
if await anyio.Path(webui_dir).exists():
logger.info(f"使用指定的 WebUI 目录: {webui_dir}")
return webui_dir
logger.warning(f"指定的 WebUI 目录 {webui_dir} 不存在,将使用默认逻辑。")
data_dist_path = os.path.join(get_astrbot_data_path(), "dist")
if await anyio.Path(data_dist_path).exists():
v = await get_dashboard_version()
if v is not None:
# 存在文件
if v == f"v{VERSION}":
logger.info("WebUI 版本已是最新。")
else:
logger.warning(
f"检测到 WebUI 版本 ({v}) 与当前 AstrBot 版本 (v{VERSION}) 不符。",
)
return data_dist_path
logger.info(
"开始下载管理面板文件...高峰期(晚上)可能导致较慢的速度。如多次下载失败,请前往 https://github.com/AstrBotDevs/AstrBot/releases/latest 下载 dist.zip,并将其中的 dist 文件夹解压至 data 目录下。",
)
try:
await download_dashboard(version=f"v{VERSION}", latest=False)
except Exception as e:
logger.warning(
f"下载指定版本(v{VERSION})的管理面板文件失败: {e},尝试下载最新版本。",
)
try:
await download_dashboard(latest=True)
except Exception as e:
logger.critical(f"下载管理面板文件失败: {e}")
return None
logger.info("管理面板下载完成。")
return data_dist_path
async def main_async(webui_dir_arg: str | None, log_broker: LogBroker) -> None:
"""主异步入口"""
# 检查仪表板文件
webui_dir = await check_dashboard_files(webui_dir_arg)
if webui_dir is None:
logger.warning(
"管理面板文件检查失败,WebUI 功能将不可用。"
"请检查网络连接或手动指定 --webui-dir 参数。",
)
db = db_helper
# 打印 logo
logger.info(logo_tmpl)
core_lifecycle = InitialLoader(db, log_broker)
core_lifecycle.webui_dir = webui_dir
await core_lifecycle.start()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="AstrBot")
parser.add_argument(
"--webui-dir",
type=str,
help="指定 WebUI 静态文件目录路径",
default=None,
)
args = parser.parse_args()
check_env()
# 启动日志代理
log_broker = LogBroker()
LogManager.set_queue_handler(logger, log_broker)
# 只使用一次 asyncio.run()
asyncio.run(main_async(args.webui_dir, log_broker))

View File

@@ -30,7 +30,7 @@ from astrbot.core.star.filter.platform_adapter_type import (
PlatformAdapterType,
)
from astrbot.core.star.register import (
register_star as register, # 注册插件Star
register_star as register, # 注册插件(Star)
)
from astrbot.core.star import Context, Star
from astrbot.core.star.config import *

View File

@@ -59,14 +59,14 @@ __all__ = [
"on_decorating_result",
"on_llm_request",
"on_llm_response",
"on_llm_tool_respond",
"on_platform_loaded",
"on_plugin_error",
"on_plugin_loaded",
"on_plugin_unloaded",
"on_platform_loaded",
"on_using_llm_tool",
"on_waiting_llm_request",
"permission_type",
"platform_adapter_type",
"regex",
"on_using_llm_tool",
"on_llm_tool_respond",
]

View File

@@ -1,7 +1,7 @@
from astrbot.core.star import Context, Star, StarTools
from astrbot.core.star.config import *
from astrbot.core.star.register import (
register_star as register, # 注册插件Star
register_star as register, # 注册插件(Star)
)
__all__ = ["Context", "Star", "StarTools", "register"]

View File

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

View File

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

View File

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

View File

@@ -23,9 +23,7 @@ class HelpCommand:
return ""
async def _build_reserved_command_lines(self) -> list[str]:
"""
使用实时指令配置生成内置指令清单,确保重命名/禁用后与实际生效状态保持一致。
"""
"""使用实时指令配置生成内置指令清单,确保重命名/禁用后与实际生效状态保持一致。"""
try:
commands = await command_management.list_commands()
except BaseException:

View File

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

View File

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

View File

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

View File

@@ -40,7 +40,10 @@ class ProviderCommands:
err_code = "TEST_FAILED"
err_reason = safe_error("", e)
self._log_reachability_failure(
provider, provider_capability_type, err_code, err_reason
provider,
provider_capability_type,
err_code,
err_reason,
)
return False, err_code, err_reason
@@ -62,7 +65,7 @@ class ProviderCommands:
check_results = [None for _ in providers]
display_data = []
for provider, reachable in zip(providers, check_results):
for provider, reachable in zip(providers, check_results, strict=False):
meta = provider.meta()
id_ = meta.id
error_code = None
@@ -103,7 +106,7 @@ class ProviderCommands:
"info": info,
"mark": mark,
"provider": provider,
}
},
)
return display_data
@@ -128,7 +131,7 @@ class ProviderCommands:
if reachability_check_enabled and (llms or ttss or stts):
await event.send(
MessageEventResult().message("👀 Testing provider reachability...")
MessageEventResult().message("👀 Testing provider reachability..."),
)
llm_data, tts_data, stt_data = await asyncio.gather(
@@ -189,12 +192,12 @@ class ProviderCommands:
elif idx == "tts":
if idx2 is None:
event.set_result(
MessageEventResult().message("Please enter the index.")
MessageEventResult().message("Please enter the index."),
)
return
if idx2 > len(self.context.get_all_tts_providers()) or idx2 < 1:
event.set_result(
MessageEventResult().message("❌ Invalid provider index.")
MessageEventResult().message("❌ Invalid provider index."),
)
return
provider = self.context.get_all_tts_providers()[idx2 - 1]
@@ -205,17 +208,17 @@ class ProviderCommands:
umo=umo,
)
event.set_result(
MessageEventResult().message(f"✅ Successfully switched to {id_}.")
MessageEventResult().message(f"✅ Successfully switched to {id_}."),
)
elif idx == "stt":
if idx2 is None:
event.set_result(
MessageEventResult().message("Please enter the index.")
MessageEventResult().message("Please enter the index."),
)
return
if idx2 > len(self.context.get_all_stt_providers()) or idx2 < 1:
event.set_result(
MessageEventResult().message("❌ Invalid provider index.")
MessageEventResult().message("❌ Invalid provider index."),
)
return
provider = self.context.get_all_stt_providers()[idx2 - 1]
@@ -226,12 +229,12 @@ class ProviderCommands:
umo=umo,
)
event.set_result(
MessageEventResult().message(f"✅ Successfully switched to {id_}.")
MessageEventResult().message(f"✅ Successfully switched to {id_}."),
)
elif isinstance(idx, int):
if idx > len(self.context.get_all_providers()) or idx < 1:
event.set_result(
MessageEventResult().message("❌ Invalid provider index.")
MessageEventResult().message("❌ Invalid provider index."),
)
return
provider = self.context.get_all_providers()[idx - 1]
@@ -242,7 +245,86 @@ class ProviderCommands:
umo=umo,
)
event.set_result(
MessageEventResult().message(f"✅ Successfully switched to {id_}.")
MessageEventResult().message(f"✅ Successfully switched to {id_}."),
)
else:
event.set_result(MessageEventResult().message("❌ Invalid parameter."))
async def model_ls(
self,
event: AstrMessageEvent,
idx_or_name: int | str | None = None,
) -> None:
"""查看或者切换当前 Provider 的模型。"""
umo = event.unified_msg_origin
provider = self.context.get_using_provider(umo=umo)
if provider is None:
event.set_result(
MessageEventResult().message("未找到任何 LLM 提供商。请先配置。"),
)
return
try:
models = await provider.get_models()
except Exception as e:
event.set_result(
MessageEventResult().message(
f"获取模型列表失败: {safe_error('', e)}",
),
)
return
current_model = provider.get_model()
if idx_or_name is None:
if not models:
event.set_result(
MessageEventResult().message(
f"当前模型: {current_model}\n此提供商未返回可切换模型列表。",
),
)
return
parts = [f"当前模型: {current_model}\n\n可用模型:\n"]
for index, model_name in enumerate(models, start=1):
suffix = " 👈" if model_name == current_model else ""
parts.append(f"{index}. {model_name}{suffix}\n")
parts.append("\n使用 /model <序号> 或 /model <模型名> 切换模型。")
event.set_result(MessageEventResult().message("".join(parts)))
return
selected_model: str | None = None
if isinstance(idx_or_name, int):
if 1 <= idx_or_name <= len(models):
selected_model = models[idx_or_name - 1]
else:
text = idx_or_name.strip()
if text.isdigit():
model_index = int(text)
if 1 <= model_index <= len(models):
selected_model = models[model_index - 1]
elif text:
selected_model = text
if not selected_model:
event.set_result(MessageEventResult().message("❌ Invalid model index."))
return
provider.set_model(selected_model)
provider.provider_config["model"] = selected_model
cfg = self.context.get_config(umo)
providers_config = cfg.get("provider", [])
if isinstance(providers_config, list):
for provider_config in providers_config:
if not isinstance(provider_config, dict):
continue
if provider_config.get("id") == provider.meta().id:
provider_config["model"] = selected_model
break
cfg.save_config()
event.set_result(
MessageEventResult().message(
f"✅ Successfully switched model to {selected_model}.",
),
)

View File

@@ -2,6 +2,16 @@ from astrbot.api import sp, star
from astrbot.api.event import AstrMessageEvent, MessageEventResult
def _normalize_session_variables(value: object) -> dict[str, str]:
if not isinstance(value, dict):
return {}
return {
key: value
for key, value in value.items()
if isinstance(key, str) and isinstance(value, str)
}
class SetUnsetCommands:
def __init__(self, context: star.Context) -> None:
self.context = context
@@ -9,28 +19,32 @@ class SetUnsetCommands:
async def set_variable(self, event: AstrMessageEvent, key: str, value: str) -> None:
"""设置会话变量"""
uid = event.unified_msg_origin
session_var = await sp.session_get(uid, "session_variables", {})
session_var = _normalize_session_variables(
await sp.session_get(uid, "session_variables", {}),
)
session_var[key] = value
await sp.session_put(uid, "session_variables", session_var)
event.set_result(
MessageEventResult().message(
f"会话 {uid} 变量 {key} 存储成功使用 /unset 移除",
f"会话 {uid} 变量 {key} 存储成功使用 /unset 移除",
),
)
async def unset_variable(self, event: AstrMessageEvent, key: str) -> None:
"""移除会话变量"""
uid = event.unified_msg_origin
session_var = await sp.session_get(uid, "session_variables", {})
session_var = _normalize_session_variables(
await sp.session_get(uid, "session_variables", {}),
)
if key not in session_var:
event.set_result(
MessageEventResult().message("没有那个变量名格式 /unset 变量名"),
MessageEventResult().message("没有那个变量名格式 /unset 变量名"),
)
else:
del session_var[key]
await sp.session_put(uid, "session_variables", session_var)
event.set_result(
MessageEventResult().message(f"会话 {uid} 变量 {key} 移除成功"),
MessageEventResult().message(f"会话 {uid} 变量 {key} 移除成功"),
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,115 @@
import copy
from sys import maxsize
import astrbot.api.message_components as Comp
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, filter
from astrbot.api.star import Context, Star
from astrbot.core.utils.session_waiter import (
FILTERS,
USER_SESSIONS,
SessionController,
SessionWaiter,
session_waiter,
)
class Main(Star):
"""会话控制"""
def __init__(self, context: Context) -> None:
super().__init__(context)
@filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize)
async def handle_session_control_agent(self, event: AstrMessageEvent) -> None:
"""会话控制代理"""
for session_filter in FILTERS:
session_id = session_filter.filter(event)
if session_id in USER_SESSIONS:
await SessionWaiter.trigger(session_id, event)
event.stop_event()
@filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize - 1)
async def handle_empty_mention(self, event: AstrMessageEvent):
"""实现了对只有一个 @ 的消息内容的处理"""
try:
messages = event.get_messages()
cfg = self.context.get_config(umo=event.unified_msg_origin)
p_settings = cfg["platform_settings"]
wake_prefix = cfg.get("wake_prefix", [])
if len(messages) == 1:
if (
isinstance(messages[0], Comp.At)
and str(messages[0].qq) == str(event.get_self_id())
and p_settings.get("empty_mention_waiting", True)
) or (
isinstance(messages[0], Comp.Plain)
and messages[0].text.strip() in wake_prefix
):
if p_settings.get("empty_mention_waiting_need_reply", True):
try:
# 尝试使用 LLM 生成更生动的回复
# func_tools_mgr = self.context.get_llm_tool_manager()
# 获取用户当前的对话信息
curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
event.unified_msg_origin,
)
conversation = None
if curr_cid:
conversation = await self.context.conversation_manager.get_conversation(
event.unified_msg_origin,
curr_cid,
)
else:
# 创建新对话
curr_cid = await self.context.conversation_manager.new_conversation(
event.unified_msg_origin,
platform_id=event.get_platform_id(),
)
# 使用 LLM 生成回复
yield event.request_llm(
prompt=(
"注意,你正在社交媒体上中与用户进行聊天,用户只是通过@来唤醒你,但并未在这条消息中输入内容,他可能会在接下来一条发送他想发送的内容。"
"你友好地询问用户想要聊些什么或者需要什么帮助,回复要符合人设,不要太过机械化。"
"请注意,你仅需要输出要回复用户的内容,不要输出其他任何东西"
),
session_id=curr_cid,
contexts=[],
system_prompt="",
conversation=conversation,
)
except Exception as e:
logger.error(f"LLM response failed: {e!s}")
# LLM 回复失败,使用原始预设回复
yield event.plain_result("想要问什么呢?😄")
@session_waiter(60)
async def empty_mention_waiter(
controller: SessionController,
event: AstrMessageEvent,
) -> None:
if not event.message_str or not event.message_str.strip():
return
event.message_obj.message.insert(
0,
Comp.At(qq=event.get_self_id(), name=event.get_self_id()),
)
new_event = copy.copy(event)
# 重新推入事件队列
self.context.get_event_queue().put_nowait(new_event)
event.stop_event()
controller.stop()
try:
await empty_mention_waiter(event)
except TimeoutError as _:
pass
except Exception as e:
yield event.plain_result("发生错误,请联系管理员: " + str(e))
finally:
event.stop_event()
except Exception as e:
logger.error("handle_empty_mention error: " + str(e))

View File

@@ -0,0 +1,146 @@
import random
import urllib.parse
from collections.abc import Callable
from dataclasses import dataclass
from aiohttp import ClientSession, ClientTimeout
from bs4 import BeautifulSoup, Tag
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 6.1; rv:84.0) Gecko/20100101 Firefox/84.0",
"Accept": "*/*",
"Connection": "keep-alive",
"Accept-Language": "en-GB,en;q=0.5",
}
USER_AGENT_BING = "Mozilla/5.0 (Windows NT 6.1; rv:84.0) Gecko/20100101 Firefox/84.0"
USER_AGENTS = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:89.0) Gecko/20100101 Firefox/89.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Version/14.1.2 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Version/14.1 Safari/537.36",
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:89.0) Gecko/20100101 Firefox/89.0",
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:88.0) Gecko/20100101 Firefox/88.0",
]
@dataclass
class SearchResult:
title: str
url: str
snippet: str
favicon: str | None = None
def __str__(self) -> str:
return f"{self.title} - {self.url}\n{self.snippet}"
class SearchEngine:
"""搜索引擎爬虫基类"""
def __init__(self) -> None:
self.TIMEOUT = ClientTimeout(total=10)
self.page = 1
self.headers = HEADERS
def _set_selector(self, selector: str) -> str:
raise NotImplementedError
async def _get_next_page(self, query: str) -> str:
raise NotImplementedError
async def _get_html(self, url: str, data: dict | None = None) -> str:
headers = self.headers
headers["Referer"] = url
headers["User-Agent"] = random.choice(USER_AGENTS)
if data:
async with (
ClientSession() as session,
session.post(
url,
headers=headers,
data=data,
timeout=self.TIMEOUT,
) as resp,
):
ret = await resp.text(encoding="utf-8")
return ret
else:
async with (
ClientSession() as session,
session.get(
url,
headers=headers,
timeout=self.TIMEOUT,
) as resp,
):
ret = await resp.text(encoding="utf-8")
return ret
def tidy_text(self, text: str) -> str:
"""清理文本,去除空格、换行符等"""
return text.strip().replace("\n", " ").replace("\r", " ").replace(" ", " ")
def _get_url(self, tag: Tag) -> str:
return self.tidy_text(tag.get_text())
async def search(self, query: str, num_results: int) -> list[SearchResult]:
query = urllib.parse.quote(query)
try:
resp = await self._get_next_page(query)
soup = BeautifulSoup(resp, "html.parser")
links = soup.select(self._set_selector("links"))
results = []
try:
text_selector = self._set_selector("text")
except (KeyError, NotImplementedError):
# Keep backward compatibility with engines that only expose
# title/url/link selectors and do not provide snippets.
text_selector = ""
for link in links:
# Safely get the title text (select_one may return None)
title_elem = link.select_one(self._set_selector("title"))
title = ""
if title_elem is not None:
title = self.tidy_text(title_elem.get_text())
url_tag = link.select_one(self._set_selector("url"))
snippet = ""
if text_selector:
text_elem = link.select_one(text_selector)
if text_elem is not None:
snippet = self.tidy_text(text_elem.get_text())
if title and url_tag:
url = self._get_url(url_tag)
if not url:
continue
if url.startswith("//"):
url = f"https:{url}"
results.append(SearchResult(title=title, url=url, snippet=snippet))
return results[:num_results] if len(results) > num_results else results
except Exception as e:
raise e
async def _search_with_result_filter(
self,
query: str,
num_results: int,
predicate: Callable[[SearchResult], bool],
) -> list[SearchResult]:
if num_results <= 0:
return []
rough_results = await SearchEngine.search(self, query, max(num_results * 2, 10))
final_results: list[SearchResult] = []
for result in rough_results:
if not predicate(result):
continue
final_results.append(result)
if len(final_results) >= num_results:
break
return final_results

View File

@@ -0,0 +1,33 @@
from . import USER_AGENT_BING, SearchEngine
class Bing(SearchEngine):
NAME = "bing"
def __init__(self) -> None:
super().__init__()
# Prefer international Bing first, keep cn endpoint as compatibility fallback.
self.base_urls = ["https://www.bing.com", "https://cn.bing.com"]
self.headers.update({"User-Agent": USER_AGENT_BING})
def _set_selector(self, selector: str):
selectors = {
"url": "div.b_attribution cite",
"title": "h2",
"text": "p",
"links": "ol#b_results > li.b_algo",
"next": 'div#b_content nav[role="navigation"] a.sb_pagN',
}
return selectors[selector]
async def _get_next_page(self, query) -> str:
# if self.page == 1:
# await self._get_html(self.base_url)
for base_url in self.base_urls:
try:
url = f"{base_url}/search?q={query}"
return await self._get_html(url, None)
except Exception as _:
self.base_url = base_url
continue
raise Exception("Bing search failed")

View File

@@ -0,0 +1,64 @@
from urllib.parse import unquote, urlencode, urlparse
from bs4 import Tag
from . import SearchEngine, SearchResult
class Comet(SearchEngine):
"""Best-effort search via public Perplexity/Comet page.
Note:
- This endpoint is often protected by anti-bot challenges.
- We intentionally treat failures as non-fatal and rely on fallback engines.
"""
NAME = "comet"
def __init__(self) -> None:
super().__init__()
self.base_url = "https://www.perplexity.ai"
def _set_selector(self, selector: str):
selectors = {
"url": "a[href^='http'], a[href^='//']",
"title": "main h1, main h2, main h3, h3, h2",
"text": "main article, main div[role='article'], main section, main p, p",
"links": "main article, main div[role='article'], main li, main div.result, article, div[role='article'], li, div.result",
"next": "",
}
return selectors[selector]
async def _get_next_page(self, query: str) -> str:
url = f"{self.base_url}/search?{urlencode({'q': unquote(query)})}"
return await self._get_html(url, None)
def _get_url(self, tag: Tag) -> str:
href = str(tag.get("href") or "")
if href.startswith("//"):
return f"https:{href}"
return href
@staticmethod
def _is_valid_result_url(url: str) -> bool:
lowered = (url or "").strip().lower()
if not lowered:
return False
if lowered.startswith(("#", "javascript:", "mailto:")):
return False
if not lowered.startswith(("http://", "https://")):
return False
netloc = urlparse(lowered).netloc
if not netloc:
return False
if netloc.endswith("perplexity.ai"):
return False
return True
async def search(self, query: str, num_results: int) -> list[SearchResult]:
return await self._search_with_result_filter(
query=query,
num_results=num_results,
predicate=lambda result: self._is_valid_result_url(result.url),
)

View File

@@ -0,0 +1,43 @@
import urllib.parse
from bs4 import Tag
from . import SearchEngine, SearchResult
class DuckDuckGo(SearchEngine):
NAME = "duckduckgo"
def __init__(self) -> None:
super().__init__()
self.base_url = "https://html.duckduckgo.com/html"
def _set_selector(self, selector: str):
selectors = {
"url": "a.result__a, h2 a",
"title": "a.result__a, h2",
"text": "a.result__snippet, div.result__snippet",
"links": "div.result, div.web-result",
"next": "a.result--more__btn",
}
return selectors[selector]
async def _get_next_page(self, query: str) -> str:
params = {"q": urllib.parse.unquote(query), "kl": "us-en"}
url = f"{self.base_url}/?{urllib.parse.urlencode(params)}"
return await self._get_html(url, None)
def _get_url(self, tag: Tag) -> str:
href = str(tag.get("href") or "")
if "duckduckgo.com/l/?" in href:
parsed = urllib.parse.urlparse(href)
target = urllib.parse.parse_qs(parsed.query).get("uddg", [""])[0]
return urllib.parse.unquote(target)
return href
async def search(self, query: str, num_results: int) -> list[SearchResult]:
return await self._search_with_result_filter(
query=query,
num_results=num_results,
predicate=lambda result: result.url.startswith("http"),
)

View File

@@ -0,0 +1,51 @@
import urllib.parse
from bs4 import Tag
from . import SearchEngine, SearchResult
class Google(SearchEngine):
NAME = "google"
def __init__(self) -> None:
super().__init__()
self.base_url = "https://www.google.com"
def _set_selector(self, selector: str):
selectors = {
"url": "a[href]",
"title": "h3",
"text": "div.VwiC3b, span.aCOpRe",
"links": "div#search div.g, div#search div.MjjYud",
"next": "a#pnnext",
}
return selectors[selector]
async def _get_next_page(self, query: str) -> str:
params = {
"q": urllib.parse.unquote(query),
"hl": "en",
"gl": "us",
"pws": "0",
"num": "10",
}
url = f"{self.base_url}/search?{urllib.parse.urlencode(params)}"
return await self._get_html(url, None)
def _get_url(self, tag: Tag) -> str:
href = str(tag.get("href") or "")
if href.startswith("/url?"):
parsed = urllib.parse.urlparse(href)
q = urllib.parse.parse_qs(parsed.query).get("q", [""])[0]
return urllib.parse.unquote(q)
return href
async def search(self, query: str, num_results: int) -> list[SearchResult]:
return await self._search_with_result_filter(
query=query,
num_results=num_results,
predicate=lambda result: (
result.url.startswith("http") and "google.com/search?" not in result.url
),
)

View File

@@ -0,0 +1,53 @@
import random
import re
from bs4 import BeautifulSoup, Tag
from . import USER_AGENTS, SearchEngine, SearchResult
class Sogo(SearchEngine):
NAME = "sogo"
def __init__(self) -> None:
super().__init__()
self.base_url = "https://www.sogou.com"
self.headers["User-Agent"] = random.choice(USER_AGENTS)
def _set_selector(self, selector: str):
selectors = {
"url": "h3 > a",
"title": "h3",
"text": "",
"links": "div.results > div.vrwrap:not(.middle-better-hintBox)",
"next": "",
}
return selectors[selector]
async def _get_next_page(self, query) -> str:
url = f"{self.base_url}/web?query={query}"
return await self._get_html(url, None)
def _get_url(self, tag: Tag) -> str:
return str(tag.get("href") or "")
async def search(self, query: str, num_results: int) -> list[SearchResult]:
results = await super().search(query, num_results)
for result in results:
if result.url.startswith("/link?"):
result.url = self.base_url + result.url
result.url = await self._parse_url(result.url)
return results
async def _parse_url(self, url) -> str:
html = await self._get_html(url)
soup = BeautifulSoup(html, "html.parser")
script = soup.find("script")
if script:
script_text = (
script.string if script.string is not None else script.get_text()
)
match = re.search('window.location.replace\\("(.+?)"\\)', script_text)
if match:
url = match.group(1)
return url

View File

@@ -0,0 +1,663 @@
import asyncio
import json
import random
import uuid
from typing import ClassVar
import aiohttp
from bs4 import BeautifulSoup
from readability import Document
from astrbot.api import AstrBotConfig, llm_tool, logger, sp, star
from astrbot.api.event import AstrMessageEvent, filter
from astrbot.api.provider import ProviderRequest
from astrbot.core.provider.func_tool_manager import FunctionToolManager
from .engines import HEADERS, USER_AGENTS, SearchResult
from .engines.bing import Bing
from .engines.comet import Comet
from .engines.duckduckgo import DuckDuckGo
from .engines.google import Google
from .engines.sogo import Sogo
from .provider_routing import (
DEFAULT_WEB_SEARCH_PROVIDER,
build_default_engine_order,
normalize_websearch_provider,
normalize_websearch_provider_for_tools,
validate_default_engine_registry,
)
class Main(star.Star):
TOOLS: ClassVar[list[str]] = [
"web_search",
"fetch_url",
"web_search_tavily",
"tavily_extract_web_page",
"web_search_bocha",
]
def __init__(self, context: star.Context) -> None:
self.context = context
self.tavily_key_index = 0
self.tavily_key_lock = asyncio.Lock()
self.bocha_key_index = 0
self.bocha_key_lock = asyncio.Lock()
# 将 str 类型的 key 迁移至 list[str],并保存
cfg = self.context.get_config()
provider_settings = cfg.get("provider_settings")
if provider_settings:
tavily_key = provider_settings.get("websearch_tavily_key")
if isinstance(tavily_key, str):
logger.info(
"检测到旧版 websearch_tavily_key (字符串格式),自动迁移为列表格式并保存。",
)
if tavily_key:
provider_settings["websearch_tavily_key"] = [tavily_key]
else:
provider_settings["websearch_tavily_key"] = []
cfg.save_config()
bocha_key = provider_settings.get("websearch_bocha_key")
if isinstance(bocha_key, str):
if bocha_key:
provider_settings["websearch_bocha_key"] = [bocha_key]
else:
provider_settings["websearch_bocha_key"] = []
cfg.save_config()
self.google_search = Google()
self.bing_search = Bing()
self.ddg_search = DuckDuckGo()
self.comet_search = Comet()
self.sogo_search = Sogo()
self.default_search_engines = {
engine.NAME: engine
for engine in (
self.google_search,
self.bing_search,
self.ddg_search,
self.comet_search,
self.sogo_search,
)
}
validate_default_engine_registry(self.default_search_engines)
self.baidu_initialized = False
async def _tidy_text(self, text: str) -> str:
"""清理文本,去除空格、换行符等"""
return text.strip().replace("\n", " ").replace("\r", " ").replace(" ", " ")
async def _get_from_url(self, url: str) -> str:
"""获取网页内容"""
header = HEADERS
header.update({"User-Agent": random.choice(USER_AGENTS)})
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(url, headers=header) as response:
html = await response.text(encoding="utf-8")
doc = Document(html)
ret = doc.summary(html_partial=True)
soup = BeautifulSoup(ret, "html.parser")
ret = await self._tidy_text(soup.get_text())
return ret
async def _process_search_result(
self,
result: SearchResult,
idx: int,
websearch_link: bool,
) -> str:
"""处理单个搜索结果"""
logger.info(f"web_searcher - scraping web: {result.title} - {result.url}")
try:
site_result = await self._get_from_url(result.url)
except BaseException:
site_result = ""
site_result = (
f"{site_result[:700]}..." if len(site_result) > 700 else site_result
)
header = f"{idx}. {result.title} "
if websearch_link and result.url:
header += result.url
return f"{header}\n{result.snippet}\n{site_result}\n\n"
async def _web_search_default(
self,
query,
num_results: int = 5,
preferred_provider: str = DEFAULT_WEB_SEARCH_PROVIDER,
) -> list[SearchResult]:
for engine_name in build_default_engine_order(preferred_provider):
engine = self.default_search_engines.get(engine_name)
if not engine:
continue
try:
results = await engine.search(query, num_results)
except Exception as e:
logger.error(
f"{engine_name} search error: {e}, try the next one...",
)
continue
if results:
logger.info(
f"web_searcher - provider `{engine_name}` success: {len(results)} results",
)
return results
logger.debug(f"search {engine_name} returned no results")
return []
async def _get_tavily_key(self, cfg: AstrBotConfig) -> str:
"""并发安全的从列表中获取并轮换Tavily API密钥。"""
tavily_keys = cfg.get("provider_settings", {}).get("websearch_tavily_key", [])
if not tavily_keys:
raise ValueError("错误:Tavily API密钥未在AstrBot中配置。")
async with self.tavily_key_lock:
key = tavily_keys[self.tavily_key_index]
self.tavily_key_index = (self.tavily_key_index + 1) % len(tavily_keys)
return key
async def _web_search_tavily(
self,
cfg: AstrBotConfig,
payload: dict,
) -> list[SearchResult]:
"""使用 Tavily 搜索引擎进行搜索"""
tavily_key = await self._get_tavily_key(cfg)
url = "https://api.tavily.com/search"
header = {
"Authorization": f"Bearer {tavily_key}",
"Content-Type": "application/json",
}
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.post(
url,
json=payload,
headers=header,
) as response:
if response.status != 200:
reason = await response.text()
raise Exception(
f"Tavily web search failed: {reason}, status: {response.status}",
)
data = await response.json()
results = []
for item in data.get("results", []):
result = SearchResult(
title=item.get("title"),
url=item.get("url"),
snippet=item.get("content"),
favicon=item.get("favicon"),
)
results.append(result)
return results
async def _extract_tavily(self, cfg: AstrBotConfig, payload: dict) -> list[dict]:
"""使用 Tavily 提取网页内容"""
tavily_key = await self._get_tavily_key(cfg)
url = "https://api.tavily.com/extract"
header = {
"Authorization": f"Bearer {tavily_key}",
"Content-Type": "application/json",
}
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.post(
url,
json=payload,
headers=header,
) as response:
if response.status != 200:
reason = await response.text()
raise Exception(
f"Tavily web search failed: {reason}, status: {response.status}",
)
data = await response.json()
results: list[dict] = data.get("results", [])
if not results:
raise ValueError(
"Error: Tavily web searcher does not return any results.",
)
return results
@llm_tool(name="web_search")
async def search_from_search_engine(
self,
event: AstrMessageEvent,
query: str,
max_results: int = 5,
) -> str:
"""搜索网络以回答用户的问题。当用户需要搜索网络以获取即时性的信息时调用此工具。
Args:
query(string): 和用户的问题最相关的搜索关键词,用于在 Google 上搜索。
max_results(number): 返回的最大搜索结果数量,默认为 5。
"""
logger.info(f"web_searcher - search_from_search_engine: {query}")
cfg = self.context.get_config(umo=event.unified_msg_origin)
websearch_link = cfg["provider_settings"].get("web_search_link", False)
preferred_provider = normalize_websearch_provider(
cfg.get("provider_settings", {}).get(
"websearch_provider",
DEFAULT_WEB_SEARCH_PROVIDER,
),
)
results = await self._web_search_default(
query,
max_results,
preferred_provider=preferred_provider,
)
if not results:
return "Error: web searcher does not return any results."
tasks = []
for idx, result in enumerate(results, 1):
task = self._process_search_result(result, idx, websearch_link)
tasks.append(task)
processed_results = await asyncio.gather(*tasks, return_exceptions=True)
ret = ""
for processed_result in processed_results:
if isinstance(processed_result, BaseException):
logger.error(f"Error processing search result: {processed_result}")
continue
ret += processed_result
if websearch_link:
ret += "\n\n针对问题,请根据上面的结果分点总结,并且在结尾处附上对应内容的参考链接(如有)。"
return ret
async def ensure_baidu_ai_search_mcp(self, umo: str | None = None) -> None:
if self.baidu_initialized:
return
cfg = self.context.get_config(umo=umo)
key = cfg.get("provider_settings", {}).get(
"websearch_baidu_app_builder_key",
"",
)
if not key:
raise ValueError(
"Error: Baidu AI Search API key is not configured in AstrBot.",
)
func_tool_mgr = self.context.get_llm_tool_manager()
await func_tool_mgr.enable_mcp_server(
"baidu_ai_search",
config={
"transport": "sse",
"url": f"http://appbuilder.baidu.com/v2/ai_search/mcp/sse?api_key={key}",
"headers": {},
"timeout": 600,
},
)
self.baidu_initialized = True
logger.info("Successfully initialized Baidu AI Search MCP server.")
@llm_tool(name="fetch_url")
async def fetch_website_content(self, event: AstrMessageEvent, url: str) -> str:
"""Fetch the content of a website with the given web url
Args:
url(string): The url of the website to fetch content from
"""
resp = await self._get_from_url(url)
return resp
@llm_tool("web_search_tavily")
async def search_from_tavily(
self,
event: AstrMessageEvent,
query: str,
max_results: int = 7,
search_depth: str = "basic",
topic: str = "general",
days: int = 3,
time_range: str = "",
start_date: str = "",
end_date: str = "",
) -> str:
"""A web search tool that uses Tavily to search the web for relevant content.
Ideal for gathering current information, news, and detailed web content analysis.
Args:
query(string): Required. Search query.
max_results(number): Optional. The maximum number of results to return. Default is 7. Range is 5-20.
search_depth(string): Optional. The depth of the search, must be one of 'basic', 'advanced'. Default is "basic".
topic(string): Optional. The topic of the search, must be one of 'general', 'news'. Default is "general".
days(number): Optional. The number of days back from the current date to include in the search results. Please note that this feature is only available when using the 'news' search topic.
time_range(string): Optional. The time range back from the current date to include in the search results. This feature is available for both 'general' and 'news' search topics. Must be one of 'day', 'week', 'month', 'year'.
start_date(string): Optional. The start date for the search results in the format 'YYYY-MM-DD'.
end_date(string): Optional. The end date for the search results in the format 'YYYY-MM-DD'.
"""
logger.info(f"web_searcher - search_from_tavily: {query}")
cfg = self.context.get_config(umo=event.unified_msg_origin)
# websearch_link = cfg["provider_settings"].get("web_search_link", False)
if not cfg.get("provider_settings", {}).get("websearch_tavily_key", []):
raise ValueError("Error: Tavily API key is not configured in AstrBot.")
# build payload
payload = {"query": query, "max_results": max_results, "include_favicon": True}
if search_depth not in ["basic", "advanced"]:
search_depth = "basic"
payload["search_depth"] = search_depth
if topic not in ["general", "news"]:
topic = "general"
payload["topic"] = topic
if topic == "news":
payload["days"] = days
if time_range in ["day", "week", "month", "year"]:
payload["time_range"] = time_range
if start_date:
payload["start_date"] = start_date
if end_date:
payload["end_date"] = end_date
results = await self._web_search_tavily(cfg, payload)
if not results:
return "Error: Tavily web searcher does not return any results."
ret_ls = []
ref_uuid = str(uuid.uuid4())[:4]
for idx, result in enumerate(results, 1):
index = f"{ref_uuid}.{idx}"
ret_ls.append(
{
"title": f"{result.title}",
"url": f"{result.url}",
"snippet": f"{result.snippet}",
# TODO: do not need ref for non-webchat platform adapter
"index": index,
},
)
if result.favicon:
sp.temporary_cache["_ws_favicon"][result.url] = result.favicon
# ret = "\n".join(ret_ls)
ret = json.dumps({"results": ret_ls}, ensure_ascii=False)
return ret
@llm_tool("tavily_extract_web_page")
async def tavily_extract_web_page(
self,
event: AstrMessageEvent,
url: str = "",
extract_depth: str = "basic",
) -> str:
"""Extract the content of a web page using Tavily.
Args:
url(string): Required. An URl to extract content from.
extract_depth(string): Optional. The depth of the extraction, must be one of 'basic', 'advanced'. Default is "basic".
"""
cfg = self.context.get_config(umo=event.unified_msg_origin)
if not cfg.get("provider_settings", {}).get("websearch_tavily_key", []):
raise ValueError("Error: Tavily API key is not configured in AstrBot.")
if not url:
raise ValueError("Error: url must be a non-empty string.")
if extract_depth not in ["basic", "advanced"]:
extract_depth = "basic"
payload = {
"urls": [url],
"extract_depth": extract_depth,
}
results = await self._extract_tavily(cfg, payload)
ret_ls = []
for result in results:
ret_ls.append(f"URL: {result.get('url', 'No URL')}")
ret_ls.append(f"Content: {result.get('raw_content', 'No content')}")
ret = "\n".join(ret_ls)
if not ret:
return "Error: Tavily web searcher does not return any results."
return ret
async def _get_bocha_key(self, cfg: AstrBotConfig) -> str:
"""并发安全的从列表中获取并轮换BoCha API密钥。"""
bocha_keys = cfg.get("provider_settings", {}).get("websearch_bocha_key", [])
if not bocha_keys:
raise ValueError("错误:BoCha API密钥未在AstrBot中配置。")
async with self.bocha_key_lock:
key = bocha_keys[self.bocha_key_index]
self.bocha_key_index = (self.bocha_key_index + 1) % len(bocha_keys)
return key
async def _web_search_bocha(
self,
cfg: AstrBotConfig,
payload: dict,
) -> list[SearchResult]:
"""使用 BoCha 搜索引擎进行搜索"""
bocha_key = await self._get_bocha_key(cfg)
url = "https://api.bochaai.com/v1/web-search"
header = {
"Authorization": f"Bearer {bocha_key}",
"Content-Type": "application/json",
}
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.post(
url,
json=payload,
headers=header,
) as response:
if response.status != 200:
reason = await response.text()
raise Exception(
f"BoCha web search failed: {reason}, status: {response.status}",
)
data = await response.json()
data = data["data"]["webPages"]["value"]
results = []
for item in data:
result = SearchResult(
title=item.get("name"),
url=item.get("url"),
snippet=item.get("snippet"),
favicon=item.get("siteIcon"),
)
results.append(result)
return results
@llm_tool("web_search_bocha")
async def search_from_bocha(
self,
event: AstrMessageEvent,
query: str,
freshness: str = "noLimit",
summary: bool = False,
include: str = "",
exclude: str = "",
count: int = 10,
) -> str:
"""A web search tool based on Bocha Search API, used to retrieve web pages
related to the user's query.
Args:
query (string): Required. User's search query.
freshness (string): Optional. Specifies the time range of the search.
Supported values:
- "noLimit": No time limit (default, recommended).
- "oneDay": Within one day.
- "oneWeek": Within one week.
- "oneMonth": Within one month.
- "oneYear": Within one year.
- "YYYY-MM-DD..YYYY-MM-DD": Search within a specific date range.
Example: "2025-01-01..2025-04-06".
- "YYYY-MM-DD": Search on a specific date.
Example: "2025-04-06".
It is recommended to use "noLimit", as the search algorithm will
automatically optimize time relevance. Manually restricting the
time range may result in no search results.
summary (boolean): Optional. Whether to include a text summary
for each search result.
- True: Include summary.
- False: Do not include summary (default).
include (string): Optional. Specifies the domains to include in
the search. Multiple domains can be separated by "|" or ",".
A maximum of 100 domains is allowed.
Examples:
- "qq.com"
- "qq.com|m.163.com"
exclude (string): Optional. Specifies the domains to exclude from
the search. Multiple domains can be separated by "|" or ",".
A maximum of 100 domains is allowed.
Examples:
- "qq.com"
- "qq.com|m.163.com"
count (number): Optional. Number of search results to return.
- Range: 150
- Default: 10
The actual number of returned results may be less than the
specified count.
"""
logger.info(f"web_searcher - search_from_bocha: {query}")
cfg = self.context.get_config(umo=event.unified_msg_origin)
# websearch_link = cfg["provider_settings"].get("web_search_link", False)
if not cfg.get("provider_settings", {}).get("websearch_bocha_key", []):
raise ValueError("Error: BoCha API key is not configured in AstrBot.")
# build payload
payload = {
"query": query,
"count": count,
}
# freshness:时间范围
if freshness:
payload["freshness"] = freshness
# 是否返回摘要
payload["summary"] = summary
# include:限制搜索域
if include:
payload["include"] = include
# exclude:排除搜索域
if exclude:
payload["exclude"] = exclude
results = await self._web_search_bocha(cfg, payload)
if not results:
return "Error: BoCha web searcher does not return any results."
ret_ls = []
ref_uuid = str(uuid.uuid4())[:4]
for idx, result in enumerate(results, 1):
index = f"{ref_uuid}.{idx}"
ret_ls.append(
{
"title": f"{result.title}",
"url": f"{result.url}",
"snippet": f"{result.snippet}",
"index": index,
},
)
if result.favicon:
sp.temporary_cache["_ws_favicon"][result.url] = result.favicon
# ret = "\n".join(ret_ls)
ret = json.dumps({"results": ret_ls}, ensure_ascii=False)
return ret
@filter.on_llm_request(priority=-10000)
async def edit_web_search_tools(
self,
event: AstrMessageEvent,
req: ProviderRequest,
) -> None:
"""Get the session conversation for the given event."""
cfg = self.context.get_config(umo=event.unified_msg_origin)
prov_settings = cfg.get("provider_settings", {})
websearch_enable = prov_settings.get("web_search", False)
raw_provider = prov_settings.get(
"websearch_provider",
DEFAULT_WEB_SEARCH_PROVIDER,
)
branch_provider, is_known_provider = normalize_websearch_provider_for_tools(
raw_provider,
)
tool_set = req.func_tool
if isinstance(tool_set, FunctionToolManager):
req.func_tool = tool_set.get_full_tool_set()
tool_set = req.func_tool
if not tool_set:
return
if not websearch_enable:
# pop tools
for tool_name in self.TOOLS:
tool_set.remove_tool(tool_name)
return
func_tool_mgr = self.context.get_llm_tool_manager()
if branch_provider == "default":
if not is_known_provider:
logger.warning(
"Unsupported websearch_provider `%s`, fallback to default search tool branch.",
raw_provider,
)
web_search_t = func_tool_mgr.get_func("web_search")
fetch_url_t = func_tool_mgr.get_func("fetch_url")
if web_search_t and web_search_t.active:
tool_set.add_tool(web_search_t)
if fetch_url_t and fetch_url_t.active:
tool_set.add_tool(fetch_url_t)
tool_set.remove_tool("web_search_tavily")
tool_set.remove_tool("tavily_extract_web_page")
tool_set.remove_tool("AIsearch")
tool_set.remove_tool("web_search_bocha")
elif branch_provider == "tavily":
web_search_tavily = func_tool_mgr.get_func("web_search_tavily")
tavily_extract_web_page = func_tool_mgr.get_func("tavily_extract_web_page")
if web_search_tavily and web_search_tavily.active:
tool_set.add_tool(web_search_tavily)
if tavily_extract_web_page and tavily_extract_web_page.active:
tool_set.add_tool(tavily_extract_web_page)
tool_set.remove_tool("web_search")
tool_set.remove_tool("fetch_url")
tool_set.remove_tool("AIsearch")
tool_set.remove_tool("web_search_bocha")
elif branch_provider == "baidu_ai_search":
try:
await self.ensure_baidu_ai_search_mcp(event.unified_msg_origin)
aisearch_tool = func_tool_mgr.get_func("AIsearch")
if aisearch_tool and aisearch_tool.active:
tool_set.add_tool(aisearch_tool)
tool_set.remove_tool("web_search")
tool_set.remove_tool("fetch_url")
tool_set.remove_tool("web_search_tavily")
tool_set.remove_tool("tavily_extract_web_page")
tool_set.remove_tool("web_search_bocha")
except Exception as e:
logger.error(f"Cannot Initialize Baidu AI Search MCP Server: {e}")
elif branch_provider == "bocha":
web_search_bocha = func_tool_mgr.get_func("web_search_bocha")
if web_search_bocha and web_search_bocha.active:
tool_set.add_tool(web_search_bocha)
tool_set.remove_tool("web_search")
tool_set.remove_tool("fetch_url")
tool_set.remove_tool("AIsearch")
tool_set.remove_tool("web_search_tavily")
tool_set.remove_tool("tavily_extract_web_page")

View File

@@ -0,0 +1,24 @@
from __future__ import annotations
DEFAULT_WEB_SEARCH_PROVIDER = "default"
# Canonical provider ids shown in config UI options.
WEB_SEARCH_PROVIDER_OPTIONS: tuple[str, ...] = (
DEFAULT_WEB_SEARCH_PROVIDER,
"duckduckgo",
"google",
"bing",
"comet",
"sogo",
"tavily",
"baidu_ai_search",
"bocha",
)
# Provider ids that select non-default tool branches directly.
WEB_SEARCH_TOOL_BRANCH_PROVIDERS: tuple[str, ...] = (
DEFAULT_WEB_SEARCH_PROVIDER,
"tavily",
"baidu_ai_search",
"bocha",
)

View File

@@ -0,0 +1,132 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from .engines.bing import Bing
from .engines.comet import Comet
from .engines.duckduckgo import DuckDuckGo
from .engines.google import Google
from .engines.sogo import Sogo
from .provider_constants import (
DEFAULT_WEB_SEARCH_PROVIDER,
WEB_SEARCH_PROVIDER_OPTIONS,
WEB_SEARCH_TOOL_BRANCH_PROVIDERS,
)
ENGINE_REGISTRY: tuple[tuple[str, type[object], bool], ...] = (
(Bing.NAME, Bing, True),
(Sogo.NAME, Sogo, True),
# Compatibility first: DDG should stay as fallback and cannot become primary.
(DuckDuckGo.NAME, DuckDuckGo, False),
(Google.NAME, Google, True),
(Comet.NAME, Comet, True),
)
DEFAULT_ENGINE_ORDER: tuple[str, ...] = tuple(name for name, _, _ in ENGINE_REGISTRY)
_ENGINE_PROVIDER_SET = {name for name, _, _ in ENGINE_REGISTRY}
_ENGINE_CAN_BE_PRIMARY = {
name: can_be_primary for name, _, can_be_primary in ENGINE_REGISTRY
}
_TOOL_BRANCH_PROVIDER_SET = set(WEB_SEARCH_TOOL_BRANCH_PROVIDERS)
_CANONICAL_PROVIDER_SET = _ENGINE_PROVIDER_SET | _TOOL_BRANCH_PROVIDER_SET
if not _CANONICAL_PROVIDER_SET.issubset(set(WEB_SEARCH_PROVIDER_OPTIONS)):
raise RuntimeError(
"web search provider options and routing providers are out of sync: "
f"canonical={sorted(_CANONICAL_PROVIDER_SET)} options={list(WEB_SEARCH_PROVIDER_OPTIONS)}",
)
_WEB_SEARCH_PROVIDER_ALIASES: dict[str, str] = {
"": DEFAULT_WEB_SEARCH_PROVIDER,
"default": DEFAULT_WEB_SEARCH_PROVIDER,
"native": DEFAULT_WEB_SEARCH_PROVIDER,
}
_WEB_SEARCH_PROVIDER_ALIASES.update({name: name for name in _CANONICAL_PROVIDER_SET})
_WEB_SEARCH_PROVIDER_ALIASES.update(
{
"duckduck_go": DuckDuckGo.NAME,
"duckduck-go": DuckDuckGo.NAME,
"ddg": DuckDuckGo.NAME,
"baidu_ai": "baidu_ai_search",
"baidu": "baidu_ai_search",
"bochaai": "bocha",
# ZeroClaw compatibility: AstrBot has no Brave provider yet, so downgrade to default.
"brave": DEFAULT_WEB_SEARCH_PROVIDER,
},
)
@dataclass(frozen=True)
class NormalizedProvider:
canonical: str
tool_branch: str
is_known: bool
def _normalize_raw_provider(provider: object) -> str:
return str(provider or "").strip().lower().replace(" ", "")
def normalize_websearch(provider: object) -> NormalizedProvider:
raw = _normalize_raw_provider(provider)
alias = _WEB_SEARCH_PROVIDER_ALIASES.get(raw, raw)
canonical = alias or DEFAULT_WEB_SEARCH_PROVIDER
is_engine = canonical in _ENGINE_PROVIDER_SET
is_tool_branch = canonical in _TOOL_BRANCH_PROVIDER_SET
is_known = is_engine or is_tool_branch
tool_branch = canonical if is_tool_branch else DEFAULT_WEB_SEARCH_PROVIDER
return NormalizedProvider(
canonical=canonical,
tool_branch=tool_branch,
is_known=is_known,
)
def normalize_websearch_provider(provider: object) -> str:
return normalize_websearch(provider).canonical
def normalize_websearch_provider_for_tools(provider: object) -> tuple[str, bool]:
normalized = normalize_websearch(provider)
return normalized.tool_branch, normalized.is_known
def resolve_tool_branch_provider(provider: object) -> str:
return normalize_websearch(provider).tool_branch
def build_default_engine_order(provider: object) -> tuple[str, ...]:
normalized = normalize_websearch(provider)
engine_name = normalized.canonical
if engine_name not in _ENGINE_PROVIDER_SET:
return DEFAULT_ENGINE_ORDER
if not _ENGINE_CAN_BE_PRIMARY.get(engine_name, False):
return DEFAULT_ENGINE_ORDER
return (
engine_name,
*tuple(name for name in DEFAULT_ENGINE_ORDER if name != engine_name),
)
def is_known_websearch_provider(provider: object) -> bool:
return normalize_websearch(provider).is_known
def validate_default_engine_registry(engines_by_name: Mapping[str, object]) -> None:
expected_names = {name for name, _, _ in ENGINE_REGISTRY}
missing = [name for name in DEFAULT_ENGINE_ORDER if name not in engines_by_name]
extra = [name for name in engines_by_name if name not in expected_names]
if not missing and not extra:
return
raise ValueError(
"default search engine registry mismatch. "
f"missing={missing}, extra={extra}, expected_order={list(DEFAULT_ENGINE_ORDER)}",
)

View File

@@ -1,3 +1,3 @@
from astrbot.core.config.default import VERSION
from astrbot import __version__
__version__ = VERSION
__all__ = ["__version__"]

View File

@@ -1,48 +1,128 @@
"""AstrBot CLI entry point"""
import os
import platform
import sys
from pathlib import Path
import click
from click.shell_completion import get_completion_class
from . import __version__
from .commands import conf, init, password, plug, run
logo_tmpl = r"""
___ _______.___________..______ .______ ______ .___________.
/ \ / | || _ \ | _ \ / __ \ | |
/ ^ \ | (----`---| |----`| |_) | | |_) | | | | | `---| |----`
/ /_\ \ \ \ | | | / | _ < | | | | | |
/ _____ \ .----) | | | | |\ \----.| |_) | | `--' | | |
/__/ \__\ |_______/ |__| | _| `._____||______/ \______/ |__|
"""
from .commands import bk, config, init, password, plugin, run, service, uninstall
from .i18n import t
@click.group()
def print_version_detail() -> None:
"""Print detailed version info (same for --version and version command)"""
from astrbot.core.utils.astrbot_path import astrbot_paths
click.echo(f"AstrBot: {__version__}")
click.echo(f"Python: {sys.version.split()[0]}")
click.echo(f"System: {platform.system()} {platform.release()}")
click.echo(f"Machine: {platform.machine()}")
git_root = Path(astrbot_paths.root) / ".git"
if git_root.exists():
import subprocess
try:
git_hash = subprocess.check_output(
["git", "rev-parse", "--short", "HEAD"],
cwd=str(astrbot_paths.root),
text=True,
).strip()
git_branch = subprocess.check_output(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=str(astrbot_paths.root),
text=True,
).strip()
click.echo(f"Git Branch: {git_branch}")
click.echo(f"Git Commit: {git_hash}")
except Exception:
pass
click.echo(f"AstrBot Root: {astrbot_paths.root}")
click.echo(f"Platform: {platform.platform()}")
def version_callback(ctx: click.Context, param: click.Parameter, value: bool) -> bool:
"""Callback for --version to show detailed version and exit."""
if not value:
return value
print_version_detail()
ctx.exit()
return value
class AstrBotCLIGroup(click.Group):
COMMAND_ALIASES = {
"conf": "config",
"plug": "plugin",
}
def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
command = super().get_command(ctx, cmd_name)
if command is not None:
return command
alias_target = self.COMMAND_ALIASES.get(cmd_name)
if alias_target is None:
return None
return super().get_command(ctx, alias_target)
@click.group(cls=AstrBotCLIGroup)
@click.version_option(__version__, prog_name="AstrBot")
def cli() -> None:
"""The AstrBot CLI"""
click.echo(logo_tmpl)
click.echo("Welcome to AstrBot CLI!")
click.echo(f"AstrBot CLI version: {__version__}")
"""Astrbot
Agentic IM Chatbot infrastructure that integrates lots of IM platforms, LLMs, plugins and AI feature, and can be your openclaw alternative. ✨
"""
@click.command()
@click.argument("command_name", required=False, type=str)
def help(command_name: str | None) -> None:
@click.option(
"--all",
"-a",
is_flag=True,
help="Show help for all commands recursively.",
)
def help(command_name: str | None, all: bool) -> None:
"""Display help information for commands
If COMMAND_NAME is provided, display detailed help for that command.
Otherwise, display general help information.
"""
ctx = click.get_current_context()
if all:
def print_recursive_help(command, parent_ctx):
name = command.name
if parent_ctx is None:
name = "astrbot"
cmd_ctx = click.Context(command, info_name=name, parent=parent_ctx)
click.echo(command.get_help(cmd_ctx))
click.echo("\n" + "-" * 50 + "\n")
if isinstance(command, click.Group):
for subcommand in command.commands.values():
print_recursive_help(subcommand, cmd_ctx)
print_recursive_help(cli, None)
return
if command_name:
# Find the specified command
command = cli.get_command(ctx, command_name)
if command:
# Display help for the specific command
click.echo(command.get_help(ctx))
parent = ctx.parent or ctx
cmd_ctx = click.Context(command, info_name=command.name, parent=parent)
click.echo(command.get_help(cmd_ctx))
else:
click.echo(f"Unknown command: {command_name}")
click.echo(t("cli_unknown_command", command=command_name))
sys.exit(1)
else:
# Display general help information
@@ -52,9 +132,56 @@ def help(command_name: str | None) -> None:
cli.add_command(init)
cli.add_command(run)
cli.add_command(help)
cli.add_command(plug)
cli.add_command(conf)
cli.add_command(plugin)
cli.add_command(config)
cli.add_command(uninstall)
cli.add_command(bk)
cli.add_command(password)
cli.add_command(service)
@click.command()
@click.argument("shell", required=False, type=click.Choice(["bash", "zsh", "fish"]))
def completion(shell: str | None) -> None:
"""Generate shell completion script"""
if shell is None:
shell_path = os.environ.get("SHELL", "")
if "zsh" in shell_path:
shell = "zsh"
elif "bash" in shell_path:
shell = "bash"
elif "fish" in shell_path:
shell = "fish"
else:
click.echo(
"Could not detect shell. Please specify one of: bash, zsh, fish",
err=True,
)
sys.exit(1)
comp_cls = get_completion_class(shell)
if comp_cls is None:
click.echo(f"No completion support for shell: {shell}", err=True)
sys.exit(1)
comp = comp_cls(
cli,
ctx_args={},
prog_name="astrbot",
complete_var="_ASTRBOT_COMPLETE",
)
click.echo(comp.source())
cli.add_command(completion)
@click.command(name="version")
def version_cmd() -> None:
"""Display detailed version information"""
print_version_detail()
cli.add_command(version_cmd)
if __name__ == "__main__":
cli()

28
astrbot/cli/banner.py Normal file
View File

@@ -0,0 +1,28 @@
"""ASCII logo and interactive mode utilities for CLI"""
import sys
logo_tmpl = r"""
___ _______.___________..______ .______ ______ .___________.
/ \ / | || _ \ | _ \ / __ \ | |
/ ^ \ | (----`---| |----`| |_) | | |_) | | | | | `---| |----`
/ /_\ \ \ \ | | | / | _ < | | | | | |
/ _____ \ .----) | | | | |\ \----.| |_) | | `--' | | |
/__/ \__\ |_______/ |__| | _| `._____||______/ \______/ |__|
"""
def is_interactive() -> bool:
"""Check if stdout is connected to a TTY (interactive terminal)"""
try:
return sys.stdout.isatty()
except Exception:
return False
def print_logo() -> None:
"""Print ASCII logo if in interactive mode"""
import click
if is_interactive():
click.echo(logo_tmpl)

View File

@@ -1,7 +1,24 @@
from .cmd_conf import conf
from .cmd_bk import bk
from .cmd_conf import conf as config
from .cmd_init import init
from .cmd_password import password
from .cmd_plug import plug
from .cmd_plug import plug as plugin
from .cmd_run import run
from .cmd_service import service
from .cmd_uninstall import uninstall
__all__ = ["conf", "init", "password", "plug", "run"]
conf = config
plug = plugin
__all__ = [
"bk",
"conf",
"config",
"init",
"password",
"plug",
"plugin",
"run",
"service",
"uninstall",
]

View File

@@ -0,0 +1,392 @@
import asyncio
import hashlib
import shutil
import subprocess
from pathlib import Path
import anyio
import click
from astrbot.core import db_helper
from astrbot.core.backup import AstrBotExporter, AstrBotImporter
async def _get_kb_manager():
"""Initialize and return a KnowledgeBaseManager with full dependency chain."""
from astrbot.core import astrbot_config, sp
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
from astrbot.core.persona_mgr import PersonaManager
from astrbot.core.provider.manager import ProviderManager
from astrbot.core.umop_config_router import UmopConfigRouter
ucr = UmopConfigRouter(sp=sp)
await ucr.initialize()
acm = AstrBotConfigManager(
default_config=astrbot_config,
ucr=ucr,
sp=sp,
)
persona_mgr = PersonaManager(db_helper, acm)
await persona_mgr.initialize()
provider_manager = ProviderManager(
acm,
db_helper,
persona_mgr,
)
kb_manager = KnowledgeBaseManager(provider_manager)
await kb_manager.initialize()
return kb_manager
@click.group(name="bk")
def bk():
"""Backup management (Export/Import)"""
@bk.command(name="export")
@click.option("--output", "-o", help="Output directory", default=None)
@click.option(
"--gpg-sign",
"-S",
is_flag=True,
help="Sign backup with GPG default private key",
)
@click.option(
"--gpg-encrypt",
"-E",
help="Encrypt for GPG recipient (Asymmetric)",
metavar="RECIPIENT",
)
@click.option(
"--gpg-symmetric",
"-C",
is_flag=True,
help="Encrypt with symmetric cipher (GPG)",
)
@click.option(
"--digest",
"-d",
type=click.Choice(["md5", "sha1", "sha256", "sha512"]),
help="Generate digital digest",
)
def export_data(
output: str | None,
gpg_sign: bool,
gpg_encrypt: str | None,
gpg_symmetric: bool,
digest: str | None,
):
"""Export all AstrBot data to a backup archive.
If any GPG option (-S, -E, -C) is used, the output file will be processed by GPG
and saved with a .gpg extension.
Examples:
\b
1. Standard Export:
astrbot bk export
-> Generates a plain .zip file.
\b
2. Signed Backup (Integrity Check):
astrbot bk export -S
-> Generates a .zip.gpg file containing the backup and your signature.
-> NOT ENCRYPTED, but packaged in OpenPGP format.
-> Use 'astrbot bk import' or 'gpg --verify' to check integrity.
\b
3. Password Protected (Symmetric Encryption):
astrbot bk export -C
-> Generates an encrypted .zip.gpg file.
-> Prompts for a passphrase.
-> Only accessible with the passphrase.
\b
4. Encrypted for Recipient (Asymmetric Encryption):
astrbot bk export -E "alice@example.com"
-> Generates an encrypted .zip.gpg file for Alice.
-> Only Alice's private key can decrypt it.
\b
5. Signed and Encrypted with Digest:
astrbot bk export -S -E "bob@example.com" -d sha256
-> Signs, encrypts for Bob, and generates a SHA256 checksum file.
"""
# Handle case where -E consumes the next flag (e.g. -E -S)
if gpg_encrypt and gpg_encrypt.startswith("-"):
consumed_flag = gpg_encrypt
click.echo(
click.style(
f"Warning: Flag '{consumed_flag}' was interpreted as the recipient for -E.",
fg="yellow",
),
)
# Recover flags
if consumed_flag == "-S":
gpg_sign = True
click.echo("Recovered flag -S (Sign).")
elif consumed_flag == "-C":
gpg_symmetric = True
click.echo("Recovered flag -C (Symmetric).")
# Prompt for the actual recipient
gpg_encrypt = click.prompt("Please enter the GPG recipient (email or key ID)")
async def _run():
if gpg_sign or gpg_encrypt or gpg_symmetric:
if not shutil.which("gpg"):
raise click.ClickException(
"GPG tool not found. Please install GnuPG to use encryption/signing features.",
)
exporter = AstrBotExporter(db_helper)
async def on_progress(stage, current, total, message):
click.echo(f"[{stage}] {message}")
try:
path_str = await exporter.export_all(output, progress_callback=on_progress)
final_path = Path(path_str)
click.echo(
click.style(f"\nRaw backup exported to: {final_path}", fg="green"),
)
# GPG Operations
if gpg_sign or gpg_encrypt or gpg_symmetric:
# Construct GPG command
# output file usually ends with .gpg
gpg_output = final_path.with_name(final_path.name + ".gpg")
cmd = ["gpg", "--output", str(gpg_output), "--yes"]
if gpg_symmetric:
if gpg_encrypt:
click.echo(
click.style(
"Warning: Symmetric encryption selected, ignoring asymmetric recipient.",
fg="yellow",
),
)
cmd.append("--symmetric")
# No --batch to allow interactive passphrase entry on TTY
else:
# Asymmetric or just Sign
# Note: If encrypting, -s adds signature to the encrypted packet.
if gpg_encrypt:
cmd.extend(["--encrypt", "--recipient", gpg_encrypt])
if gpg_sign:
cmd.append("--sign")
cmd.append(str(final_path))
click.echo(f"Running GPG: {' '.join(cmd)}")
# Replace subprocess.run with asyncio.create_subprocess_exec to avoid blocking the event loop
process = await asyncio.create_subprocess_exec(*cmd)
await process.wait()
if process.returncode != 0:
raise subprocess.CalledProcessError(process.returncode or 1, cmd)
# Clean up original file
await anyio.Path(final_path).unlink()
final_path = gpg_output
click.echo(
click.style(f"Processed backup created: {final_path}", fg="green"),
)
# Digest Generation
if digest:
click.echo(f"Calculating {digest} digest...")
hash_func = getattr(hashlib, digest)()
# Read file in chunks
async with await anyio.open_file(final_path, "rb") as f:
while chunk := await f.read(8192):
hash_func.update(chunk)
digest_val = hash_func.hexdigest()
digest_file = final_path.with_name(final_path.name + f".{digest}")
await anyio.Path(digest_file).write_text(
f"{digest_val} *{final_path.name}\n",
encoding="utf-8",
)
click.echo(click.style(f"Digest generated: {digest_file}", fg="green"))
except subprocess.CalledProcessError as e:
click.echo(click.style(f"\nGPG process failed: {e}", fg="red"), err=True)
except Exception as e:
click.echo(click.style(f"\nExport failed: {e}", fg="red"), err=True)
asyncio.run(_run())
@bk.command(name="import")
@click.argument("backup_file")
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompts")
def import_data_command(backup_file: str, yes: bool):
"""Import AstrBot data from a backup archive.
Automatically handles .zip files and .gpg files (signed or encrypted).
If the file is encrypted, you will be prompted for the passphrase.
If a digest file (.sha256, .md5, etc.) exists, it will be verified automatically.
"""
backup_path = Path(backup_file)
if not backup_path.exists():
raise click.ClickException(f"Backup file not found: {backup_file}")
# 1. Verify Digest if exists
def _verify_digest(file_path: Path) -> bool:
supported_digests = ["sha256", "sha512", "md5", "sha1"]
digest_verified = True # Default true if no digest file found
for algo in supported_digests:
digest_file = file_path.with_name(f"{file_path.name}.{algo}")
if digest_file.exists():
click.echo(f"Found digest file: {digest_file.name}")
try:
# Parse digest file
content = digest_file.read_text(encoding="utf-8").strip()
# Format: "digest *filename" or "digest filename"
# We expect the hash to be the first part
if " " in content:
expected_digest = content.split()[0].lower()
else:
expected_digest = content.lower()
click.echo(f"Verifying {algo} digest...")
hash_func = getattr(hashlib, algo)()
with open(file_path, "rb") as f:
while chunk := f.read(8192):
hash_func.update(chunk)
calculated_digest = hash_func.hexdigest().lower()
if calculated_digest == expected_digest:
click.echo(
click.style("Digest verification PASSED.", fg="green"),
)
else:
click.echo(
click.style(
"Digest verification FAILED!",
fg="red",
bold=True,
),
)
click.echo(f" Expected: {expected_digest}")
click.echo(f" Actual: {calculated_digest}")
digest_verified = False
except Exception as e:
click.echo(click.style(f"Error checking digest: {e}", fg="red"))
digest_verified = False
return digest_verified
if not _verify_digest(backup_path):
if not yes:
if not click.confirm(
"Digest verification failed. Abort import?",
default=True,
abort=True,
):
pass
else:
click.echo(
click.style(
"Warning: Digest verification failed. Continuing due to --yes.",
fg="yellow",
),
)
if not yes:
click.confirm(
"This will OVERWRITE all current data (DB, Config, Plugins). Continue?",
abort=True,
default=False,
)
async def _run():
zip_path = backup_path
is_temp_file = False
# Handle GPG encrypted files
if backup_path.suffix == ".gpg":
if not shutil.which("gpg"):
raise click.ClickException(
"GPG tool not found. Cannot decrypt .gpg file.",
)
# Remove .gpg extension for output
decrypted_path = backup_path.with_suffix("")
# If it doesn't look like a zip after stripping .gpg, maybe append .zip?
# But the exporter creates .zip.gpg, so stripping .gpg gives .zip.
click.echo(f"Processing GPG file {backup_path}...")
try:
cmd = [
"gpg",
"--output",
str(decrypted_path),
"--decrypt", # This handles both decryption and signature verification/extraction
str(backup_path),
]
# Allow interactive passphrase
process = await asyncio.create_subprocess_exec(*cmd)
await process.wait()
if process.returncode != 0:
raise subprocess.CalledProcessError(process.returncode or 1, cmd)
zip_path = decrypted_path
is_temp_file = True
except subprocess.CalledProcessError:
click.echo(
click.style(
"GPG processing failed. Verify signature or decryption key.",
fg="red",
),
err=True,
)
return
kb_mgr = await _get_kb_manager()
importer = AstrBotImporter(db_helper, kb_mgr)
async def on_progress(stage, current, total, message):
click.echo(f"[{stage}] {message}")
try:
result = await importer.import_all(
str(zip_path),
progress_callback=on_progress,
)
if result.errors:
click.echo(
click.style("\nImport failed with errors:", fg="red"),
err=True,
)
for err in result.errors:
click.echo(f" - {err}", err=True)
else:
click.echo(click.style("\nImport completed successfully!", fg="green"))
if result.warnings:
click.echo(click.style("\nWarnings:", fg="yellow"))
for warn in result.warnings:
click.echo(f" - {warn}")
finally:
if is_temp_file and await anyio.Path(zip_path).exists():
await anyio.Path(zip_path).unlink()
click.echo(f"Cleaned up temporary file: {zip_path}")
asyncio.run(_run())

View File

@@ -1,77 +1,95 @@
"""Configuration CLI for AstrBot.
This module provides:
- secure hashing utilities for the dashboard password (argon2)
- validators for commonly configurable items
- click CLI group with `set`, `get`, and `password` subcommands
"""
from __future__ import annotations
import json
import zoneinfo
from collections.abc import Callable
from typing import Any
import click
from filelock import FileLock, Timeout
from astrbot.cli.i18n import t
from astrbot.core.config.default import DEFAULT_CONFIG
from astrbot.core.utils.astrbot_path import astrbot_paths
from astrbot.core.utils.auth_password import (
_is_argon2_hash,
_is_pbkdf2_hash,
hash_dashboard_password,
hash_md5_dashboard_password,
hash_legacy_dashboard_password,
is_legacy_dashboard_password,
validate_dashboard_password,
)
from ..utils import check_astrbot_root, get_astrbot_root
# --- Password hashing & validation utilities ---
def is_dashboard_password_hash(value: str) -> bool:
"""Heuristic: return True if `value` looks like a supported dashboard password hash."""
if not isinstance(value, str) or not value:
return False
return _is_argon2_hash(value) or _is_pbkdf2_hash(value)
# --- Validators for CLI configuration items ---
def _validate_log_level(value: str) -> str:
"""Validate log level"""
value = value.upper()
if value not in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]:
raise click.ClickException(
"Log level must be one of DEBUG/INFO/WARNING/ERROR/CRITICAL",
)
return value
value_up = value.upper()
allowed = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
if value_up not in allowed:
raise click.ClickException(t("config_log_level_invalid"))
return value_up
def _validate_dashboard_port(value: str) -> int:
"""Validate Dashboard port"""
try:
port = int(value)
if port < 1 or port > 65535:
raise click.ClickException("Port must be in range 1-65535")
return port
except ValueError:
raise click.ClickException("Port must be a number")
raise click.ClickException(t("config_port_must_be_number")) from None
if port < 1 or port > 65535:
raise click.ClickException(t("config_port_range_invalid"))
return port
def _validate_dashboard_username(value: str) -> str:
"""Validate Dashboard username"""
if not value:
raise click.ClickException("Username cannot be empty")
return value
if value is None or value.strip() == "":
raise click.ClickException(t("config_username_empty"))
return value.strip()
def _validate_dashboard_password(value: str) -> str:
"""Validate Dashboard password"""
if value is None or value == "":
raise click.ClickException(t("config_password_empty"))
try:
validate_dashboard_password(value)
except ValueError as e:
raise click.ClickException(str(e))
raise click.ClickException(str(e)) from e
# Return the plaintext value; callers hash it before storage.
return value
def _validate_timezone(value: str) -> str:
"""Validate timezone"""
try:
zoneinfo.ZoneInfo(value)
except Exception:
raise click.ClickException(
f"Invalid timezone: {value}. Please use a valid IANA timezone name"
)
except Exception as e:
raise click.ClickException(t("config_timezone_invalid", value=value)) from e
return value
def _validate_callback_api_base(value: str) -> str:
"""Validate callback API base URL"""
if not value.startswith("http://") and not value.startswith("https://"):
raise click.ClickException(
"Callback API base must start with http:// or https://"
)
if not (value.startswith("http://") or value.startswith("https://")):
raise click.ClickException(t("config_callback_invalid"))
return value
# Configuration items settable via CLI, mapping config keys to validator functions
CONFIG_VALIDATORS: dict[str, Callable[[str], Any]] = {
"timezone": _validate_timezone,
"log_level": _validate_log_level,
@@ -82,18 +100,22 @@ CONFIG_VALIDATORS: dict[str, Callable[[str], Any]] = {
}
# --- Config file helpers ---
def _load_config() -> dict[str, Any]:
"""Load or initialize config file"""
root = get_astrbot_root()
if not check_astrbot_root(root):
"""Load or initialize the CLI config file (data/cmd_config.json).
Ensures the astrbot root is valid before proceeding.
"""
root = astrbot_paths.root
if not astrbot_paths.is_root:
raise click.ClickException(
f"{root} is not a valid AstrBot root directory. Use 'astrbot init' to initialize",
)
config_path = root / "data" / "cmd_config.json"
config_path = astrbot_paths.data / "cmd_config.json"
if not config_path.exists():
from astrbot.core.config.default import DEFAULT_CONFIG
# Write DEFAULT_CONFIG to disk if file missing
config_path.write_text(
json.dumps(DEFAULT_CONFIG, ensure_ascii=False, indent=2),
encoding="utf-8-sig",
@@ -102,39 +124,94 @@ def _load_config() -> dict[str, Any]:
try:
return json.loads(config_path.read_text(encoding="utf-8-sig"))
except json.JSONDecodeError as e:
raise click.ClickException(f"Failed to parse config file: {e!s}")
raise click.ClickException(f"Failed to parse config file: {e!s}") from e
def _save_config(config: dict[str, Any]) -> None:
"""Save config file"""
config_path = get_astrbot_root() / "data" / "cmd_config.json"
config_path = astrbot_paths.data / "cmd_config.json"
config_path.write_text(
json.dumps(config, ensure_ascii=False, indent=2),
encoding="utf-8-sig",
)
def ensure_config_file() -> dict[str, Any]:
return _load_config()
def _set_nested_item(obj: dict[str, Any], path: str, value: Any) -> None:
"""Set a value in a nested dictionary"""
parts = path.split(".")
cur = obj
for part in parts[:-1]:
if part not in obj:
obj[part] = {}
elif not isinstance(obj[part], dict):
if part not in cur:
cur[part] = {}
elif not isinstance(cur[part], dict):
raise click.ClickException(
f"Config path conflict: {'.'.join(parts[: parts.index(part) + 1])} is not a dict",
)
obj = obj[part]
obj[parts[-1]] = value
cur = cur[part]
cur[parts[-1]] = value
def _get_nested_item(obj: dict[str, Any], path: str) -> Any:
"""Get a value from a nested dictionary"""
parts = path.split(".")
cur = obj
for part in parts:
obj = obj[part]
return obj
cur = cur[part]
return cur
# --- CLI commands ---
def prompt_dashboard_password(prompt: str = "Dashboard password") -> str:
# 显示密码规则提示
click.echo()
click.echo("密码规则:")
click.echo(" - 至少 12 个字符")
click.echo(" - 必须包含至少一个大写字母")
click.echo(" - 必须包含至少一个小写字母")
click.echo(" - 必须包含至少一个数字")
click.echo()
password = click.prompt(prompt, hide_input=True, confirmation_prompt=True, type=str)
click.echo(f"密码长度: {len(password)} 字符")
return _validate_dashboard_password(password)
def set_dashboard_credentials(
config: dict[str, Any],
*,
username: str | None = None,
password_hash: str | None = None,
) -> None:
if username is not None:
_set_nested_item(
config,
"dashboard.username",
_validate_dashboard_username(username),
)
if password_hash is not None:
if isinstance(password_hash, str) and is_dashboard_password_hash(password_hash):
_set_nested_item(config, "dashboard.password", password_hash)
else:
if is_legacy_dashboard_password(password_hash):
raise click.ClickException(
"Storing legacy dashboard password hashes is no longer supported. "
"Please provide the plaintext password (it will be hashed securely), "
"or provide an Argon2-encoded hash string.",
)
validated = _validate_dashboard_password(password_hash)
_set_nested_item(
config,
"dashboard.pbkdf2_password",
hash_dashboard_password(validated),
)
_set_nested_item(
config,
"dashboard.password",
hash_legacy_dashboard_password(validated),
)
def _set_dashboard_password(config: dict[str, Any], raw_password: str) -> None:
@@ -147,29 +224,23 @@ def _set_dashboard_password(config: dict[str, Any], raw_password: str) -> None:
_set_nested_item(
config,
"dashboard.password",
hash_md5_dashboard_password(raw_password),
hash_legacy_dashboard_password(raw_password),
)
_set_nested_item(config, "dashboard.password_storage_upgraded", True)
_set_nested_item(config, "dashboard.password_change_required", False)
@click.group(name="conf")
@click.group(name="config")
def conf() -> None:
"""Configuration management commands
"""Configuration management commands.
Supported config keys:
- timezone: Timezone setting (e.g. Asia/Shanghai)
- log_level: Log level (DEBUG/INFO/WARNING/ERROR/CRITICAL)
- dashboard.port: Dashboard port
- dashboard.username: Dashboard username
- dashboard.password: Dashboard password
- callback_api_base: Callback API base URL
- timezone
- log_level
- dashboard.port
- dashboard.username
- dashboard.password
- callback_api_base
"""
@@ -177,14 +248,17 @@ def conf() -> None:
@click.argument("key")
@click.argument("value")
def set_config(key: str, value: str) -> None:
"""Set the value of a config item"""
if key not in CONFIG_VALIDATORS:
raise click.ClickException(f"Unsupported config key: {key}")
config = _load_config()
try:
old_value = _get_nested_item(config, key)
# Attempt to get old value (may raise KeyError)
try:
old_value = _get_nested_item(config, key)
except Exception:
old_value = "<not set>"
validated_value = CONFIG_VALIDATORS[key](value)
if key == "dashboard.password":
_set_dashboard_password(config, validated_value)
@@ -193,47 +267,103 @@ def set_config(key: str, value: str) -> None:
_save_config(config)
click.echo(f"Config updated: {key}")
if key == "dashboard.password":
click.echo(" Old value: ********")
click.echo(" New value: ********")
else:
click.echo(f" Old value: {old_value}")
click.echo(f" New value: {validated_value}")
except KeyError:
raise click.ClickException(f"Unknown config key: {key}")
click.echo(f" Old value: {old_value}")
click.echo(f" New value: {validated_value}")
except KeyError as e:
raise click.ClickException(f"Unknown config key: {key}") from e
except click.ClickException:
raise
except Exception as e:
raise click.UsageError(f"Failed to set config: {e!s}")
raise click.UsageError(f"Failed to set config: {e!s}") from e
@conf.command(name="get")
@click.argument("key", required=False)
def get_config(key: str | None = None) -> None:
"""Get the value of a config item. If no key is provided, show all configurable items"""
config = _load_config()
if key:
if key not in CONFIG_VALIDATORS:
raise click.ClickException(f"Unsupported config key: {key}")
try:
value = _get_nested_item(config, key)
if key == "dashboard.password":
value = "********"
click.echo(f"{key}: {value}")
except KeyError:
raise click.ClickException(f"Unknown config key: {key}")
except KeyError as e:
raise click.ClickException(f"Unknown config key: {key}") from e
except Exception as e:
raise click.UsageError(f"Failed to get config: {e!s}")
raise click.UsageError(f"Failed to get config: {e!s}") from e
else:
click.echo("Current config:")
for key in CONFIG_VALIDATORS:
for k in CONFIG_VALIDATORS:
try:
value = (
v = (
"********"
if key == "dashboard.password"
else _get_nested_item(config, key)
if k == "dashboard.password"
else _get_nested_item(config, k)
)
click.echo(f" {key}: {value}")
click.echo(f" {k}: {v}")
except (KeyError, TypeError):
# Missing or non-dict paths are simply skipped in listing
pass
def _check_astrbot_not_running() -> None:
"""Refuse to proceed if astrbot is currently running (lock file held)."""
lock_file = astrbot_paths.root / "astrbot.lock"
if not lock_file.exists():
return
lock = FileLock(lock_file, timeout=1)
try:
lock.acquire()
except Timeout:
raise click.ClickException(
"AstrBot is currently running. "
"Please stop it first before changing the password via CLI.",
) from None
else:
lock.release()
@conf.command(name="admin")
@click.option("-u", "--username", type=str, help="Update admain username as well")
@click.option(
"-p",
"--password",
type=str,
help="Set admain password directly without interactive prompt",
)
def set_dashboard_password(username: str | None, password: str | None) -> None:
"""Interactively set dashboard password (with confirmation) or set directly with -p.
Acceptable inputs:
- Plaintext password (recommended): it will be hashed securely before storage.
- Argon2 encoded hash (advanced): stored as-is.
"""
_check_astrbot_not_running()
config = _load_config()
if password is not None:
if isinstance(password, str) and is_dashboard_password_hash(password):
password_hash = password
else:
if is_legacy_dashboard_password(password):
raise click.ClickException(
"Providing legacy dashboard password hashes is no longer supported. "
"Please supply the plaintext password (it will be hashed securely), "
"or provide an Argon2-encoded hash string.",
)
password_hash = _validate_dashboard_password(password)
else:
password_hash = prompt_dashboard_password()
set_dashboard_credentials(
config,
username=username.strip() if username is not None else None,
password_hash=password_hash,
)
_save_config(config)
if username is not None:
click.echo(f"Dashboard username updated: {username.strip()}")
click.echo("Dashboard password updated.")

View File

@@ -1,11 +1,17 @@
import asyncio
import json
import os
import re
from pathlib import Path
import click
from filelock import FileLock, Timeout
from ..utils import check_dashboard, get_astrbot_root
from astrbot.cli.utils import DashboardManager
from astrbot.core.config.default import DEFAULT_CONFIG
from astrbot.core.utils.astrbot_path import astrbot_paths
from .cmd_conf import ensure_config_file, set_dashboard_credentials
DASHBOARD_INITIAL_PASSWORD_ENV = "ASTRBOT_DASHBOARD_INITIAL_PASSWORD"
@@ -20,51 +26,212 @@ def _initialize_config_from_env(astrbot_root: Path) -> None:
click.echo("Initialized data/cmd_config.json with dashboard initial password.")
async def initialize_astrbot(astrbot_root: Path) -> None:
async def initialize_astrbot(
astrbot_root: Path,
*,
yes: bool,
backend_only: bool,
admin_username: str | None,
admin_password: str | None,
) -> None:
"""Execute AstrBot initialization logic"""
dot_astrbot = astrbot_root / ".astrbot"
from astrbot.cli.banner import print_logo
click.echo("=" * 60)
click.echo("AstrBot 初始化向导")
click.echo("=" * 60)
print_logo()
click.echo()
dot_astrbot = astrbot_root / ".astrbot"
if not dot_astrbot.exists():
if click.confirm(
f"Install AstrBot to this directory? {astrbot_root}",
if yes or click.confirm(
f"确定要将 AstrBot 安装到以下目录吗?\n {astrbot_root}",
default=True,
abort=True,
):
dot_astrbot.touch()
click.echo(f"Created {dot_astrbot}")
click.echo(f"[OK] 已创建: {dot_astrbot}")
paths = {
"data": astrbot_root / "data",
"config": astrbot_root / "data" / "config",
"plugins": astrbot_root / "data" / "plugins",
"temp": astrbot_root / "data" / "temp",
"skills": astrbot_root / "data" / "skills",
}
for name, path in paths.items():
path.mkdir(parents=True, exist_ok=True)
click.echo(f"{'Created' if not path.exists() else 'Directory exists'}: {path}")
status = "Created" if not path.exists() else "Exists"
click.echo(f" [{status}] {name.title()}: {path}")
_initialize_config_from_env(astrbot_root)
await check_dashboard(astrbot_root / "data")
config_path = astrbot_root / "data" / "cmd_config.json"
if not config_path.exists():
config_path.write_text(
json.dumps(DEFAULT_CONFIG, ensure_ascii=False, indent=2),
encoding="utf-8-sig",
)
click.echo(f"[OK] 配置文件已创建: {config_path}")
ASTRBOT_ROOT = astrbot_root
env_file = ASTRBOT_ROOT / ".env"
if not env_file.exists():
tmpl_candidates = [
Path("/opt/astrbot/config.template"),
getattr(astrbot_paths, "project_root", Path.cwd()) / "config.template",
Path.cwd() / "config.template",
]
tmpl = None
for t in tmpl_candidates:
try:
if t.exists():
tmpl = t
break
except Exception:
continue
if tmpl is not None:
try:
txt = tmpl.read_text(encoding="utf-8")
instance_name = astrbot_root.name or "astrbot"
txt = re.sub("\\$\\{INSTANCE_NAME(:-[^}]*)?\\}", instance_name, txt)
port_val = (
os.environ.get("ASTRBOT_PORT") or os.environ.get("PORT") or "8000"
)
txt = re.sub("\\$\\{PORT(:-[^}]*)?\\}", str(port_val), txt)
txt = re.sub("\\$\\{ASTRBOT_ROOT(:-[^}]*)?\\}", str(ASTRBOT_ROOT), txt)
header = f"# Generated from config.template by astrbot init for instance: {instance_name}\n# This file will be auto-loaded by 'astrbot run'\n\n"
env_file.write_text(header + txt, encoding="utf-8")
env_file.chmod(420)
click.echo(f"[OK] 环境变量文件已创建: {env_file}")
except Exception as e:
click.echo(f"[警告] 无法从模板生成 .env 文件: {e!s}")
else:
click.echo("[提示] 未找到 config.template 文件,跳过 .env 生成")
if admin_password is not None:
raise click.ClickException(
"--admin-password is no longer supported during init. Run 'astrbot conf admin' after initialization.",
)
effective_admin_username = (
admin_username.strip()
if admin_username
else str(DEFAULT_CONFIG["dashboard"]["username"])
)
if admin_username:
config = ensure_config_file()
set_dashboard_credentials(
config,
username=effective_admin_username,
password_hash=None,
)
config_path.write_text(
json.dumps(config, ensure_ascii=False, indent=2),
encoding="utf-8-sig",
)
click.echo(f"[OK] Dashboard admin 用户名已设置为: {effective_admin_username}")
click.echo()
click.echo("!" * 60)
click.echo("重要提示:")
click.echo(" 1. Dashboard 密码尚未设置!首次登录前必须先设置密码")
click.echo(" 2. 设置命令: astrbot conf admin")
click.echo(" 3. 登录地址: http://localhost:6185 或 http://服务器IP:6185")
click.echo("!" * 60)
click.echo()
if not backend_only and (
yes
or click.confirm(
"是否需要集成式 WebUI个人电脑推荐服务器推荐使用后端模式",
default=True,
)
):
await DashboardManager().ensure_installed(astrbot_root)
else:
click.echo()
click.echo("[提示] 你选择了后端模式,可以使用以下方式管理 AstrBot")
click.echo(" - 使用在线 Dashboard: 在浏览器中访问远程服务器的 WebUI")
click.echo(" - 使用 CLI 命令: astrbot conf / astrbot plug 等")
click.echo()
click.echo("!" * 60)
click.echo("安全提示:")
click.echo(" HTTPS 前端只能安全连接 localhost 的 HTTP 后端")
click.echo(" 不支持远程 + HTTP 后端(不安全)")
click.echo(" 如需远程访问,请使用 HTTPS 后端或通过反向代理")
click.echo("!" * 60)
click.echo()
@click.command()
def init() -> None:
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompts")
@click.option("--backend-only", "-b", is_flag=True, help="Only initialize the backend")
@click.option("--backup", "-f", help="Initialize from backup file", type=str)
@click.option(
"-u",
"--admin-username",
type=str,
help="Set dashboard admin username during initialization",
)
@click.option(
"-p",
"--admin-password",
type=str,
help="Deprecated. Run `astrbot conf admin` after initialization.",
)
@click.option(
"--root",
help="ASTRBOT root directory to initialize (overrides ASTRBOT_ROOT env)",
type=str,
)
def init(
yes: bool,
backend_only: bool,
backup: str | None,
admin_username: str | None,
admin_password: str | None,
root: str | None = None,
) -> None:
"""Initialize AstrBot"""
click.echo("Initializing AstrBot...")
astrbot_root = get_astrbot_root()
if os.environ.get("ASTRBOT_SYSTEMD") == "1":
yes = True
from astrbot.core.utils.astrbot_path import astrbot_paths
astrbot_root = Path(root) if root else astrbot_paths.root
lock_file = astrbot_root / "astrbot.lock"
lock = FileLock(lock_file, timeout=5)
try:
with lock.acquire():
asyncio.run(initialize_astrbot(astrbot_root))
click.echo("Done! You can now run 'astrbot run' to start AstrBot")
except Timeout:
raise click.ClickException(
"Cannot acquire lock file. Please check if another instance is running"
)
asyncio.run(
initialize_astrbot(
astrbot_root,
yes=yes,
backend_only=backend_only,
admin_username=admin_username,
admin_password=admin_password,
),
)
if backup:
from .cmd_bk import import_data_command
click.echo(f"Restoring from backup: {backup}")
click.get_current_context().invoke(
import_data_command,
backup_file=backup,
yes=True,
)
click.echo()
click.echo("=" * 60)
click.echo("初始化完成!")
click.echo("=" * 60)
click.echo()
click.echo("启动 AstrBot")
click.echo(" 完整模式(含 Dashboard: astrbot run")
click.echo(" 仅后端模式: astrbot run --backend-only")
click.echo()
click.echo("首次使用前请先设置管理员密码:")
click.echo(" astrbot conf admin")
click.echo()
except Timeout as err:
raise click.ClickException(
"Cannot acquire lock file. Please check if another instance is running",
) from err
except Exception as e:
raise click.ClickException(f"Initialization failed: {e!s}")
raise click.ClickException(f"Initialization failed: {e!s}") from e

View File

@@ -1,39 +1,28 @@
import re
import shutil
from pathlib import Path
import click
from ..utils import (
from astrbot.cli.i18n import t
from astrbot.cli.utils import (
PluginStatus,
build_plug_list,
check_astrbot_root,
get_astrbot_root,
get_git_repo,
manage_plugin,
)
@click.group()
@click.group(name="plugin")
def plug() -> None:
"""Plugin management"""
def _get_data_path() -> Path:
base = get_astrbot_root()
if not check_astrbot_root(base):
raise click.ClickException(
f"{base} is not a valid AstrBot root directory. Use 'astrbot init' to initialize",
)
return (base / "data").resolve()
def display_plugins(plugins, title=None, color=None) -> None:
if title:
click.echo(click.style(title, fg=color, bold=True))
click.echo(
f"{'Name':<20} {'Version':<10} {'Status':<10} {'Author':<15} {'Description':<30}"
f"{'Name':<20} {'Version':<10} {'Status':<10} {'Author':<15} {'Description':<30}",
)
click.echo("-" * 85)
@@ -49,11 +38,13 @@ def display_plugins(plugins, title=None, color=None) -> None:
@click.argument("name")
def new(name: str) -> None:
"""Create a new plugin"""
base_path = _get_data_path()
from astrbot.core.utils.astrbot_path import astrbot_paths
base_path = astrbot_paths.data
plug_path = base_path / "plugins" / name
if plug_path.exists():
raise click.ClickException(f"Plugin {name} already exists")
raise click.ClickException(t("plugin_already_exists", name=name))
author = click.prompt("Enter plugin author", type=str)
desc = click.prompt("Enter plugin description", type=str)
@@ -84,7 +75,7 @@ def new(name: str) -> None:
# Rewrite README.md
with open(plug_path / "README.md", "w", encoding="utf-8") as f:
f.write(
f"# {name}\n\n{desc}\n\n# Support\n\n[Documentation](https://docs.astrbot.app)\n"
f"# {name}\n\n{desc}\n\n# Support\n\n[Documentation](https://docs.astrbot.app)\n",
)
# Rewrite main.py
@@ -106,7 +97,9 @@ def new(name: str) -> None:
@click.option("--all", "-a", is_flag=True, help="List uninstalled plugins")
def list(all: bool) -> None:
"""List plugins"""
base_path = _get_data_path()
from astrbot.core.utils.astrbot_path import astrbot_paths
base_path = astrbot_paths.data
plugins = build_plug_list(base_path / "plugins")
# Unpublished plugins
@@ -147,7 +140,9 @@ def list(all: bool) -> None:
@click.option("--proxy", help="Proxy server address")
def install(name: str, proxy: str | None) -> None:
"""Install a plugin"""
base_path = _get_data_path()
from astrbot.core.utils.astrbot_path import astrbot_paths
base_path = astrbot_paths.data
plug_path = base_path / "plugins"
plugins = build_plug_list(base_path / "plugins")
@@ -161,7 +156,7 @@ def install(name: str, proxy: str | None) -> None:
)
if not plugin:
raise click.ClickException(f"Plugin {name} not found or already installed")
raise click.ClickException(t("plugin_not_found_or_installed", name=name))
manage_plugin(plugin, plug_path, is_update=False, proxy=proxy)
@@ -170,24 +165,26 @@ def install(name: str, proxy: str | None) -> None:
@click.argument("name")
def remove(name: str) -> None:
"""Uninstall a plugin"""
base_path = _get_data_path()
from astrbot.core.utils.astrbot_path import astrbot_paths
base_path = astrbot_paths.data
plugins = build_plug_list(base_path / "plugins")
plugin = next((p for p in plugins if p["name"] == name), None)
if not plugin or not plugin.get("local_path"):
raise click.ClickException(f"Plugin {name} does not exist or is not installed")
raise click.ClickException(t("plugin_not_found_or_installed", name=name))
plugin_path = plugin["local_path"]
click.confirm(
f"Are you sure you want to uninstall plugin {name}?", default=False, abort=True
)
click.confirm(t("plugin_uninstall_confirm", name=name), default=False, abort=True)
try:
shutil.rmtree(plugin_path)
click.echo(f"Plugin {name} has been uninstalled")
click.echo(t("plugin_uninstall_success", name=name))
except Exception as e:
raise click.ClickException(f"Failed to uninstall plugin {name}: {e}")
raise click.ClickException(
t("plugin_uninstall_failed_ex", name=name, error=str(e)),
) from e
@plug.command()
@@ -195,7 +192,9 @@ def remove(name: str) -> None:
@click.option("--proxy", help="GitHub proxy address")
def update(name: str, proxy: str | None) -> None:
"""Update plugins"""
base_path = _get_data_path()
from astrbot.core.utils.astrbot_path import astrbot_paths
base_path = astrbot_paths.data
plug_path = base_path / "plugins"
plugins = build_plug_list(base_path / "plugins")
@@ -211,7 +210,7 @@ def update(name: str, proxy: str | None) -> None:
if not plugin:
raise click.ClickException(
f"Plugin {name} does not need updating or cannot be updated"
f"Plugin {name} does not need updating or cannot be updated",
)
manage_plugin(plugin, plug_path, is_update=True, proxy=proxy)
@@ -221,13 +220,13 @@ def update(name: str, proxy: str | None) -> None:
]
if not need_update_plugins:
click.echo("No plugins need updating")
click.echo(t("plugin_no_update_needed"))
return
click.echo(f"Found {len(need_update_plugins)} plugin(s) needing update")
click.echo(t("plugin_found_update", count=str(len(need_update_plugins))))
for plugin in need_update_plugins:
plugin_name = plugin["name"]
click.echo(f"Updating plugin {plugin_name}...")
click.echo(t("plugin_updating", name=plugin_name))
manage_plugin(plugin, plug_path, is_update=True, proxy=proxy)
@@ -235,7 +234,9 @@ def update(name: str, proxy: str | None) -> None:
@click.argument("query")
def search(query: str) -> None:
"""Search for plugins"""
base_path = _get_data_path()
from astrbot.core.utils.astrbot_path import astrbot_paths
base_path = astrbot_paths.data
plugins = build_plug_list(base_path / "plugins")
matched_plugins = [
@@ -247,7 +248,7 @@ def search(query: str) -> None:
]
if not matched_plugins:
click.echo(f"No plugins matching '{query}' found")
click.echo(t("plugin_search_no_result", query=query))
return
display_plugins(matched_plugins, f"Search results: '{query}'", "cyan")
display_plugins(matched_plugins, t("plugin_search_results", query=query), "cyan")

View File

@@ -1,15 +1,92 @@
"""AstrBot Run
Environment Variables Used in Project:
Core:
- `ASTRBOT_ROOT`: AstrBot root directory path.
- `ASTRBOT_LOG_LEVEL`: Log level (e.g. INFO, DEBUG).
- `ASTRBOT_CLI`: Flag indicating execution via CLI.
- `ASTRBOT_DESKTOP_CLIENT`: Flag indicating execution via desktop client.
- `ASTRBOT_SYSTEMD`: Flag indicating execution via systemd service.
- `ASTRBOT_RELOAD`: Enable plugin auto-reload (set to "1").
- `ASTRBOT_DISABLE_METRICS`: Disable metrics upload (set to "1").
- `TESTING`: Enable testing mode.
- `DEMO_MODE`: Enable demo mode.
- `PYTHON`: Python executable path override (for local code execution).
Dashboard / Backend:
- `ASTRBOT_DASHBOARD_ENABLE`: Enable/Disable Dashboard.
- `ASTRBOT_HOST`: Dashboard bind host.
- `ASTRBOT_PORT`: Dashboard bind port.
SSL (AstrBot-standard names):
- `ASTRBOT_SSL_ENABLE`: Enable SSL for API.
- `ASTRBOT_SSL_CERT`: SSL Certificate path for backend.
- `ASTRBOT_SSL_KEY`: SSL Key path for backend.
- `ASTRBOT_SSL_CA_CERTS`: SSL CA Certs path for backend.
Network:
- `http_proxy` / `https_proxy`: Proxy URL.
- `no_proxy`: No proxy list.
Internationalization:
- `ASTRBOT_CLI_LANG`: CLI interface language (zh/en).
Integrations:
- `DASHSCOPE_API_KEY`: Alibaba DashScope API Key (for Rerank).
- `COZE_API_KEY` / `COZE_BOT_ID`: Coze integration.
- `BAY_DATA_DIR`: Computer Use data directory.
Platform Specific:
- `TEST_MODE`: Test mode for QQOfficial.
"""
from __future__ import annotations
import asyncio
import os
import re
import sys
import traceback
from pathlib import Path
import click
from dotenv import load_dotenv
from filelock import FileLock, Timeout
from ..utils import check_astrbot_root, check_dashboard, get_astrbot_root
from astrbot.cli.utils import DashboardManager
from astrbot.runtime_bootstrap import initialize_runtime_bootstrap
DASHBOARD_RESET_PASSWORD_ENV = "ASTRBOT_RESET_DASHBOARD_PASSWORD"
# Python version check: require 3.12 or 3.13
if not (sys.version_info.major == 3 and sys.version_info.minor in (12, 13)):
sys.exit(1)
# Regular expression to find bash-like parameter expansions:
# ${VAR:-default} or ${VAR}
_PARAM_EXPAND_RE = re.compile(r"\$\{([^}:]+?)(:-([^}]*))?\}")
def _expand_parameter(
match: re.Match,
env: dict[str, str],
local: dict[str, str],
) -> str:
"""Helper to expand a single ${VAR:-default} or ${VAR} occurrence.
Precedence:
1. local dict (parsed from the same file, earlier entries)
2. environment variables
3. default provided in the expansion (if any)
4. empty string
"""
var = match.group(1)
default = match.group(3) if match.group(3) is not None else ""
# Prefer 'local' parsed values first
if var in local and local[var] != "":
return local[var]
val = env.get(var, "")
if val != "":
return val
return default
async def run_astrbot(astrbot_root: Path) -> None:
@@ -17,7 +94,11 @@ async def run_astrbot(astrbot_root: Path) -> None:
from astrbot.core import LogBroker, LogManager, db_helper, logger
from astrbot.core.initial_loader import InitialLoader
await check_dashboard(astrbot_root / "data")
if (
os.environ.get("ASTRBOT_DASHBOARD_ENABLE", os.environ.get("DASHBOARD_ENABLE"))
== "True"
):
await DashboardManager().ensure_installed(astrbot_root)
log_broker = LogBroker()
LogManager.set_queue_handler(logger, log_broker)
@@ -29,46 +110,316 @@ async def run_astrbot(astrbot_root: Path) -> None:
@click.option("--reload", "-r", is_flag=True, help="Auto-reload plugins")
@click.option("--host", "-H", help="AstrBot Dashboard Host", required=False, type=str)
@click.option("--port", "-p", help="AstrBot Dashboard port", required=False, type=str)
@click.option("--root", help="AstrBot root directory", required=False, type=str)
@click.option(
"--reset-password",
is_flag=True,
help="Reset dashboard initial password on startup",
"--service-config",
"-c",
help="Service configuration file path (supports ${VAR:-default} style expansion)",
required=False,
type=str,
)
@click.option(
"--backend-only",
"-b",
is_flag=True,
default=False,
help="Disable WebUI, run backend only",
)
@click.option(
"--log-level",
"-l",
help="Log level",
required=False,
type=str,
default="INFO",
)
@click.option(
"--ssl-cert",
help="SSL certificate file path for backend (preferred env name: ASTRBOT_SSL_CERT)",
required=False,
type=str,
)
@click.option(
"--ssl-key",
help="SSL private key file path for backend (preferred env name: ASTRBOT_SSL_KEY)",
required=False,
type=str,
)
@click.option(
"--ssl-ca",
help="SSL CA certificates file path for backend (preferred env name: ASTRBOT_SSL_CA_CERTS)",
required=False,
type=str,
)
@click.option("--debug", is_flag=True, help="Enable debug mode")
@click.command()
def run(reload: bool, port: str | None, reset_password: bool) -> None:
def run(
reload: bool,
host: str,
port: str,
root: str,
service_config: str,
backend_only: bool,
log_level: str,
ssl_cert: str,
ssl_key: str,
ssl_ca: str,
debug: bool,
) -> None:
"""Run AstrBot"""
initialize_runtime_bootstrap()
try:
os.environ["ASTRBOT_CLI"] = "1"
astrbot_root = get_astrbot_root()
if debug:
log_level = "DEBUG"
if not check_astrbot_root(astrbot_root):
# --- Step 1: Resolve service-config path (if provided). We'll treat it as a .env file later. ---
svc_path: Path | None = None
if service_config:
candidate = Path(service_config)
if not candidate.exists():
# Try to expand user and resolve
candidate = Path(os.path.expanduser(service_config))
if candidate.exists():
svc_path = candidate
# NOTE:
# Loading of common .env files (CWD/.env, packaged project .env, ASTRBOT_ROOT/.env)
# has been moved to astrbot.core.utils.astrbot_path during import-time to avoid
# early-initialization ordering issues. Those files are loaded there using
# `override=False` so they do not clobber environment variables provided by the
# systemd unit or the caller.
#
# Here we only load an explicit service-config file (if given). Service-config
# should be able to override the common .env files, but CLI-provided values must
# still win; the CLI will set/overwrite corresponding environment variables
# below after this load.
if svc_path and svc_path.exists():
# Load service-config as an env file and allow it to override previously-loaded
# .env values (those were loaded by astrbot_path). CLI variables are applied
# after this point and will take precedence.
load_dotenv(dotenv_path=str(svc_path), override=True)
# Mark CLI execution
os.environ["ASTRBOT_CLI"] = "1"
from astrbot.core.utils.astrbot_path import astrbot_paths
# Resolve astrbot_root with the following precedence:
# 1. CLI --root parameter (local variable `root`)
# 2. ASTRBOT_ROOT environment variable (possibly from .env or parsed service config)
# 3. packaged default astrbot_paths.root
if root:
os.environ["ASTRBOT_ROOT"] = root
astrbot_root = Path(root)
elif os.environ.get("ASTRBOT_ROOT"):
astrbot_root = Path(os.environ["ASTRBOT_ROOT"])
else:
astrbot_root = astrbot_paths.root
if not astrbot_paths.is_root:
raise click.ClickException(
f"{astrbot_root} is not a valid AstrBot root directory. Use 'astrbot init' to initialize",
)
# Ensure ASTRBOT_ROOT env var is set to the resolved root (without overriding a CLI-provided root value above)
os.environ["ASTRBOT_ROOT"] = str(astrbot_root)
sys.path.insert(0, str(astrbot_root))
if port:
os.environ["DASHBOARD_PORT"] = port
# Host/Port precedence: CLI args > parsed service config/env/.env > defaults.
if port is not None:
os.environ["ASTRBOT_PORT"] = port
if host is not None:
os.environ["ASTRBOT_HOST"] = host
# CLI-provided SSL paths should set backend-standard env names.
if ssl_cert is not None:
os.environ["ASTRBOT_SSL_CERT"] = ssl_cert
if ssl_key is not None:
os.environ["ASTRBOT_SSL_KEY"] = ssl_key
if ssl_ca is not None:
os.environ["ASTRBOT_SSL_CA_CERTS"] = ssl_ca
# Dashboard enable is derived from CLI flag (--backend-only). CLI decision should win.
os.environ["ASTRBOT_DASHBOARD_ENABLE"] = str(not backend_only)
os.environ["ASTRBOT_LOG_LEVEL"] = log_level
if reload:
click.echo("Plugin auto-reload enabled")
os.environ["ASTRBOT_RELOAD"] = "1"
if reset_password:
os.environ[DASHBOARD_RESET_PASSWORD_ENV] = "1"
if debug:
keys_to_print = [
"ASTRBOT_ROOT",
"ASTRBOT_LOG_LEVEL",
"ASTRBOT_CLI",
"ASTRBOT_DESKTOP_CLIENT",
"ASTRBOT_SYSTEMD",
"ASTRBOT_RELOAD",
"ASTRBOT_DISABLE_METRICS",
"TESTING",
"DEMO_MODE",
"PYTHON",
"ASTRBOT_DASHBOARD_ENABLE",
"DASHBOARD_ENABLE",
"ASTRBOT_HOST",
"DASHBOARD_HOST",
"ASTRBOT_PORT",
"DASHBOARD_PORT",
# Dashboard SSL (legacy)
"ASTRBOT_SSL_ENABLE",
"DASHBOARD_SSL_ENABLE",
"ASTRBOT_SSL_CERT",
"DASHBOARD_SSL_CERT",
"ASTRBOT_SSL_KEY",
"DASHBOARD_SSL_KEY",
"ASTRBOT_SSL_CA_CERTS",
"DASHBOARD_SSL_CA_CERTS",
# Backend-standard SSL (preferred)
"ASTRBOT_SSL_ENABLE",
"ASTRBOT_SSL_CERT",
"ASTRBOT_SSL_KEY",
"ASTRBOT_SSL_CA_CERTS",
"http_proxy",
"https_proxy",
"no_proxy",
"DASHSCOPE_API_KEY",
"COZE_API_KEY",
"COZE_BOT_ID",
"BAY_DATA_DIR",
"TEST_MODE",
]
click.secho("\n[Debug Mode] Environment Variables:", fg="yellow", bold=True)
for key in keys_to_print:
if key in os.environ:
val = os.environ[key]
if "KEY" in key or "PASSWORD" in key or "SECRET" in key:
if len(val) > 8:
val = val[:4] + "****" + val[-4:]
else:
val = "****"
click.echo(f" {click.style(key, fg='cyan')}: {val}")
if svc_path:
click.echo(
f" {click.style('SERVICE_CONFIG', fg='cyan')}: {svc_path!s}",
)
click.echo("")
lock_file = astrbot_root / "astrbot.lock"
lock = FileLock(lock_file, timeout=5)
with lock.acquire():
asyncio.run(run_astrbot(astrbot_root))
async def run_with_logging() -> None:
from astrbot.core import LogBroker, LogManager, db_helper, logger
from astrbot.core.initial_loader import InitialLoader
if (
os.environ.get(
"ASTRBOT_DASHBOARD_ENABLE",
os.environ.get("DASHBOARD_ENABLE"),
)
== "True"
):
await DashboardManager().ensure_installed(astrbot_root)
log_broker = LogBroker()
LogManager.set_queue_handler(logger, log_broker)
# Register a stdout subscriber for real-time log streaming
log_queue = log_broker.register()
db = db_helper
initial_loader = InitialLoader(db, log_broker)
# Start a task to stream logs to stdout
async def stream_logs() -> None:
"""Stream logs from LogBroker to stdout."""
while True:
try:
log_entry = await asyncio.wait_for(
log_queue.get(),
timeout=0.5,
)
# Format: [LEVEL] message
level = log_entry.get("level_name", "INFO")
message = log_entry.get("message", "")
if message:
level_color = {
"DEBUG": "cyan",
"INFO": "green",
"WARNING": "yellow",
"ERROR": "red",
"CRITICAL": "red",
}.get(level, "white")
click.secho(
f"[{level}]",
fg=level_color,
bold=False,
nl=False,
)
click.echo(f" {message}")
except TimeoutError:
continue
except asyncio.CancelledError:
break
# Start streaming task
stream_task = asyncio.create_task(stream_logs())
try:
await initial_loader.start()
finally:
stream_task.cancel()
try:
await stream_task
except asyncio.CancelledError:
pass
click.echo()
click.echo("=" * 60)
click.echo("AstrBot 启动中...")
click.echo("=" * 60)
from astrbot.cli.banner import print_logo
print_logo()
click.echo()
if backend_only:
click.echo("[模式] 仅后端模式(无本地 Dashboard")
click.echo()
click.echo("[提示] 可以通过以下方式访问 WebUI")
click.echo(" - 使用远程服务器的在线 Dashboard")
click.echo(" - 地址: http://服务器IP:6185")
click.echo()
else:
dashboard_url = f"http://{host or 'localhost'}:{port or '6185'}"
click.echo("[模式] 完整模式(含本地 Dashboard")
click.echo()
click.echo(f"[Dashboard] 请访问: {dashboard_url}")
click.echo()
click.echo("!" * 60)
click.echo("安全提示:")
click.echo(" HTTPS 前端只能安全连接 localhost 的 HTTP 后端")
click.echo(" 不支持远程 + HTTP 后端(不安全)")
click.echo("!" * 60)
click.echo()
click.echo("正在启动服务...(日志输出中)")
click.echo()
asyncio.run(run_with_logging())
except KeyboardInterrupt:
click.echo("AstrBot has been shut down.")
except Timeout:
raise click.ClickException(
"Cannot acquire lock file. Please check if another instance is running"
)
"Cannot acquire lock file. Please check if another instance is running",
) from None
except Exception as e:
raise click.ClickException(f"Runtime error: {e}\n{traceback.format_exc()}")
# Keep original traceback visible for diagnostics
raise click.ClickException(
f"Runtime error: {e}\n{traceback.format_exc()}",
) from e

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,69 @@
import os
import shutil
from pathlib import Path
import click
from astrbot.core.utils.astrbot_path import astrbot_paths
@click.command()
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompts")
@click.option(
"--keep-data",
is_flag=True,
help="Keep data directory (config, plugins, etc.)",
)
def uninstall(yes: bool, keep_data: bool) -> None:
"""Remove AstrBot files from the current root directory."""
if os.environ.get("ASTRBOT_SYSTEMD") == "1":
yes = True
dot_astrbot = astrbot_paths.root / ".astrbot"
lock_file = astrbot_paths.root / "astrbot.lock"
data_dir = astrbot_paths.data
removable_paths: list[Path] = [dot_astrbot, lock_file]
if not keep_data:
removable_paths.insert(0, data_dir)
# Check if this looks like an AstrBot root before blowing things up
if not dot_astrbot.exists() and not data_dir.exists():
click.echo("No AstrBot initialization found in current directory.")
return
if keep_data:
click.echo("Keeping data directory as requested.")
if yes or click.confirm(
f"Are you sure you want to remove AstrBot data at {astrbot_paths.root}? \n"
f"This will delete:\n"
f" - {data_dir} (Config, Plugins, Database)\n"
f" - {dot_astrbot}\n"
f" - {lock_file}",
default=False,
abort=True,
):
removed_any = False
for path in removable_paths:
if not path.exists():
continue
removed_any = True
if path.is_dir():
click.echo(f"Removing directory: {path}")
shutil.rmtree(path)
else:
click.echo(f"Removing file: {path}")
path.unlink()
if removed_any:
click.echo("AstrBot files removed successfully.")
else:
click.echo("No removable AstrBot files were found.")
# TODO: Consider adding an explicit `--service` cleanup mode instead of
# touching systemd or other service managers during normal uninstall.
# TODO: Consider adding package-manager-specific uninstall helpers once
# the CLI can reliably detect the installation source.
click.echo("uv: uv tool uninstall astrbot")
click.echo("paru/yay: paru -R astrbot")

278
astrbot/cli/i18n.py Normal file
View File

@@ -0,0 +1,278 @@
"""Internationalization support for AstrBot CLI.
This module provides i18n support with Chinese and English languages.
Language is auto-detected from environment or can be set manually.
"""
from __future__ import annotations
import os
from enum import Enum
from functools import lru_cache
class Language(Enum):
"""Supported languages."""
ZH = "zh"
EN = "en"
# Translation dictionaries
_TRANSLATIONS: dict[Language, dict[str, str]] = {
Language.ZH: {
# CLI welcome and general
"cli_welcome": "欢迎使用 AstrBot CLI!",
"cli_version": "AstrBot CLI 版本: {version}",
"cli_unknown_command": "未知命令: {command}",
"cli_help_available": "使用 astrbot help --all 查看所有命令",
# Dashboard commands
"dashboard_bundled": "Dashboard 已打包在安装包中 - 跳过下载",
"dashboard_not_installed": "Dashboard 未安装",
"dashboard_install_confirm": "是否安装 Dashboard?",
"dashboard_installing": "正在安装 Dashboard...",
"dashboard_install_success": "Dashboard 安装成功",
"dashboard_install_failed": "Dashboard 安装失败: {error}",
"dashboard_not_needed": "Dashboard 不需要安装",
"dashboard_declined": "Dashboard 安装已取消",
"dashboard_already_up_to_date": "Dashboard 已是最新版本",
"dashboard_version": "Dashboard 版本: {version}",
"dashboard_download_failed": "Dashboard 下载失败: {error}",
"dashboard_init_dir": "正在初始化 Dashboard 目录...",
"dashboard_init_success": "Dashboard 初始化成功",
# Plugin commands
"plugin_installing": "正在安装插件: {name}",
"plugin_install_success": "插件安装成功: {name}",
"plugin_install_failed": "插件安装失败: {name}",
"plugin_uninstall_confirm": "确定要卸载插件 {name} 吗?",
"plugin_uninstall_success": "插件卸载成功: {name}",
"plugin_uninstall_failed": "插件卸载失败: {name}",
"plugin_list_empty": "未安装任何插件",
"plugin_already_installed": "插件已安装: {name}",
"plugin_not_found": "插件未找到: {name}",
"plugin_already_exists": "插件已存在: {name}",
"plugin_not_found_or_installed": "插件未找到或已安装: {name}",
"plugin_uninstall_failed_ex": "插件卸载失败 {name}: {error}",
"plugin_no_update_needed": "没有需要更新的插件",
"plugin_found_update": "发现 {count} 个插件需要更新",
"plugin_updating": "正在更新插件 {name}...",
"plugin_search_no_result": "未找到匹配 '{query}' 的插件",
"plugin_search_results": "搜索结果: '{query}'",
# Config commands
"config_show": "显示配置",
"config_set_success": "配置项已更新: {key} = {value}",
"config_set_failed": "配置项更新失败: {key}",
"config_set_failed_ex": "设置配置失败: {error}",
"config_get_success": "{key} = {value}",
"config_get_not_found": "配置项未找到: {key}",
"config_reset_confirm": "确定要重置所有配置吗?",
"config_reset_success": "配置已重置",
# Config validators
"config_log_level_invalid": "日志级别必须是 DEBUG/INFO/WARNING/ERROR/CRITICAL 之一",
"config_port_must_be_number": "端口必须是数字",
"config_port_range_invalid": "端口必须在 1-65535 范围内",
"config_username_empty": "用户名不能为空",
"config_password_empty": "密码不能为空",
"config_timezone_invalid": "无效的时区: {value}。请使用有效的 IANA 时区名称",
"config_callback_invalid": "回调 API 基础路径必须以 http:// 或 https:// 开头",
"config_key_unsupported": "不支持的配置项: {key}",
"config_key_unknown": "未知的配置项: {key}",
"config_updated": "配置已更新: {key}",
# Init command
"init_creating": "正在创建配置目录...",
"init_created": "配置目录已创建: {path}",
"init_copying": "正在复制配置文件...",
"init_copied": "配置文件已复制",
"init_success": "AstrBot 初始化完成!",
"init_failed": "初始化失败: {error}",
# Run command
"run_starting": "正在启动 AstrBot...",
"run_started": "AstrBot 已启动!",
"run_backend_only": "以无界面模式启动",
"run_failed": "启动失败: {error}",
"run_stopped": "AstrBot 已停止",
# Common
"yes": "",
"no": "",
"cancel": "取消",
"confirm": "确认",
"error": "错误",
"success": "成功",
"warning": "警告",
"info": "信息",
"loading": "加载中...",
"done": "完成",
"failed": "失败",
"retry": "重试",
"exit": "退出",
"continue": "继续",
},
Language.EN: {
# CLI welcome and general
"cli_welcome": "Welcome to AstrBot CLI!",
"cli_version": "AstrBot CLI version: {version}",
"cli_unknown_command": "Unknown command: {command}",
"cli_help_available": "Use astrbot help --all to see all commands",
# Dashboard commands
"dashboard_bundled": "Dashboard is bundled with the package - skipping download",
"dashboard_not_installed": "Dashboard is not installed",
"dashboard_install_confirm": "Install Dashboard?",
"dashboard_installing": "Installing Dashboard...",
"dashboard_install_success": "Dashboard installed successfully",
"dashboard_install_failed": "Failed to install dashboard: {error}",
"dashboard_not_needed": "Dashboard not needed",
"dashboard_declined": "Dashboard installation declined.",
"dashboard_already_up_to_date": "Dashboard is already up to date",
"dashboard_version": "Dashboard version: {version}",
"dashboard_download_failed": "Failed to download dashboard: {error}",
"dashboard_init_dir": "Initializing dashboard directory...",
"dashboard_init_success": "Dashboard initialized successfully",
# Plugin commands
"plugin_installing": "Installing plugin: {name}",
"plugin_install_success": "Plugin installed successfully: {name}",
"plugin_install_failed": "Failed to install plugin: {name}",
"plugin_uninstall_confirm": "Uninstall plugin {name}?",
"plugin_uninstall_success": "Plugin uninstalled successfully: {name}",
"plugin_uninstall_failed": "Failed to uninstall plugin: {name}",
"plugin_list_empty": "No plugins installed",
"plugin_already_installed": "Plugin already installed: {name}",
"plugin_not_found": "Plugin not found: {name}",
"plugin_already_exists": "Plugin {name} already exists",
"plugin_not_found_or_installed": "Plugin {name} not found or already installed",
"plugin_uninstall_failed_ex": "Failed to uninstall plugin {name}: {error}",
"plugin_no_update_needed": "No plugins need updating",
"plugin_found_update": "Found {count} plugin(s) needing update",
"plugin_updating": "Updating plugin {name}...",
"plugin_search_no_result": "No plugins matching '{query}' found",
"plugin_search_results": "Search results: '{query}'",
# Config commands
"config_show": "Show configuration",
"config_set_success": "Configuration updated: {key} = {value}",
"config_set_failed": "Failed to update configuration: {key}",
"config_set_failed_ex": "Failed to set config: {error}",
"config_get_success": "{key} = {value}",
"config_get_not_found": "Configuration key not found: {key}",
"config_reset_confirm": "Reset all configuration?",
"config_reset_success": "Configuration reset",
# Config validators
"config_log_level_invalid": "Log level must be one of DEBUG/INFO/WARNING/ERROR/CRITICAL",
"config_port_must_be_number": "Port must be a number",
"config_port_range_invalid": "Port must be in range 1-65535",
"config_username_empty": "Username cannot be empty",
"config_password_empty": "Password cannot be empty",
"config_timezone_invalid": "Invalid timezone: {value}. Please use a valid IANA timezone name",
"config_callback_invalid": "Callback API base must start with http:// or https://",
"config_key_unsupported": "Unsupported config key: {key}",
"config_key_unknown": "Unknown config key: {key}",
"config_updated": "Config updated: {key}",
# Init command
"init_creating": "Creating config directory...",
"init_created": "Config directory created: {path}",
"init_copying": "Copying config files...",
"init_copied": "Config files copied",
"init_success": "AstrBot initialized successfully!",
"init_failed": "Initialization failed: {error}",
# Run command
"run_starting": "Starting AstrBot...",
"run_started": "AstrBot started!",
"run_backend_only": "Starting in backend-only mode",
"run_failed": "Failed to start: {error}",
"run_stopped": "AstrBot stopped",
# Common
"yes": "Yes",
"no": "No",
"cancel": "Cancel",
"confirm": "Confirm",
"error": "Error",
"success": "Success",
"warning": "Warning",
"info": "Info",
"loading": "Loading...",
"done": "Done",
"failed": "Failed",
"retry": "Retry",
"exit": "Exit",
"continue": "Continue",
},
}
@lru_cache(maxsize=1)
def get_current_language() -> Language:
"""Get the current language based on environment or default.
Detection order:
1. ASTRBOT_CLI_LANG environment variable (zh/en)
2. LANG environment variable (if contains zh/cn)
3. LC_ALL environment variable (if contains zh/cn)
4. Default to Chinese (most users are Chinese)
"""
# Check explicit override first
explicit = os.environ.get("ASTRBOT_CLI_LANG", "").lower()
if explicit in ("zh", "en"):
return Language.ZH if explicit == "zh" else Language.EN
# Check LANG/LC_ALL for Chinese
for env_var in ("LANG", "LC_ALL"):
lang = os.environ.get(env_var, "").lower()
if "zh" in lang or "cn" in lang:
return Language.ZH
# Default to Chinese for broader appeal
return Language.ZH
def set_language(lang: Language) -> None:
"""Set the current language (clears all translation caches)."""
get_current_language.cache_clear()
_t_cached.cache_clear()
# Set environment variable for persistence
os.environ["ASTRBOT_CLI_LANG"] = lang.value
@lru_cache(maxsize=128)
def _t_cached(key: str, lang: Language) -> str:
"""Cached translation lookup."""
return _TRANSLATIONS.get(lang, {}).get(key, key)
def t(translation_key: str, **kwargs: str) -> str:
"""Get translation for the given key in the current language.
Args:
translation_key: Translation key (e.g., "cli_welcome", "plugin_installing")
**kwargs: Format arguments for the translation string
Returns:
Translated string, or the key itself if not found
"""
result = _t_cached(translation_key, get_current_language())
if kwargs:
result = result.format(**kwargs)
return result
def tr(key: str, **kwargs: str) -> str:
"""Get translation (alias for t())."""
return t(key, **kwargs)
class CLITranslations:
"""Translation accessor class for CLI contexts.
Usage:
translations = CLITranslations()
print(translations.cli_welcome)
print(translations.plugin_installing(name="my_plugin"))
"""
def __getattr__(self, key: str) -> str:
return t(key)
def __call__(self, key: str, **kwargs: str) -> str:
return t(key, **kwargs)
# Convenience instance
translations = CLITranslations()

View File

@@ -1,18 +1,12 @@
from .basic import (
check_astrbot_root,
check_dashboard,
get_astrbot_root,
)
from .dashboard import DashboardManager
from .plugin import PluginStatus, build_plug_list, get_git_repo, manage_plugin
from .version_comparator import VersionComparator
__all__ = [
"DashboardManager",
"PluginStatus",
"VersionComparator",
"build_plug_list",
"check_astrbot_root",
"check_dashboard",
"get_astrbot_root",
"get_git_repo",
"manage_plugin",
]

View File

@@ -42,7 +42,6 @@ async def check_dashboard(astrbot_root: Path) -> None:
if click.confirm(
"Install dashboard?",
default=True,
abort=True,
):
click.echo("Installing dashboard...")
await download_dashboard(

View File

@@ -0,0 +1,79 @@
import sys
from importlib import resources
from pathlib import Path
import click
from astrbot.cli.i18n import t
from .version_comparator import VersionComparator
class DashboardManager:
_bundled_dist = resources.files("astrbot") / "dashboard" / "dist"
async def ensure_installed(self, astrbot_root: Path) -> None:
"""Ensure the dashboard assets are installed and up to date."""
from astrbot.core.config.default import VERSION
from astrbot.core.utils.io import download_dashboard, get_dashboard_version
if self._bundled_dist.is_dir():
click.echo(t("dashboard_bundled"))
return
try:
dashboard_version = await get_dashboard_version()
match dashboard_version:
case None:
click.echo(t("dashboard_not_installed"))
# Skip interactive prompt in non-interactive environments
if not sys.stdin.isatty():
click.echo(t("dashboard_not_needed"))
return
if click.confirm(t("dashboard_install_confirm"), default=True):
click.echo(t("dashboard_installing"))
try:
await download_dashboard(
path="data/dashboard.zip",
extract_path=str(astrbot_root / "data"),
version=f"v{VERSION}",
latest=False,
)
click.echo(t("dashboard_install_success"))
except Exception as e:
click.echo(t("dashboard_install_failed", error=str(e)))
else:
click.echo(t("dashboard_declined"))
case str():
if (
VersionComparator.compare_version(VERSION, dashboard_version)
<= 0
):
click.echo(t("dashboard_already_up_to_date"))
return
try:
version = dashboard_version.split("v")[1]
click.echo(t("dashboard_version", version=version))
await download_dashboard(
path="data/dashboard.zip",
extract_path=str(astrbot_root / "data"),
version=f"v{VERSION}",
latest=False,
)
except Exception as e:
click.echo(t("dashboard_download_failed", error=str(e)))
return
except FileNotFoundError:
click.echo(t("dashboard_init_dir"))
try:
await download_dashboard(
path=str(astrbot_root / "data" / "dashboard.zip"),
extract_path=str(astrbot_root / "data"),
version=f"v{VERSION}",
latest=False,
)
click.echo(t("dashboard_init_success"))
except Exception as e:
click.echo(t("dashboard_download_failed", error=str(e)))
return

View File

@@ -3,6 +3,7 @@ import tempfile
from enum import Enum
from io import BytesIO
from pathlib import Path
from typing import Any
from zipfile import ZipFile
import click
@@ -32,7 +33,7 @@ def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
release_url = f"https://api.github.com/repos/{author}/{repo}/releases"
try:
with httpx.Client(
proxy=proxy if proxy else None,
proxy=proxy or None,
follow_redirects=True,
) as client:
resp = client.get(release_url)
@@ -56,7 +57,7 @@ def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
# Download and extract
with httpx.Client(
proxy=proxy if proxy else None,
proxy=proxy or None,
follow_redirects=True,
) as client:
resp = client.get(download_url)
@@ -83,7 +84,7 @@ def get_git_repo(url: str, target_path: Path, proxy: str | None = None) -> None:
shutil.rmtree(temp_dir, ignore_errors=True)
def load_yaml_metadata(plugin_dir: Path) -> dict:
def load_yaml_metadata(plugin_dir: Path) -> dict[str, Any]:
"""Load plugin metadata from metadata.yaml file
Args:
@@ -96,7 +97,10 @@ def load_yaml_metadata(plugin_dir: Path) -> dict:
yaml_path = plugin_dir / "metadata.yaml"
if yaml_path.exists():
try:
return yaml.safe_load(yaml_path.read_text(encoding="utf-8")) or {}
data = yaml.safe_load(yaml_path.read_text(encoding="utf-8"))
if isinstance(data, dict):
return dict[str, Any](data)
return {}
except Exception as e:
click.echo(f"Failed to read {yaml_path}: {e}", err=True)
return {}
@@ -114,10 +118,9 @@ def build_plug_list(plugins_dir: Path) -> list:
"""
# Get local plugin info
result = []
if plugins_dir.is_dir():
for plugin_dir in plugins_dir.iterdir():
if not plugin_dir.is_dir():
continue
if plugins_dir.exists():
for plugin_name in [d.name for d in plugins_dir.glob("*") if d.is_dir()]:
plugin_dir = plugins_dir / plugin_name
# Load metadata from metadata.yaml
metadata = load_yaml_metadata(plugin_dir)
@@ -142,44 +145,54 @@ def build_plug_list(plugins_dir: Path) -> list:
)
# Get online plugin list
online_plugins_dict = {}
online_plugins = []
try:
with httpx.Client() as client:
resp = client.get("https://api.soulter.top/astrbot/plugins")
resp.raise_for_status()
data = resp.json()
for plugin_id, plugin_info in data.items():
online_plugins_dict[str(plugin_id)] = {
"name": str(plugin_id),
"desc": str(plugin_info.get("desc", "")),
"version": str(plugin_info.get("version", "")),
"author": str(plugin_info.get("author", "")),
"repo": str(plugin_info.get("repo", "")),
"status": PluginStatus.NOT_INSTALLED,
"local_path": None,
}
online_plugins.append(
{
"name": str(plugin_id),
"desc": str(plugin_info.get("desc", "")),
"version": str(plugin_info.get("version", "")),
"author": str(plugin_info.get("author", "")),
"repo": str(plugin_info.get("repo", "")),
"status": PluginStatus.NOT_INSTALLED,
"local_path": None,
},
)
except Exception as e:
click.echo(f"Failed to get online plugin list: {e}", err=True)
# Compare with online plugins and update status
online_plugin_names = {plugin["name"] for plugin in online_plugins}
for local_plugin in result:
online_plugin = online_plugins_dict.pop(local_plugin["name"], None)
if online_plugin is None:
if local_plugin["name"] in online_plugin_names:
# Find the corresponding online plugin
online_plugin = next(
p for p in online_plugins if p["name"] == local_plugin["name"]
)
if (
VersionComparator.compare_version(
local_plugin["version"] or "",
online_plugin["version"] or "",
)
< 0
):
local_plugin["status"] = PluginStatus.NEED_UPDATE
else:
# Local plugin is not published online
local_plugin["status"] = PluginStatus.NOT_PUBLISHED
continue
if (
VersionComparator.compare_version(
local_plugin["version"],
online_plugin["version"],
)
< 0
):
local_plugin["status"] = PluginStatus.NEED_UPDATE
# Add uninstalled online plugins
result.extend(online_plugins_dict.values())
for online_plugin in online_plugins:
if not any(plugin["name"] == online_plugin["name"] for plugin in result):
clean: dict[str, str] = {
k: v for k, v in online_plugin.items() if v is not None
}
result.append(clean)
return result
@@ -213,7 +226,7 @@ def manage_plugin(
# Check if plugin exists
if is_update and not target_path.exists():
raise click.ClickException(
f"Plugin {plugin_name} is not installed and cannot be updated"
f"Plugin {plugin_name} is not installed and cannot be updated",
)
# Backup existing plugin
@@ -232,7 +245,7 @@ def manage_plugin(
if is_update and backup_path is not None and backup_path.exists():
shutil.rmtree(backup_path)
click.echo(
f"Plugin {plugin_name} {'updated' if is_update else 'installed'} successfully"
f"Plugin {plugin_name} {'updated' if is_update else 'installed'} successfully",
)
except Exception as e:
if target_path.exists():
@@ -241,4 +254,4 @@ def manage_plugin(
shutil.move(backup_path, target_path)
raise click.ClickException(
f"Error {'updating' if is_update else 'installing'} plugin {plugin_name}: {e}",
)
) from e

View File

@@ -62,12 +62,9 @@ class VersionComparator:
return -1
if isinstance(p1, str) and isinstance(p2, int):
return 1
if isinstance(p1, int) and isinstance(p2, int):
if p1 > p2:
return 1
if p1 < p2:
return -1
elif isinstance(p1, str) and isinstance(p2, str):
if (isinstance(p1, int) and isinstance(p2, int)) or (
isinstance(p1, str) and isinstance(p2, str)
):
if p1 > p2:
return 1
if p1 < p2:

View File

@@ -22,11 +22,29 @@ from astrbot.core.utils.requirements_utils import (
from astrbot.core.utils.shared_preferences import SharedPreferences
from astrbot.core.utils.t2i.renderer import HtmlRenderer
from .log import LogBroker, LogManager # noqa
from .utils.astrbot_path import get_astrbot_data_path
from .log import LogBroker, LogManager
from .utils.astrbot_path import (
get_astrbot_config_path,
get_astrbot_data_path,
get_astrbot_knowledge_base_path,
get_astrbot_plugin_path,
get_astrbot_site_packages_path,
get_astrbot_skills_path,
get_astrbot_temp_path,
)
# 初始化数据存储文件夹
os.makedirs(get_astrbot_data_path(), exist_ok=True)
# Initialize required data directories eagerly so later agent/tool flows do not
# fail on missing paths when the runtime root resolves to a fresh location.
for required_dir in (
get_astrbot_data_path(),
get_astrbot_config_path(),
get_astrbot_plugin_path(),
get_astrbot_temp_path(),
get_astrbot_knowledge_base_path(),
get_astrbot_skills_path(),
get_astrbot_site_packages_path(),
):
os.makedirs(required_dir, exist_ok=True)
DEMO_MODE = os.getenv("DEMO_MODE", "False").strip().lower() in ("true", "1", "t")
@@ -34,7 +52,11 @@ astrbot_config = AstrBotConfig()
t2i_base_url = astrbot_config.get("t2i_endpoint", "https://t2i.soulter.top/text2img")
html_renderer = HtmlRenderer(t2i_base_url)
logger = LogManager.GetLogger(log_name="astrbot")
LogManager.configure_logger(logger, astrbot_config)
LogManager.configure_logger(
logger,
astrbot_config,
override_level=os.getenv("ASTRBOT_LOG_LEVEL"),
)
LogManager.configure_trace_logger(astrbot_config)
db_helper = SQLiteDatabase(DB_PATH)
# 简单的偏好设置存储, 这里后续应该存储到数据库中, 一些部分可以存储到配置中
@@ -45,3 +67,17 @@ pip_installer = PipInstaller(
astrbot_config.get("pip_install_arg", ""),
astrbot_config.get("pypi_index_url", None),
)
__all__ = [
"DEMO_MODE",
"AstrBotConfig",
"LogBroker",
"LogManager",
"astrbot_config",
"db_helper",
"file_token_service",
"html_renderer",
"logger",
"pip_installer",
"sp",
"t2i_base_url",
]

View File

@@ -1,6 +1,6 @@
from astrbot import logger
from astrbot.core.agent.message import Message
from ..message import Message
from .compressor import LLMSummaryCompressor, TruncateByTurnsCompressor
from .config import ContextConfig
from .token_counter import EstimateTokenCounter
@@ -22,6 +22,7 @@ class ContextManager:
Args:
config: The context configuration.
"""
self.config = config
@@ -39,11 +40,13 @@ class ContextManager:
)
else:
self.compressor = TruncateByTurnsCompressor(
truncate_turns=config.truncate_turns
truncate_turns=config.truncate_turns,
)
async def process(
self, messages: list[Message], trusted_token_usage: int = 0
self,
messages: list[Message],
trusted_token_usage: int = 0,
) -> list[Message]:
"""Process the messages.
@@ -52,6 +55,7 @@ class ContextManager:
Returns:
The processed message list.
"""
try:
result = messages
@@ -67,11 +71,14 @@ class ContextManager:
# 2. 基于 token 的压缩
if self.config.max_context_tokens > 0:
total_tokens = self.token_counter.count_tokens(
result, trusted_token_usage
result,
trusted_token_usage,
)
if self.compressor.should_compress(
result, total_tokens, self.config.max_context_tokens
result,
total_tokens,
self.config.max_context_tokens,
):
result = await self._run_compression(result, total_tokens)
@@ -81,10 +88,11 @@ class ContextManager:
return messages
async def _run_compression(
self, messages: list[Message], prev_tokens: int
self,
messages: list[Message],
prev_tokens: int,
) -> list[Message]:
"""
Compress/truncate the messages.
"""Compress/truncate the messages.
Args:
messages: The original message list.
@@ -92,6 +100,7 @@ class ContextManager:
Returns:
The compressed/truncated message list.
"""
logger.debug("Compress triggered, starting compression...")
@@ -110,10 +119,12 @@ class ContextManager:
# last check
if self.compressor.should_compress(
messages, tokens_after_summary, self.config.max_context_tokens
messages,
tokens_after_summary,
self.config.max_context_tokens,
):
logger.info(
"Context still exceeds max tokens after compression, applying halving truncation..."
"Context still exceeds max tokens after compression, applying halving truncation...",
)
# still need compress, truncate by half
messages = self.truncator.truncate_by_halving(messages)

View File

@@ -1,18 +1,25 @@
import json
from typing import Protocol, runtime_checkable
from ..message import AudioURLPart, ImageURLPart, Message, TextPart, ThinkPart
from astrbot.core.agent.message import (
AudioURLPart,
ImageURLPart,
Message,
TextPart,
ThinkPart,
)
@runtime_checkable
class TokenCounter(Protocol):
"""
Protocol for token counters.
"""Protocol for token counters.
Provides an interface for counting tokens in message lists.
"""
def count_tokens(
self, messages: list[Message], trusted_token_usage: int = 0
self,
messages: list[Message],
trusted_token_usage: int = 0,
) -> int:
"""Count the total tokens in the message list.
@@ -24,13 +31,14 @@ class TokenCounter(Protocol):
Returns:
The total token count.
"""
...
# 图片/音频 token 开销估算值参考 OpenAI vision pricing:
# low-res ~85 tokens, high-res ~170 per 512px tile, 通常几百到上千
# 这里取一个保守中位数宁可偏高触发压缩也不要偏低导致 API 报错
# 图片/音频 token 开销估算值,参考 OpenAI vision pricing:
# low-res ~85 tokens, high-res ~170 per 512px tile, 通常几百到上千
# 这里取一个保守中位数,宁可偏高触发压缩也不要偏低导致 API 报错
IMAGE_TOKEN_ESTIMATE = 765
AUDIO_TOKEN_ESTIMATE = 500
@@ -44,7 +52,9 @@ class EstimateTokenCounter:
"""
def count_tokens(
self, messages: list[Message], trusted_token_usage: int = 0
self,
messages: list[Message],
trusted_token_usage: int = 0,
) -> int:
if trusted_token_usage > 0:
return trusted_token_usage

View File

@@ -1,4 +1,4 @@
from ..message import Message
from astrbot.core.agent.message import Message
class ContextTruncator:
@@ -20,6 +20,7 @@ class ContextTruncator:
Returns:
tuple: (system_messages, non_system_messages)
"""
first_non_system = 0
for i, msg in enumerate(messages):
@@ -34,19 +35,44 @@ class ContextTruncator:
truncated: list[Message],
original_messages: list[Message],
) -> list[Message]:
"""Ensure the result always contains the first user message right after
system messages. This is required by many LLM APIs (e.g. Zhipu) that
mandate a ``user`` message immediately following the ``system`` message.
"""Ensure the result always contains a `user` message immediately after
system messages, as required by some LLM APIs.
Optimization strategy:
- If `truncated` already begins with a `user` message, return it as-is.
- If a `user` message exists later in `truncated`, move that message to
be the first non-system message while preserving the relative order of
the remaining truncated messages (without mutating the original list).
- Otherwise, fall back to the first `user` message from
`original_messages`.
This reduces unnecessary duplication and ensures the required ordering.
"""
if truncated and truncated[0].role == "user":
return system_messages + truncated
# Locate the first user message from the *original* list.
# If a user message exists inside the truncated list, promote it to the front.
index_in_truncated = next(
(i for i, m in enumerate(truncated) if m.role == "user"),
None,
)
if index_in_truncated is not None:
# Build a new truncated list that places the found user message first,
# preserving the order of the other messages and avoiding in-place mutation.
user_msg = truncated[index_in_truncated]
new_truncated = [
user_msg,
*truncated[:index_in_truncated],
*truncated[index_in_truncated + 1 :],
]
return system_messages + new_truncated
# Fallback: find the first user message in the original messages.
first_user = next((m for m in original_messages if m.role == "user"), None)
if first_user is None:
# No user messages at all; return system messages + whatever was truncated.
return system_messages + truncated
return system_messages + [first_user] + truncated
return [*system_messages, first_user, *truncated]
def fix_messages(self, messages: list[Message]) -> list[Message]:
"""Fix the message list to ensure the validity of tool call and tool response pairing.
@@ -103,8 +129,7 @@ class ContextTruncator:
keep_most_recent_turns: int,
drop_turns: int = 1,
) -> list[Message]:
"""
Turn-based truncation strategy, which drops the oldest turns while keeping the most recent N turns.
"""Turn-based truncation strategy, which drops the oldest turns while keeping the most recent N turns.
A turn consists of a user message and an assistant message.
This method ensures that the truncated context list conforms to OpenAI's context format.
@@ -115,6 +140,7 @@ class ContextTruncator:
Returns:
The truncated list of messages.
"""
if keep_most_recent_turns == -1:
return messages
@@ -139,7 +165,9 @@ class ContextTruncator:
truncated_contexts = truncated_contexts[index:]
result = self._ensure_user_message(
system_messages, truncated_contexts, messages
system_messages,
truncated_contexts,
messages,
)
return self.fix_messages(result)
@@ -168,7 +196,9 @@ class ContextTruncator:
truncated_non_system = truncated_non_system[index:]
result = self._ensure_user_message(
system_messages, truncated_non_system, messages
system_messages,
truncated_non_system,
messages,
)
return self.fix_messages(result)
@@ -197,6 +227,8 @@ class ContextTruncator:
truncated_non_system = truncated_non_system[index:]
result = self._ensure_user_message(
system_messages, truncated_non_system, messages
system_messages,
truncated_non_system,
messages,
)
return self.fix_messages(result)

View File

@@ -1,3 +1,7 @@
"""MCP client
This file exists solely for backward compatibility and will be removed in a future version.
"""
import asyncio
import copy
import logging
@@ -7,7 +11,7 @@ import sys
from contextlib import AsyncExitStack
from datetime import timedelta
from pathlib import Path, PureWindowsPath
from typing import Any, Generic
from typing import Any, Generic, TextIO
import httpx
from tenacity import (
@@ -18,13 +22,14 @@ from tenacity import (
wait_exponential,
)
from astrbot import logger
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.utils.log_pipe import LogPipe
from .run_context import TContext
from .tool import FunctionTool
logger = logging.getLogger("astrbot")
_DEFAULT_STDIO_COMMAND_ALLOWLIST = frozenset(
{
"python",
@@ -100,7 +105,7 @@ try:
from mcp.client.sse import sse_client
except (ModuleNotFoundError, ImportError):
logger.warning(
"Warning: Missing 'mcp' dependency, MCP services will be unavailable."
"Warning: Missing 'mcp' dependency, MCP services will be unavailable.",
)
streamable_http_client_legacy = None
@@ -121,13 +126,31 @@ except (ModuleNotFoundError, ImportError):
)
class TenacityLogger:
"""Wraps a logging.Logger to satisfy tenacity's LoggerProtocol."""
__slots__ = ("_logger",)
_logger: logging.Logger
def __init__(self, logger: logging.Logger) -> None:
self._logger = logger
def log(
self,
level: int,
msg: str,
/,
*args: Any,
**kwargs: Any,
) -> None:
self._logger.log(level, msg, *args, **kwargs)
def _prepare_config(config: dict) -> dict:
"""Prepare configuration, handle nested format"""
if config.get("mcpServers"):
first_key = next(iter(config["mcpServers"]))
config = dict(config["mcpServers"][first_key])
else:
config = dict(config)
config = config["mcpServers"][first_key]
config.pop("active", None)
return config
@@ -157,11 +180,6 @@ def _get_stdio_command_allowlist() -> set[str]:
return allowed
def _is_stdio_config(config: dict) -> bool:
cfg = _prepare_config(config.copy())
return "url" not in cfg
def _validate_stdio_args(command_name: str, args: object) -> None:
if args is None:
return
@@ -213,7 +231,7 @@ def _validate_stdio_args(command_name: str, args: object) -> None:
def validate_mcp_stdio_config(config: dict) -> None:
"""Validate stdio MCP config before any subprocess can be spawned."""
"""Validate MCP stdio configuration in a backward-compatible way."""
cfg = _prepare_config(config.copy())
if "url" in cfg:
return
@@ -260,10 +278,10 @@ def _prepare_stdio_env(config: dict) -> dict:
def _merge_environment_variables(env: dict) -> dict:
"""合并环境变量处理Windows不区分大小写的情况"""
"""Merge environment variables in case-insensitive systems."""
merged = env.copy()
# 将用户环境变量转换为统一的大小写形式便于比较
# Use lower-case keys for case-insensitive matching on Windows.
user_keys_lower = {k.lower(): k for k in merged.keys()}
for sys_key, sys_value in os.environ.items():
@@ -331,7 +349,7 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]:
return True, ""
return False, f"HTTP {response.status}: {response.reason}"
except asyncio.TimeoutError:
except TimeoutError:
return False, f"Connection timeout: {timeout} seconds"
except Exception as e:
return False, f"{e!s}"
@@ -368,7 +386,7 @@ def _normalize_mcp_input_schema(schema: dict[str, Any]) -> dict[str, Any]:
if not isinstance(prop_schema, dict):
continue
original_prop_schema = original_properties.get(prop_name, {})
original_prop_schema = (original_properties or {}).get(prop_name, {})
prop_required = (
original_prop_schema.get("required")
if isinstance(original_prop_schema, dict)
@@ -402,6 +420,7 @@ class MCPClient:
self.tools: list[mcp.Tool] = []
self.server_errlogs: list[str] = []
self.running_event = asyncio.Event()
self.process_pid: int | None = None
# Store connection config for reconnection
self._mcp_server_config: dict | None = None
@@ -409,6 +428,24 @@ class MCPClient:
self._reconnect_lock = asyncio.Lock() # Lock for thread-safe reconnection
self._reconnecting: bool = False # For logging and debugging
@staticmethod
def _extract_stdio_process_pid(streams_context: object) -> int | None:
"""Best-effort extraction for stdio subprocess PID used by lease cleanup.
TODO(refactor): replace this async-generator frame introspection with a
stable MCP library hook once the upstream transport exposes process PID.
"""
generator = getattr(streams_context, "gen", None)
frame = getattr(generator, "ag_frame", None)
if frame is None:
return None
process = frame.f_locals.get("process")
pid = getattr(process, "pid", None)
try:
return int(pid) if pid is not None else None
except (TypeError, ValueError):
return None
async def connect_to_server(self, mcp_server_config: dict, name: str) -> None:
"""Connect to MCP server
@@ -424,17 +461,17 @@ class MCPClient:
# Store config for reconnection
self._mcp_server_config = mcp_server_config
self._server_name = name
self.process_pid = None
cfg = _prepare_config(mcp_server_config.copy())
def logging_callback(
msg: str | mcp.types.LoggingMessageNotificationParams,
async def logging_callback(
params: mcp.types.LoggingMessageNotificationParams,
) -> None:
# Handle MCP service error logs
if isinstance(msg, mcp.types.LoggingMessageNotificationParams):
if msg.level in ("warning", "error", "critical", "alert", "emergency"):
log_msg = f"[{msg.level.upper()}] {str(msg.data)}"
self.server_errlogs.append(log_msg)
if params.level in ("warning", "error", "critical", "alert", "emergency"):
log_msg = f"[{params.level.upper()}] {params.data!s}"
self.server_errlogs.append(log_msg)
if "url" in cfg:
success, error_msg = await _quick_test_mcp_connection(cfg)
@@ -456,19 +493,21 @@ class MCPClient:
timeout=cfg.get("timeout", 5),
sse_read_timeout=cfg.get("sse_read_timeout", 60 * 5),
)
streams = await self.exit_stack.enter_async_context(
read_stream, write_stream = await self.exit_stack.enter_async_context(
self._streams_context,
)
# Create a new client session
read_timeout = timedelta(seconds=cfg.get("session_read_timeout", 60))
self.session = await self.exit_stack.enter_async_context(
session = await self.exit_stack.enter_async_context(
mcp.ClientSession(
*streams,
read_stream=read_stream,
write_stream=write_stream,
read_timeout_seconds=read_timeout,
logging_callback=logging_callback, # type: ignore
logging_callback=logging_callback,
),
)
self.session = session
else:
timeout_seconds = cfg.get("timeout", 30)
sse_read_timeout_seconds = cfg.get("sse_read_timeout", 60 * 5)
@@ -508,17 +547,17 @@ class MCPClient:
# Create a new client session
read_timeout = timedelta(seconds=cfg.get("session_read_timeout", 60))
self.session = await self.exit_stack.enter_async_context(
session = await self.exit_stack.enter_async_context(
mcp.ClientSession(
read_stream=read_s,
write_stream=write_s,
read_timeout_seconds=read_timeout,
logging_callback=logging_callback, # type: ignore
logging_callback=logging_callback,
),
)
self.session = session
else:
validate_mcp_stdio_config(cfg)
cfg = _prepare_stdio_env(cfg)
server_params = mcp.StdioServerParameters(
**cfg,
@@ -534,25 +573,35 @@ class MCPClient:
"alert",
"emergency",
):
log_msg = f"[{msg.level.upper()}] {str(msg.data)}"
log_msg = f"[{msg.level.upper()}] {msg.data!s}"
self.server_errlogs.append(log_msg)
log_pipe = self.exit_stack.enter_context(
LogPipe(
level=logging.INFO,
logger=logger,
identifier=f"MCPServer-{name}",
callback=callback,
),
)
errlog_stream: TextIO = self.exit_stack.enter_context(
os.fdopen(os.dup(log_pipe.fileno()), "w"),
)
stdio_transport = await self.exit_stack.enter_async_context(
mcp.stdio_client(
server_params,
errlog=LogPipe(
level=logging.INFO,
logger=logger,
identifier=f"MCPServer-{name}",
callback=callback,
), # type: ignore
errlog=errlog_stream,
),
)
self.process_pid = self._extract_stdio_process_pid(stdio_transport)
# Create a new client session
self.session = await self.exit_stack.enter_async_context(
session = await self.exit_stack.enter_async_context(
mcp.ClientSession(*stdio_transport),
)
self.session = session
assert self.session is not None
await self.session.initialize()
async def list_tools_and_save(self) -> mcp.ListToolsResult:
@@ -570,12 +619,13 @@ class MCPClient:
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"
f"MCP Client {self._server_name} is already reconnecting, skipping",
)
return
@@ -585,7 +635,7 @@ class MCPClient:
self._reconnecting = True
try:
logger.info(
f"Attempting to reconnect to MCP server {self._server_name}..."
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)
@@ -603,11 +653,11 @@ class MCPClient:
await self.list_tools_and_save()
logger.info(
f"Successfully reconnected to MCP server {self._server_name}"
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}"
f"Failed to reconnect to MCP server {self._server_name}: {e}",
)
raise
finally:
@@ -632,13 +682,14 @@ class MCPClient:
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),
before_sleep=before_sleep_log(TenacityLogger(logger), logging.WARNING),
reraise=True,
)
async def _call_with_retry():
@@ -653,7 +704,7 @@ class MCPClient:
)
except anyio.ClosedResourceError:
logger.warning(
f"MCP tool {tool_name} call failed (ClosedResourceError), attempting to reconnect..."
f"MCP tool {tool_name} call failed (ClosedResourceError), attempting to reconnect...",
)
# Attempt to reconnect
await self._reconnect()
@@ -677,13 +728,18 @@ class MCPClient:
# Set running_event first to unblock any waiting tasks
self.running_event.set()
self.process_pid = None
class MCPTool(FunctionTool, Generic[TContext]):
"""A function tool that calls an MCP service."""
def __init__(
self, mcp_tool: mcp.Tool, mcp_client: MCPClient, mcp_server_name: str, **kwargs
self,
mcp_tool: mcp.Tool,
mcp_client: MCPClient,
mcp_server_name: str,
**kwargs,
) -> None:
super().__init__(
name=mcp_tool.name,
@@ -693,9 +749,12 @@ class MCPTool(FunctionTool, Generic[TContext]):
self.mcp_tool = mcp_tool
self.mcp_client = mcp_client
self.mcp_server_name = mcp_server_name
self.source = "mcp"
async def call(
self, context: ContextWrapper[TContext], **kwargs
self,
context: ContextWrapper[TContext],
**kwargs,
) -> mcp.types.CallToolResult:
return await self.mcp_client.call_tool_with_reconnect(
tool_name=self.mcp_tool.name,

View File

@@ -1,7 +1,7 @@
# Inspired by MoonshotAI/kosong, credits to MoonshotAI/kosong authors for the original implementation.
# License: Apache License 2.0
from typing import Any, ClassVar, Literal, TypeVar, cast
from typing import Any, ClassVar, Literal, Self, TypeVar, cast
from pydantic import (
BaseModel,
@@ -37,7 +37,9 @@ class ContentPart(BaseModel):
@classmethod
def __get_pydantic_core_schema__(
cls, source_type: Any, handler: GetCoreSchemaHandler
cls,
source_type: Any,
handler: GetCoreSchemaHandler,
) -> core_schema.CoreSchema:
# If we're dealing with the base ContentPart class, use custom validation
if cls.__name__ == "ContentPart":
@@ -49,12 +51,12 @@ class ContentPart(BaseModel):
# if it's a dict with a type field, dispatch to the appropriate subclass
if isinstance(value, dict) and "type" in value:
type_value: Any | None = cast(dict[str, Any], value).get("type")
type_value: Any | None = cast("dict[str, Any]", value).get("type")
if not isinstance(type_value, str):
raise ValueError(f"Cannot validate {value} as ContentPart")
target_class = cls.__content_part_registry[type_value]
part = target_class.model_validate(value)
if cast(dict[str, Any], value).get("_no_save"):
if cast("dict[str, Any]", value).get("_no_save"):
part._no_save = True
return part
@@ -65,7 +67,7 @@ class ContentPart(BaseModel):
# for subclasses, use the default schema
return handler(source_type)
def mark_as_temp(self: ContentPartT) -> ContentPartT:
def mark_as_temp(self) -> Self:
"""Mark this content part as provider-facing only, not persisted."""
self._no_save = True
return self
@@ -78,8 +80,7 @@ class ContentPart(BaseModel):
class TextPart(ContentPart):
"""
>>> TextPart(text="Hello, world!").model_dump()
"""TextPart(text="Hello, world!").model_dump()
{'type': 'text', 'text': 'Hello, world!'}
"""
@@ -88,8 +89,7 @@ class TextPart(ContentPart):
class ThinkPart(ContentPart):
"""
>>> ThinkPart(think="I think I need to think about this.").model_dump()
"""ThinkPart(think="I think I need to think about this.").model_dump()
{'type': 'think', 'think': 'I think I need to think about this.', 'encrypted': None}
"""
@@ -110,8 +110,7 @@ class ThinkPart(ContentPart):
class ImageURLPart(ContentPart):
"""
>>> ImageURLPart(image_url="http://example.com/image.jpg").model_dump()
"""ImageURLPart(image_url="http://example.com/image.jpg").model_dump()
{'type': 'image_url', 'image_url': 'http://example.com/image.jpg'}
"""
@@ -126,8 +125,7 @@ class ImageURLPart(ContentPart):
class AudioURLPart(ContentPart):
"""
>>> AudioURLPart(audio_url=AudioURLPart.AudioURL(url="https://example.com/audio.mp3")).model_dump()
"""AudioURLPart(audio_url=AudioURLPart.AudioURL(url="https://example.com/audio.mp3")).model_dump()
{'type': 'audio_url', 'audio_url': {'url': 'https://example.com/audio.mp3', 'id': None}}
"""
@@ -142,10 +140,9 @@ class AudioURLPart(ContentPart):
class ToolCall(BaseModel):
"""
A tool call requested by the assistant.
"""A tool call requested by the assistant.
>>> ToolCall(
ToolCall(
... id="123",
... function=ToolCall.FunctionBody(
... name="function",
@@ -232,7 +229,7 @@ class Message(BaseModel):
# other all cases: content is required
if self.content is None:
raise ValueError(
"content is required unless role='assistant' and tool_calls is not None"
"content is required unless role='assistant' and tool_calls is not None",
)
return self
@@ -356,6 +353,8 @@ def dump_messages_with_checkpoints(messages: list[Message]) -> list[dict]:
dumped.append(message_data)
if message._checkpoint_after is not None:
dumped.append(
CheckpointMessageSegment(content=message._checkpoint_after).model_dump()
CheckpointMessageSegment(
content=message._checkpoint_after,
).model_dump(),
)
return dumped

View File

@@ -1,4 +1,4 @@
from typing import Any, Generic
from typing import Any, Generic, cast
from pydantic import Field
from pydantic.dataclasses import dataclass
@@ -13,7 +13,7 @@ TContext = TypeVar("TContext", default=Any)
class ContextWrapper(Generic[TContext]):
"""A context for running an agent, which can be used to pass additional data or state."""
context: TContext
context: TContext = cast("TContext", None)
messages: list[Message] = Field(default_factory=list)
"""This field stores the llm message context for the agent run, agent runners will maintain this field automatically."""
tool_call_timeout: int = 120 # Default tool call timeout in seconds

View File

@@ -1,13 +1,16 @@
import abc
import typing as T
import asyncio
from collections.abc import AsyncGenerator
from enum import Enum, auto
from typing import Any, Generic
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 astrbot.core.agent.hooks import BaseAgentRunHooks
from astrbot.core.agent.response import AgentResponse
from astrbot.core.agent.run_context import ContextWrapper, TContext
from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor
from astrbot.core.provider.entities import LLMResponse, ProviderRequest
from astrbot.core.provider.provider import Provider
class AgentState(Enum):
@@ -19,13 +22,33 @@ class AgentState(Enum):
ERROR = auto() # Error state
class BaseAgentRunner(T.Generic[TContext]):
class BaseAgentRunner(Generic[TContext]):
def __init__(
self,
):
self.tasks: set[asyncio.Task[object]] = set()
self._state = AgentState.IDLE
@abc.abstractmethod
async def reset(
self,
provider: Provider,
request: ProviderRequest,
run_context: ContextWrapper[TContext],
tool_executor: BaseFunctionToolExecutor[TContext],
agent_hooks: BaseAgentRunHooks[TContext],
**kwargs: T.Any,
streaming: bool = False,
enforce_max_turns: int = -1,
llm_compress_instruction: str | None = None,
llm_compress_keep_recent: int = 0,
llm_compress_provider: Provider | None = None,
truncate_turns: int = 1,
custom_token_counter: Any = None,
custom_compressor: Any = None,
tool_schema_mode: str | None = "full",
fallback_providers: list[Provider] | None = None,
provider_config: dict | None = None,
**kwargs: Any,
) -> None:
"""Reset the agent to its initial state.
This method should be called before starting a new run.
@@ -33,14 +56,12 @@ class BaseAgentRunner(T.Generic[TContext]):
...
@abc.abstractmethod
async def step(self) -> T.AsyncGenerator[AgentResponse, None]:
def step(self) -> AsyncGenerator[AgentResponse, None]:
"""Process a single step of the agent."""
...
@abc.abstractmethod
async def step_until_done(
self, max_step: int
) -> T.AsyncGenerator[AgentResponse, None]:
def step_until_done(self, max_step: int) -> AsyncGenerator[AgentResponse, None]:
"""Process steps until the agent is done."""
...

View File

@@ -1,29 +1,25 @@
import base64
import json
import sys
import typing as T
from typing import Any, override
import astrbot.core.message.components as Comp
from astrbot import logger
from astrbot.core import sp
from astrbot.core.agent.hooks import BaseAgentRunHooks
from astrbot.core.agent.message import is_checkpoint_message
from astrbot.core.agent.response import AgentResponse, AgentResponseData
from astrbot.core.agent.run_context import ContextWrapper, TContext
from astrbot.core.agent.runners.base import AgentState, BaseAgentRunner
from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import (
LLMResponse,
ProviderRequest,
)
from astrbot.core.utils.media_utils import MediaResolver, describe_media_ref
from astrbot.core.provider.provider import Provider
from ...hooks import BaseAgentRunHooks
from ...message import is_checkpoint_message
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"""
@@ -31,32 +27,45 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
@override
async def reset(
self,
provider: Provider,
request: ProviderRequest,
run_context: ContextWrapper[TContext],
tool_executor: BaseFunctionToolExecutor[TContext],
agent_hooks: BaseAgentRunHooks[TContext],
provider_config: dict,
**kwargs: T.Any,
streaming: bool = False,
enforce_max_turns: int = -1,
llm_compress_instruction: str | None = None,
llm_compress_keep_recent: int = 0,
llm_compress_provider: Provider | None = None,
truncate_turns: int = 1,
custom_token_counter: Any = None,
custom_compressor: Any = None,
tool_schema_mode: str | None = "full",
fallback_providers: list[Provider] | None = None,
provider_config: dict | None = None,
**kwargs: Any,
) -> None:
self.req = request
self.streaming = kwargs.get("streaming", False)
self.streaming = streaming
self.final_llm_resp = None
self._state = AgentState.IDLE
self.agent_hooks = agent_hooks
self.run_context = run_context
provider_config = provider_config or {}
self.api_key = provider_config.get("coze_api_key", "")
if not self.api_key:
raise Exception("Coze 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 不能为空")
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:// 开头",
"Coze API Base URL 格式不正确,必须以 http:// 或 https:// 开头",
)
self.timeout = provider_config.get("timeout", 120)
@@ -72,9 +81,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
@override
async def step(self):
"""
执行 Coze Agent 的一个步骤
"""
"""执行 Coze Agent 的一个步骤"""
if not self.req:
raise ValueError("Request is not set. Please call reset() first.")
@@ -84,7 +91,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
except Exception as e:
logger.error(f"Error in on_agent_begin hook: {e}", exc_info=True)
# 开始处理转换到运行状态
# 开始处理,转换到运行状态
self._transition_state(AgentState.RUNNING)
try:
@@ -92,24 +99,23 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
async for response in self._execute_coze_request():
yield response
except Exception as e:
logger.error(f"Coze 请求失败{str(e)}")
logger.error(f"Coze 请求失败:{e!s}")
self._transition_state(AgentState.ERROR)
self.final_llm_resp = LLMResponse(
role="err", completion_text=f"Coze 请求失败:{str(e)}"
role="err",
completion_text=f"Coze 请求失败:{e!s}",
)
yield AgentResponse(
type="err",
data=AgentResponseData(
chain=MessageChain().message(f"Coze 请求失败{str(e)}")
chain=MessageChain().message(f"Coze 请求失败:{e!s}"),
),
)
finally:
await self.api_client.close()
@override
async def step_until_done(
self, max_step: int = 30
) -> T.AsyncGenerator[AgentResponse, None]:
async def step_until_done(self, max_step: int):
while not self.done():
async for resp in self.step():
yield resp
@@ -155,7 +161,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
# 处理上下文中的图片
content = ctx["content"]
if isinstance(content, list):
# 多模态内容需要处理图片
# 多模态内容,需要处理图片
processed_content = []
for item in content:
if isinstance(item, dict):
@@ -169,7 +175,8 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
if url:
file_id = (
await self._download_and_upload_image(
url, session_id
url,
session_id,
)
)
processed_content.append(
@@ -177,7 +184,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
"type": "file",
"file_id": file_id,
"file_url": url,
}
},
)
except Exception as e:
logger.warning(f"处理上下文图片失败: {e}")
@@ -189,7 +196,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
"role": ctx["role"],
"content": processed_content,
"content_type": "object_string",
}
},
)
else:
# 纯文本内容
@@ -198,7 +205,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
"role": ctx["role"],
"content": content,
"content_type": "text",
}
},
)
# 构建当前消息
@@ -210,23 +217,18 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
object_string_content.append({"type": "text", "text": prompt})
for url in image_urls:
# the url is a base64 string
try:
file_id = await self._download_and_upload_image(
url,
session_id,
)
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(
"处理图片失败 %s: %s",
describe_media_ref(url),
e,
)
logger.warning(f"处理图片失败 {url}: {e}")
continue
if object_string_content:
@@ -236,7 +238,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
"role": "user",
"content": content,
"content_type": "object_string",
}
},
)
elif prompt:
# 纯文本
@@ -285,12 +287,12 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
accumulated_content += content
message_started = True
# 如果是流式响应发送增量数据
# 如果是流式响应,发送增量数据
if self.streaming:
yield AgentResponse(
type="streaming_delta",
data=AgentResponseData(
chain=MessageChain().message(content)
chain=MessageChain().message(content),
),
)
@@ -336,7 +338,7 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
image_url: str,
session_id: str | None = None,
) -> str:
"""下载图片并上传到 Coze返回 file_id"""
"""下载图片并上传到 Coze,返回 file_id"""
import hashlib
# 计算哈希实现缓存
@@ -352,20 +354,17 @@ class CozeAgentRunner(BaseAgentRunner[TContext]):
return file_id
try:
image_bytes = await MediaResolver(
image_url,
media_type="image",
).to_bytes()
file_id = await self.api_client.upload_file(image_bytes)
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}")
logger.debug(f"[Coze] 图片上传成功并缓存,file_id: {file_id}")
return file_id
except Exception as e:
logger.error("处理图片失败 %s: %s", describe_media_ref(image_url), e)
logger.error(f"处理图片失败 {image_url}: {e!s}")
raise Exception(f"处理图片失败: {e!s}") from e
@override

View File

@@ -66,7 +66,7 @@ class CozeAPIClient:
timeout=aiohttp.ClientTimeout(total=60),
) as response:
if response.status == 401:
raise Exception("Coze API 认证失败请检查 API Key 是否正确")
raise Exception("Coze API 认证失败,请检查 API Key 是否正确")
response_text = await response.text()
logger.debug(
@@ -75,27 +75,27 @@ class CozeAPIClient:
if response.status != 200:
raise Exception(
f"文件上传失败状态码: {response.status}, 响应: {response_text}",
f"文件上传失败,状态码: {response.status}, 响应: {response_text}",
)
try:
result = await response.json()
except json.JSONDecodeError:
raise Exception(f"文件上传响应解析失败: {response_text}")
raise Exception(f"文件上传响应解析失败: {response_text}") from None
if result.get("code") != 0:
raise Exception(f"文件上传失败: {result.get('msg', '未知错误')}")
file_id = result["data"]["id"]
logger.debug(f"[Coze] 图片上传成功file_id: {file_id}")
logger.debug(f"[Coze] 图片上传成功,file_id: {file_id}")
return file_id
except asyncio.TimeoutError:
except TimeoutError:
logger.error("文件上传超时")
raise Exception("文件上传超时")
raise Exception("文件上传超时") from None
except Exception as e:
logger.error(f"文件上传失败: {e!s}")
raise Exception(f"文件上传失败: {e!s}")
raise Exception(f"文件上传失败: {e!s}") from e
async def download_image(self, image_url: str) -> bytes:
"""下载图片并返回字节数据
@@ -111,14 +111,14 @@ class CozeAPIClient:
try:
async with session.get(image_url) as response:
if response.status != 200:
raise Exception(f"下载图片失败状态码: {response.status}")
raise Exception(f"下载图片失败,状态码: {response.status}")
image_data = await response.read()
return image_data
except Exception as e:
logger.error(f"下载图片失败 {image_url}: {e!s}")
raise Exception(f"下载图片失败: {e!s}")
raise Exception(f"下载图片失败: {e!s}") from e
async def chat_messages(
self,
@@ -145,7 +145,7 @@ class CozeAPIClient:
session = await self._ensure_session()
url = f"{self.api_base}/v3/chat"
payload = {
payload: dict[str, Any] = {
"bot_id": bot_id,
"user_id": user_id,
"stream": stream,
@@ -169,10 +169,10 @@ class CozeAPIClient:
timeout=aiohttp.ClientTimeout(total=timeout),
) as response:
if response.status == 401:
raise Exception("Coze API 认证失败请检查 API Key 是否正确")
raise Exception("Coze API 认证失败,请检查 API Key 是否正确")
if response.status != 200:
raise Exception(f"Coze API 流式请求失败状态码: {response.status}")
raise Exception(f"Coze API 流式请求失败,状态码: {response.status}")
# SSE
buffer = ""
@@ -203,10 +203,10 @@ class CozeAPIClient:
except json.JSONDecodeError:
event_data = {"content": data_str}
except asyncio.TimeoutError:
raise Exception(f"Coze API 流式请求超时 ({timeout}秒)")
except TimeoutError:
raise Exception(f"Coze API 流式请求超时 ({timeout}秒)") from None
except Exception as e:
raise Exception(f"Coze API 流式请求失败: {e!s}")
raise Exception(f"Coze API 流式请求失败: {e!s}") from e
async def clear_context(self, conversation_id: str):
"""清空会话上下文
@@ -226,20 +226,20 @@ class CozeAPIClient:
response_text = await response.text()
if response.status == 401:
raise Exception("Coze API 认证失败请检查 API Key 是否正确")
raise Exception("Coze API 认证失败,请检查 API Key 是否正确")
if response.status != 200:
raise Exception(f"Coze API 请求失败状态码: {response.status}")
raise Exception(f"Coze API 请求失败,状态码: {response.status}")
try:
return json.loads(response_text)
except json.JSONDecodeError:
raise Exception("Coze API 返回非JSON格式")
raise Exception("Coze API 返回非JSON格式") from None
except asyncio.TimeoutError:
raise Exception("Coze API 请求超时")
except TimeoutError:
raise Exception("Coze API 请求超时") from None
except aiohttp.ClientError as e:
raise Exception(f"Coze API 请求失败: {e!s}")
raise Exception(f"Coze API 请求失败: {e!s}") from e
async def get_message_list(
self,
@@ -275,7 +275,7 @@ class CozeAPIClient:
except Exception as e:
logger.error(f"获取Coze消息列表失败: {e!s}")
raise Exception(f"获取Coze消息列表失败: {e!s}")
raise Exception(f"获取Coze消息列表失败: {e!s}") from e
async def close(self) -> None:
"""关闭会话"""
@@ -288,17 +288,18 @@ if __name__ == "__main__":
import asyncio
import os
import anyio
async def test_coze_api_client() -> None:
api_key = os.getenv("COZE_API_KEY", "")
bot_id = os.getenv("COZE_BOT_ID", "")
client = CozeAPIClient(api_key=api_key)
try:
with open("README.md", "rb") as f:
file_data = f.read()
async with await anyio.open_file("README.md", "rb") as f:
file_data = await f.read()
file_id = await client.upload_file(file_data)
print(f"Uploaded file_id: {file_id}")
async for event in client.chat_messages(
async for _event in client.chat_messages(
bot_id=bot_id,
user_id="test_user",
additional_messages=[
@@ -316,7 +317,7 @@ if __name__ == "__main__":
],
stream=True,
):
print(f"Event: {event}")
pass
finally:
await client.close()

View File

@@ -2,30 +2,26 @@ import asyncio
import functools
import queue
import re
import sys
import threading
import typing as T
from collections.abc import AsyncGenerator
from typing import Any, override
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.agent.hooks import BaseAgentRunHooks
from astrbot.core.agent.response import AgentResponseData
from astrbot.core.agent.run_context import ContextWrapper, TContext
from astrbot.core.agent.runners.base import AgentResponse, AgentState, BaseAgentRunner
from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor
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
from astrbot.core.provider.provider import Provider
class DashscopeAgentRunner(BaseAgentRunner[TContext]):
@@ -34,28 +30,41 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
@override
async def reset(
self,
provider: Provider,
request: ProviderRequest,
run_context: ContextWrapper[TContext],
tool_executor: BaseFunctionToolExecutor[TContext],
agent_hooks: BaseAgentRunHooks[TContext],
provider_config: dict,
**kwargs: T.Any,
streaming: bool = False,
enforce_max_turns: int = -1,
llm_compress_instruction: str | None = None,
llm_compress_keep_recent: int = 0,
llm_compress_provider: Provider | None = None,
truncate_turns: int = 1,
custom_token_counter: Any = None,
custom_compressor: Any = None,
tool_schema_mode: str | None = "full",
fallback_providers: list[Provider] | None = None,
provider_config: dict | None = None,
**kwargs: Any,
) -> None:
self.req = request
self.streaming = kwargs.get("streaming", False)
self.final_llm_resp = None
self.streaming = streaming
self.final_llm_resp: LLMResponse | None = None
self._state = AgentState.IDLE
self.agent_hooks = agent_hooks
self.run_context = run_context
provider_config = provider_config or {}
self.api_key = provider_config.get("dashscope_api_key", "")
if not self.api_key:
raise Exception("阿里云百炼 API Key 不能为空")
raise Exception("阿里云百炼 API Key 不能为空")
self.app_id = provider_config.get("dashscope_app_id", "")
if not self.app_id:
raise Exception("阿里云百炼 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 类型不能为空")
raise Exception("阿里云百炼 APP 类型不能为空")
self.variables: dict = provider_config.get("variables", {}) or {}
self.rag_options: dict = provider_config.get("rag_options", {})
@@ -83,9 +92,7 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
@override
async def step(self):
"""
执行 Dashscope Agent 的一个步骤
"""
"""执行 Dashscope Agent 的一个步骤"""
if not self.req:
raise ValueError("Request is not set. Please call reset() first.")
@@ -95,7 +102,7 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
except Exception as e:
logger.error(f"Error in on_agent_begin hook: {e}", exc_info=True)
# 开始处理转换到运行状态
# 开始处理,转换到运行状态
self._transition_state(AgentState.RUNNING)
try:
@@ -103,28 +110,29 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
async for response in self._execute_dashscope_request():
yield response
except Exception as e:
logger.error(f"阿里云百炼请求失败{str(e)}")
logger.error(f"阿里云百炼请求失败:{e!s}")
self._transition_state(AgentState.ERROR)
self.final_llm_resp = LLMResponse(
role="err", completion_text=f"阿里云百炼请求失败:{str(e)}"
role="err",
completion_text=f"阿里云百炼请求失败:{e!s}",
)
yield AgentResponse(
type="err",
data=AgentResponseData(
chain=MessageChain().message(f"阿里云百炼请求失败{str(e)}")
chain=MessageChain().message(f"阿里云百炼请求失败:{e!s}"),
),
)
@override
async def step_until_done(
self, max_step: int = 30
) -> T.AsyncGenerator[AgentResponse, None]:
async def step_until_done(self, max_step: int):
while not self.done():
async for resp in self.step():
yield resp
def _consume_sync_generator(
self, response: T.Any, response_queue: queue.Queue
self,
response: Any,
response_queue: queue.Queue,
) -> None:
"""在线程中消费同步generator,将结果放入队列
@@ -145,7 +153,9 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
response_queue.put(("done", None))
async def _process_stream_chunk(
self, chunk: ApplicationResponse, output_text: str
self,
chunk: ApplicationResponse,
output_text: str,
) -> tuple[str, list | None, AgentResponse | None]:
"""处理流式响应的单个chunk
@@ -161,7 +171,7 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
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",
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 = (
@@ -180,7 +190,8 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
),
)
chunk_text = chunk.output.get("text", "") or ""
chunk_text_value = chunk.output.get("text", "")
chunk_text = chunk_text_value if isinstance(chunk_text_value, str) else ""
# RAG 引用脚标格式化
chunk_text = re.sub(r"<ref>\[(\d+)\]</ref>", r"[\1]", chunk_text)
@@ -193,7 +204,10 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
)
# 获取文档引用
doc_references = chunk.output.get("doc_references", None)
raw_doc_references = chunk.output.get("doc_references")
doc_references = (
raw_doc_references if isinstance(raw_doc_references, list) else None
)
return output_text, doc_references, response
@@ -217,7 +231,11 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
return f"\n\n回答来源:\n{ref_str}"
async def _build_request_payload(
self, prompt: str, session_id: str, contexts: list, system_prompt: str
self,
prompt: str,
session_id: str,
contexts: list,
system_prompt: str,
) -> dict:
"""构建请求payload
@@ -238,15 +256,17 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
default="",
)
# 获得会话变量
payload_vars = self.variables.copy()
session_var = await sp.get_async(
scope="umo",
scope_id=session_id,
key="session_variables",
default={},
payload_vars: dict = self.variables.copy()
session_var: dict = (
await sp.get_async(
scope="umo",
scope_id=session_id,
key="session_variables",
default={},
)
or {}
)
payload_vars.update(session_var)
if (
self.dashscope_app_type in ["agent", "dialog-workflow"]
and not self.has_rag_options()
@@ -263,23 +283,24 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
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
# 不支持多轮对话的
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]:
self,
response: Any,
session_id: str,
) -> AsyncGenerator[AgentResponse, None]:
"""处理流式响应
Args:
@@ -289,7 +310,7 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
AgentResponse 对象
"""
response_queue = queue.Queue()
response_queue: queue.Queue[tuple[str, Any]] = queue.Queue()
consumer_thread = threading.Thread(
target=self._consume_sync_generator,
args=(response, response_queue),
@@ -303,7 +324,10 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
while True:
try:
item_type, item_data = await asyncio.get_running_loop().run_in_executor(
None, response_queue.get, True, 1
None,
response_queue.get,
True,
1,
)
except queue.Empty:
continue
@@ -311,6 +335,10 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
if item_type == "done":
break
elif item_type == "error":
if not isinstance(item_data, BaseException):
raise RuntimeError(
f"Unexpected Dashscope error payload: {item_data!r}",
)
raise item_data
elif item_type == "data":
chunk = item_data
@@ -319,14 +347,14 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
(
output_text,
chunk_doc_refs,
response,
agent_response,
) = await self._process_stream_chunk(chunk, output_text)
if response:
if response.type == "err":
yield response
if agent_response:
if agent_response.type == "err":
yield agent_response
return
yield response
yield agent_response
if chunk_doc_refs:
doc_references = chunk_doc_refs
@@ -352,11 +380,12 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
# 创建最终响应
chain = MessageChain(chain=[Comp.Plain(output_text)])
self.final_llm_resp = LLMResponse(role="assistant", result_chain=chain)
final_llm_resp = LLMResponse(role="assistant", result_chain=chain)
self.final_llm_resp = final_llm_resp
self._transition_state(AgentState.DONE)
try:
await self.agent_hooks.on_agent_done(self.run_context, self.final_llm_resp)
await self.agent_hooks.on_agent_done(self.run_context, final_llm_resp)
except Exception as e:
logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
@@ -376,11 +405,14 @@ class DashscopeAgentRunner(BaseAgentRunner[TContext]):
# 检查图片输入
if image_urls:
logger.warning("阿里云百炼暂不支持图片输入将自动忽略图片内容")
logger.warning("阿里云百炼暂不支持图片输入,将自动忽略图片内容")
# 构建请求payload
payload = await self._build_request_payload(
prompt, session_id, contexts, system_prompt
prompt,
session_id,
contexts,
system_prompt,
)
if not self.streaming:

View File

@@ -1,26 +1,28 @@
import asyncio
import hashlib
import json
import sys
import typing as T
from collections import deque
from dataclasses import dataclass, field
from typing import Any, override
from uuid import uuid4
import astrbot.core.message.components as Comp
from astrbot import logger
from astrbot.core import sp
from astrbot.core.agent.hooks import BaseAgentRunHooks
from astrbot.core.agent.response import AgentResponse, AgentResponseData
from astrbot.core.agent.run_context import ContextWrapper, TContext
from astrbot.core.agent.runners.base import AgentState, BaseAgentRunner
from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import (
LLMResponse,
ProviderRequest,
)
from astrbot.core.provider.provider import Provider
from astrbot.core.utils.config_number import coerce_int_config
from ...hooks import BaseAgentRunHooks
from ...response import AgentResponseData
from ...run_context import ContextWrapper, TContext
from ..base import AgentResponse, AgentState, BaseAgentRunner
from .constants import DEERFLOW_SESSION_PREFIX, DEERFLOW_THREAD_ID_KEY
from .deerflow_api_client import DeerFlowAPIClient
from .deerflow_content_mapper import (
@@ -41,16 +43,12 @@ from .deerflow_stream_utils import (
get_message_id,
)
if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import override
class DeerFlowAgentRunner(BaseAgentRunner[TContext]):
"""DeerFlow Agent Runner via LangGraph HTTP API."""
_MAX_VALUES_HISTORY = 200
final_llm_resp: LLMResponse | None
@dataclass(frozen=True)
class _RunnerConfig:
@@ -131,7 +129,9 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]):
logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
async def _finish_with_result(
self, chain: MessageChain, role: str
self,
chain: MessageChain,
role: str,
) -> AgentResponse:
self.final_llm_resp = LLMResponse(
role=role,
@@ -248,7 +248,7 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]):
await old_client.close()
except Exception as e:
logger.warning(
f"Failed to close previous DeerFlow API client cleanly: {e}"
f"Failed to close previous DeerFlow API client cleanly: {e}",
)
self.api_client = DeerFlowAPIClient(
@@ -262,20 +262,32 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]):
@override
async def reset(
self,
provider: Provider,
request: ProviderRequest,
run_context: ContextWrapper[TContext],
tool_executor: BaseFunctionToolExecutor[TContext],
agent_hooks: BaseAgentRunHooks[TContext],
provider_config: dict,
**kwargs: T.Any,
streaming: bool = False,
enforce_max_turns: int = -1,
llm_compress_instruction: str | None = None,
llm_compress_keep_recent: int = 0,
llm_compress_provider: Provider | None = None,
truncate_turns: int = 1,
custom_token_counter: Any = None,
custom_compressor: Any = None,
tool_schema_mode: str | None = "full",
fallback_providers: list[Provider] | None = None,
provider_config: dict | None = None,
**kwargs: Any,
) -> None:
self.req = request
self.streaming = kwargs.get("streaming", False)
self.streaming = streaming
self.final_llm_resp = None
self._state = AgentState.IDLE
self.agent_hooks = agent_hooks
self.run_context = run_context
await self._load_config_and_client(provider_config)
await self._load_config_and_client(provider_config or {})
@override
async def step(self):
@@ -304,9 +316,7 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]):
yield await self._finish_with_error(err_msg)
@override
async def step_until_done(
self, max_step: int = 30
) -> T.AsyncGenerator[AgentResponse, None]:
async def step_until_done(self, max_step: int):
if max_step <= 0:
raise ValueError("max_step must be greater than 0")
@@ -318,7 +328,7 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]):
if not self.done():
raise RuntimeError(
f"DeerFlow agent reached max_step ({max_step}) without completion."
f"DeerFlow agent reached max_step ({max_step}) without completion.",
)
def _extract_new_messages_from_values(
@@ -383,7 +393,7 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]):
thread_id = thread.get("thread_id", "")
if not thread_id:
raise Exception(
f"DeerFlow create thread returned invalid payload: {thread}"
f"DeerFlow create thread returned invalid payload: {thread}",
)
await sp.put_async(
@@ -539,7 +549,7 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]):
AgentResponse(
type="streaming_delta",
data=AgentResponseData(chain=MessageChain().message(delta)),
)
),
]
if delta_text:
@@ -549,9 +559,9 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]):
AgentResponse(
type="streaming_delta",
data=AgentResponseData(
chain=MessageChain().message(delta_text)
chain=MessageChain().message(delta_text),
),
)
),
]
return []
@@ -603,7 +613,7 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]):
self._update_text_and_maybe_stream(
state=state,
new_full_text=latest_text or None,
)
),
)
return responses
@@ -620,7 +630,7 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]):
self._update_text_and_maybe_stream(
state=state,
delta_text=delta,
)
),
)
maybe_clarification = extract_clarification_from_event_data(data)
@@ -737,7 +747,7 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]):
if event_type == "end":
break
except (asyncio.TimeoutError, TimeoutError):
except TimeoutError:
logger.warning(
"DeerFlow stream timed out after %ss for thread_id=%s; returning partial result.",
self.timeout,

View File

@@ -1,7 +1,8 @@
import codecs
import json
import types
from collections.abc import AsyncGenerator
from typing import Any
from typing import Any, Self
from aiohttp import ClientResponse, ClientSession, ClientTimeout
@@ -155,26 +156,26 @@ class DeerFlowAPIClient:
self._session = ClientSession(trust_env=True)
return self._session
async def __aenter__(self) -> "DeerFlowAPIClient":
async def __aenter__(self) -> Self:
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: object | None,
tb: types.TracebackType | None,
) -> None:
await self.close()
async def create_thread(self, timeout: float = 20) -> dict[str, Any]:
session = self._get_session()
url = f"{self.api_base}/api/langgraph/threads"
payload = {"metadata": {}}
payload: dict[str, dict[str, object]] = {"metadata": {}}
async with session.post(
url,
json=payload,
headers=self.headers,
timeout=timeout,
timeout=ClientTimeout(total=timeout),
proxy=self.proxy,
) as resp:
if resp.status not in (200, 201):
@@ -217,7 +218,8 @@ class DeerFlowAPIClient:
input_payload = payload.get("input")
message_count = 0
if isinstance(input_payload, dict) and isinstance(
input_payload.get("messages"), list
input_payload.get("messages"),
list,
):
message_count = len(input_payload["messages"])
# Log only a minimal summary to avoid exposing sensitive user content.
@@ -290,7 +292,7 @@ class DeerFlowAPIClient:
return
logger.warning(
"DeerFlowAPIClient garbage collected with unclosed session; "
"explicit close() should be called by runner lifecycle (or `async with`)."
"explicit close() should be called by runner lifecycle (or `async with`).",
)
@property

View File

@@ -62,7 +62,7 @@ def build_user_content(prompt: str, image_urls: list[str]) -> Any:
if not is_likely_base64_image(url):
skipped_invalid_images += 1
logger.debug(
"Skipped DeerFlow image input because it is neither URL/data URI nor valid base64."
"Skipped DeerFlow image input because it is neither URL/data URI nor valid base64.",
)
continue
compact_base64 = url.replace("\n", "").replace("\r", "")
@@ -250,14 +250,18 @@ def append_components_from_content(
if "content" in content:
append_components_from_content(
content.get("content"), components, image_resolver
content.get("content"),
components,
image_resolver,
)
return
kwargs = content.get("kwargs")
if isinstance(kwargs, dict) and "content" in kwargs:
append_components_from_content(
kwargs.get("content"), components, image_resolver
kwargs.get("content"),
components,
image_resolver,
)

View File

@@ -1,25 +1,23 @@
import sys
import typing as T
import base64
import os
from typing import Any, override
import astrbot.core.message.components as Comp
from astrbot.core import logger, sp
from astrbot.core.agent.hooks import BaseAgentRunHooks
from astrbot.core.agent.response import AgentResponseData
from astrbot.core.agent.run_context import ContextWrapper, TContext
from astrbot.core.agent.runners.base import AgentResponse, AgentState, BaseAgentRunner
from astrbot.core.agent.runners.dify.dify_api_client import DifyAPIClient
from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import (
LLMResponse,
ProviderRequest,
)
from astrbot.core.utils.media_utils import MediaResolver
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
from astrbot.core.provider.provider import Provider
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.io import download_file
class DifyAgentRunner(BaseAgentRunner[TContext]):
@@ -28,19 +26,32 @@ class DifyAgentRunner(BaseAgentRunner[TContext]):
@override
async def reset(
self,
provider: Provider,
request: ProviderRequest,
run_context: ContextWrapper[TContext],
tool_executor: BaseFunctionToolExecutor[TContext],
agent_hooks: BaseAgentRunHooks[TContext],
provider_config: dict,
**kwargs: T.Any,
streaming: bool = False,
enforce_max_turns: int = -1,
llm_compress_instruction: str | None = None,
llm_compress_keep_recent: int = 0,
llm_compress_provider: Provider | None = None,
truncate_turns: int = 1,
custom_token_counter: Any = None,
custom_compressor: Any = None,
tool_schema_mode: str | None = "full",
fallback_providers: list[Provider] | None = None,
provider_config: dict | None = None,
**kwargs: Any,
) -> None:
self.req = request
self.streaming = kwargs.get("streaming", False)
self.streaming = streaming
self.final_llm_resp = None
self._state = AgentState.IDLE
self.agent_hooks = agent_hooks
self.run_context = run_context
provider_config = provider_config or {}
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")
@@ -61,9 +72,7 @@ class DifyAgentRunner(BaseAgentRunner[TContext]):
@override
async def step(self):
"""
执行 Dify Agent 的一个步骤
"""
"""执行 Dify Agent 的一个步骤"""
if not self.req:
raise ValueError("Request is not set. Please call reset() first.")
@@ -73,7 +82,7 @@ class DifyAgentRunner(BaseAgentRunner[TContext]):
except Exception as e:
logger.error(f"Error in on_agent_begin hook: {e}", exc_info=True)
# 开始处理转换到运行状态
# 开始处理,转换到运行状态
self._transition_state(AgentState.RUNNING)
try:
@@ -81,64 +90,27 @@ class DifyAgentRunner(BaseAgentRunner[TContext]):
async for response in self._execute_dify_request():
yield response
except Exception as e:
logger.error(f"Dify 请求失败{str(e)}")
logger.error(f"Dify 请求失败:{e!s}")
self._transition_state(AgentState.ERROR)
self.final_llm_resp = LLMResponse(
role="err", completion_text=f"Dify 请求失败:{str(e)}"
role="err",
completion_text=f"Dify 请求失败:{e!s}",
)
yield AgentResponse(
type="err",
data=AgentResponseData(
chain=MessageChain().message(f"Dify 请求失败{str(e)}")
chain=MessageChain().message(f"Dify 请求失败:{e!s}"),
),
)
finally:
await self.api_client.close()
@override
async def step_until_done(
self, max_step: int = 30
) -> T.AsyncGenerator[AgentResponse, None]:
async def step_until_done(self, max_step: int):
while not self.done():
async for resp in self.step():
yield resp
async def _upload_image_for_dify(
self,
image_url: str,
session_id: str,
) -> dict[str, str] | None:
image_data = await MediaResolver(
image_url,
media_type="image",
).to_base64_data(strict=True)
if image_data is None:
logger.warning("Dify 图片预处理结果为空,将忽略。")
return None
image_extension = image_data.mime_type.split("/", 1)[-1] or "png"
if image_extension == "jpeg":
image_extension = "jpg"
file_response = await self.api_client.file_upload(
file_data=image_data.to_bytes(),
user=session_id,
mime_type=image_data.mime_type,
file_name=f"image.{image_extension}",
)
logger.debug(f"Dify 上传图片响应:{file_response}")
if "id" not in file_response:
logger.warning(
f"上传图片后得到未知的 Dify 响应:{file_response},图片将忽略。"
)
return None
return {
"type": "image",
"transfer_method": "local_file",
"upload_file_id": file_response["id"],
}
async def _execute_dify_request(self):
"""执行 Dify 请求的核心逻辑"""
prompt = self.req.prompt or ""
@@ -157,22 +129,43 @@ class DifyAgentRunner(BaseAgentRunner[TContext]):
# 处理图片上传
files_payload = []
for image_url in image_urls:
# image_url is a base64 string
try:
image_payload = await self._upload_image_for_dify(image_url, session_id)
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}")
logger.warning(f"上传图片失败:{e}")
continue
if image_payload:
files_payload.append(image_payload)
# 获得会话变量
payload_vars = self.variables.copy()
# 动态变量
session_var = await sp.get_async(
scope="umo",
scope_id=session_id,
key="session_variables",
default={},
session_var: dict = (
await sp.get_async(
scope="umo",
scope_id=session_id,
key="session_variables",
default={},
)
or {}
)
payload_vars.update(session_var)
payload_vars["system_prompt"] = system_prompt
@@ -181,7 +174,7 @@ class DifyAgentRunner(BaseAgentRunner[TContext]):
match self.api_type:
case "chat" | "agent" | "chatflow":
if not prompt:
prompt = "请描述这张图片"
prompt = "请描述这张图片"
async for chunk in self.api_client.chat_messages(
inputs={
@@ -189,9 +182,9 @@ class DifyAgentRunner(BaseAgentRunner[TContext]):
},
query=prompt,
user=session_id,
conversation_id=conversation_id,
conversation_id=conversation_id or "",
files=files_payload,
timeout=self.timeout,
request_timeout=self.timeout,
):
logger.debug(f"dify resp chunk: {chunk}")
if chunk["event"] == "message" or chunk["event"] == "agent_message":
@@ -205,21 +198,21 @@ class DifyAgentRunner(BaseAgentRunner[TContext]):
)
conversation_id = chunk["conversation_id"]
# 如果是流式响应发送增量数据
# 如果是流式响应,发送增量数据
if self.streaming and chunk["answer"]:
yield AgentResponse(
type="streaming_delta",
data=AgentResponseData(
chain=MessageChain().message(chunk["answer"])
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}")
logger.error(f"Dify 出现错误:{chunk}")
raise Exception(
f"Dify 出现错误 status: {chunk['status']} message: {chunk['message']}"
f"Dify 出现错误 status: {chunk['status']} message: {chunk['message']}",
)
case "workflow":
@@ -231,17 +224,17 @@ class DifyAgentRunner(BaseAgentRunner[TContext]):
},
user=session_id,
files=files_payload,
timeout=self.timeout,
request_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']})开始运行"
f"Dify 工作流(ID: {chunk['workflow_run_id']})开始运行",
)
case "node_finished":
logger.debug(
f"Dify 工作流节点(ID: {chunk['data']['node_id']} Title: {chunk['data'].get('title', '')})运行结束"
f"Dify 工作流节点(ID: {chunk['data']['node_id']} Title: {chunk['data'].get('title', '')})运行结束",
)
case "text_chunk":
if self.streaming and chunk["data"]["text"]:
@@ -249,32 +242,32 @@ class DifyAgentRunner(BaseAgentRunner[TContext]):
type="streaming_delta",
data=AgentResponseData(
chain=MessageChain().message(
chunk["data"]["text"]
)
chunk["data"]["text"],
),
),
)
case "workflow_finished":
logger.info(
f"Dify 工作流(ID: {chunk['workflow_run_id']})运行结束"
f"Dify 工作流(ID: {chunk['workflow_run_id']})运行结束",
)
logger.debug(f"Dify 工作流结果{chunk}")
logger.debug(f"Dify 工作流结果:{chunk}")
if chunk["data"]["error"]:
logger.error(
f"Dify 工作流出现错误{chunk['data']['error']}"
f"Dify 工作流出现错误:{chunk['data']['error']}",
)
raise Exception(
f"Dify 工作流出现错误{chunk['data']['error']}"
f"Dify 工作流出现错误:{chunk['data']['error']}",
)
if self.workflow_output_key not in chunk["data"]["outputs"]:
raise Exception(
f"Dify 工作流的输出不包含指定的键名{self.workflow_output_key}"
f"Dify 工作流的输出不包含指定的键名:{self.workflow_output_key}",
)
result = chunk
case _:
raise Exception(f"未知的 Dify API 类型{self.api_type}")
raise Exception(f"未知的 Dify API 类型:{self.api_type}")
if not result:
logger.warning("Dify 请求结果为空请查看 Debug 日志")
logger.warning("Dify 请求结果为空,请查看 Debug 日志")
# 解析结果
chain = await self.parse_dify_result(result)
@@ -300,24 +293,23 @@ class DifyAgentRunner(BaseAgentRunner[TContext]):
# Chat
return MessageChain(chain=[Comp.Plain(chunk)])
async def parse_file(item: dict):
async def parse_file(item: dict) -> Comp.BaseMessageComponent:
match item["type"]:
case "image":
return Comp.Image(file=item["url"], url=item["url"])
case "audio":
audio_path = await MediaResolver(
item["url"],
media_type="audio",
default_suffix=".wav",
).to_path(target_format="wav")
return Comp.Record(file=audio_path, url=audio_path)
# 仅支持 wav
temp_dir = get_astrbot_temp_path()
path = os.path.join(temp_dir, f"dify_{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 = []
chains: list[Comp.BaseMessageComponent] = []
if isinstance(output, str):
# 纯文本输出
chains.append(Comp.Plain(output))

View File

@@ -3,7 +3,8 @@ import json
from collections.abc import AsyncGenerator
from typing import Any
from aiohttp import ClientResponse, ClientSession, FormData
import anyio
from aiohttp import ClientResponse, ClientSession, ClientTimeout, FormData
from astrbot.core import logger
@@ -35,66 +36,74 @@ class DifyAPIClient:
self.api_key = api_key
self.api_base = api_base
self.session = ClientSession(trust_env=True)
self.headers = {
self.headers: dict[str, str] = {
"Authorization": f"Bearer {self.api_key}",
}
async def chat_messages(
self,
inputs: dict,
inputs: dict[str, object],
query: str,
user: str,
response_mode: str = "streaming",
conversation_id: str = "",
files: list[dict[str, Any]] | None = None,
timeout: float = 60,
files: list[dict[str, object]] | None = None,
request_timeout: float = 60,
) -> AsyncGenerator[dict[str, Any], None]:
if files is None:
files = []
url = f"{self.api_base}/chat-messages"
payload = locals()
payload.pop("self")
payload.pop("timeout")
payload: dict[str, object] = {
"inputs": inputs,
"query": query,
"user": user,
"response_mode": response_mode,
"conversation_id": conversation_id,
"files": files,
}
logger.info(f"chat_messages payload: {payload}")
async with self.session.post(
url,
json=payload,
headers=self.headers,
timeout=timeout,
timeout=ClientTimeout(total=request_timeout),
) as resp:
if resp.status != 200:
text = await resp.text()
raise Exception(
f"Dify /chat-messages 接口请求失败{resp.status}. {text}",
f"Dify /chat-messages 接口请求失败:{resp.status}. {text}",
)
async for event in _stream_sse(resp):
yield event
async def workflow_run(
self,
inputs: dict,
inputs: dict[str, object],
user: str,
response_mode: str = "streaming",
files: list[dict[str, Any]] | None = None,
timeout: float = 60,
files: list[dict[str, object]] | None = None,
request_timeout: float = 60,
):
if files is None:
files = []
url = f"{self.api_base}/workflows/run"
payload = locals()
payload.pop("self")
payload.pop("timeout")
payload: dict[str, object] = {
"inputs": inputs,
"user": user,
"response_mode": response_mode,
"files": files,
}
logger.info(f"workflow_run payload: {payload}")
async with self.session.post(
url,
json=payload,
headers=self.headers,
timeout=timeout,
timeout=ClientTimeout(total=request_timeout),
) as resp:
if resp.status != 200:
text = await resp.text()
raise Exception(
f"Dify /workflows/run 接口请求失败{resp.status}. {text}",
f"Dify /workflows/run 接口请求失败:{resp.status}. {text}",
)
async for event in _stream_sse(resp):
yield event
@@ -114,8 +123,10 @@ class DifyAPIClient:
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"
@@ -134,8 +145,8 @@ class DifyAPIClient:
# 使用文件路径
import os
with open(file_path, "rb") as f:
file_content = f.read()
async with await anyio.open_file(file_path, "rb") as f:
file_content = await f.read()
form.add_field(
"file",
file_content,
@@ -148,11 +159,11 @@ class DifyAPIClient:
async with self.session.post(
url,
data=form,
headers=self.headers, # 不包含 Content-Type让 aiohttp 自动设置
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}")
raise Exception(f"Dify 文件上传失败:{resp.status}. {text}")
return await resp.json() # {"id": "xxx", ...}
async def close(self) -> None:
@@ -161,11 +172,11 @@ class DifyAPIClient:
async def get_chat_convs(self, user: str, limit: int = 20):
# conversations. GET
url = f"{self.api_base}/conversations"
payload = {
params: dict[str, str | int] = {
"user": user,
"limit": limit,
}
async with self.session.get(url, params=payload, headers=self.headers) as resp:
async with self.session.get(url, params=params, headers=self.headers) as resp:
return await resp.json()
async def delete_chat_conv(self, user: str, conversation_id: str):

View File

@@ -1,6 +1,5 @@
import asyncio
import copy
import sys
import time
import traceback
import typing as T
@@ -8,6 +7,7 @@ import uuid
from contextlib import suppress
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import override
from mcp.types import (
BlobResourceContents,
@@ -63,11 +63,6 @@ from ..run_context import ContextWrapper, TContext
from ..tool_executor import BaseFunctionToolExecutor
from .base import AgentResponse, AgentState, BaseAgentRunner
if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import override
@dataclass(slots=True)
class _HandleFunctionToolsResult:

View File

@@ -1,6 +1,6 @@
import copy
from collections.abc import AsyncGenerator, Awaitable, Callable
from typing import Any, Generic
from typing import Any, Generic, TypedDict
import jsonschema
import mcp
@@ -16,6 +16,12 @@ ParametersType = dict[str, Any]
ToolExecResult = str | mcp.types.CallToolResult
class ToolArgumentSpec(TypedDict):
name: str
type: str
description: str
@dataclass
class ToolSchema:
"""A class representing the schema of a tool for function calling."""
@@ -26,14 +32,20 @@ class ToolSchema:
description: str
"""The description of the tool."""
parameters: ParametersType
parameters: ParametersType | None = None
"""The parameters of the tool, in JSON Schema format."""
active: bool = True
"""Whether the tool is active."""
"""The parameters of the tool, in JSON Schema format."""
@model_validator(mode="after")
def validate_parameters(self) -> "ToolSchema":
jsonschema.validate(
self.parameters, jsonschema.Draft202012Validator.META_SCHEMA
)
if self.parameters is not None:
jsonschema.validate(
self.parameters,
jsonschema.Draft202012Validator.META_SCHEMA,
)
return self
@@ -63,14 +75,23 @@ class FunctionTool(ToolSchema, Generic[TContext]):
Declare this tool as a background task. Background tasks return immediately
with a task identifier while the real work continues asynchronously.
"""
source: str = "plugin"
"""
Origin of this tool: 'plugin' (from star plugins), 'internal' (AstrBot built-in),
or 'mcp' (from MCP servers). Used by WebUI for display grouping.
"""
def __repr__(self) -> str:
return f"FuncTool(name={self.name}, parameters={self.parameters}, description={self.description})"
async def call(self, context: ContextWrapper[TContext], **kwargs) -> ToolExecResult:
async def call(
self,
context: ContextWrapper[TContext],
**kwargs: Any,
) -> ToolExecResult:
"""Run the tool with the given arguments. The handler field has priority."""
raise NotImplementedError(
"FunctionTool.call() must be implemented by subclasses or set a handler."
"FunctionTool.call() must be implemented by subclasses or set a handler.",
)
@@ -82,13 +103,13 @@ class ToolSet:
convert the tools to different API formats (OpenAI, Anthropic, Google GenAI).
"""
tools: list[FunctionTool] = Field(default_factory=list)
tools: list[ToolSchema] = Field(default_factory=list)
def empty(self) -> bool:
"""Check if the tool set is empty."""
return len(self.tools) == 0
def add_tool(self, tool: FunctionTool) -> None:
def add_tool(self, tool: ToolSchema) -> None:
"""Add a tool to the set.
If a tool with the same name already exists:
@@ -111,16 +132,26 @@ class ToolSet:
"""Remove a tool by its name."""
self.tools = [tool for tool in self.tools if tool.name != name]
def normalize(self) -> None:
"""Sort tools by name for deterministic serialization.
This ensures the serialized tool schema sent to the LLM is
identical across requests regardless of registration/injection
order, enabling LLM provider prefix cache hits.
"""
self.tools.sort(key=lambda t: t.name)
def get_tool(self, name: str) -> FunctionTool | None:
"""Get a tool by its name."""
for tool in self.tools:
if tool.name == name:
return tool
if isinstance(tool, FunctionTool):
return tool
return None
def get_light_tool_set(self) -> "ToolSet":
"""Return a light tool set with only name/description."""
light_tools = []
light_tools: list[ToolSchema] = []
for tool in self.tools:
if hasattr(tool, "active") and not tool.active:
continue
@@ -131,16 +162,16 @@ class ToolSet:
light_tools.append(
FunctionTool(
name=tool.name,
parameters=light_params,
description=tool.description,
parameters=light_params,
handler=None,
)
),
)
return ToolSet(light_tools)
def get_param_only_tool_set(self) -> "ToolSet":
"""Return a tool set with name/parameters only (no description)."""
param_tools = []
param_tools: list[ToolSchema] = []
for tool in self.tools:
if hasattr(tool, "active") and not tool.active:
continue
@@ -152,10 +183,10 @@ class ToolSet:
param_tools.append(
FunctionTool(
name=tool.name,
parameters=params,
description="",
parameters=params,
handler=None,
)
),
)
return ToolSet(param_tools)
@@ -163,17 +194,18 @@ class ToolSet:
def add_func(
self,
name: str,
func_args: list,
func_args: list[ToolArgumentSpec],
desc: str,
handler: Callable[..., Awaitable[Any]],
) -> None:
"""Add a function tool to the set."""
properties: dict[str, dict[str, str]] = {}
params = {
"type": "object", # hard-coded here
"properties": {},
"properties": properties,
}
for param in func_args:
params["properties"][param["name"]] = {
properties[param["name"]] = {
"type": param["type"],
"description": param["description"],
}
@@ -198,22 +230,28 @@ class ToolSet:
@property
def func_list(self) -> list[FunctionTool]:
"""Get the list of function tools."""
return self.tools
return [t for t in self.tools if isinstance(t, FunctionTool)]
def list_tools(self) -> list[FunctionTool]:
"""Get the list of function tools (alias for func_list)."""
return [t for t in self.tools if isinstance(t, FunctionTool)]
def openai_schema(self, omit_empty_parameter_field: bool = False) -> list[dict]:
"""Convert tools to OpenAI API function calling schema format."""
result = []
for tool in self.tools:
func_def = {"type": "function", "function": {"name": tool.name}}
function_dict: dict[str, Any] = {"name": tool.name}
if tool.description:
func_def["function"]["description"] = tool.description
function_dict["description"] = tool.description
if tool.parameters is not None:
if (
tool.parameters and tool.parameters.get("properties")
) or not omit_empty_parameter_field:
func_def["function"]["parameters"] = tool.parameters
function_dict["parameters"] = tool.parameters
func_def: dict[str, Any] = {
"type": "function",
"function": function_dict,
}
result.append(func_def)
return result

View File

@@ -1,3 +1,4 @@
import abc
from collections.abc import AsyncGenerator
from typing import Any, Generic
@@ -7,8 +8,9 @@ from .run_context import ContextWrapper, TContext
from .tool import FunctionTool
class BaseFunctionToolExecutor(Generic[TContext]):
class BaseFunctionToolExecutor(abc.ABC, Generic[TContext]):
@classmethod
@abc.abstractmethod
async def execute(
cls,
tool: FunctionTool,

View File

@@ -7,7 +7,7 @@ import base64
import os
import time
from dataclasses import dataclass, field
from typing import ClassVar
from typing import ClassVar, Self
from astrbot import logger
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
@@ -35,16 +35,20 @@ class ToolImageCache:
Images are stored in data/temp/tool_images/ and can be retrieved by file path.
"""
_instance: ClassVar["ToolImageCache | None"] = None
_instance: ClassVar[Self | None] = None
CACHE_DIR_NAME: ClassVar[str] = "tool_images"
# Cache expiry time in seconds (1 hour)
CACHE_EXPIRY: ClassVar[int] = 3600
_initialized: bool
_cache_dir: str
def __new__(cls) -> "ToolImageCache":
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __new__(cls) -> Self:
instance = cls._instance
if instance is None:
instance = super().__new__(cls)
instance._initialized = False
cls._instance = instance
return instance
def __init__(self) -> None:
if self._initialized:
@@ -85,6 +89,7 @@ class ToolImageCache:
Returns:
CachedImage object with file path.
"""
ext = self._get_file_extension(mime_type)
file_name = f"{tool_call_id}_{index}{ext}"
@@ -108,7 +113,9 @@ class ToolImageCache:
)
def get_image_base64_by_path(
self, file_path: str, mime_type: str = "image/png"
self,
file_path: str,
mime_type: str = "image/png",
) -> tuple[str, str] | None:
"""Read an image file and return its base64 encoded data.
@@ -118,6 +125,7 @@ class ToolImageCache:
Returns:
Tuple of (base64_data, mime_type) if found, None otherwise.
"""
if not os.path.exists(file_path):
return None
@@ -136,6 +144,7 @@ class ToolImageCache:
Returns:
Number of images cleaned up.
"""
now = time.time()
cleaned = 0

View File

@@ -1,3 +1,5 @@
from typing import ClassVar
from pydantic import Field
from pydantic.dataclasses import dataclass
@@ -8,7 +10,7 @@ from astrbot.core.star.context import Context
@dataclass
class AstrAgentContext:
__pydantic_config__ = {"arbitrary_types_allowed": True}
__pydantic_config__: ClassVar[dict[str, bool]] = {"arbitrary_types_allowed": True}
context: Context
"""The star context instance"""

View File

@@ -10,22 +10,31 @@ from astrbot.core.pipeline.context_utils import call_event_hook
from astrbot.core.star.star_handler import EventType
class MainAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
async def on_agent_begin(
self, run_context: ContextWrapper[AstrAgentContext]
) -> None:
await call_event_hook(
run_context.context.event,
EventType.OnAgentBeginEvent,
run_context,
)
def _sdk_safe_payload(value: Any) -> Any:
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, list):
return [_sdk_safe_payload(item) for item in value]
if isinstance(value, dict):
return {str(key): _sdk_safe_payload(item) for key, item in value.items()}
model_dump = getattr(value, "model_dump", None)
if callable(model_dump):
try:
dumped = model_dump()
except Exception:
return str(value)
return _sdk_safe_payload(dumped)
return str(value)
class MainAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
async def on_agent_done(self, run_context, llm_response) -> None:
# 执行事件钩子
if llm_response and llm_response.reasoning_content:
# we will use this in result_decorate stage to inject reasoning content to chain
run_context.context.event.set_extra(
"_llm_reasoning_content", llm_response.reasoning_content
"_llm_reasoning_content",
llm_response.reasoning_content,
)
await call_event_hook(
@@ -33,12 +42,32 @@ class MainAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
EventType.OnLLMResponseEvent,
llm_response,
)
await call_event_hook(
run_context.context.event,
EventType.OnAgentDoneEvent,
run_context,
llm_response,
sdk_plugin_bridge = getattr(
run_context.context.context,
"sdk_plugin_bridge",
None,
)
if sdk_plugin_bridge is not None:
try:
await sdk_plugin_bridge.dispatch_message_event(
"llm_response",
run_context.context.event,
{
"completion_text": (
llm_response.completion_text if llm_response else ""
),
"tool_call_names": (
list(llm_response.tools_call_name)
if llm_response and llm_response.tools_call_name
else []
),
},
llm_response=llm_response,
)
except Exception as exc:
from astrbot.core import logger
logger.warning("SDK llm_response dispatch failed: %s", exc)
async def on_tool_start(
self,
@@ -52,6 +81,25 @@ class MainAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
tool,
tool_args,
)
sdk_plugin_bridge = getattr(
run_context.context.context,
"sdk_plugin_bridge",
None,
)
if sdk_plugin_bridge is not None:
try:
await sdk_plugin_bridge.dispatch_message_event(
"using_llm_tool",
run_context.context.event,
{
"tool_name": tool.name,
"tool_args": _sdk_safe_payload(tool_args),
},
)
except Exception as exc:
from astrbot.core import logger
logger.warning("SDK using_llm_tool dispatch failed: %s", exc)
async def on_tool_end(
self,
@@ -68,6 +116,26 @@ class MainAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
tool_args,
tool_result,
)
sdk_plugin_bridge = getattr(
run_context.context.context,
"sdk_plugin_bridge",
None,
)
if sdk_plugin_bridge is not None:
try:
await sdk_plugin_bridge.dispatch_message_event(
"llm_tool_respond",
run_context.context.event,
{
"tool_name": tool.name,
"tool_args": _sdk_safe_payload(tool_args),
"tool_result": _sdk_safe_payload(tool_result),
},
)
except Exception as exc:
from astrbot.core import logger
logger.warning("SDK llm_tool_respond dispatch failed: %s", exc)
class EmptyAgentHooks(BaseAgentRunHooks[AstrAgentContext]):

View File

@@ -3,7 +3,8 @@ import re
import time
import traceback
from collections.abc import AsyncGenerator
from typing import Any
import anyio
from astrbot.core import logger
from astrbot.core.agent.message import Message
@@ -48,7 +49,8 @@ def _extract_chain_json_data(msg_chain: MessageChain) -> dict | None:
def _record_tool_call_name(
tool_info: dict | None, tool_name_by_call_id: dict[str, str]
tool_info: dict | None,
tool_name_by_call_id: dict[str, str],
) -> None:
if not isinstance(tool_info, dict):
return
@@ -66,7 +68,8 @@ def _build_tool_call_status_message(tool_info: dict | None) -> str:
def _build_tool_result_status_message(
msg_chain: MessageChain, tool_name_by_call_id: dict[str, str]
msg_chain: MessageChain,
tool_name_by_call_id: dict[str, str],
) -> str:
tool_name = "unknown"
tool_result = ""
@@ -115,7 +118,7 @@ def _merge_buffered_llm_chains(
async def run_agent(
agent_runner: AgentRunner,
max_step: int = 30,
max_step: int = 3,
show_tool_use: bool = True,
show_tool_call_result: bool = False,
stream_to_general: bool = False,
@@ -136,7 +139,7 @@ async def run_agent(
if step_idx == max_step + 1:
logger.warning(
f"Agent reached max steps ({max_step}), forcing a final response."
f"Agent reached max steps ({max_step}), forcing a final response.",
)
if not agent_runner.done():
# 拔掉所有工具
@@ -146,8 +149,8 @@ async def run_agent(
agent_runner.run_context.messages.append(
Message(
role="user",
content="工具调用次数已达到上限请停止使用工具并根据已经收集到的信息对你的任务和发现进行总结然后直接回复用户",
)
content="工具调用次数已达到上限,请停止使用工具,并根据已经收集到的信息,对你的任务和发现进行总结,然后直接回复用户",
),
)
stop_watcher = asyncio.create_task(
@@ -189,7 +192,7 @@ async def run_agent(
astr_event.trace.record(
"agent_tool_result",
tool_result=msg_chain.get_plain_text(
with_other_comps_mark=True
with_other_comps_mark=True,
),
)
@@ -201,12 +204,13 @@ async def run_agent(
await astr_event.send(msg_chain)
elif show_tool_use and show_tool_call_result:
status_msg = _build_tool_result_status_message(
msg_chain, tool_name_by_call_id
msg_chain,
tool_name_by_call_id,
)
await astr_event.send(
MessageChain(type="tool_call").message(status_msg)
MessageChain(type="tool_call").message(status_msg),
)
# 对于其他情况暂时先不处理
# 对于其他情况,暂时先不处理
continue
elif resp.type == "tool_call":
if agent_runner.streaming and show_tool_use:
@@ -221,7 +225,7 @@ async def run_agent(
tool_info = _extract_chain_json_data(resp.data["chain"])
astr_event.trace.record(
"agent_tool_call",
tool_name=tool_info if tool_info else "unknown",
tool_name=tool_info or "unknown",
)
_record_tool_call_name(tool_info, tool_name_by_call_id)
@@ -232,7 +236,7 @@ async def run_agent(
# Delay tool status notification until tool_call_result.
continue
chain = MessageChain(type="tool_call").message(
_build_tool_call_status_message(tool_info)
_build_tool_call_status_message(tool_info),
)
await astr_event.send(chain)
continue
@@ -296,7 +300,7 @@ async def run_agent(
MessageChain(
type="agent_stats",
chain=[Json(data=agent_runner.stats.to_dict())],
)
),
)
break
@@ -311,7 +315,7 @@ async def run_agent(
logger.error(traceback.format_exc())
custom_error_message = extract_persona_custom_error_message_from_event(
astr_event
astr_event,
)
if custom_error_message:
err_msg = custom_error_message
@@ -319,7 +323,7 @@ async def run_agent(
err_msg = (
f"Error occurred during AI execution.\n"
f"Error Type: {type(e).__name__}\n"
f"Error Message: {str(e)}"
f"Error Message: {e!s}"
)
error_llm_response = LLMResponse(
@@ -328,7 +332,8 @@ async def run_agent(
)
try:
await agent_runner.agent_hooks.on_agent_done(
agent_runner.run_context, error_llm_response
agent_runner.run_context,
error_llm_response,
)
except Exception:
logger.exception("Error in on_agent_done hook")
@@ -351,13 +356,13 @@ async def _watch_agent_stop_signal(agent_runner: AgentRunner, astr_event) -> Non
async def run_live_agent(
agent_runner: AgentRunner,
tts_provider: TTSProvider | None = None,
max_step: int = 30,
max_step: int = 3,
show_tool_use: bool = True,
show_tool_call_result: bool = False,
show_reasoning: bool = False,
buffer_intermediate_messages: bool = False,
) -> AsyncGenerator[MessageChain | None, None]:
"""Live Mode 的 Agent 运行器支持流式 TTS
"""Live Mode 的 Agent 运行器,支持流式 TTS
Args:
agent_runner: Agent 运行器
@@ -369,8 +374,9 @@ async def run_live_agent(
Yields:
MessageChain: 包含文本或音频数据的消息链
"""
# 如果没有 TTS Provider直接发送文本
# 如果没有 TTS Provider,直接发送文本
if not tts_provider:
async for chain in run_agent(
agent_runner,
@@ -386,11 +392,11 @@ async def run_live_agent(
support_stream = tts_provider.support_stream()
if support_stream:
logger.info("[Live Agent] 使用流式 TTS原生支持 get_audio_stream")
logger.info("[Live Agent] 使用流式 TTS(原生支持 get_audio_stream)")
else:
logger.info(
f"[Live Agent] 使用 TTS{tts_provider.meta().type} "
"使用 get_audio将按句子分块生成音频"
f"[Live Agent] 使用 TTS({tts_provider.meta().type} "
"使用 get_audio,将按句子分块生成音频)",
)
# 统计数据初始化
@@ -403,7 +409,7 @@ async def run_live_agent(
# audio_queue stored bytes or (text, bytes)
audio_queue: asyncio.Queue[bytes | tuple[str, bytes] | None] = asyncio.Queue()
# 1. 启动 Agent Feeder 任务负责运行 Agent 并将文本分句喂给 text_queue
# 1. 启动 Agent Feeder 任务:负责运行 Agent 并将文本分句喂给 text_queue
feeder_task = asyncio.create_task(
_run_agent_feeder(
agent_runner,
@@ -413,25 +419,20 @@ async def run_live_agent(
show_tool_call_result,
show_reasoning,
buffer_intermediate_messages,
)
),
)
# 2. 启动 TTS 任务负责从 text_queue 读取文本并生成音频到 audio_queue
# 2. 启动 TTS 任务:负责从 text_queue 读取文本并生成音频到 audio_queue
if support_stream:
tts_task = asyncio.create_task(
_safe_tts_stream_wrapper(tts_provider, text_queue, audio_queue)
_safe_tts_stream_wrapper(tts_provider, text_queue, audio_queue),
)
else:
tts_task = asyncio.create_task(
_simulated_stream_tts(
tts_provider,
text_queue,
audio_queue,
agent_runner.run_context.context.event,
)
_simulated_stream_tts(tts_provider, text_queue, audio_queue),
)
# 3. 主循环从 audio_queue 读取音频并 yield
# 3. 主循环:从 audio_queue 读取音频并 yield
try:
while True:
queue_item = await audio_queue.get()
@@ -446,7 +447,7 @@ async def run_live_agent(
audio_data = queue_item
if not first_chunk_received:
# 记录首帧延迟从开始处理到收到第一个音频块
# 记录首帧延迟(从开始处理到收到第一个音频块)
tts_first_frame_time = time.time() - tts_start_time
first_chunk_received = True
@@ -470,7 +471,6 @@ async def run_live_agent(
tts_task.cancel()
# 确保队列被消费
pass
tts_end_time = time.time()
@@ -489,10 +489,10 @@ async def run_live_agent(
"tts_first_frame_time": tts_first_frame_time,
"tts": tts_provider.meta().type,
"chat_model": agent_runner.provider.get_model(),
}
)
},
),
],
)
),
)
except Exception as e:
logger.error(f"发送 TTS 统计信息失败: {e}")
@@ -527,9 +527,9 @@ async def _run_agent_feeder(
if text:
buffer += text
# 分句逻辑匹配标点符号
# r"([.。!?\n]+)" 会保留分隔符
parts = re.split(r"([.。!?\n]+)", buffer)
# 分句逻辑:匹配标点符号
# r"([.。!!??\n]+)" 会保留分隔符
parts = re.split(r"([.。!!??\n]+)", buffer)
if len(parts) > 1:
# 处理完整的句子
@@ -579,18 +579,8 @@ async def _simulated_stream_tts(
tts_provider: TTSProvider,
text_queue: asyncio.Queue[str | None],
audio_queue: "asyncio.Queue[bytes | tuple[str, bytes] | None]",
astr_event: Any,
) -> None:
"""模拟流式 TTS 分句生成音频.
Args:
tts_provider: Provider used to synthesize audio files.
text_queue: Text chunks to synthesize. ``None`` ends the worker.
audio_queue: Synthesized audio bytes output queue.
astr_event: Current event used to cleanup generated TTS files after the
event finishes.
"""
"""模拟流式 TTS 分句生成音频"""
try:
while True:
text = await text_queue.get()
@@ -601,13 +591,12 @@ async def _simulated_stream_tts(
audio_path = await tts_provider.get_audio(text)
if audio_path:
with open(audio_path, "rb") as f:
audio_data = f.read()
astr_event.track_temporary_local_file(audio_path)
async with await anyio.open_file(audio_path, "rb") as f:
audio_data = await f.read()
await audio_queue.put((text, audio_data))
except Exception as e:
logger.error(
f"[Live TTS Simulated] Error processing text '{text[:20]}...': {e}"
f"[Live TTS Simulated] Error processing text '{text[:20]}...': {e}",
)
# 继续处理下一句

View File

@@ -2,10 +2,10 @@ import asyncio
import inspect
import json
import traceback
import typing as T
import uuid
from collections.abc import Sequence
from collections.abc import AsyncGenerator, Awaitable, Callable, Sequence
from collections.abc import Set as AbstractSet
from typing import Any
import mcp
@@ -14,11 +14,13 @@ from astrbot.core.agent.handoff import HandoffTool
from astrbot.core.agent.mcp_client import MCPTool
from astrbot.core.agent.message import Message
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import FunctionTool, ToolSet
from astrbot.core.agent.tool import FunctionTool, ToolSchema, ToolSet
from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.astr_main_agent_resources import (
BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT,
BACKGROUND_TASK_WOKE_USER_PROMPT,
CONVERSATION_HISTORY_INJECT_PREFIX,
)
from astrbot.core.cron.events import CronMessageEvent
from astrbot.core.message.components import Image
@@ -30,21 +32,7 @@ from astrbot.core.message.message_event_result import (
from astrbot.core.platform.message_session import MessageSession
from astrbot.core.provider.entites import ProviderRequest
from astrbot.core.provider.register import llm_tools
from astrbot.core.tools.computer_tools import (
CuaKeyboardTypeTool,
CuaMouseClickTool,
CuaScreenshotTool,
ExecuteShellTool,
FileDownloadTool,
FileEditTool,
FileReadTool,
FileUploadTool,
FileWriteTool,
GrepTool,
LocalPythonTool,
PythonTool,
)
from astrbot.core.tools.message_tools import SendMessageToUserTool
from astrbot.core.tools.send_message import SEND_MESSAGE_TO_USER_TOOL
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.history_saver import persist_agent_history
from astrbot.core.utils.image_ref_utils import is_supported_image_ref
@@ -53,18 +41,15 @@ from astrbot.core.utils.string_utils import normalize_and_dedupe_strings
class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
@classmethod
def _collect_image_urls_from_args(cls, image_urls_raw: T.Any) -> list[str]:
def _collect_image_urls_from_args(cls, image_urls_raw: Any) -> list[str]:
if image_urls_raw is None:
return []
if isinstance(image_urls_raw, str):
return [image_urls_raw]
if isinstance(image_urls_raw, (Sequence, AbstractSet)) and not isinstance(
image_urls_raw, (str, bytes, bytearray)
if isinstance(image_urls_raw, (Sequence, AbstractSet)) and (
not isinstance(image_urls_raw, (str, bytes, bytearray))
):
return [item for item in image_urls_raw if isinstance(item, str)]
logger.debug(
"Unsupported image_urls type in handoff tool args: %s",
type(image_urls_raw).__name__,
@@ -73,7 +58,8 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
@classmethod
async def _collect_image_urls_from_message(
cls, run_context: ContextWrapper[AstrAgentContext]
cls,
run_context: ContextWrapper[AstrAgentContext],
) -> list[str]:
urls: list[str] = []
event = getattr(run_context.context, "event", None)
@@ -100,12 +86,11 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
async def _collect_handoff_image_urls(
cls,
run_context: ContextWrapper[AstrAgentContext],
image_urls_raw: T.Any,
image_urls_raw: Any,
) -> list[str]:
candidates: list[str] = []
candidates.extend(cls._collect_image_urls_from_args(image_urls_raw))
candidates.extend(await cls._collect_image_urls_from_message(run_context))
normalized = normalize_and_dedupe_strings(candidates)
extensionless_local_roots = (get_astrbot_temp_path(),)
sanitized = [
@@ -127,11 +112,13 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
@classmethod
async def execute(cls, tool, run_context, **tool_args):
"""执行函数调用
"""执行函数调用
Args:
event (AstrMessageEvent): 事件对象, 当 origin 为 local 时必须提供。
**kwargs: 函数调用的参数。
tool: The tool to execute.
run_context: The run context.
**tool_args: Tool-specific arguments.
**kwargs: 函数调用的参数。
Returns:
AsyncGenerator[None | mcp.types.CallToolResult, None]
@@ -141,19 +128,19 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
is_bg = tool_args.pop("background_task", False)
if is_bg:
async for r in cls._execute_handoff_background(
tool, run_context, **tool_args
tool,
run_context,
**tool_args,
):
yield r
return
async for r in cls._execute_handoff(tool, run_context, **tool_args):
yield r
return
elif isinstance(tool, MCPTool):
async for r in cls._execute_mcp(tool, run_context, **tool_args):
yield r
return
elif tool.is_background_task:
task_id = uuid.uuid4().hex
@@ -165,7 +152,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
task_id=task_id,
**tool_args,
)
except Exception as e: # noqa: BLE001
except Exception as e:
logger.error(
f"Background task {task_id} failed: {e!s}",
exc_info=True,
@@ -177,68 +164,99 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
text=f"Background task submitted. task_id={task_id}",
)
yield mcp.types.CallToolResult(content=[text_content])
return
else:
rejection = cls._check_sandbox_capability(tool, run_context)
if rejection is not None:
yield rejection
return
async for r in cls._execute_local(tool, run_context, **tool_args):
yield r
return
_BROWSER_TOOL_NAMES: frozenset[str] = frozenset(
{
"astrbot_execute_browser",
"astrbot_execute_browser_batch",
"astrbot_run_browser_skill",
},
)
@classmethod
def _check_sandbox_capability(
cls,
tool: FunctionTool,
run_context: ContextWrapper[AstrAgentContext],
) -> mcp.types.CallToolResult | None:
"""Return a rejection result if the tool requires a sandbox capability
that is not available, or None if the tool may proceed.
"""
if tool.name not in cls._BROWSER_TOOL_NAMES:
return None
from astrbot.core.computer.computer_client import get_sandbox_capabilities
session_id = run_context.context.event.unified_msg_origin
caps = get_sandbox_capabilities(session_id)
if caps is None:
return None
if "browser" not in caps:
msg = f"Tool '{tool.name}' requires browser capability, but the current sandbox profile does not include it (capabilities: {list(caps)}). Please ask the administrator to switch to a sandbox profile with browser support, or use shell/python tools instead."
logger.warning(
"[ToolExec] capability_rejected tool=%s caps=%s",
tool.name,
list(caps),
)
return mcp.types.CallToolResult(
content=[mcp.types.TextContent(type="text", text=msg)],
isError=True,
)
return None
@classmethod
def _get_runtime_computer_tools(
cls,
runtime: str,
tool_mgr,
tool_mgr: Any = None,
booter: str | None = None,
) -> dict[str, FunctionTool]:
booter = "" if booter is None else str(booter).lower()
if runtime == "sandbox":
shell_tool = tool_mgr.get_builtin_tool(ExecuteShellTool)
python_tool = tool_mgr.get_builtin_tool(PythonTool)
upload_tool = tool_mgr.get_builtin_tool(FileUploadTool)
download_tool = tool_mgr.get_builtin_tool(FileDownloadTool)
read_tool = tool_mgr.get_builtin_tool(FileReadTool)
write_tool = tool_mgr.get_builtin_tool(FileWriteTool)
edit_tool = tool_mgr.get_builtin_tool(FileEditTool)
grep_tool = tool_mgr.get_builtin_tool(GrepTool)
tools = {
shell_tool.name: shell_tool,
python_tool.name: python_tool,
upload_tool.name: upload_tool,
download_tool.name: download_tool,
read_tool.name: read_tool,
write_tool.name: write_tool,
edit_tool.name: edit_tool,
grep_tool.name: grep_tool,
}
if booter == "cua":
screenshot_tool = tool_mgr.get_builtin_tool(CuaScreenshotTool)
mouse_click_tool = tool_mgr.get_builtin_tool(CuaMouseClickTool)
keyboard_type_tool = tool_mgr.get_builtin_tool(CuaKeyboardTypeTool)
tools.update(
{
screenshot_tool.name: screenshot_tool,
mouse_click_tool.name: mouse_click_tool,
keyboard_type_tool.name: keyboard_type_tool,
}
)
return tools
if runtime == "local":
shell_tool = tool_mgr.get_builtin_tool(ExecuteShellTool)
python_tool = tool_mgr.get_builtin_tool(LocalPythonTool)
read_tool = tool_mgr.get_builtin_tool(FileReadTool)
write_tool = tool_mgr.get_builtin_tool(FileWriteTool)
edit_tool = tool_mgr.get_builtin_tool(FileEditTool)
grep_tool = tool_mgr.get_builtin_tool(GrepTool)
return {
shell_tool.name: shell_tool,
python_tool.name: python_tool,
read_tool.name: read_tool,
write_tool.name: write_tool,
edit_tool.name: edit_tool,
grep_tool.name: grep_tool,
}
return {}
session_id: str = "",
sandbox_cfg: dict | None = None,
) -> dict[str, ToolSchema]:
"""Get computer runtime tools via ComputerToolProvider.
Delegates tool discovery to ComputerToolProvider for decoupled
sandbox / local tool injection. The *tool_mgr* parameter is kept
for backward compatibility but is no longer used.
Args:
runtime: ``'sandbox'``, ``'local'``, or ``'none'``.
tool_mgr: Kept for backward compatibility (unused).
booter: Short-form booter type (e.g. ``'shipyard_neo'``).
session_id: Session identifier.
sandbox_cfg: Full sandbox configuration dict (preferred over
*booter* when both are provided).
Returns:
Dict mapping tool name to FunctionTool instance.
"""
from astrbot.core.computer.computer_tool_provider import (
ComputerToolProvider,
)
from astrbot.core.tool_provider import ToolProviderContext
cfg: dict = {}
if sandbox_cfg is not None:
cfg = sandbox_cfg
elif booter:
cfg["booter"] = booter
ctx = ToolProviderContext(
computer_use_runtime=runtime,
sandbox_cfg=cfg,
session_id=session_id,
)
tools = ComputerToolProvider().get_tools(ctx)
return {t.name: t for t in tools}
@classmethod
def _build_handoff_toolset(
@@ -251,19 +269,12 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
cfg = ctx.get_config(umo=event.unified_msg_origin)
provider_settings = cfg.get("provider_settings", {})
runtime = str(provider_settings.get("computer_use_runtime", "local"))
tool_mgr = (
ctx.get_llm_tool_manager()
if hasattr(ctx, "get_llm_tool_manager")
else llm_tools
)
tool_mgr = ctx.get_llm_tool_manager()
runtime_computer_tools = cls._get_runtime_computer_tools(
runtime,
tool_mgr,
provider_settings.get("sandbox", {}).get("booter"),
)
# Keep persona semantics aligned with the main agent: tools=None means
# "all tools", including runtime computer-use tools.
if tools is None:
toolset = ToolSet()
handoff_names = {
@@ -279,10 +290,8 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
for runtime_tool in runtime_computer_tools.values():
toolset.add_tool(runtime_tool)
return None if toolset.empty() else toolset
if not tools:
return None
toolset = ToolSet()
for tool_name_or_obj in tools:
if isinstance(tool_name_or_obj, str):
@@ -300,11 +309,11 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
@classmethod
async def _execute_handoff(
cls,
tool: HandoffTool,
run_context: ContextWrapper[AstrAgentContext],
tool: HandoffTool[Any],
run_context: ContextWrapper[Any],
*,
image_urls_prepared: bool = False,
**tool_args: T.Any,
**tool_args: Any,
):
tool_args = dict(tool_args)
input_ = tool_args.get("input")
@@ -324,21 +333,15 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
tool_args.get("image_urls"),
)
tool_args["image_urls"] = image_urls
# Build handoff toolset from registered tools plus runtime computer tools.
toolset = cls._build_handoff_toolset(run_context, tool.agent.tools)
ctx = run_context.context.context
event = run_context.context.event
umo = event.unified_msg_origin
# Use per-subagent provider override if configured; otherwise fall back
# to the current/default provider resolution.
prov_id = getattr(
tool, "provider_id", None
tool,
"provider_id",
None,
) or await ctx.get_current_chat_provider_id(umo)
# prepare begin dialogs
contexts = None
dialogs = tool.agent.begin_dialogs
if dialogs:
@@ -348,13 +351,12 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
contexts.append(
dialog
if isinstance(dialog, Message)
else Message.model_validate(dialog)
else Message.model_validate(dialog),
)
except Exception:
continue
prov_settings: dict = ctx.get_config(umo=umo).get("provider_settings", {})
agent_max_step = int(prov_settings.get("max_agent_step", 30))
agent_max_step = int(prov_settings.get("max_agent_step", 3))
stream = prov_settings.get("streaming_response", False)
llm_resp = await ctx.tool_loop_agent(
event=event,
@@ -369,7 +371,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
stream=stream,
)
yield mcp.types.CallToolResult(
content=[mcp.types.TextContent(type="text", text=llm_resp.completion_text)]
content=[mcp.types.TextContent(type="text", text=llm_resp.completion_text)],
)
@classmethod
@@ -397,21 +399,16 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
task_id=task_id,
**tool_args,
)
except Exception as e: # noqa: BLE001
except Exception as e:
logger.error(
f"Background handoff {task_id} ({tool.name}) failed: {e!s}",
exc_info=True,
)
asyncio.create_task(_run_handoff_in_background())
text_content = mcp.types.TextContent(
type="text",
text=(
f"Background task dedicated to subagent '{tool.agent.name}' submitted. task_id={task_id}. "
f"The subagent '{tool.agent.name}' is working on the task on hehalf you. "
f"You will be notified when it finishes."
),
text=f"Background task dedicated to subagent '{tool.agent.name}' submitted. task_id={task_id}. The subagent '{tool.agent.name}' is working on the task on behalf of you. You will be notified when it finishes.",
)
yield mcp.types.CallToolResult(content=[text_content])
@@ -445,19 +442,15 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
result_text = (
f"error: Background task execution failed, internal error: {e!s}"
)
event = run_context.context.event
await cls._wake_main_agent_for_background_result(
run_context=run_context,
task_id=task_id,
tool_name=tool.name,
result_text=result_text,
tool_args=tool_args,
note=(
event.get_extra("background_note")
or f"Background task for subagent '{tool.agent.name}' finished."
),
note=event.get_extra("background_note")
or f"Background task for subagent '{tool.agent.name}' finished.",
summary_name=f"Dedicated to subagent `{tool.agent.name}`",
extra_result_fields={"subagent_name": tool.agent.name},
)
@@ -470,13 +463,14 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
task_id: str,
**tool_args,
) -> None:
# run the tool
result_text = ""
try:
async for r in cls._execute_local(
tool, run_context, tool_call_timeout=3600, **tool_args
tool,
run_context,
tool_call_timeout=3600,
**tool_args,
):
# collect results, currently we just collect the text results
if isinstance(r, mcp.types.CallToolResult):
result_text = ""
for content in r.content:
@@ -486,19 +480,15 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
result_text = (
f"error: Background task execution failed, internal error: {e!s}"
)
event = run_context.context.event
await cls._wake_main_agent_for_background_result(
run_context=run_context,
task_id=task_id,
tool_name=tool.name,
result_text=result_text,
tool_args=tool_args,
note=(
event.get_extra("background_note")
or f"Background task {tool.name} finished."
),
note=event.get_extra("background_note")
or f"Background task {tool.name} finished.",
summary_name=tool.name,
)
@@ -510,10 +500,10 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
task_id: str,
tool_name: str,
result_text: str,
tool_args: dict[str, T.Any],
tool_args: dict[str, Any],
note: str,
summary_name: str,
extra_result_fields: dict[str, T.Any] | None = None,
extra_result_fields: dict[str, Any] | None = None,
) -> None:
from astrbot.core.astr_main_agent import (
MainAgentBuildConfig,
@@ -523,7 +513,6 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
event = run_context.context.event
ctx = run_context.context.context
task_result = {
"task_id": task_id,
"tool_name": tool_name,
@@ -533,7 +522,6 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
if extra_result_fields:
task_result.update(extra_result_fields)
extras = {"background_task_result": task_result}
session = MessageSession.from_str(event.unified_msg_origin)
cron_event = CronMessageEvent(
context=ctx,
@@ -543,14 +531,15 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
message_type=session.message_type,
)
cron_event.role = event.role
config = MainAgentBuildConfig(
tool_call_timeout=run_context.tool_call_timeout,
streaming_response=ctx.get_config()
.get("provider_settings", {})
.get("stream", False),
)
req = ProviderRequest()
req.system_prompt = ""
conv = await _get_session_conv(event=cron_event, plugin_context=ctx)
req.conversation = conv
context = json.loads(conv.history)
@@ -558,47 +547,30 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
req.contexts = context
context_dump = req._print_friendly_context()
req.contexts = []
req.system_prompt += (
"\n\nBellow is you and user previous conversation history:\n"
f"{context_dump}"
)
req.system_prompt += CONVERSATION_HISTORY_INJECT_PREFIX + context_dump
bg = json.dumps(extras["background_task_result"], ensure_ascii=False)
req.system_prompt += BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT.format(
background_task_result=bg
)
req.prompt = (
"Proceed according to your system instructions. "
"Output using same language as previous conversation. "
"If you need to deliver the result to the user immediately, "
"you MUST use `send_message_to_user` tool to send the message directly to the user, "
"otherwise the user will not see the result. "
"After completing your task, summarize and output your actions and results. "
background_task_result=bg,
)
req.prompt = BACKGROUND_TASK_WOKE_USER_PROMPT
if not req.func_tool:
req.func_tool = ToolSet()
req.func_tool.add_tool(
ctx.get_llm_tool_manager().get_builtin_tool(SendMessageToUserTool)
)
req.func_tool.add_tool(SEND_MESSAGE_TO_USER_TOOL)
result = await build_main_agent(
event=cron_event, plugin_context=ctx, config=config, req=req
event=cron_event,
plugin_context=ctx,
config=config,
req=req,
)
if not result:
logger.error(f"Failed to build main agent for background task {tool_name}.")
return
runner = result.agent_runner
async for _ in runner.step_until_done(30):
# agent will send message to user via using tools
async for _ in runner.step_until_done(3):
pass
llm_resp = runner.get_final_llm_resp()
task_meta = extras.get("background_task_result", {})
summary_note = (
f"[BackgroundTask] {summary_name} "
f"(task_id={task_meta.get('task_id', task_id)}) finished. "
f"Result: {task_meta.get('result') or result_text or 'no content'}"
)
summary_note = f"[BackgroundTask] {summary_name} (task_id={task_meta.get('task_id', task_id)}) finished. Result: {task_meta.get('result') or result_text or 'no content'}"
if llm_resp and llm_resp.completion_text:
summary_note += (
f"I finished the task, here is the result: {llm_resp.completion_text}"
@@ -625,17 +597,13 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
event = run_context.context.event
if not event:
raise ValueError("Event must be provided for local function tools.")
is_override_call = False
for ty in type(tool).mro():
if "call" in ty.__dict__ and ty.__dict__["call"] is not FunctionTool.call:
is_override_call = True
break
# 检查 tool 下有没有 run 方法
if not tool.handler and not hasattr(tool, "run") and not is_override_call:
if not tool.handler and (not hasattr(tool, "run")) and (not is_override_call):
raise ValueError("Tool must have a valid handler or override 'run' method.")
awaitable = None
method_name = ""
if tool.handler:
@@ -644,12 +612,36 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
elif is_override_call:
awaitable = tool.call
method_name = "call"
elif hasattr(tool, "run"):
awaitable = getattr(tool, "run")
method_name = "run"
else:
awaitable = getattr(tool, "run", None)
if awaitable is not None:
method_name = "run"
if awaitable is None:
raise ValueError("Tool must have a valid handler or override 'run' method.")
sdk_plugin_bridge = getattr(
run_context.context.context,
"sdk_plugin_bridge",
None,
)
if sdk_plugin_bridge is not None:
try:
await sdk_plugin_bridge.dispatch_message_event(
"calling_func_tool",
event,
{
"tool_name": tool.name,
"tool_args": json.loads(
json.dumps(tool_args, ensure_ascii=False, default=str),
),
},
)
except Exception as exc:
logger.warning("SDK calling_func_tool dispatch failed: %s", exc)
_HandlerType = Callable[
...,
Awaitable[MessageEventResult | mcp.types.CallToolResult | str | None]
| AsyncGenerator[MessageEventResult | CommandResult | str | None, None],
]
wrapper = call_local_llm_tool(
context=run_context,
handler=awaitable,
@@ -672,28 +664,31 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
)
yield mcp.types.CallToolResult(content=[text_content])
else:
# NOTE: Tool 在这里直接请求发送消息给用户
# TODO: 是否需要判断 event.get_result() 是否为空?
# 如果为空,则说明没有发送消息给用户,并且返回值为空,将返回一个特殊的 TextContent,其内容如"工具没有返回内容"
if res := run_context.context.event.get_result():
if res.chain:
try:
await event.send(
MessageChain(
chain=res.chain,
type="tool_direct_result",
)
)
except Exception as e:
logger.error(
f"Tool 直接发送消息失败: {e}",
exc_info=True,
)
yield None
except asyncio.TimeoutError:
res = run_context.context.event.get_result()
if res and res.chain:
try:
await event.send(
MessageChain(
chain=res.chain,
type="tool_direct_result",
),
)
except Exception as e:
logger.error(f"Tool 直接发送消息失败: {e}", exc_info=True)
yield None
else:
yield mcp.types.CallToolResult(
content=[
mcp.types.TextContent(
type="text",
text="Tool executed successfully with no output.",
),
],
)
except TimeoutError:
raise Exception(
f"tool {tool.name} execution timeout after {tool_call_timeout or run_context.tool_call_timeout} seconds.",
)
) from None
except StopAsyncIteration:
break
@@ -712,22 +707,19 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
async def call_local_llm_tool(
context: ContextWrapper[AstrAgentContext],
handler: T.Callable[
handler: Callable[
...,
T.Awaitable[MessageEventResult | mcp.types.CallToolResult | str | None]
| T.AsyncGenerator[MessageEventResult | CommandResult | str | None, None],
Awaitable[MessageEventResult | mcp.types.CallToolResult | str | None]
| AsyncGenerator[MessageEventResult | CommandResult | str | None, None],
],
method_name: str,
*args,
**kwargs,
) -> T.AsyncGenerator[T.Any, None]:
) -> AsyncGenerator[Any, None]:
"""执行本地 LLM 工具的处理函数并处理其返回结果"""
ready_to_call = None # 一个协程或者异步生成器
ready_to_call = None
trace_ = None
event = context.context.event
try:
if method_name == "run" or method_name == "decorator_handler":
ready_to_call = handler(event, *args, **kwargs)
@@ -738,19 +730,15 @@ async def call_local_llm_tool(
except ValueError as e:
raise Exception(f"Tool execution ValueError: {e}") from e
except TypeError as e:
# 获取函数的签名(包括类型),除了第一个 event/context 参数。
try:
sig = inspect.signature(handler)
params = list(sig.parameters.values())
# 跳过第一个参数event 或 context
if params:
params = params[1:]
param_strs = []
for param in params:
param_str = param.name
if param.annotation != inspect.Parameter.empty:
# 获取类型注解的字符串表示
if isinstance(param.annotation, type):
type_str = param.annotation.__name__
else:
@@ -759,46 +747,35 @@ async def call_local_llm_tool(
if param.default != inspect.Parameter.empty:
param_str += f" = {param.default!r}"
param_strs.append(param_str)
handler_param_str = (
", ".join(param_strs) if param_strs else "(no additional parameters)"
)
except Exception:
handler_param_str = "(unable to inspect signature)"
raise Exception(
f"Tool handler parameter mismatch, please check the handler definition. Handler parameters: {handler_param_str}"
f"Tool handler parameter mismatch, please check the handler definition. Handler parameters: {handler_param_str}",
) from e
except Exception as e:
trace_ = traceback.format_exc()
raise Exception(f"Tool execution error: {e}. Traceback: {trace_}") from e
if not ready_to_call:
return
if inspect.isasyncgen(ready_to_call):
_has_yielded = False
try:
async for ret in ready_to_call:
# 这里逐步执行异步生成器, 对于每个 yield 返回的 ret, 执行下面的代码
# 返回值只能是 MessageEventResult 或者 None无返回值
_has_yielded = True
if isinstance(ret, MessageEventResult | CommandResult):
# 如果返回值是 MessageEventResult, 设置结果并继续
event.set_result(ret)
yield
else:
# 如果返回值是 None, 则不设置结果并继续
# 继续执行后续阶段
yield ret
if not _has_yielded:
# 如果这个异步生成器没有执行到 yield 分支
yield
except Exception as e:
logger.error(f"Previous Error: {trace_}")
raise e
elif inspect.iscoroutine(ready_to_call):
# 如果只是一个协程, 直接执行
ret = await ready_to_call
if isinstance(ret, MessageEventResult | CommandResult):
event.set_result(ret)

View File

@@ -85,6 +85,8 @@ from astrbot.core.tools.web_search_tools import (
BaiduWebSearchTool,
BochaWebSearchTool,
BraveWebSearchTool,
ExaGetContentsTool,
ExaWebSearchTool,
FirecrawlExtractWebPageTool,
FirecrawlWebSearchTool,
TavilyExtractWebPageTool,
@@ -130,6 +132,7 @@ WEB_SEARCH_CITATION_TOOL_NAMES = frozenset(
"web_search_tavily",
"web_search_bocha",
"web_search_brave",
"web_search_exa",
}
)
WEB_SEARCH_CITATION_PROMPT = (
@@ -278,10 +281,11 @@ async def _apply_kb(
)
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}"
)
req.extra_user_content_parts.append(
TextPart(
text=f"[Related Knowledge Base Results]:\n{kb_result}",
).mark_as_temp()
)
except Exception as exc: # noqa: BLE001
logger.error("Error occurred while retrieving knowledge base: %s", exc)
else:
@@ -456,10 +460,10 @@ async def _ensure_persona_and_skills(
cfg: dict,
plugin_context: Context,
event: AstrMessageEvent,
) -> set[str] | None:
) -> None:
"""Ensure persona and skills are applied to the request's system prompt or user prompt."""
if not req.conversation:
return None
return
(
persona_id,
@@ -526,13 +530,11 @@ async def _ensure_persona_and_skills(
# inject toolset in the persona
if (persona and persona.get("tools") is None) or not persona:
persona_allowed_tools = None
persona_toolset = tmgr.get_full_tool_set()
for tool in list(persona_toolset):
if not tool.active:
persona_toolset.remove_tool(tool.name)
else:
persona_allowed_tools = {str(tool_name) for tool_name in persona["tools"]}
persona_toolset = ToolSet()
if persona["tools"]:
for tool_name in persona["tools"]:
@@ -613,7 +615,6 @@ async def _ensure_persona_and_skills(
)
except Exception:
pass
return persona_allowed_tools
async def _request_img_caption(
@@ -946,13 +947,12 @@ async def _decorate_llm_request(
plugin_context: Context,
config: MainAgentBuildConfig,
provider: Provider | None = None,
) -> set[str] | None:
) -> None:
cfg = config.provider_settings or plugin_context.get_config(
umo=event.unified_msg_origin
).get("provider_settings", {})
_apply_prompt_prefix(req, cfg)
persona_allowed_tools = None
main_provider_supports_image = provider is not None and _provider_supports_modality(
provider, "image"
@@ -961,9 +961,7 @@ async def _decorate_llm_request(
quote_images_already_captioned = False
if req.conversation:
persona_allowed_tools = await _ensure_persona_and_skills(
req, cfg, plugin_context, event
)
await _ensure_persona_and_skills(req, cfg, plugin_context, event)
if img_cap_prov_id and req.image_urls and not main_provider_supports_image:
await _ensure_img_caption(
@@ -992,7 +990,6 @@ async def _decorate_llm_request(
tz = plugin_context.get_config().get("timezone")
_append_system_reminders(event, req, cfg, tz)
_apply_workspace_extra_prompt(event, req)
return persona_allowed_tools
def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None:
@@ -1213,6 +1210,9 @@ async def _apply_web_search_tools(
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FirecrawlExtractWebPageTool))
elif provider == "baidu_ai_search":
req.func_tool.add_tool(tool_mgr.get_builtin_tool(BaiduWebSearchTool))
elif provider == "exa":
req.func_tool.add_tool(tool_mgr.get_builtin_tool(ExaWebSearchTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(ExaGetContentsTool))
def _apply_web_search_citation_prompt(
@@ -1521,9 +1521,7 @@ async def build_main_agent(
else:
return None
persona_allowed_tools = await _decorate_llm_request(
event, req, plugin_context, config, provider=provider
)
await _decorate_llm_request(event, req, plugin_context, config, provider=provider)
await _apply_kb(event, req, plugin_context, config)
@@ -1559,11 +1557,6 @@ async def build_main_agent(
)
)
if persona_allowed_tools is not None and req.func_tool:
req.func_tool.tools = [
tool for tool in req.func_tool.tools if tool.name in persona_allowed_tools
]
fallback_providers = _get_fallback_chat_providers(
provider, plugin_context, config.provider_settings
)

View File

@@ -99,6 +99,26 @@ BACKGROUND_TASK_RESULT_WOKE_SYSTEM_PROMPT = (
"{background_task_result}"
)
CONVERSATION_HISTORY_INJECT_PREFIX = (
"\n\nBelow is your and the user's previous conversation history:\n"
)
BACKGROUND_TASK_WOKE_USER_PROMPT = (
"Proceed according to your system instructions. "
"Output using same language as previous conversation. "
"If you need to deliver the result to the user immediately, "
"you MUST use `send_message_to_user` tool to send the message directly to the user, "
"otherwise the user will not see the result. "
"After completing your task, summarize and output your actions and results. "
)
CRON_TASK_WOKE_USER_PROMPT = (
"You are now responding to a scheduled task. "
"Proceed according to your system instructions. "
"Output using same language as previous conversation. "
"After completing your task, summarize and output your actions and results."
)
# we prevent astrbot from connecting to known malicious hosts
# these hosts are base64 encoded
BLOCKED = {"dGZid2h2d3IuY2xvdWQuc2VhbG9zLmlv", "a291cmljaGF0"}

View File

@@ -1,6 +1,6 @@
import os
import uuid
from typing import TypedDict, TypeVar
from typing import Any, TypedDict, TypeVar
from astrbot.core import AstrBotConfig, logger
from astrbot.core.config.astrbot_config import ASTRBOT_CONFIG_PATH
@@ -13,7 +13,7 @@ from astrbot.core.utils.shared_preferences import SharedPreferences
_VT = TypeVar("_VT")
class ConfInfo(TypedDict):
class ConfInfo(TypedDict, total=False):
"""Configuration information for a specific session or platform."""
id: str # UUID of the configuration or "default"
@@ -42,7 +42,7 @@ class AstrBotConfigManager:
self.confs: dict[str, AstrBotConfig] = {}
"""uuid / "default" -> AstrBotConfig"""
self.confs["default"] = default_config
self.abconf_data = None
self.abconf_data: dict | None = None
self._load_all_configs()
def _get_abconf_data(self) -> dict:
@@ -54,7 +54,7 @@ class AstrBotConfigManager:
scope="global",
scope_id="global",
)
return self.abconf_data
return self.abconf_data # type: ignore[return-value]
def _load_all_configs(self) -> None:
"""Load all configurations from the shared preferences."""
@@ -107,12 +107,13 @@ class AstrBotConfigManager:
abconf_name: str | None = None,
) -> None:
"""保存配置文件的映射关系"""
abconf_data = self.sp.get(
raw_abconf: dict[str, Any] | None = self.sp.get(
"abconf_mapping",
{},
scope="global",
scope_id="global",
)
abconf_data: dict[str, dict[str, str]] = raw_abconf or {}
random_word = abconf_name or uuid.uuid4().hex[:8]
abconf_data[abconf_id] = {
"path": abconf_path,
@@ -122,7 +123,7 @@ class AstrBotConfigManager:
self.abconf_data = abconf_data
def get_conf(self, umo: str | MessageSession | None) -> AstrBotConfig:
"""获取指定 umo 的配置文件如果不存在则 fallback 到默认配置文件"""
"""获取指定 umo 的配置文件如果不存在,则 fallback 到默认配置文件"""
if not umo:
return self.confs["default"]
if isinstance(umo, MessageSession):
@@ -191,11 +192,14 @@ class AstrBotConfigManager:
raise ValueError("不能删除默认配置文件")
# 从映射中移除
abconf_data = self.sp.get(
"abconf_mapping",
{},
scope="global",
scope_id="global",
abconf_data: dict[str, dict[str, str]] = (
self.sp.get(
"abconf_mapping",
{},
scope="global",
scope_id="global",
)
or {}
)
if conf_id not in abconf_data:
logger.warning(f"配置文件 {conf_id} 不存在于映射中")
@@ -242,11 +246,14 @@ class AstrBotConfigManager:
if conf_id == "default":
raise ValueError("不能更新默认配置文件的信息")
abconf_data = self.sp.get(
"abconf_mapping",
{},
scope="global",
scope_id="global",
abconf_data: dict[str, dict[str, str]] = (
self.sp.get(
"abconf_mapping",
{},
scope="global",
scope_id="global",
)
or {}
)
if conf_id not in abconf_data:
logger.warning(f"配置文件 {conf_id} 不存在于映射中")
@@ -266,9 +273,9 @@ class AstrBotConfigManager:
self,
umo: str | None = None,
key: str | None = None,
default: _VT = None,
) -> _VT:
"""获取配置项umo 为 None 时使用默认配置"""
default: _VT | None = None,
) -> _VT | None:
"""获取配置项umo 为 None 时使用默认配置"""
if umo is None:
return self.confs["default"].get(key, default)
conf = self.get_conf(umo)

View File

@@ -1,6 +1,6 @@
"""AstrBot 备份与恢复模块
提供数据导出和导入功能支持用户在服务器迁移时一键备份和恢复所有数据
提供数据导出和导入功能,支持用户在服务器迁移时一键备份和恢复所有数据
"""
# 从 constants 模块导入共享常量
@@ -16,11 +16,11 @@ from .exporter import AstrBotExporter
from .importer import AstrBotImporter, ImportPreCheckResult
__all__ = [
"BACKUP_MANIFEST_VERSION",
"KB_METADATA_MODELS",
"MAIN_DB_MODELS",
"AstrBotExporter",
"AstrBotImporter",
"ImportPreCheckResult",
"MAIN_DB_MODELS",
"KB_METADATA_MODELS",
"get_backup_directories",
"BACKUP_MANIFEST_VERSION",
]

View File

@@ -1,6 +1,6 @@
"""AstrBot 备份模块共享常量
此文件定义了导出器和导入器共享的常量确保两端配置一致
此文件定义了导出器和导入器共享的常量,确保两端配置一致
"""
from sqlmodel import SQLModel
@@ -29,7 +29,6 @@ from astrbot.core.utils.astrbot_path import (
get_astrbot_config_path,
get_astrbot_plugin_data_path,
get_astrbot_plugin_path,
get_astrbot_skills_path,
get_astrbot_t2i_templates_path,
get_astrbot_temp_path,
get_astrbot_webchat_path,
@@ -67,10 +66,11 @@ KB_METADATA_MODELS: dict[str, type[SQLModel]] = {
def get_backup_directories() -> dict[str, str]:
"""获取需要备份的目录列表
使用 astrbot_path 模块动态获取路径支持通过环境变量 ASTRBOT_ROOT 自定义根目录
使用 astrbot_path 模块动态获取路径,支持通过环境变量 ASTRBOT_ROOT 自定义根目录
Returns:
dict: 键为备份文件中的目录名称值为目录的绝对路径
dict: 键为备份文件中的目录名称,值为目录的绝对路径
"""
return {
"plugins": get_astrbot_plugin_path(), # 插件本体
@@ -79,7 +79,6 @@ def get_backup_directories() -> dict[str, str]:
"t2i_templates": get_astrbot_t2i_templates_path(), # T2I 模板
"webchat": get_astrbot_webchat_path(), # WebChat 数据
"temp": get_astrbot_temp_path(), # 临时文件
"skills": get_astrbot_skills_path(), # Skills
}

View File

@@ -1,7 +1,7 @@
"""AstrBot 数据导出器
负责将所有数据导出为 ZIP 备份文件
导出格式为 JSON这是数据库无关的方案支持未来向 MySQL/PostgreSQL 迁移
负责将所有数据导出为 ZIP 备份文件
导出格式为 JSON,这是数据库无关的方案,支持未来向 MySQL/PostgreSQL 迁移
"""
import hashlib
@@ -12,6 +12,7 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Any
import anyio
from sqlalchemy import select
from astrbot.core import logger
@@ -39,19 +40,19 @@ CMD_CONFIG_FILE_PATH = os.path.join(get_astrbot_data_path(), "cmd_config.json")
class AstrBotExporter:
"""AstrBot 数据导出器
导出内容
- 主数据库所有表data/data_v4.db
- 知识库元数据data/knowledge_base/kb.db
导出内容:
- 主数据库所有表(data/data_v4.db)
- 知识库元数据(data/knowledge_base/kb.db)
- 每个知识库的向量文档数据
- 配置文件data/cmd_config.json
- 配置文件(data/cmd_config.json)
- 附件文件
- 知识库多媒体文件
- 插件目录data/plugins
- 插件数据目录data/plugin_data
- 配置目录data/config
- T2I 模板目录data/t2i_templates
- WebChat 数据目录data/webchat
- 临时文件目录data/temp
- 插件目录(data/plugins)
- 插件数据目录(data/plugin_data)
- 配置目录(data/config)
- T2I 模板目录(data/t2i_templates)
- WebChat 数据目录(data/webchat)
- 临时文件目录(data/temp)
"""
def __init__(
@@ -74,16 +75,17 @@ class AstrBotExporter:
Args:
output_dir: 输出目录
progress_callback: 进度回调函数接收参数 (stage, current, total, message)
progress_callback: 进度回调函数,接收参数 (stage, current, total, message)
Returns:
str: 生成的 ZIP 文件路径
"""
if output_dir is None:
output_dir = get_astrbot_backups_path()
# 确保输出目录存在
Path(output_dir).mkdir(parents=True, exist_ok=True)
await anyio.Path(output_dir).mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
zip_filename = f"astrbot_backup_{timestamp}.zip"
@@ -98,7 +100,10 @@ class AstrBotExporter:
await progress_callback("main_db", 0, 100, "正在导出主数据库...")
main_data = await self._export_main_database()
main_db_json = json.dumps(
main_data, ensure_ascii=False, indent=2, default=str
main_data,
ensure_ascii=False,
indent=2,
default=str,
)
zf.writestr("databases/main_db.json", main_db_json)
self._add_checksum("databases/main_db.json", main_db_json)
@@ -114,17 +119,26 @@ class AstrBotExporter:
if self.kb_manager:
if progress_callback:
await progress_callback(
"kb_metadata", 0, 100, "正在导出知识库元数据..."
"kb_metadata",
0,
100,
"正在导出知识库元数据...",
)
kb_meta_data = await self._export_kb_metadata()
kb_meta_json = json.dumps(
kb_meta_data, ensure_ascii=False, indent=2, default=str
kb_meta_data,
ensure_ascii=False,
indent=2,
default=str,
)
zf.writestr("databases/kb_metadata.json", kb_meta_json)
self._add_checksum("databases/kb_metadata.json", kb_meta_json)
if progress_callback:
await progress_callback(
"kb_metadata", 100, 100, "知识库元数据导出完成"
"kb_metadata",
100,
100,
"知识库元数据导出完成",
)
# 导出每个知识库的文档数据
@@ -140,7 +154,10 @@ class AstrBotExporter:
)
doc_data = await self._export_kb_documents(kb_helper)
doc_json = json.dumps(
doc_data, ensure_ascii=False, indent=2, default=str
doc_data,
ensure_ascii=False,
indent=2,
default=str,
)
doc_path = f"databases/kb_{kb_id}/documents.json"
zf.writestr(doc_path, doc_json)
@@ -154,15 +171,21 @@ class AstrBotExporter:
if progress_callback:
await progress_callback(
"kb_documents", total_kbs, total_kbs, "知识库文档导出完成"
"kb_documents",
total_kbs,
total_kbs,
"知识库文档导出完成",
)
# 3. 导出配置文件
if progress_callback:
await progress_callback("config", 0, 100, "正在导出配置文件...")
if os.path.exists(self.config_path):
with open(self.config_path, encoding="utf-8") as f:
config_content = f.read()
if await anyio.Path(self.config_path).exists():
async with await anyio.open_file(
self.config_path,
encoding="utf-8",
) as f:
config_content = await f.read()
zf.writestr("config/cmd_config.json", config_content)
self._add_checksum("config/cmd_config.json", config_content)
if progress_callback:
@@ -178,7 +201,10 @@ class AstrBotExporter:
# 5. 导出插件和其他目录
if progress_callback:
await progress_callback(
"directories", 0, 100, "正在导出插件和数据目录..."
"directories",
0,
100,
"正在导出插件和数据目录...",
)
dir_stats = await self._export_directories(zf)
if progress_callback:
@@ -199,8 +225,8 @@ class AstrBotExporter:
except Exception as e:
logger.error(f"备份导出失败: {e}")
# 清理失败的文件
if os.path.exists(zip_path):
os.remove(zip_path)
if await anyio.Path(zip_path).exists():
await anyio.Path(zip_path).unlink()
raise
async def _export_main_database(self) -> dict[str, list[dict]]:
@@ -216,7 +242,7 @@ class AstrBotExporter:
self._model_to_dict(record) for record in records
]
logger.debug(
f"导出表 {table_name}: {len(export_data[table_name])} 条记录"
f"导出表 {table_name}: {len(export_data[table_name])} 条记录",
)
except Exception as e:
logger.warning(f"导出表 {table_name} 失败: {e}")
@@ -240,7 +266,7 @@ class AstrBotExporter:
self._model_to_dict(record) for record in records
]
logger.debug(
f"导出知识库表 {table_name}: {len(export_data[table_name])} 条记录"
f"导出知识库表 {table_name}: {len(export_data[table_name])} 条记录",
)
except Exception as e:
logger.warning(f"导出知识库表 {table_name} 失败: {e}")
@@ -286,7 +312,10 @@ class AstrBotExporter:
logger.warning(f"导出 FAISS 索引失败: {e}")
async def _export_kb_media_files(
self, zf: zipfile.ZipFile, kb_helper: Any, kb_id: str
self,
zf: zipfile.ZipFile,
kb_helper: Any,
kb_id: str,
) -> None:
"""导出知识库的多媒体文件"""
try:
@@ -305,20 +334,22 @@ class AstrBotExporter:
logger.warning(f"导出知识库媒体文件失败: {e}")
async def _export_directories(
self, zf: zipfile.ZipFile
self,
zf: zipfile.ZipFile,
) -> dict[str, dict[str, int]]:
"""导出插件和其他数据目录
Returns:
dict: 每个目录的统计信息 {dir_name: {"files": count, "size": bytes}}
"""
stats: dict[str, dict[str, int]] = {}
backup_directories = get_backup_directories()
for dir_name, dir_path in backup_directories.items():
full_path = Path(dir_path)
if not full_path.exists():
logger.debug(f"目录不存在跳过: {full_path}")
if not await anyio.Path(full_path).exists():
logger.debug(f"目录不存在,跳过: {full_path}")
continue
file_count = 0
@@ -347,7 +378,7 @@ class AstrBotExporter:
stats[dir_name] = {"files": file_count, "size": total_size}
logger.debug(
f"导出目录 {dir_name}: {file_count} 个文件, {total_size} 字节"
f"导出目录 {dir_name}: {file_count} 个文件, {total_size} 字节",
)
except Exception as e:
logger.warning(f"导出目录 {dir_path} 失败: {e}")
@@ -356,13 +387,15 @@ class AstrBotExporter:
return stats
async def _export_attachments(
self, zf: zipfile.ZipFile, attachments: list[dict]
self,
zf: zipfile.ZipFile,
attachments: list[dict],
) -> None:
"""导出附件文件"""
for attachment in attachments:
try:
file_path = attachment.get("path", "")
if file_path and os.path.exists(file_path):
if file_path and await anyio.Path(file_path).exists():
# 使用 attachment_id 作为文件名
attachment_id = attachment.get("attachment_id", "")
ext = os.path.splitext(file_path)[1]
@@ -374,9 +407,9 @@ class AstrBotExporter:
def _model_to_dict(self, record: Any) -> dict:
"""将 SQLModel 实例转换为字典
这是数据库无关的序列化方式支持未来迁移到其他数据库
这是数据库无关的序列化方式,支持未来迁移到其他数据库
"""
# 使用 SQLModel 内置的 model_dump 方法如果可用
# 使用 SQLModel 内置的 model_dump 方法(如果可用)
if hasattr(record, "model_dump"):
data = record.model_dump(mode="python")
# 处理 datetime 类型
@@ -437,7 +470,7 @@ class AstrBotExporter:
media_files: list[str] = []
media_dir = kb_helper.kb_medias_dir
if media_dir.exists():
for root, _, files in os.walk(media_dir):
for _root, _, files in os.walk(media_dir):
for file in files:
media_files.append(file)
if media_files:
@@ -447,7 +480,7 @@ class AstrBotExporter:
"version": BACKUP_MANIFEST_VERSION,
"astrbot_version": VERSION,
"exported_at": datetime.now(timezone.utc).isoformat(),
"origin": "exported", # 标记备份来源exported=本实例导出, uploaded=用户上传
"origin": "exported", # 标记备份来源:exported=本实例导出, uploaded=用户上传
"schema_version": {
"main_db": "v4",
"kb_db": "v1",

View File

@@ -1,9 +1,9 @@
"""AstrBot 数据导入器
负责从 ZIP 备份文件恢复所有数据
导入时进行版本校验
- 主版本前两位不同时直接拒绝导入
- 小版本第三位不同时提示警告用户可选择强制导入
负责从 ZIP 备份文件恢复所有数据
导入时进行版本校验:
- 主版本(前两位)不同时直接拒绝导入
- 小版本(第三位)不同时提示警告,用户可选择强制导入
- 版本匹配时也需要用户确认
"""
@@ -16,6 +16,7 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Any
import anyio
from sqlalchemy import delete
from astrbot.core import logger
@@ -40,13 +41,14 @@ if TYPE_CHECKING:
def _get_major_version(version_str: str) -> str:
"""提取版本的主版本部分前两位
"""提取版本的主版本部分(前两位)
Args:
version_str: 版本字符串"4.9.1", "4.10.0-beta"
version_str: 版本字符串,"4.9.1", "4.10.0-beta"
Returns:
主版本字符串"4.9", "4.10"
主版本字符串,"4.9", "4.10"
"""
if not version_str:
return "0.0"
@@ -55,7 +57,7 @@ def _get_major_version(version_str: str) -> str:
parts = [p for p in version.split(".") if p] # 过滤空字符串
if len(parts) >= 2:
return f"{parts[0]}.{parts[1]}"
elif len(parts) == 1 and parts[0]:
if len(parts) == 1 and parts[0]:
return f"{parts[0]}.0"
return "0.0"
@@ -119,14 +121,14 @@ class _InvalidCountWarnLimiter:
if self.limit > 0:
if self._count < self.limit:
logger.warning(
"platform_stats count 非法已按 0 处理: value=%r, key=%s",
"platform_stats count 非法,已按 0 处理: value=%r, key=%s",
value,
key_for_log,
)
self._count += 1
if self._count == self.limit and not self._suppression_logged:
logger.warning(
"platform_stats 非法 count 告警已达到上限 (%d)后续将抑制",
"platform_stats 非法 count 告警已达到上限 (%d),后续将抑制",
self.limit,
)
self._suppression_logged = True
@@ -135,7 +137,7 @@ class _InvalidCountWarnLimiter:
if not self._suppression_logged:
# limit <= 0: emit only one suppression warning.
logger.warning(
"platform_stats 非法 count 告警已达到上限 (%d)后续将抑制",
"platform_stats 非法 count 告警已达到上限 (%d),后续将抑制",
self.limit,
)
self._suppression_logged = True
@@ -145,15 +147,15 @@ class _InvalidCountWarnLimiter:
class ImportPreCheckResult:
"""导入预检查结果
用于在实际导入前检查备份文件的版本兼容性
并返回确认信息让用户决定是否继续导入
用于在实际导入前检查备份文件的版本兼容性,
并返回确认信息让用户决定是否继续导入
"""
# 检查是否通过文件有效且版本可导入
# 检查是否通过(文件有效且版本可导入)
valid: bool = False
# 是否可以导入版本兼容
# 是否可以导入(版本兼容)
can_import: bool = False
# 版本状态: match完全匹配, minor_diff小版本差异, major_diff主版本不同拒绝
# 版本状态: match(完全匹配), minor_diff(小版本差异), major_diff(主版本不同,拒绝)
version_status: str = ""
# 备份文件中的 AstrBot 版本
backup_version: str = ""
@@ -161,11 +163,11 @@ class ImportPreCheckResult:
current_version: str = VERSION
# 备份创建时间
backup_time: str = ""
# 确认消息显示给用户
# 确认消息(显示给用户)
confirm_message: str = ""
# 警告消息列表
warnings: list[str] = field(default_factory=list)
# 错误消息如果检查失败
# 错误消息(如果检查失败)
error: str = ""
# 备份包含的内容摘要
backup_summary: dict = field(default_factory=dict)
@@ -223,18 +225,18 @@ class DatabaseClearError(RuntimeError):
class AstrBotImporter:
"""AstrBot 数据导入器
导入备份文件中的所有数据包括
导入备份文件中的所有数据,包括:
- 主数据库所有表
- 知识库元数据和文档
- 配置文件
- 附件文件
- 知识库多媒体文件
- 插件目录data/plugins
- 插件数据目录data/plugin_data
- 配置目录data/config
- T2I 模板目录data/t2i_templates
- WebChat 数据目录data/webchat
- 临时文件目录data/temp
- 插件目录(data/plugins)
- 插件数据目录(data/plugin_data)
- 配置目录(data/config)
- T2I 模板目录(data/t2i_templates)
- WebChat 数据目录(data/webchat)
- 临时文件目录(data/temp)
"""
def __init__(
@@ -252,14 +254,15 @@ class AstrBotImporter:
def pre_check(self, zip_path: str) -> ImportPreCheckResult:
"""预检查备份文件
在实际导入前检查备份文件的有效性和版本兼容性
返回检查结果供前端显示确认对话框
在实际导入前检查备份文件的有效性和版本兼容性
返回检查结果供前端显示确认对话框
Args:
zip_path: ZIP 备份文件路径
Returns:
ImportPreCheckResult: 预检查结果
"""
result = ImportPreCheckResult()
result.current_version = VERSION
@@ -275,7 +278,7 @@ class AstrBotImporter:
manifest_data = zf.read("manifest.json")
manifest = json.loads(manifest_data)
except KeyError:
result.error = "备份文件缺少 manifest.json不是有效的 AstrBot 备份"
result.error = "备份文件缺少 manifest.json,不是有效的 AstrBot 备份"
return result
except json.JSONDecodeError as e:
result.error = f"manifest.json 格式错误: {e}"
@@ -300,7 +303,7 @@ class AstrBotImporter:
result.can_import = version_check["can_import"]
# 版本信息由前端根据 version_status 和 i18n 生成显示
# 不再将版本消息添加到 warnings 列表中避免中文硬编码
# 不再将版本消息添加到 warnings 列表中,避免中文硬编码
# warnings 列表保留用于其他非版本相关的警告
return result
@@ -315,12 +318,13 @@ class AstrBotImporter:
def _check_version_compatibility(self, backup_version: str) -> dict:
"""检查版本兼容性
规则
- 主版本前两位如 4.9必须一致否则拒绝
- 小版本第三位如 4.9.1 vs 4.9.2不同时警告但允许导入
规则:
- 主版本(前两位,如 4.9)必须一致,否则拒绝
- 小版本(第三位,如 4.9.1 vs 4.9.2)不同时,警告但允许导入
Returns:
dict: {status, can_import, message}
"""
if not backup_version:
return {
@@ -329,7 +333,7 @@ class AstrBotImporter:
"message": "备份文件缺少版本信息",
}
# 提取主版本前两位进行比较
# 提取主版本(前两位)进行比较
backup_major = _get_major_version(backup_version)
current_major = _get_major_version(VERSION)
@@ -339,8 +343,8 @@ class AstrBotImporter:
"status": "major_diff",
"can_import": False,
"message": (
f"主版本不兼容: 备份版本 {backup_version}, 当前版本 {VERSION}"
f"跨主版本导入可能导致数据损坏请使用相同主版本的 AstrBot"
f"主版本不兼容: 备份版本 {backup_version}, 当前版本 {VERSION}"
f"跨主版本导入可能导致数据损坏,请使用相同主版本的 AstrBot"
),
}
@@ -351,7 +355,7 @@ class AstrBotImporter:
"status": "minor_diff",
"can_import": True,
"message": (
f"小版本差异: 备份版本 {backup_version}, 当前版本 {VERSION}"
f"小版本差异: 备份版本 {backup_version}, 当前版本 {VERSION}"
),
}
@@ -371,15 +375,16 @@ class AstrBotImporter:
Args:
zip_path: ZIP 备份文件路径
mode: 导入模式目前仅支持 "replace"清空后导入
progress_callback: 进度回调函数接收参数 (stage, current, total, message)
mode: 导入模式,目前仅支持 "replace"(清空后导入)
progress_callback: 进度回调函数,接收参数 (stage, current, total, message)
Returns:
ImportResult: 导入结果
"""
result = ImportResult()
if not os.path.exists(zip_path):
if not await anyio.Path(zip_path).exists():
result.add_error(f"备份文件不存在: {zip_path}")
return result
@@ -461,12 +466,12 @@ class AstrBotImporter:
try:
config_content = zf.read("config/cmd_config.json")
# 备份现有配置
if os.path.exists(self.config_path):
if await anyio.Path(self.config_path).exists():
backup_path = f"{self.config_path}.bak"
shutil.copy2(self.config_path, backup_path)
with open(self.config_path, "wb") as f:
f.write(config_content)
async with await anyio.open_file(self.config_path, "wb") as f:
await f.write(config_content)
result.imported_files["config"] = 1
except Exception as e:
result.add_warning(f"导入配置文件失败: {e}")
@@ -479,7 +484,8 @@ class AstrBotImporter:
await progress_callback("attachments", 0, 100, "正在导入附件...")
attachment_count = await self._import_attachments(
zf, main_data.get("attachments", [])
zf,
main_data.get("attachments", []),
)
result.imported_files["attachments"] = attachment_count
@@ -489,7 +495,10 @@ class AstrBotImporter:
# 6. 导入插件和其他目录
if progress_callback:
await progress_callback(
"directories", 0, 100, "正在导入插件和数据目录..."
"directories",
0,
100,
"正在导入插件和数据目录...",
)
dir_stats = await self._import_directories(zf, manifest, result)
@@ -511,8 +520,8 @@ class AstrBotImporter:
def _validate_version(self, manifest: dict) -> None:
"""验证版本兼容性 - 仅允许相同主版本导入
注意此方法仅在 import_all 中调用用于双重校验
前端应先调用 pre_check 获取详细的版本信息并让用户确认
注意:此方法仅在 import_all 中调用,用于双重校验
前端应先调用 pre_check 获取详细的版本信息并让用户确认
"""
backup_version = manifest.get("astrbot_version")
if not backup_version:
@@ -530,16 +539,15 @@ class AstrBotImporter:
async def _clear_main_db(self) -> None:
"""清空主数据库所有表"""
async with self.main_db.get_db() as session:
async with session.begin():
for table_name, model_class in MAIN_DB_MODELS.items():
try:
await session.execute(delete(model_class))
logger.debug(f"已清空表 {table_name}")
except Exception as e:
raise DatabaseClearError(
f"清空表 {table_name} 失败: {e}"
) from e
async with self.main_db.get_db() as session, session.begin():
for table_name, model_class in MAIN_DB_MODELS.items():
try:
await session.execute(delete(model_class))
logger.debug(f"已清空表 {table_name}")
except Exception as e:
raise DatabaseClearError(
f"清空表 {table_name} 失败: {e}",
) from e
async def _clear_kb_data(self) -> None:
"""清空知识库数据"""
@@ -547,14 +555,13 @@ class AstrBotImporter:
return
# 清空知识库元数据表
async with self.kb_manager.kb_db.get_db() as session:
async with session.begin():
for table_name, model_class in KB_METADATA_MODELS.items():
try:
await session.execute(delete(model_class))
logger.debug(f"已清空知识库表 {table_name}")
except Exception as e:
logger.warning(f"清空知识库表 {table_name} 失败: {e}")
async with self.kb_manager.kb_db.get_db() as session, session.begin():
for table_name, model_class in KB_METADATA_MODELS.items():
try:
await session.execute(delete(model_class))
logger.debug(f"已清空知识库表 {table_name}")
except Exception as e:
logger.warning(f"清空知识库表 {table_name} 失败: {e}")
# 删除知识库文件目录
for kb_id in list(self.kb_manager.kb_insts.keys()):
@@ -569,45 +576,47 @@ class AstrBotImporter:
self.kb_manager.kb_insts.clear()
async def _import_main_database(
self, data: dict[str, list[dict]]
self,
data: dict[str, list[dict]],
) -> dict[str, int]:
"""导入主数据库数据"""
imported: dict[str, int] = {}
async with self.main_db.get_db() as session:
async with session.begin():
for table_name, rows in data.items():
model_class = MAIN_DB_MODELS.get(table_name)
if not model_class:
logger.warning(f"未知的表: {table_name}")
continue
normalized_rows = self._preprocess_main_table_rows(table_name, rows)
async with self.main_db.get_db() as session, session.begin():
for table_name, rows in data.items():
model_class = MAIN_DB_MODELS.get(table_name)
if not model_class:
logger.warning(f"未知的表: {table_name}")
continue
normalized_rows = self._preprocess_main_table_rows(table_name, rows)
count = 0
for row in normalized_rows:
try:
# 转换 datetime 字符串为 datetime 对象
row = self._convert_datetime_fields(row, model_class)
obj = model_class(**row)
session.add(obj)
count += 1
except Exception as e:
logger.warning(f"导入记录到 {table_name} 失败: {e}")
count = 0
for row in normalized_rows:
try:
# 转换 datetime 字符串为 datetime 对象
row = self._convert_datetime_fields(row, model_class)
obj = model_class(**row)
session.add(obj)
count += 1
except Exception as e:
logger.warning(f"导入记录到 {table_name} 失败: {e}")
imported[table_name] = count
logger.debug(f"导入表 {table_name}: {count} 条记录")
imported[table_name] = count
logger.debug(f"导入表 {table_name}: {count} 条记录")
return imported
def _preprocess_main_table_rows(
self, table_name: str, rows: list[dict[str, Any]]
self,
table_name: str,
rows: list[dict[str, Any]],
) -> list[dict[str, Any]]:
if table_name == "platform_stats":
normalized_rows = self._merge_platform_stats_rows(rows)
duplicate_count = len(rows) - len(normalized_rows)
if duplicate_count > 0:
logger.warning(
"检测到 %s 重复键 %d已在导入前聚合",
"检测到 %s 重复键 %d,已在导入前聚合",
table_name,
duplicate_count,
)
@@ -615,7 +624,8 @@ class AstrBotImporter:
return rows
def _merge_platform_stats_rows(
self, rows: list[dict[str, Any]]
self,
rows: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Merge duplicate platform_stats rows by normalized timestamp/platform key.
@@ -623,6 +633,7 @@ class AstrBotImporter:
- Invalid/empty timestamps are kept as distinct rows to avoid accidental merging.
- Non-string platform_id/platform_type are kept as distinct rows.
- Invalid count warnings are rate-limited per function invocation.
"""
merged: dict[tuple[str, str, str], dict[str, Any]] = {}
result: list[dict[str, Any]] = []
@@ -722,24 +733,23 @@ class AstrBotImporter:
return
# 1. 导入知识库元数据
async with self.kb_manager.kb_db.get_db() as session:
async with session.begin():
for table_name, rows in kb_meta_data.items():
model_class = KB_METADATA_MODELS.get(table_name)
if not model_class:
continue
async with self.kb_manager.kb_db.get_db() as session, session.begin():
for table_name, rows in kb_meta_data.items():
model_class = KB_METADATA_MODELS.get(table_name)
if not model_class:
continue
count = 0
for row in rows:
try:
row = self._convert_datetime_fields(row, model_class)
obj = model_class(**row)
session.add(obj)
count += 1
except Exception as e:
logger.warning(f"导入知识库记录到 {table_name} 失败: {e}")
count = 0
for row in rows:
try:
row = self._convert_datetime_fields(row, model_class)
obj = model_class(**row)
session.add(obj)
count += 1
except Exception as e:
logger.warning(f"导入知识库记录到 {table_name} 失败: {e}")
result.imported_tables[f"kb_{table_name}"] = count
result.imported_tables[f"kb_{table_name}"] = count
# 2. 导入每个知识库的文档和文件
for kb_data in kb_meta_data.get("knowledge_bases", []):
@@ -768,8 +778,10 @@ class AstrBotImporter:
if faiss_path in zf.namelist():
try:
target_path = kb_dir / "index.faiss"
with zf.open(faiss_path) as src, open(target_path, "wb") as dst:
dst.write(src.read())
with zf.open(faiss_path) as src:
content = src.read()
async with await anyio.open_file(target_path, "wb") as dst:
await dst.write(content)
except Exception as e:
result.add_warning(f"导入知识库 {kb_id} 的 FAISS 索引失败: {e}")
@@ -785,8 +797,10 @@ class AstrBotImporter:
logger.warning(f"媒体文件路径越界,已跳过: {target_path}")
continue
target_path.parent.mkdir(parents=True, exist_ok=True)
with zf.open(name) as src, open(target_path, "wb") as dst:
dst.write(src.read())
with zf.open(name) as src:
content = src.read()
async with await anyio.open_file(target_path, "wb") as dst:
await dst.write(content)
except Exception as e:
result.add_warning(f"导入媒体文件 {name} 失败: {e}")
@@ -852,8 +866,10 @@ class AstrBotImporter:
continue
target_path.parent.mkdir(parents=True, exist_ok=True)
with zf.open(name) as src, open(target_path, "wb") as dst:
dst.write(src.read())
with zf.open(name) as src:
content = src.read()
async with await anyio.open_file(target_path, "wb") as dst:
await dst.write(content)
count += 1
except Exception as e:
logger.warning(f"导入附件 {name} 失败: {e}")
@@ -875,13 +891,14 @@ class AstrBotImporter:
Returns:
dict: 每个目录导入的文件数量
"""
dir_stats: dict[str, int] = {}
# 检查备份版本是否支持目录备份需要版本 >= 1.1
# 检查备份版本是否支持目录备份(需要版本 >= 1.1)
backup_version = manifest.get("version", "1.0")
if VersionComparator.compare_version(backup_version, "1.1") < 0:
logger.info("备份版本不支持目录备份跳过目录导入")
logger.info("备份版本不支持目录备份,跳过目录导入")
return dir_stats
backed_up_dirs = manifest.get("directories", [])
@@ -908,16 +925,16 @@ class AstrBotImporter:
if not dir_files:
continue
# 备份现有目录如果存在
if target_dir.exists():
# 备份现有目录(如果存在)
if await anyio.Path(target_dir).exists():
backup_path = Path(f"{target_dir}.bak")
if backup_path.exists():
if await anyio.Path(backup_path).exists():
shutil.rmtree(backup_path)
shutil.move(str(target_dir), str(backup_path))
logger.debug(f"已备份现有目录 {target_dir}{backup_path}")
# 创建目标目录
target_dir.mkdir(parents=True, exist_ok=True)
await anyio.Path(target_dir).mkdir(parents=True, exist_ok=True)
# 解压文件
for name in dir_files:
@@ -939,8 +956,10 @@ class AstrBotImporter:
target_path.parent.mkdir(parents=True, exist_ok=True)
with zf.open(name) as src, open(target_path, "wb") as dst:
dst.write(src.read())
with zf.open(name) as src:
content = src.read()
async with await anyio.open_file(target_path, "wb") as dst:
await dst.write(content)
file_count += 1
except Exception as e:
result.add_warning(f"导入文件 {name} 失败: {e}")
@@ -960,9 +979,10 @@ class AstrBotImporter:
# 获取模型的 datetime 字段
from sqlalchemy import inspect as sa_inspect
from sqlalchemy.orm import Mapper
try:
mapper = sa_inspect(model_class)
mapper: Mapper[Any] = sa_inspect(model_class)
for column in mapper.columns:
if column.name in result and result[column.name] is not None:
# 检查是否是 datetime 类型的列

View File

@@ -1,4 +1,9 @@
from ..olayer import (
from __future__ import annotations
import abc
from typing import TYPE_CHECKING
from astrbot.core.computer.olayer import (
BrowserComponent,
FileSystemComponent,
GUIComponent,
@@ -6,16 +11,25 @@ from ..olayer import (
ShellComponent,
)
if TYPE_CHECKING:
from astrbot.core.agent.tool import ToolSchema
class ComputerBooter:
class ComputerBooter(abc.ABC):
@property
def fs(self) -> FileSystemComponent: ...
@abc.abstractmethod
def fs(self) -> FileSystemComponent:
raise NotImplementedError("Subclass must implement fs property")
@property
def python(self) -> PythonComponent: ...
@abc.abstractmethod
def python(self) -> PythonComponent:
raise NotImplementedError("Subclass must implement python property")
@property
def shell(self) -> ShellComponent: ...
@abc.abstractmethod
def shell(self) -> ShellComponent:
raise NotImplementedError("Subclass must implement shell property")
@property
def capabilities(self) -> tuple[str, ...] | None:
@@ -34,29 +48,44 @@ class ComputerBooter:
def gui(self) -> GUIComponent | None:
return None
@abc.abstractmethod
async def boot(self, session_id: str) -> None: ...
@abc.abstractmethod
async def shutdown(self, **kwargs) -> None:
"""Shut down the computer sandbox.
Subclasses may accept extra keyword arguments for
type-specific cleanup (e.g. ``delete_sandbox`` for
ShipyardNeoBooter). The default implementation ignores
them.
Subclasses may accept type-specific keyword arguments.
"""
...
async def upload_file(self, path: str, file_name: str) -> dict:
"""Upload file to the computer.
Should return a dict with `success` (bool) and `file_path` (str) keys.
"""
...
raise NotImplementedError("Subclass must implement upload_file method")
async def download_file(self, remote_path: str, local_path: str) -> None:
"""Download file from the computer."""
...
raise NotImplementedError("Subclass must implement download_file method")
@abc.abstractmethod
async def available(self) -> bool:
"""Check if the computer is available."""
...
raise NotImplementedError("Subclass must implement available method")
@classmethod
def get_default_tools(cls) -> list[ToolSchema]:
"""Conservative full tool list (no instance needed, pre-boot)."""
return []
def get_tools(self) -> list[ToolSchema]:
"""Capability-filtered tool list (post-boot).
Defaults to get_default_tools().
"""
return self.__class__.get_default_tools()
@classmethod
def get_system_prompt_parts(cls) -> list[str]:
"""Booter-specific system prompt fragments (static text, no instance needed)."""
return []

View File

@@ -60,7 +60,7 @@ class BayContainerManager:
raise RuntimeError(
"Failed to connect to Docker daemon. "
"Ensure Docker is installed and running, or configure "
"an explicit Bay endpoint instead of auto-start mode."
"an explicit Bay endpoint instead of auto-start mode.",
) from exc
# 1. Look for an existing managed container
@@ -72,13 +72,12 @@ class BayContainerManager:
logger.info("[BayManager] Reusing existing Bay container: %s", cid)
self._container = await self._docker.containers.get(existing["Id"])
return f"http://127.0.0.1:{self._host_port}"
else:
# Container exists but stopped — restart it
logger.info("[BayManager] Restarting stopped Bay container")
container = await self._docker.containers.get(existing["Id"])
await container.start()
self._container = container
return f"http://127.0.0.1:{self._host_port}"
# Container exists but stopped — restart it
logger.info("[BayManager] Restarting stopped Bay container")
container = await self._docker.containers.get(existing["Id"])
await container.start()
self._container = container
return f"http://127.0.0.1:{self._host_port}"
# 2. Pull image if needed
await self._pull_image_if_needed()
@@ -96,7 +95,7 @@ class BayContainerManager:
"BAY_SERVER__HOST=0.0.0.0",
f"BAY_SERVER__PORT={BAY_PORT}",
"BAY_DATA_DIR=/app/data",
# allow_anonymous=false auto-provisions API key
# allow_anonymous=false auto-provisions API key
"BAY_SECURITY__ALLOW_ANONYMOUS=false",
],
"HostConfig": {
@@ -111,7 +110,8 @@ class BayContainerManager:
},
}
self._container = await self._docker.containers.create_or_replace(
BAY_CONTAINER_NAME, config
BAY_CONTAINER_NAME,
config,
)
await self._container.start()
logger.info("[BayManager] Bay container started: %s", BAY_CONTAINER_NAME)
@@ -129,7 +129,8 @@ class BayContainerManager:
while loop.time() < deadline:
try:
async with session.get(
url, timeout=aiohttp.ClientTimeout(total=3)
url,
timeout=aiohttp.ClientTimeout(total=3),
) as resp:
if resp.status == 200:
logger.info("[BayManager] Bay is healthy")
@@ -141,7 +142,7 @@ class BayContainerManager:
await asyncio.sleep(HEALTH_POLL_INTERVAL_S)
raise TimeoutError(
f"Bay did not become healthy within {timeout}s (last error: {last_error})"
f"Bay did not become healthy within {timeout}s (last error: {last_error})",
)
async def read_credentials(self) -> str:
@@ -202,7 +203,8 @@ class BayContainerManager:
return api_key
except Exception as exc:
logger.debug(
"[BayManager] Failed to read credentials from container: %s", exc
"[BayManager] Failed to read credentials from container: %s",
exc,
)
return ""

View File

@@ -1,18 +1,30 @@
from __future__ import annotations
import asyncio
import functools
import random
from typing import Any
from typing import TYPE_CHECKING, Any, cast
import aiohttp
import anyio
import boxlite
from shipyard import FileSystemComponent as ShipyardFileSystemComponent
from shipyard.filesystem import FileSystemComponent as ShipyardFileSystemComponent
from shipyard.python import PythonComponent as ShipyardPythonComponent
from shipyard.shell import ShellComponent as ShipyardShellComponent
from astrbot.api import logger
from ..olayer import FileSystemComponent, PythonComponent, ShellComponent
if TYPE_CHECKING:
from astrbot.core.agent.tool import ToolSchema
from astrbot.core.computer.olayer import (
FileSystemComponent,
PythonComponent,
ShellComponent,
)
from .base import ComputerBooter
from .shipyard import ShipyardFileSystemWrapper
from .shipyard import ShipyardFileSystemWrapper, ShipyardShellWrapper
class MockShipyardSandboxClient:
@@ -35,11 +47,10 @@ class MockShipyardSandboxClient:
) as response:
if response.status == 200:
return await response.json()
else:
error_text = await response.text()
raise Exception(
f"Failed to exec operation: {response.status} {error_text}"
)
error_text = await response.text()
raise Exception(
f"Failed to exec operation: {response.status} {error_text}",
)
async def upload_file(self, path: str, remote_path: str) -> dict:
"""Upload a file to the sandbox"""
@@ -47,15 +58,15 @@ class MockShipyardSandboxClient:
try:
# Read file content
with open(path, "rb") as f:
file_content = f.read()
async with await anyio.open_file(path, "rb") as f:
file_content = await f.read()
# Create multipart form data
data = aiohttp.FormData()
data.add_field(
"file",
file_content,
filename=remote_path.split("/")[-1],
filename=remote_path.rsplit("/", maxsplit=1)[-1],
content_type="application/octet-stream",
)
data.add_field("file_path", remote_path)
@@ -66,7 +77,7 @@ class MockShipyardSandboxClient:
async with session.post(url, data=data) as response:
if response.status == 200:
logger.info(
"[Computer] File uploaded to Boxlite sandbox: %s",
"[Computer] file_upload booter=boxlite remote_path=%s",
remote_path,
)
return {
@@ -74,39 +85,52 @@ class MockShipyardSandboxClient:
"message": "File uploaded successfully",
"file_path": remote_path,
}
else:
error_text = await response.text()
return {
"success": False,
"error": f"Server returned {response.status}: {error_text}",
"message": "File upload failed",
}
error_text = await response.text()
logger.warning(
"[Computer] file_upload_failed booter=boxlite error=http_status status=%s remote_path=%s",
response.status,
remote_path,
)
return {
"success": False,
"error": f"Server returned {response.status}: {error_text}",
"message": "File upload failed",
}
except aiohttp.ClientError as e:
logger.error(f"Failed to upload file: {e}")
logger.error("[Computer] file_upload_failed booter=boxlite error=%s", e)
return {
"success": False,
"error": f"Connection error: {str(e)}",
"error": f"Connection error: {e!s}",
"message": "File upload failed",
}
except asyncio.TimeoutError:
except TimeoutError:
logger.warning(
"[Computer] file_upload_failed booter=boxlite error=timeout remote_path=%s",
remote_path,
)
return {
"success": False,
"error": "File upload timeout",
"message": "File upload failed",
}
except FileNotFoundError:
logger.error(f"File not found: {path}")
logger.error(
"[Computer] file_upload_failed booter=boxlite error=file_not_found path=%s",
path,
)
return {
"success": False,
"error": f"File not found: {path}",
"message": "File upload failed",
}
except Exception as e:
logger.error(f"Unexpected error uploading file: {e}")
except Exception as exc:
logger.exception(
"[Computer] file_upload_failed booter=boxlite error=unexpected",
)
return {
"success": False,
"error": f"Internal error: {str(e)}",
"error": f"Internal error: {exc!s}",
"message": "File upload failed",
}
@@ -115,27 +139,46 @@ class MockShipyardSandboxClient:
loop = 60
while loop > 0:
try:
logger.info(
f"Checking health for sandbox {ship_id} on {self.sb_url}..."
logger.debug(
"[Computer] health_check booter=boxlite ship_id=%s session=%s endpoint=%s attempt=%s healthy=pending",
ship_id,
session_id,
self.sb_url,
61 - loop,
)
url = f"{self.sb_url}/health"
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
if response.status == 200:
logger.info(f"Sandbox {ship_id} is healthy")
return
logger.debug(
"[Computer] health_check booter=boxlite ship_id=%s session=%s endpoint=%s healthy=true",
ship_id,
session_id,
self.sb_url,
)
return
await asyncio.sleep(1)
loop -= 1
except Exception:
await asyncio.sleep(1)
loop -= 1
logger.warning(
"[Computer] health_check_timeout booter=boxlite ship_id=%s session=%s endpoint=%s",
ship_id,
session_id,
self.sb_url,
)
class BoxliteBooter(ComputerBooter):
async def boot(self, session_id: str) -> None:
logger.info(
f"Booting(Boxlite) for session: {session_id}, this may take a while..."
"[Computer] booter_boot booter=boxlite session=%s status=starting",
session_id,
)
random_port = random.randint(20000, 30000)
self.box = boxlite.SimpleBox(
SimpleBox = vars(boxlite)["SimpleBox"]
self.box = SimpleBox(
image="soulter/shipyard-ship",
memory_mib=512,
cpus=1,
@@ -143,39 +186,51 @@ class BoxliteBooter(ComputerBooter):
{
"host_port": random_port,
"guest_port": 8123,
}
},
],
)
await self.box.start()
logger.info(f"Boxlite booter started for session: {session_id}")
logger.info(
"[Computer] booter_boot booter=boxlite session=%s status=ready ship_id=%s",
session_id,
self.box.id,
)
self.mocked = MockShipyardSandboxClient(
sb_url=f"http://127.0.0.1:{random_port}"
sb_url=f"http://127.0.0.1:{random_port}",
)
raw_fs = ShipyardFileSystemComponent(
client=cast("Any", self.mocked),
ship_id=self.box.id,
session_id=session_id,
)
self._python = ShipyardPythonComponent(
client=self.mocked, # type: ignore
client=cast("Any", self.mocked),
ship_id=self.box.id,
session_id=session_id,
)
self._shell = ShipyardShellComponent(
client=self.mocked, # type: ignore
raw_shell = ShipyardShellComponent(
client=cast("Any", self.mocked),
ship_id=self.box.id,
session_id=session_id,
)
self._ship_fs = ShipyardFileSystemComponent(
client=self.mocked, # type: ignore
ship_id=self.box.id,
session_id=session_id,
)
self._fs = ShipyardFileSystemWrapper(
_shipyard_fs=self._ship_fs, _shipyard_shell=self._shell
)
self._shell = ShipyardShellWrapper(cast("Any", raw_shell))
self._fs = ShipyardFileSystemWrapper(cast("Any", raw_fs), self._shell)
await self.mocked.wait_healthy(self.box.id, session_id)
async def shutdown(self) -> None:
logger.info(f"Shutting down Boxlite booter for ship: {self.box.id}")
async def shutdown(self, **kwargs) -> None:
logger.info(
"[Computer] booter_shutdown booter=boxlite ship_id=%s status=starting",
self.box.id,
)
self.box.shutdown()
logger.info(f"Boxlite booter for ship: {self.box.id} stopped")
logger.info(
"[Computer] booter_shutdown booter=boxlite ship_id=%s status=done",
self.box.id,
)
async def available(self) -> bool:
return hasattr(self, "box")
@property
def fs(self) -> FileSystemComponent:
@@ -192,3 +247,24 @@ class BoxliteBooter(ComputerBooter):
async def upload_file(self, path: str, file_name: str) -> dict:
"""Upload file to sandbox"""
return await self.mocked.upload_file(path, file_name)
@classmethod
@functools.cache
def _default_tools(cls) -> tuple[ToolSchema, ...]:
from astrbot.core.computer.tools import (
ExecuteShellTool,
FileDownloadTool,
FileUploadTool,
PythonTool,
)
return (
ExecuteShellTool(),
PythonTool(),
FileUploadTool(),
FileDownloadTool(),
)
@classmethod
def get_default_tools(cls) -> list[ToolSchema]:
return list(cls._default_tools())

View File

@@ -0,0 +1,446 @@
from __future__ import annotations
import asyncio
import locale
import os
import shlex
import shutil
import subprocess
import sys
from dataclasses import dataclass, field
from typing import Any
from astrbot.core.computer.olayer import (
FileSystemComponent,
PythonComponent,
ShellComponent,
)
from astrbot.core.utils.astrbot_path import (
get_astrbot_temp_path,
)
from .base import ComputerBooter
def _decode_shell_output(output: bytes | None) -> str:
if output is None:
return ""
preferred = locale.getpreferredencoding(False) or "utf-8"
try:
return output.decode("utf-8")
except (LookupError, UnicodeDecodeError):
pass
try:
return output.decode(preferred)
except (LookupError, UnicodeDecodeError):
pass
return output.decode("utf-8", errors="replace")
def _write_file_sync(path: str, content: str, mode: str, encoding: str) -> None:
with open(path, mode, encoding=encoding) as f:
f.write(content)
def _read_file_sync(path: str, encoding: str) -> str:
with open(path, encoding=encoding) as f:
return f.read()
@dataclass
class BwrapConfig:
workspace_dir: str
ro_binds: list[str] = field(default_factory=list)
rw_binds: list[str] = field(default_factory=list)
share_net: bool = True
def __post_init__(self):
# Merge default required system binds with any additional ro_binds passed
default_ro = ["/usr", "/lib", "/lib64", "/bin", "/etc", "/opt"]
for p in default_ro:
if p not in self.ro_binds:
self.ro_binds.append(p)
def build_bwrap_cmd(config: BwrapConfig, script_cmd: list[str]) -> list[str]:
"""Helper to build a bubblewrap command."""
cmd = ["bwrap"]
if not config.share_net:
cmd.append("--unshare-net")
# Bind paths to itself so paths match
for path in config.ro_binds:
if os.path.exists(path):
cmd.extend(["--ro-bind", path, path])
for path in config.rw_binds:
# Avoid bind mounting dangerous host paths
if path == "/" or path.startswith("/root"):
continue
if os.path.exists(path):
cmd.extend(["--bind", path, path])
# Make system binds the last to avoid issues about ro `/`
cmd.extend(
[
"--unshare-pid",
"--unshare-ipc",
"--unshare-uts",
"--die-with-parent",
"--dir",
"/tmp",
"--dir",
"/var/tmp",
"--proc",
"/proc",
"--dev",
"/dev",
"--bind",
config.workspace_dir,
config.workspace_dir,
],
)
cmd.extend(["--"])
cmd.extend(script_cmd)
return cmd
@dataclass
class BwrapShellComponent(ShellComponent):
config: BwrapConfig
async def exec(
self,
command: str,
cwd: str | None = None,
env: dict[str, str] | None = None,
timeout: int | None = 30,
shell: bool = True,
background: bool = False,
session_id: str | None = None,
) -> dict[str, Any]:
_ = session_id
def _run() -> dict[str, Any]:
run_env = os.environ.copy()
if env:
run_env.update({str(k): str(v) for k, v in env.items()})
working_dir = cwd or self.config.workspace_dir
# Use /bin/sh -c to run the evaluated command
# The command must be run inside bwrap
script_cmd = ["/bin/sh", "-c", command] if shell else shlex.split(command)
bwrap_cmd = build_bwrap_cmd(self.config, script_cmd)
if background:
proc = subprocess.Popen(
bwrap_cmd,
cwd=working_dir,
env=run_env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return {"pid": proc.pid, "stdout": "", "stderr": "", "exit_code": None}
result = subprocess.run(
bwrap_cmd,
check=False,
cwd=working_dir,
env=run_env,
timeout=timeout,
capture_output=True,
)
return {
"stdout": _decode_shell_output(result.stdout),
"stderr": _decode_shell_output(result.stderr),
"exit_code": result.returncode,
}
return await asyncio.to_thread(_run)
@dataclass
class BwrapPythonComponent(PythonComponent):
config: BwrapConfig
async def exec(
self,
code: str,
kernel_id: str | None = None,
timeout: int = 30,
silent: bool = False,
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
bwrap_cmd = build_bwrap_cmd(
self.config,
[os.environ.get("PYTHON", "python3"), "-c", code],
)
try:
result = subprocess.run(
bwrap_cmd,
check=False,
timeout=timeout,
capture_output=True,
text=True,
)
stdout = "" if silent else result.stdout
return {
"stdout": stdout,
"stderr": result.stderr,
"exit_code": result.returncode,
}
except subprocess.TimeoutExpired as e:
return {
"stdout": e.stdout.decode()
if isinstance(e.stdout, bytes)
else str(e.stdout or ""),
"stderr": f"Execution timed out after {timeout} seconds.",
"exit_code": 1,
}
except Exception as e:
return {
"stdout": "",
"stderr": str(e),
"exit_code": 1,
}
return await asyncio.to_thread(_run)
@dataclass
class HostBackedFileSystemComponent(FileSystemComponent):
"""File operations happen safely on host mapping to workspace, making I/O extremely fast."""
workspace_dir: str
def _safe_path(self, path: str) -> str:
# Simply maps it. In a stricter implementation, we could verify it's inside workspace_dir.
# But for this implementation, we trust the agent or restrict to workspace_dir.
if not path.startswith("/"):
path = os.path.join(self.workspace_dir, path)
return path
async def create_file(
self,
path: str,
content: str = "",
mode: int = 0o644,
) -> dict[str, Any]:
p = self._safe_path(path)
await asyncio.to_thread(os.makedirs, os.path.dirname(p), exist_ok=True)
await asyncio.to_thread(_write_file_sync, p, content, "w", "utf-8")
await asyncio.to_thread(os.chmod, p, mode)
return {"success": True, "path": p}
async def read_file(
self,
path: str,
encoding: str = "utf-8",
offset: int | None = None,
limit: int | None = None,
) -> dict[str, Any]:
_ = offset, limit
p = self._safe_path(path)
try:
content = await asyncio.to_thread(_read_file_sync, p, encoding)
return {"success": True, "content": content}
except Exception as e:
return {"success": False, "error": str(e)}
async def write_file(
self,
path: str,
content: str,
mode: str = "w",
encoding: str = "utf-8",
) -> dict[str, Any]:
p = self._safe_path(path)
await asyncio.to_thread(os.makedirs, os.path.dirname(p), exist_ok=True)
try:
await asyncio.to_thread(_write_file_sync, p, content, mode, encoding)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def delete_file(self, path: str) -> dict[str, Any]:
p = self._safe_path(path)
try:
if await asyncio.to_thread(os.path.isdir, p):
await asyncio.to_thread(shutil.rmtree, p)
else:
await asyncio.to_thread(os.remove, p)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def list_dir(
self,
path: str = ".",
show_hidden: bool = False,
) -> dict[str, Any]:
p = self._safe_path(path)
try:
items = os.listdir(p)
if not show_hidden:
items = [item for item in items if not item.startswith(".")]
return {"success": True, "items": items}
except Exception as e:
return {"success": False, "error": str(e), "items": []}
async def search_files(
self,
pattern: str,
path: str | None = None,
glob: str | None = None,
after_context: int | None = None,
before_context: int | None = None,
) -> dict[str, Any]:
p = path or self.workspace_dir
try:
import subprocess
cmd = ["grep", "-r", pattern, p]
result = await asyncio.to_thread(
subprocess.run,
cmd,
capture_output=True,
text=True,
)
return {
"success": True,
"matches": result.stdout.splitlines() if result.stdout else [],
}
except Exception as e:
return {"success": False, "error": str(e), "matches": []}
async def edit_file(
self,
path: str,
old_string: str,
new_string: str,
replace_all: bool = False,
encoding: str = "utf-8",
) -> dict[str, Any]:
p = self._safe_path(path)
try:
content = await asyncio.to_thread(_read_file_sync, p, encoding)
if replace_all:
new_content = content.replace(old_string, new_string)
else:
parts = content.split(old_string, 1)
if len(parts) == 1:
return {"success": False, "error": "Pattern not found"}
new_content = new_string.join(parts)
await asyncio.to_thread(_write_file_sync, p, new_content, "w", encoding)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
class BwrapBooter(ComputerBooter):
def __init__(
self,
rw_binds: list[str] | None = None,
ro_binds: list[str] | None = None,
):
self._rw_binds = rw_binds or []
self._ro_binds = ro_binds or []
self._fs: HostBackedFileSystemComponent | None = None
self._python: BwrapPythonComponent | None = None
self._shell: BwrapShellComponent | None = None
self.config: BwrapConfig | None = None
@property
def fs(self) -> FileSystemComponent:
if self._fs is None:
raise RuntimeError("BwrapBooter filesystem is unavailable before boot")
return self._fs
@property
def python(self) -> PythonComponent:
if self._python is None:
raise RuntimeError("BwrapBooter python is unavailable before boot")
return self._python
@property
def shell(self) -> ShellComponent:
if self._shell is None:
raise RuntimeError("BwrapBooter shell is unavailable before boot")
return self._shell
@property
def capabilities(self) -> tuple[str, ...]:
return ("python", "shell", "filesystem")
async def boot(self, session_id: str) -> None:
workspace_dir = os.path.join(
get_astrbot_temp_path(),
f"sandbox_workspace_{session_id}",
)
await asyncio.to_thread(os.makedirs, workspace_dir, exist_ok=True)
self.config = BwrapConfig(
workspace_dir=await asyncio.to_thread(os.path.abspath, workspace_dir),
rw_binds=self._rw_binds,
ro_binds=self._ro_binds,
)
self._fs = HostBackedFileSystemComponent(self.config.workspace_dir)
self._python = BwrapPythonComponent(self.config)
self._shell = BwrapShellComponent(self.config)
if not await self.available():
raise RuntimeError(
"BubbleWrap sandbox unavailable on current machine for no bwrap executable.",
)
test_shl = await self._shell.exec(command="ls > /dev/null")
if test_shl["exit_code"] != 0:
raise RuntimeError(
"""BubbleWrap sandbox fails to exec test shell command "ls > /dev/null" with stderr:
{}""".format(test_shl["stderr"]),
)
test_py = await self._python.exec(code="print('Yes')")
if test_py["exit_code"] != 0:
raise RuntimeError(
"""BubbleWrap sandbox fails to exec test python code "print('Yes')" with stderr:
{}""".format(test_py["stderr"]),
)
async def shutdown(self, **kwargs) -> None:
config = self.config
if config is None:
return
if await asyncio.to_thread(os.path.exists, config.workspace_dir):
await asyncio.to_thread(
shutil.rmtree,
config.workspace_dir,
ignore_errors=True,
)
async def upload_file(self, path: str, file_name: str) -> dict:
if not self._fs or not self.config:
return {"success": False, "error": "Not booted"}
target = os.path.join(self.config.workspace_dir, file_name)
try:
shutil.copy2(path, target)
return {"success": True, "file_path": target}
except Exception as e:
return {"success": False, "error": str(e)}
async def download_file(self, remote_path: str, local_path: str) -> None:
if not self._fs or not self.config:
return
if not remote_path.startswith("/"):
remote_path = os.path.join(self.config.workspace_dir, remote_path)
shutil.copy2(remote_path, local_path)
async def available(self) -> bool:
if sys.platform == "win32":
return False
if shutil.which("bwrap") is None:
return False
return True

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