Compare commits

...

34 Commits

Author SHA1 Message Date
Soulter a21bb5b234 chore: bump version to 4.20.0 2026-03-13 00:33:36 +08:00
Soulter 994d39241e chore: ruff format 2026-03-13 00:26:40 +08:00
2ndelement e6c1164755 perf(QQ Official API): improve streaming message delivery reliability and proactive media sending (#6131)
* fix(qqofficial): fix streaming message delivery for C2C

* fix(qqofficial): rewrite send_streaming for C2C vs non-C2C split

* fix(qqofficial): add _extract_response_message_id for safe id extraction

* fix(qqofficial): flush stream segment on tool-call break signal

* fix(qqofficial): downgrade rich-media to non-stream send in C2C

* fix(qqofficial): auto-append \n to final stream chunk (state=10)

* fix(qqofficial): propagate stream param to all _send_with_markdown_fallback call sites

* fix(qqofficial): retry on STREAM_MARKDOWN_NEWLINE_ERROR with newline fix

* fix(qqofficial): handle None/non-dict response in post_c2c_message gracefully

* fix(qqofficial): remove msg_id from video/file media payloads in send_by_session

QQ API rejects msg_id on proactive media (video/file, msg_type=7) messages
sent via the tool-call path, returning "请求参数msg_id无效或越权". The
msg_id passive-reply credential is consumed by the first send and cannot be
reused for subsequent media uploads in the same session.

Remove msg_id from the payload after setting msg_type=7 for video and file
sends, for both FRIEND_MESSAGE (C2C) and GROUP_MESSAGE paths.

* fix(qqofficial): replace deprecated get_event_loop() with get_running_loop()

asyncio.get_event_loop() is deprecated since Python 3.10 and raises a
DeprecationWarning (or errors) when called from inside a running coroutine
without a current event loop set on the thread.  Replace both call-sites
in the streaming throttle logic with asyncio.get_running_loop(), which is
the correct API to use inside an already-running async context.

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

---------

Co-authored-by: 2ndelement <2ndelement@users.noreply.github.com>
Co-authored-by: Claude Sonnet <noreply@anthropic.com>
2026-03-13 00:24:15 +08:00
Aleksandr 89cc8a1a65 feat: add Russian translation (#6081)
* feat: add Russian translation

* revert: remove auth route changes from PR
2026-03-13 00:08:37 +08:00
Stable Genius c0e4f1e114 fix(dashboard): restore README dialog anchor navigation (#6083)
Co-authored-by: stablegenius49 <185121704+stablegenius49@users.noreply.github.com>
2026-03-13 00:02:45 +08:00
Stable Genius 7b43448ce4 fix: prefer named weekday cron examples (#6091)
Co-authored-by: stablegenius49 <185121704+stablegenius49@users.noreply.github.com>
2026-03-12 23:57:45 +08:00
orbisai0security bdac0b65f4 fix: resolve critical vulnerability V-004 (#6093)
Automatically generated security fix

Co-authored-by: orbisai0security <orbisai0security@users.noreply.github.com>
2026-03-12 23:53:47 +08:00
Gao Jinzhe cf9ee6f20c Merge pull request #6135 from advent259141/feat/add-community-links
docs: 添加 Astrbook 和玖帕喵社区链接
2026-03-12 23:11:19 +08:00
advent259141 01eae72a64 docs: 添加 Astrbook 和玖帕喵社区链接 2026-03-12 23:05:00 +08:00
letr bca1476eab fix(extension): refresh plugin market install state after install (#6124)
* fix(extension): refresh market install state after plugin install

* chore: remove redundant call

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-12 20:19:00 +08:00
エイカク fbcbde0a4b chore: update dependency and workflow versions (#6119) 2026-03-12 20:18:23 +09:00
エイカク 3914d766db fix: install only missing plugin dependencies (#6088)
* chore: ignore local worktrees

* fix: install only missing plugin dependencies

* fix: harden missing dependency install fallback

* fix: clarify dependency install fallback logging

* refactor: simplify dependency install test helpers

* refactor: reuse requirements precheck planning
2026-03-12 11:50:29 +09:00
DOHEX 3e2cb6a2ab fix(telegram): remove deprecated normalize_whitespace param from (#6044)
telegramify_markdown.markdownify calls
2026-03-12 00:34:07 +08:00
莫思潋 25830524f3 fix(docs): typo in docker.md & napcat.md (#6048)
* Fix wording in admin ID configuration instructions

* Update docker.md
2026-03-12 00:30:31 +08:00
Soulter 304094630c perf: optimize booter selection for edge cases and message sending tool (#6064)
* feat: add video message support and enhance message type descriptions in SendMessageToUserTool

* feat: add error handling for disabled sandbox runtime in get_booter function
2026-03-12 00:29:52 +08:00
Soulter 5c3643c54c feat: added support for file, voice, and video messages for QQ Official Bot (including WebSocket mode). (#6063) 2026-03-12 00:26:08 +08:00
エイカク 589cce18af fix: improve Windows local skill file reading (#6028)
* chore: ignore local worktrees

* fix: improve Windows local skill file reading

* fix: address Windows path and decoding review feedback

* fix: simplify shell decoding follow-up

* fix: harden sandbox skill prompt metadata

* fix: preserve safe sandbox skill summaries

* fix: relax sandbox summary sanitization

* fix: tighten path sanitization for skill prompts

* fix: harden sandbox skill display metadata

* fix: preserve Unicode skill paths in prompts

* fix: quote Windows skill prompt paths

* fix: simplify local shell output decoding

* fix: localize Windows prompt path handling

* fix: normalize Windows-style skill paths in prompts

* fix: align prompt and shell decoding behavior
2026-03-11 23:58:28 +09:00
Soulter e254caf82d fix(docs): add official developer group ID to multiple language READMEs and enhance regex description in config metadata 2026-03-11 21:26:11 +08:00
Soulter 7efcd242d6 fix(docs): update edit link patterns and remove obsolete repository reference 2026-03-11 17:42:42 +08:00
JIANG Zijun 5d811d3949 fix: Persist Discord pre-ack emoji config across restart by adding missing default key (#6031)
* Initial plan

* fix: add discord default platform_specific pre-ack config

Co-authored-by: Jzjerry <20167827+Jzjerry@users.noreply.github.com>

* Delete tests/unit/test_config.py

we don't need to add tests

* fix: use 🤔 as default discord pre-ack emoji

Co-authored-by: Jzjerry <20167827+Jzjerry@users.noreply.github.com>

* add back old test config

* doc: discord pre-ack-emoji doc

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Jzjerry <20167827+Jzjerry@users.noreply.github.com>
2026-03-11 16:41:08 +08:00
Flartiny 8e6aaee10c fix(webui): unify search input clear behavior (#6017)
* fix(webui): unify search input clear behavior

* fix: centralize search input normalization
2026-03-11 15:14:16 +08:00
エイカク 6da59cfb07 fix: 插件依赖自动安装逻辑与 Dashboard 安装体验优化 (#5954)
* fix: install plugin requirements before first load

* fix: handle pip option arguments correctly

* fix: harden pip install input parsing

* refactor: simplify pip install input parsing

* fix: align plugin dependency install handling

* fix: respect configured pip index overrides

* test: parameterize plugin dependency install flows

* refactor: simplify multiline pip input parsing

* fix: install plugin dependencies before loading

* fix: protect core dependencies from downgrades and simplify package input splitting

* fix: enhance dependency conflict reporting and improve user-facing warnings

* refactor: preserve pip log indentation and fix CodeQL URL sanitization alert

* fix: explicit re-export for DependencyConflictError to satisfy ruff F401

* test: enhance index override verification in pip installer tests

* fix: correctly map pip ERROR and WARNING outputs to proper log levels

* refactor: show specific version conflicts in DependencyConflictError and revert log level mapping

* refactor: simplify install() by decoupling pip logging, failure classification and constraint file management

* refactor: further simplify pip installer and requirement parsing logic

* refactor: simplify dependency installation logic and improve circular requirement reporting

* style: organize imports in astrbot/core/__init__.py

* refactor: optimize requirement parsing efficiency and flatten pip installer API

* style: fix import sorting in astrbot/core/__init__.py

* refactor: consolidate requirement parsing, optimize core protection, and improve exception propagation

* fix: preserve valid pip requirement parsing

* fix: skip empty pip installs and preserve blank output

* chore: normalize gitignore entry style

* fix: tighten pip trust and requirement parsing

* refactor: centralize pip install parsing and failure handling

* fix: redact pip argv credentials in logs

* fix: surface plugin dependency install errors

* fix: cache core constraints and clarify requirement installs

* fix: harden pip requirement parsing for plugin installs

* fix: simplify pip installer parsing internals

* fix: tighten pip installer parsing and redaction

* refactor: simplify plugin dependency install flow

* fix: preserve core constraint conflict errors

* fix: harden pip installer fallback resolution

* refactor: split pip requirement and constraint helpers

* refactor: simplify pip installer helper flow

* refactor: streamline requirement precheck helpers

* refactor: clarify core constraint resolution

* fix: surface pip install failures explicitly

* refactor: separate pip conflict context parsing

* fix: harden core constraint resolution

* test: cover pip installer failure call sites

* refactor: remove dead requirements fallback helper

* refactor: narrow core constraint error handling

* refactor: unify requirement iteration

* refactor: share requirement name parsing

* test: align pip helper coverage

* fix: bind pip output limit at runtime

* refactor: reuse core requirement parser for tokens
2026-03-11 14:21:55 +09:00
Soulter 10ceacfbb1 chore: bump version to 4.19.5 2026-03-11 00:17:14 +08:00
ChuwuYo 66f5ccd902 fix: add file size validation to TTS provider test and MiniMax empty audio detection (#5999)
- Add audio data validation in MiniMax TTS get_audio() method to detect empty responses
- Validate generated audio file size in TTSProvider.test() to ensure valid output
- Provide detailed error messages guiding users to check group_id configuration
- Auto-cleanup test audio files after validation
- Fixes issue where 0KB audio files would pass TTS detection when group_id is not configured
2026-03-11 00:07:19 +08:00
Soulter 3379587223 feat(mcp): enhance logging and initialize MCP clients in background (#5993)
* feat(mcp): enhance logging and initialize MCP clients in background

fixes: #5777

* rf

* fix(mcp): simplify MCP client initialization in background

* fix(mcp): update error message for MCP background initialization failure
2026-03-11 00:00:48 +08:00
邹永赫 e25a1a42cf Revert "fix: clarify missing MCP stdio command errors (#5992)"
This reverts commit 0c771e4a77.
2026-03-11 00:08:06 +09:00
エイカク 0c771e4a77 fix: clarify missing MCP stdio command errors (#5992)
* fix: clarify missing MCP stdio command errors

* refactor: tighten MCP error presentation helpers

* fix: improve MCP test connection feedback

* fix: structure MCP test connection errors

* refactor: share MCP test error codes
2026-03-10 23:05:50 +09:00
camera-2018 ec21cb13d3 feat(lark): supports CardKit streaming output for feishu (#5777)
* feat(lark): 支持飞书 CardKit 流式输出

* refactor(lark): extract streaming fallback logic and deduplicate final text update

* fix(lark): 修复流式输出竞态条件及增强健壮性

- 修复 sender loop 中 delta 快照竟态: await 期间 delta 被 generator
  更新导致 last_sent 记录了未发送的值, 造成输出卡在最后一段
- send_streaming 入口增加 platform_meta 守卫, 未启用时直接回退
- _fallback_send_streaming 移除对已耗尽 generator 的 super() 调用,
  改为内联父类副作用 (Metric.upload + _has_send_oper)
- Metric.upload 统一改为 await, 确保指标上报在方法返回前完成
- 装饰器 support_streaming_message 改为 False, 与 meta() 动态配置对齐
- i18n hint 补充提示: 需在「AI 配置 → 其他配置」中开启流式输出

* chore(lark): 收口配置

* docs(lark): update streaming output instructions and client version requirements

---------

Co-authored-by: bread-ovo <2570425204@qq.com>
Co-authored-by: Soulter <905617992@qq.com>
2026-03-10 19:40:46 +08:00
Soulter 1d26b96d90 fix(workflow): update build-docs.yml to trigger on version tags instead of master branch 2026-03-10 17:16:56 +08:00
一袋米要扛幾樓 be017c87f4 fix: 前端修正切換到 chat 切換後回 welcome 的配置保存最終切換頁面 (#5792)
* 前端修正切換到chat切換後回 welcome 的配置保存最終切換頁面

* 修復 SSR 不含localStorage 環境驗證
2026-03-10 17:14:28 +08:00
lustresixx 23fffa95c8 fix(provider): support 84-char Azure TTS subscription keys (#5813)
* fix(provider): support 84-char Azure TTS subscription keys

* test(provider): add negative Azure TTS key validation cases

* chore: delete test

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-03-10 17:09:13 +08:00
dependabot[bot] 5b303e2e6d chore(deps): bump the github-actions group with 7 updates (#5966)
Bumps the github-actions group with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [actions/setup-node](https://github.com/actions/setup-node) | `2` | `6` |
| [actions/checkout](https://github.com/actions/checkout) | `4` | `6` |
| [actions/setup-python](https://github.com/actions/setup-python) | `5` | `6` |
| [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) | `3` | `4` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `3` | `4` |
| [docker/login-action](https://github.com/docker/login-action) | `3` | `4` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `6` | `7` |


Updates `actions/setup-node` from 2 to 6
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v2...v6)

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

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

Updates `docker/setup-qemu-action` from 3 to 4
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

Updates `docker/setup-buildx-action` from 3 to 4
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

Updates `docker/login-action` from 3 to 4
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

Updates `docker/build-push-action` from 6 to 7
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/build-push-action
  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-03-10 16:56:52 +08:00
Soulter fc33b3eb68 docs: transfer AstrBotDevs/AstrBot-docs to AstrBotDevs/AstrBot (#5960)
* docs: transfer AstrBotDevs/AstrBot-docs to AstrBotDevs/AstrBot
* refactor: reorder imports and improve type hints in sync_docs_to_wiki.py and upload_doc_images_to_r2.py
* feat: add GitHub Actions workflow to sync wiki with documentation

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
Co-authored-by: anka-afk <110004162+anka-afk@users.noreply.github.com>
Co-authored-by: zouyonghe <62183434+zouyonghe@users.noreply.github.com>
Co-authored-by: shuiping233 <49360196+shuiping233@users.noreply.github.com>
Co-authored-by: LIghtJUNction <106986785+LIghtJUNction@users.noreply.github.com>
Co-authored-by: Sjshi763 <179909421+Sjshi763@users.noreply.github.com>
Co-authored-by: xiewoc <70128845+xiewoc@users.noreply.github.com>
Co-authored-by: QingFeng-awa <151742581+QingFeng-awa@users.noreply.github.com>
Co-authored-by: PaloMiku <96452465+PaloMiku@users.noreply.github.com>
Co-authored-by: shangxueink <138397030+shangxueink@users.noreply.github.com>
Co-authored-by: IGCrystal-A <244300990+IGCrystal-A@users.noreply.github.com>
Co-authored-by: RC-CHN <67079377+RC-CHN@users.noreply.github.com>
Co-authored-by: MC090610 <113341105+MC090610@users.noreply.github.com>
Co-authored-by: Waterwzy <196913419+Waterwzy@users.noreply.github.com>
Co-authored-by: Lanhuace-Wan <186303160+Lanhuace-Wan@users.noreply.github.com>
Co-authored-by: LiAlH4qwq <61769640+LiAlH4qwq@users.noreply.github.com>
Co-authored-by: HSOS6 <209910899+HSOS6@users.noreply.github.com>
Co-authored-by: th-dd <162813557+th-dd@users.noreply.github.com>
Co-authored-by: miaoxutao123 <81676466+miaoxutao123@users.noreply.github.com>
Co-authored-by: nuomicici <143102889+nuomicici@users.noreply.github.com>
Co-authored-by: nasyt233 <210103278+nasyt233@users.noreply.github.com>
Co-authored-by: jlugjb <7426462+jlugjb@users.noreply.github.com>
Co-authored-by: Raven95676 <176760093+Raven95676@users.noreply.github.com>
Co-authored-by: Futureppo <180109455+Futureppo@users.noreply.github.com>
Co-authored-by: MliKiowa <61873808+MliKiowa@users.noreply.github.com>
Co-authored-by: Fridemn <150212937+Fridemn@users.noreply.github.com>
Co-authored-by: BakaCookie520 <138355736+BakaCookie520@users.noreply.github.com>
Co-authored-by: YumeYuka <125112916+YumeYuka@users.noreply.github.com>
Co-authored-by: xming521 <32786500+xming521@users.noreply.github.com>
Co-authored-by: ywh555hhh <121592812+ywh555hhh@users.noreply.github.com>
Co-authored-by: stevessr <89645372+stevessr@users.noreply.github.com>
Co-authored-by: roeseth <41995115+roeseth@users.noreply.github.com>
Co-authored-by: ikun-1145141 <265925499+ikun-1145141@users.noreply.github.com>
Co-authored-by: evpeople <54983536+evpeople@users.noreply.github.com>
Co-authored-by: Yue-bin <60509781+Yue-bin@users.noreply.github.com>
Co-authored-by: W1ndys <109416673+W1ndys@users.noreply.github.com>
Co-authored-by: TheFurina <218887821+TheFurina@users.noreply.github.com>
Co-authored-by: Seayon <12275933+Seayon@users.noreply.github.com>
Co-authored-by: OnlyblackTea <38585636+OnlyblackTea@users.noreply.github.com>
Co-authored-by: ocetars <74854972+ocetars@users.noreply.github.com>
Co-authored-by: railgun19457 <117180744+railgun19457@users.noreply.github.com>
Co-authored-by: JunieXD <107397009+JunieXD@users.noreply.github.com>
Co-authored-by: advent259141 <197440256+advent259141@users.noreply.github.com>
Co-authored-by: Doge2077 <91442300+Doge2077@users.noreply.github.com>
Co-authored-by: Bocity <23430545+Bocity@users.noreply.github.com>
Co-authored-by: Aurora-xk <192227833+Aurora-xk@users.noreply.github.com>
2026-03-09 23:38:21 +08:00
ChuwuYo 795aec9578 feat(extension): add filtering and sorting for installed plugins in WebUI (#5923)
* feat(extension): add PluginSortControl reusable component for sorting

* i18n: add i18n keys for plugin sorting and filtering features

* feat(extension): add sorting and status filtering for installed plugins

Backend changes (plugin.py):
- Add _resolve_plugin_dir method to resolve plugin directory path
- Add _get_plugin_installed_at method to get installation time from file mtime
- Add installed_at field to plugin API response

Frontend changes (InstalledPluginsTab.vue):
- Import PluginSortControl component
- Add status filter toggle (all/enabled/disabled) using v-btn-toggle
- Integrate PluginSortControl for sorting options
- Add toolbar layout with actions and controls sections

Frontend changes (MarketPluginsTab.vue):
- Import PluginSortControl component
- Replace v-select + v-btn combination with unified PluginSortControl

Frontend changes (useExtensionPage.js):
- Add installedStatusFilter, installedSortBy, installedSortOrder refs
- Add installedSortItems and installedSortUsesOrder computed properties
- Add sortInstalledPlugins function with multi-criteria support
- Support sorting by install time, name, author, and update status
- Add status filtering in filteredPlugins computed property
- Disable default table sorting by setting sortable: false

* test: add tests for installed_at field in plugin API

- Assert all plugins have installed_at field in get_plugins response
- Assert installed_at is not null after plugin installation

* fix(extension): add explicit fallbacks for installed plugin sort comparisons

* i18n(extension): rename install time label to last modified

* fix(extension): cache installed_at parsing and validate timestamp format in tests

* test(dashboard): strengthen installed_at coverage for plugin API
2026-03-09 17:12:22 +09:00
327 changed files with 31671 additions and 951 deletions
+43
View File
@@ -0,0 +1,43 @@
name: release
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest # 运行环境
steps:
- name: checkout
uses: actions/checkout@v6
- name: nodejs installation
uses: actions/setup-node@v6
with:
node-version: "18"
- name: npm install
run: npm add -D vitepress
working-directory: './docs' # working-directory 指定 shell 命令运行目录
- name: npm run build
run: npm run docs:build
working-directory: './docs'
- name: scp
uses: appleboy/scp-action@v1.0.0
with:
host: ${{ secrets.HOST_NEKO }}
username: ${{ secrets.USERNAME }}
password: ${{ secrets.PASSWORDNEKO }}
source: 'docs/.vitepress/dist/*'
target: '/tmp/'
- name: script
uses: appleboy/ssh-action@v1.2.5
with:
host: ${{ secrets.HOST_NEKO }}
username: ${{ secrets.USERNAME }}
password: ${{ secrets.PASSWORDNEKO }}
script: |
mkdir -p /root/docker_data/caddy/caddy_data/static_site/abv4/
rm -rf /root/docker_data/caddy/caddy_data/static_site/abv4/*
mv /tmp/docs/.vitepress/dist/* /root/docker_data/caddy/caddy_data/static_site/abv4/
rm -rf /tmp/docs/
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
- name: Create GitHub Release
if: github.event_name == 'push'
uses: ncipollo/release-action@v1
uses: ncipollo/release-action@v1.20.0
with:
tag: release-${{ github.sha }}
owner: AstrBotDevs
+10 -10
View File
@@ -64,20 +64,20 @@ jobs:
echo "build_date=$build_date" >> $GITHUB_OUTPUT
- name: Set QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4.0.0
- name: Set Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4.0.0
- name: Log in to DockerHub
uses: docker/login-action@v3
uses: docker/login-action@v4.0.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Login to GitHub Container Registry
if: env.HAS_GHCR_TOKEN == 'true'
uses: docker/login-action@v3
uses: docker/login-action@v4.0.0
with:
registry: ghcr.io
username: ${{ env.GHCR_OWNER }}
@@ -98,7 +98,7 @@ jobs:
echo "EOF" >> $GITHUB_OUTPUT
- name: Build and Push Nightly Image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7.0.0
with:
context: .
platforms: linux/amd64,linux/arm64
@@ -163,27 +163,27 @@ jobs:
cp -r dashboard/dist data/
- name: Set QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4.0.0
- name: Set Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4.0.0
- name: Log in to DockerHub
uses: docker/login-action@v3
uses: docker/login-action@v4.0.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Login to GitHub Container Registry
if: env.HAS_GHCR_TOKEN == 'true'
uses: docker/login-action@v3
uses: docker/login-action@v4.0.0
with:
registry: ghcr.io
username: ${{ env.GHCR_OWNER }}
password: ${{ secrets.GHCR_GITHUB_TOKEN }}
- name: Build and Push Release Image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7.0.0
with:
context: .
platforms: linux/amd64,linux/arm64
+1 -1
View File
@@ -50,7 +50,7 @@ jobs:
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v4.3.0
with:
version: 10.28.2
+68
View File
@@ -0,0 +1,68 @@
name: sync wiki
on:
workflow_dispatch:
push:
branches:
- master
paths:
- '.github/workflows/sync-wiki.yml'
- 'docs/scripts/sync_docs_to_wiki.py'
- 'docs/tests/test_sync_docs_to_wiki.py'
- 'docs/zh/**'
- 'docs/en/**'
concurrency:
group: sync-wiki-${{ github.ref }}
cancel-in-progress: true
jobs:
sync:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Validate manual ref
if: github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/master'
run: |
echo "This workflow only publishes from refs/heads/master. Re-run it from the master branch."
exit 1
- name: Check out docs repository
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Run sync unit tests
working-directory: docs
run: python -m unittest discover -s tests -p 'test_sync_docs_to_wiki.py' -v
- name: Validate internal doc links
run: python docs/scripts/sync_docs_to_wiki.py --source-root docs --check-links-only
- name: Clone AstrBot wiki
env:
WIKI_TOKEN: ${{ secrets.ASTRBOT_WIKI_TOKEN }}
run: |
test -n "$WIKI_TOKEN"
git clone "https://x-access-token:${WIKI_TOKEN}@github.com/AstrBotDevs/AstrBot.wiki.git" wiki
- name: Generate wiki pages
run: python docs/scripts/sync_docs_to_wiki.py --source-root docs --wiki-root wiki
- name: Commit and push wiki changes
working-directory: wiki
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add .
if git diff --cached --quiet; then
echo "No wiki changes to push"
exit 0
fi
git commit -m "docs: sync wiki from AstrBot-1/docs"
git push
+2
View File
@@ -61,3 +61,5 @@ GenieData/
.codex/
.opencode/
.kilocode/
.worktrees/
+2 -1
View File
@@ -234,7 +234,8 @@ pre-commit install
- Group 7: 743746109
- Group 8: 1030353265
- Developer Group: 975206796
- Developer Group(Chit-chat): 975206796
- Developer Group(Formal): 1039761811
### Discord Server
+1
View File
@@ -222,6 +222,7 @@ pre-commit install
- Groupe 5 : 822130018
- Groupe 6 : 753075035
- Groupe développeurs : 975206796
- Groupe développeurs (officiel) : 1039761811
### Serveur Discord
+1
View File
@@ -223,6 +223,7 @@ pre-commit install
- 5群: 822130018
- 6群: 753075035
- 開発者群: 975206796
- 開発者群(正式): 1039761811
### Discord サーバー
+1
View File
@@ -222,6 +222,7 @@ pre-commit install
- Группа 5: 822130018
- Группа 6: 753075035
- Группа разработчиков: 975206796
- Группа разработчиков (официальная): 1039761811
### Сервер Discord
+2 -1
View File
@@ -225,7 +225,8 @@ pre-commit install
- 6 群:753075035
- 7 群:743746109
- 8 群:1030353265
- 開發者群:975206796
- 開發者群(闲聊吹水)975206796
- 開發者群(正式):1039761811
### Discord 群組
+2 -1
View File
@@ -226,7 +226,8 @@ pre-commit install
- 6 群:753075035
- 7 群:743746109
- 8 群:1030353265
- 开发者群:975206796
- 开发者群(偏闲聊吹水)975206796
- 开发者群(正式):1039761811
### Discord 频道
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.19.4"
__version__ = "4.20.0"
+15 -1
View File
@@ -4,7 +4,21 @@ from astrbot.core.config import AstrBotConfig
from astrbot.core.config.default import DB_PATH
from astrbot.core.db.sqlite import SQLiteDatabase
from astrbot.core.file_token_service import FileTokenService
from astrbot.core.utils.pip_installer import PipInstaller
from astrbot.core.utils.pip_installer import (
DependencyConflictError as DependencyConflictError,
)
from astrbot.core.utils.pip_installer import (
PipInstaller,
)
from astrbot.core.utils.requirements_utils import (
RequirementsPrecheckFailed as RequirementsPrecheckFailed,
)
from astrbot.core.utils.requirements_utils import (
find_missing_requirements as find_missing_requirements,
)
from astrbot.core.utils.requirements_utils import (
find_missing_requirements_or_raise as find_missing_requirements_or_raise,
)
from astrbot.core.utils.shared_preferences import SharedPreferences
from astrbot.core.utils.t2i.renderer import HtmlRenderer
+19 -6
View File
@@ -144,10 +144,14 @@ class MCPClient:
cfg = _prepare_config(mcp_server_config.copy())
def logging_callback(msg: str) -> None:
def logging_callback(
msg: str | mcp.types.LoggingMessageNotificationParams,
) -> None:
# Handle MCP service error logs
print(f"MCP Server {name} Error: {msg}")
self.server_errlogs.append(msg)
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 "url" in cfg:
success, error_msg = await _quick_test_mcp_connection(cfg)
@@ -214,15 +218,24 @@ class MCPClient:
**cfg,
)
def callback(msg: str) -> None:
def callback(msg: str | mcp.types.LoggingMessageNotificationParams) -> None:
# Handle MCP service error logs
self.server_errlogs.append(msg)
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)
stdio_transport = await self.exit_stack.enter_async_context(
mcp.stdio_client(
server_params,
errlog=LogPipe(
level=logging.ERROR,
level=logging.INFO,
logger=logger,
identifier=f"MCPServer-{name}",
callback=callback,
+14 -1
View File
@@ -204,7 +204,7 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
"type": "string",
"description": (
"Component type. One of: "
"plain, image, record, file, mention_user"
"plain, image, record, video, file, mention_user. Record is voice message."
),
},
"text": {
@@ -320,6 +320,19 @@ class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
components.append(Comp.Record.fromURL(url=url))
else:
return f"error: messages[{idx}] must include path or url for record component."
elif msg_type == "video":
path = msg.get("path")
url = msg.get("url")
if path:
(
local_path,
file_from_sandbox,
) = await self._resolve_path_from_sandbox(context, path)
components.append(Comp.Video.fromFileSystem(path=local_path))
elif url:
components.append(Comp.Video.fromURL(url=url))
else:
return f"error: messages[{idx}] must include path or url for video component."
elif msg_type == "file":
path = msg.get("path")
url = msg.get("url")
+38 -8
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import locale
import os
import shutil
import subprocess
@@ -52,6 +53,31 @@ def _ensure_safe_path(path: str) -> str:
return abs_path
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
if os.name == "nt":
for encoding in ("mbcs", "cp936", "gbk", "gb18030"):
try:
return output.decode(encoding)
except (LookupError, UnicodeDecodeError):
continue
try:
return output.decode(preferred)
except (LookupError, UnicodeDecodeError):
pass
return output.decode("utf-8", errors="replace")
@dataclass
class LocalShellComponent(ShellComponent):
async def exec(
@@ -72,28 +98,32 @@ class LocalShellComponent(ShellComponent):
run_env.update({str(k): str(v) for k, v in env.items()})
working_dir = _ensure_safe_path(cwd) if cwd else get_astrbot_root()
if background:
proc = subprocess.Popen(
# `command` is intentionally executed through the current shell so
# local computer-use behavior matches existing tool semantics.
# Safety relies on `_is_safe_command()` and the allowed-root checks.
proc = subprocess.Popen( # noqa: S602 # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit
command,
shell=shell,
cwd=working_dir,
env=run_env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return {"pid": proc.pid, "stdout": "", "stderr": "", "exit_code": None}
result = subprocess.run(
# `command` is intentionally executed through the current shell so
# local computer-use behavior matches existing tool semantics.
# Safety relies on `_is_safe_command()` and the allowed-root checks.
result = subprocess.run( # noqa: S602 # nosemgrep: python.lang.security.audit.dangerous-subprocess-use-audit
command,
shell=shell,
cwd=working_dir,
env=run_env,
timeout=timeout,
capture_output=True,
text=True,
)
return {
"stdout": result.stdout,
"stderr": result.stderr,
"stdout": _decode_shell_output(result.stdout),
"stderr": _decode_shell_output(result.stderr),
"exit_code": result.returncode,
}
+6
View File
@@ -422,6 +422,12 @@ async def get_booter(
) -> ComputerBooter:
config = context.get_config(umo=session_id)
runtime = config.get("provider_settings", {}).get("computer_use_runtime", "local")
if runtime == "local":
return get_local_booter()
elif runtime == "none":
raise RuntimeError("Sandbox runtime is disabled by configuration.")
sandbox_cfg = config.get("provider_settings", {}).get("sandbox", {})
booter_type = sandbox_cfg.get("booter", "shipyard_neo")
+4 -1
View File
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.19.4"
VERSION = "4.20.0"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -219,6 +219,9 @@ DEFAULT_CONFIG = {
"telegram": {
"pre_ack_emoji": {"enable": False, "emojis": ["✍️"]},
},
"discord": {
"pre_ack_emoji": {"enable": False, "emojis": ["🤔"]},
},
},
"wake_prefix": ["/"],
"log_level": "INFO",
@@ -34,7 +34,7 @@ from .server import LarkWebhookServer
@register_platform_adapter(
"lark", "飞书机器人官方 API 适配器", support_streaming_message=False
"lark", "飞书机器人官方 API 适配器", support_streaming_message=True
)
class LarkPlatformAdapter(Platform):
def __init__(
@@ -491,7 +491,7 @@ class LarkPlatformAdapter(Platform):
name="lark",
description="飞书机器人官方 API 适配器",
id=cast(str, self.config.get("id")),
support_streaming_message=False,
support_streaming_message=True,
)
async def convert_msg(self, event: lark.im.v1.P2ImMessageReceiveV1) -> None:
@@ -1,3 +1,4 @@
import asyncio
import base64
import json
import os
@@ -5,6 +6,14 @@ import uuid
from io import BytesIO
import lark_oapi as lark
from lark_oapi.api.cardkit.v1 import (
ContentCardElementRequest,
ContentCardElementRequestBody,
CreateCardRequest,
CreateCardRequestBody,
SettingsCardRequest,
SettingsCardRequestBody,
)
from lark_oapi.api.im.v1 import (
CreateFileRequest,
CreateFileRequestBody,
@@ -28,6 +37,7 @@ from astrbot.core.utils.media_utils import (
convert_video_format,
get_media_duration,
)
from astrbot.core.utils.metrics import Metric
class LarkMessageEvent(AstrMessageEvent):
@@ -555,15 +565,257 @@ class LarkMessageEvent(AstrMessageEvent):
logger.error(f"发送飞书表情回应失败({response.code}): {response.msg}")
return
async def send_streaming(self, generator, use_fallback: bool = False):
async def _create_streaming_card(self) -> str | None:
"""创建一个开启流式更新模式的卡片实体,返回 card_id。"""
if self.bot.cardkit is None:
logger.error("[Lark] API Client cardkit 模块未初始化")
return None
card_json = {
"schema": "2.0",
"header": {
"title": {"content": "", "tag": "plain_text"},
},
"config": {
"streaming_mode": True,
"summary": {"content": ""},
"streaming_config": {
"print_frequency_ms": {"default": 50},
"print_step": {"default": 2},
"print_strategy": "fast",
},
},
"body": {
"elements": [
{
"tag": "markdown",
"content": "",
"element_id": "markdown_1",
}
]
},
}
request = (
CreateCardRequest.builder()
.request_body(
CreateCardRequestBody.builder()
.type("card_json")
.data(json.dumps(card_json, ensure_ascii=False))
.build()
)
.build()
)
try:
response = await self.bot.cardkit.v1.card.acreate(request)
except Exception as e:
logger.error(f"[Lark] 创建流式卡片实体失败: {e}")
return None
if not response.success():
logger.error(
f"[Lark] 创建流式卡片实体失败({response.code}): {response.msg}"
)
return None
if response.data is None or not response.data.card_id:
logger.error("[Lark] 创建流式卡片实体成功但未返回 card_id")
return None
card_id = response.data.card_id
logger.debug(f"[Lark] 创建流式卡片实体成功: {card_id}")
return card_id
async def _send_card_message(
self,
card_id: str,
reply_message_id: str | None = None,
receive_id: str | None = None,
receive_id_type: str | None = None,
) -> bool:
"""将卡片实体作为 interactive 消息发送。"""
content = json.dumps(
{"type": "card", "data": {"card_id": card_id}},
ensure_ascii=False,
)
return await self._send_im_message(
self.bot,
content=content,
msg_type="interactive",
reply_message_id=reply_message_id,
receive_id=receive_id,
receive_id_type=receive_id_type,
)
async def _update_streaming_text(
self,
card_id: str,
content: str,
sequence: int,
) -> bool:
"""调用 CardKit 流式更新文本接口,向 markdown_1 组件推送全量文本。"""
if self.bot.cardkit is None:
logger.error("[Lark] API Client cardkit 模块未初始化")
return False
request = (
ContentCardElementRequest.builder()
.card_id(card_id)
.element_id("markdown_1")
.request_body(
ContentCardElementRequestBody.builder()
.content(content)
.sequence(sequence)
.uuid(str(uuid.uuid4()))
.build()
)
.build()
)
try:
response = await self.bot.cardkit.v1.card_element.acontent(request)
except Exception as e:
logger.debug(f"[Lark] 流式更新文本失败 (ignored): {e}")
return False
if not response.success():
logger.debug(f"[Lark] 流式更新文本失败({response.code}): {response.msg}")
return False
return True
async def _close_streaming_mode(
self,
card_id: str,
sequence: int,
) -> None:
"""关闭卡片的流式更新模式,使其可正常转发、摘要恢复。"""
if self.bot.cardkit is None:
logger.error("[Lark] API Client cardkit 模块未初始化")
return
settings_json = json.dumps(
{"config": {"streaming_mode": False}},
ensure_ascii=False,
)
request = (
SettingsCardRequest.builder()
.card_id(card_id)
.request_body(
SettingsCardRequestBody.builder()
.settings(settings_json)
.sequence(sequence)
.uuid(str(uuid.uuid4()))
.build()
)
.build()
)
try:
response = await self.bot.cardkit.v1.card.asettings(request)
except Exception as e:
logger.error(f"[Lark] 关闭流式模式失败: {e}")
return
if not response.success():
logger.error(f"[Lark] 关闭流式模式失败({response.code}): {response.msg}")
else:
logger.debug(f"[Lark] 流式模式已关闭: {card_id}")
async def _fallback_send_streaming(self, generator, use_fallback: bool = False):
"""回退到非流式发送:缓冲全部文本后一次性发送,并保留父类副作用。"""
buffer = None
async for chain in generator:
if not buffer:
buffer = chain
else:
buffer.chain.extend(chain.chain)
if not buffer:
return None
buffer.squash_plain()
await self.send(buffer)
return await super().send_streaming(generator, use_fallback)
if buffer:
buffer.squash_plain()
await self.send(buffer)
await Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
self._has_send_oper = True
async def send_streaming(self, generator, use_fallback: bool = False):
"""使用 CardKit 流式卡片实现打字机效果。
流程:创建卡片实体 → 发送消息 → 流式更新文本 → 关闭流式模式。
使用解耦发送循环,LLM token 到达时只更新 buffer 并唤醒发送协程,
发送频率由网络 RTT 自然限流。
"""
# Step 1: 创建流式卡片实体
card_id = await self._create_streaming_card()
if not card_id:
logger.warning("[Lark] 无法创建流式卡片,回退到非流式发送")
await self._fallback_send_streaming(generator, use_fallback)
return
# Step 2: 发送卡片消息
sent = await self._send_card_message(
card_id,
reply_message_id=self.message_obj.message_id,
)
if not sent:
logger.error("[Lark] 发送流式卡片消息失败,回退到非流式发送")
await self._fallback_send_streaming(generator, use_fallback)
return
logger.info("[Lark] 流式输出: 使用 CardKit 流式卡片")
# Step 3: 解耦发送循环 (Event-driven, 参考 Telegram Draft 路径)
sequence = 0
delta = ""
last_sent = ""
done = False
text_changed = asyncio.Event()
async def _sender_loop() -> None:
"""信号驱动的文本发送循环,有新内容就发,RTT 自然限流。"""
nonlocal sequence, last_sent
while not done:
await text_changed.wait()
text_changed.clear()
snapshot = delta
if snapshot and snapshot != last_sent:
sequence += 1
ok = await self._update_streaming_text(card_id, snapshot, sequence)
if ok:
last_sent = snapshot
if delta != snapshot:
text_changed.set()
sender_task = asyncio.create_task(_sender_loop())
try:
async for chain in generator:
if not isinstance(chain, MessageChain):
continue
if chain.type == "break":
# 飞书卡片不支持分段,忽略 break
continue
for comp in chain.chain:
if isinstance(comp, Plain):
delta += comp.text
text_changed.set()
finally:
done = True
text_changed.set()
await sender_task
# Step 4: 必要时补发最终文本 + 关闭流式模式
if delta and delta != last_sent:
sequence += 1
await self._update_streaming_text(card_id, delta, sequence)
sequence += 1
await self._close_streaming_mode(card_id, sequence)
# Step 5: 内联父类 send_streaming 的副作用
await Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
self._has_send_oper = True
@@ -18,7 +18,7 @@ from botpy.types.message import MarkdownPayload, Media
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import Image, Plain, Record
from astrbot.api.message_components import File, Image, Plain, Record, Video
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.io import download_image_by_url, file_to_base64
@@ -47,6 +47,11 @@ _patch_qq_botpy_formdata()
class QQOfficialMessageEvent(AstrMessageEvent):
MARKDOWN_NOT_ALLOWED_ERROR = "不允许发送原生 markdown"
IMAGE_FILE_TYPE = 1
VIDEO_FILE_TYPE = 2
VOICE_FILE_TYPE = 3
FILE_FILE_TYPE = 4
STREAM_MARKDOWN_NEWLINE_ERROR = "流式消息md分片需要\\n结束"
def __init__(
self,
@@ -65,35 +70,71 @@ class QQOfficialMessageEvent(AstrMessageEvent):
await self._post_send()
async def send_streaming(self, generator, use_fallback: bool = False):
"""流式输出仅支持消息列表私聊"""
"""流式输出仅支持消息列表私聊C2C),其他消息源退化为普通发送"""
# 先标记事件层“已执行发送操作”,避免异常路径遗漏
await super().send_streaming(generator, use_fallback)
# QQ C2C 流式协议:开始/中间分片使用 state=1,结束分片使用 state=10
stream_payload = {"state": 1, "id": None, "index": 0, "reset": False}
last_edit_time = 0 # 上次编辑消息的时间
throttle_interval = 1 # 编辑消息的间隔时间 (秒)
last_edit_time = 0 # 上次发送分片的时间
throttle_interval = 1 # 分片间最短间隔 (秒)
ret = None
source = (
self.message_obj.raw_message
) # 提前获取,避免 generator 为空时 NameError
try:
async for chain in generator:
source = self.message_obj.raw_message
if not isinstance(source, botpy.message.C2CMessage):
# 非 C2C 场景:直接累积,最后统一发
if not self.send_buffer:
self.send_buffer = chain
else:
self.send_buffer.chain.extend(chain.chain)
continue
# ---- C2C 流式场景 ----
# tool_call break 信号:工具开始执行,先把已有 buffer 以 state=10 结束当前流式段
if chain.type == "break":
if self.send_buffer:
stream_payload["state"] = 10
ret = await self._post_send(stream=stream_payload)
ret_id = self._extract_response_message_id(ret)
if ret_id is not None:
stream_payload["id"] = ret_id
# 重置 stream_payload,为下一段流式做准备
stream_payload = {
"state": 1,
"id": None,
"index": 0,
"reset": False,
}
last_edit_time = 0
continue
# 累积内容
if not self.send_buffer:
self.send_buffer = chain
else:
self.send_buffer.chain.extend(chain.chain)
if isinstance(source, botpy.message.C2CMessage):
# 真流式传输
current_time = asyncio.get_running_loop().time()
time_since_last_edit = current_time - last_edit_time
if time_since_last_edit >= throttle_interval:
ret = cast(
message.Message,
await self._post_send(stream=stream_payload),
)
stream_payload["index"] += 1
stream_payload["id"] = ret["id"]
last_edit_time = asyncio.get_running_loop().time()
# 节流:按时间间隔发送中间分片
current_time = asyncio.get_running_loop().time()
if current_time - last_edit_time >= throttle_interval:
ret = cast(
message.Message,
await self._post_send(stream=stream_payload),
)
stream_payload["index"] += 1
ret_id = self._extract_response_message_id(ret)
if ret_id is not None:
stream_payload["id"] = ret_id
last_edit_time = asyncio.get_running_loop().time()
self.send_buffer = None # 清空已发送的分片,避免下次重复发送旧内容
if isinstance(source, botpy.message.C2CMessage):
# 结束流式对话,并且传输 buffer 中剩余的消息
# 结束流式对话,发送 buffer 中剩余内容
stream_payload["state"] = 10
ret = await self._post_send(stream=stream_payload)
else:
@@ -101,9 +142,22 @@ class QQOfficialMessageEvent(AstrMessageEvent):
except Exception as e:
logger.error(f"发送流式消息时出错: {e}", exc_info=True)
# 避免累计内容在异常后被整包重复发送:仅清理缓存,不做非流式整包兜底
# 如需兜底,应该只发送未发送 delta(后续可继续优化)
self.send_buffer = None
return await super().send_streaming(generator, use_fallback)
return None
@staticmethod
def _extract_response_message_id(ret) -> str | None:
"""兼容 qq-botpy 返回 Message 对象或 dict 两种形态。"""
if ret is None:
return None
if isinstance(ret, dict):
ret_id = ret.get("id")
return str(ret_id) if ret_id is not None else None
ret_id = getattr(ret, "id", None)
return str(ret_id) if ret_id is not None else None
async def _post_send(self, stream: dict | None = None):
if not self.send_buffer:
@@ -126,16 +180,37 @@ class QQOfficialMessageEvent(AstrMessageEvent):
image_base64,
image_path,
record_file_path,
video_file_source,
file_source,
file_name,
) = await QQOfficialMessageEvent._parse_to_qqofficial(self.send_buffer)
# C2C 流式仅用于文本分片,富媒体时降级为普通发送,避免平台侧流式校验报错。
if stream and (image_base64 or record_file_path):
logger.debug("[QQOfficial] 检测到富媒体,降级为非流式发送。")
stream = None
if (
not plain_text
and not image_base64
and not image_path
and not record_file_path
and not video_file_source
and not file_source
):
return None
# QQ C2C 流式 API 说明:
# - 开始/中间分片(state=1):增量追加内容,不需要 \n(加了会导致强制换行)
# - 最终分片(state=10):结束流,content 必须以 \n 结尾(QQ API 要求)
if (
stream
and stream.get("state") == 10
and plain_text
and not plain_text.endswith("\n")
):
plain_text = plain_text + "\n"
payload: dict = {
# "content": plain_text,
"markdown": MarkdownPayload(content=plain_text) if plain_text else None,
@@ -157,7 +232,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
if image_base64:
media = await self.upload_group_and_c2c_image(
image_base64,
1,
self.IMAGE_FILE_TYPE,
group_openid=source.group_openid,
)
payload["media"] = media
@@ -165,15 +240,39 @@ class QQOfficialMessageEvent(AstrMessageEvent):
payload.pop("markdown", None)
payload["content"] = plain_text or None
if record_file_path: # group record msg
media = await self.upload_group_and_c2c_record(
media = await self.upload_group_and_c2c_media(
record_file_path,
3,
self.VOICE_FILE_TYPE,
group_openid=source.group_openid,
)
payload["media"] = media
payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
if video_file_source:
media = await self.upload_group_and_c2c_media(
video_file_source,
self.VIDEO_FILE_TYPE,
group_openid=source.group_openid,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
if file_source:
media = await self.upload_group_and_c2c_media(
file_source,
self.FILE_FILE_TYPE,
file_name=file_name,
group_openid=source.group_openid,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
ret = await self._send_with_markdown_fallback(
send_func=lambda retry_payload: self.bot.api.post_group_message(
group_openid=source.group_openid, # type: ignore
@@ -181,13 +280,14 @@ class QQOfficialMessageEvent(AstrMessageEvent):
),
payload=payload,
plain_text=plain_text,
stream=stream,
)
case botpy.message.C2CMessage():
if image_base64:
media = await self.upload_group_and_c2c_image(
image_base64,
1,
self.IMAGE_FILE_TYPE,
openid=source.author.user_openid,
)
payload["media"] = media
@@ -195,15 +295,39 @@ class QQOfficialMessageEvent(AstrMessageEvent):
payload.pop("markdown", None)
payload["content"] = plain_text or None
if record_file_path: # c2c record
media = await self.upload_group_and_c2c_record(
media = await self.upload_group_and_c2c_media(
record_file_path,
3,
self.VOICE_FILE_TYPE,
openid=source.author.user_openid,
)
payload["media"] = media
payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
if video_file_source:
media = await self.upload_group_and_c2c_media(
video_file_source,
self.VIDEO_FILE_TYPE,
openid=source.author.user_openid,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
if file_source:
media = await self.upload_group_and_c2c_media(
file_source,
self.FILE_FILE_TYPE,
file_name=file_name,
openid=source.author.user_openid,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("markdown", None)
payload["content"] = plain_text or None
if stream:
ret = await self._send_with_markdown_fallback(
send_func=lambda retry_payload: self.post_c2c_message(
@@ -213,6 +337,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
),
payload=payload,
plain_text=plain_text,
stream=stream,
)
else:
ret = await self._send_with_markdown_fallback(
@@ -222,6 +347,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
),
payload=payload,
plain_text=plain_text,
stream=stream,
)
logger.debug(f"Message sent to C2C: {ret}")
@@ -237,6 +363,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
),
payload=payload,
plain_text=plain_text,
stream=stream,
)
case botpy.message.DirectMessage():
@@ -251,6 +378,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
),
payload=payload,
plain_text=plain_text,
stream=stream,
)
case _:
@@ -267,10 +395,31 @@ class QQOfficialMessageEvent(AstrMessageEvent):
send_func,
payload: dict,
plain_text: str,
stream: dict | None = None,
):
try:
return await send_func(payload)
except botpy.errors.ServerError as err:
# QQ 流式 markdown 分片校验:内容必须以换行结尾。
# 某些边界场景服务端仍可能判定失败,这里做一次修正重试。
if stream and self.STREAM_MARKDOWN_NEWLINE_ERROR in str(err):
retry_payload = payload.copy()
markdown_payload = retry_payload.get("markdown")
if isinstance(markdown_payload, dict):
md_content = cast(str, markdown_payload.get("content", "") or "")
if md_content and not md_content.endswith("\n"):
retry_payload["markdown"] = {"content": md_content + "\n"}
content = cast(str | None, retry_payload.get("content"))
if content and not content.endswith("\n"):
retry_payload["content"] = content + "\n"
logger.warning(
"[QQOfficial] 流式 markdown 分片换行校验失败,已修正后重试一次。"
)
return await send_func(retry_payload)
if (
self.MARKDOWN_NOT_ALLOWED_ERROR not in str(err)
or not payload.get("markdown")
@@ -282,10 +431,14 @@ class QQOfficialMessageEvent(AstrMessageEvent):
"[QQOfficial] markdown 发送被拒绝,回退到 content 模式重试。"
)
fallback_payload = payload.copy()
fallback_payload["markdown"] = None
fallback_payload.pop("markdown", None)
fallback_payload["content"] = plain_text
if fallback_payload.get("msg_type") == 2:
fallback_payload["msg_type"] = 0
if stream:
fallback_content = cast(str, fallback_payload.get("content") or "")
if fallback_content and not fallback_content.endswith("\n"):
fallback_payload["content"] = fallback_content + "\n"
return await send_func(fallback_payload)
async def upload_group_and_c2c_image(
@@ -327,16 +480,19 @@ class QQOfficialMessageEvent(AstrMessageEvent):
ttl=result.get("ttl", 0),
)
async def upload_group_and_c2c_record(
async def upload_group_and_c2c_media(
self,
file_source: str,
file_type: int,
srv_send_msg: bool = False,
file_name: str | None = None,
**kwargs,
) -> Media | None:
"""上传媒体文件"""
# 构建基础payload
payload = {"file_type": file_type, "srv_send_msg": srv_send_msg}
if file_name:
payload["file_name"] = file_name
# 处理文件数据
if os.path.exists(file_source):
@@ -400,13 +556,21 @@ class QQOfficialMessageEvent(AstrMessageEvent):
) -> message.Message:
payload = locals()
payload.pop("self", None)
# QQ API does not accept stream.id=None; remove it when not yet assigned
if "stream" in payload and payload["stream"] is not None:
stream_data = dict(payload["stream"])
if stream_data.get("id") is None:
stream_data.pop("id", None)
payload["stream"] = stream_data
route = Route("POST", "/v2/users/{openid}/messages", openid=openid)
result = await self.bot.api._http.request(route, json=payload)
if result is None:
logger.warning("[QQOfficial] post_c2c_message: API 返回 None,跳过本次发送")
return None
if not isinstance(result, dict):
raise RuntimeError(
f"Failed to post c2c message, response is not dict: {result}"
)
logger.error(f"[QQOfficial] post_c2c_message: 响应不是 dict: {result}")
return None
return message.Message(**result)
@@ -416,6 +580,9 @@ class QQOfficialMessageEvent(AstrMessageEvent):
image_base64 = None # only one img supported
image_file_path = None
record_file_path = None
video_file_source = None
file_source = None
file_name = None
for i in message.chain:
if isinstance(i, Plain):
plain_text += i.text
@@ -454,6 +621,30 @@ class QQOfficialMessageEvent(AstrMessageEvent):
except Exception as e:
logger.error(f"处理语音时出错: {e}")
record_file_path = None
elif isinstance(i, Video) and not video_file_source:
if i.file.startswith("file:///"):
video_file_source = i.file[8:]
else:
video_file_source = i.file
elif isinstance(i, File) and not file_source:
file_name = i.name
if i.file_:
file_path = i.file_
if file_path.startswith("file:///"):
file_path = file_path[8:]
elif file_path.startswith("file://"):
file_path = file_path[7:]
file_source = file_path
elif i.url:
file_source = i.url
else:
logger.debug(f"qq_official 忽略 {i.type}")
return plain_text, image_base64, image_file_path, record_file_path
return (
plain_text,
image_base64,
image_file_path,
record_file_path,
video_file_source,
file_source,
file_name,
)
@@ -3,8 +3,10 @@ from __future__ import annotations
import asyncio
import logging
import os
import random
import time
from typing import cast
from types import SimpleNamespace
from typing import Any, cast
import botpy
import botpy.message
@@ -12,7 +14,7 @@ from botpy import Client
from astrbot import logger
from astrbot.api.event import MessageChain
from astrbot.api.message_components import At, File, Image, Plain
from astrbot.api.message_components import At, File, Image, Plain, Record, Video
from astrbot.api.platform import (
AstrBotMessage,
MessageMember,
@@ -46,6 +48,7 @@ class botClient(Client):
)
abm.group_id = cast(str, message.group_openid)
abm.session_id = abm.group_id
self.platform.remember_session_scene(abm.session_id, "group")
self._commit(abm)
# 收到频道消息
@@ -56,6 +59,7 @@ class botClient(Client):
)
abm.group_id = message.channel_id
abm.session_id = abm.group_id
self.platform.remember_session_scene(abm.session_id, "channel")
self._commit(abm)
# 收到私聊消息
@@ -67,6 +71,7 @@ class botClient(Client):
MessageType.FRIEND_MESSAGE,
)
abm.session_id = abm.sender.user_id
self.platform.remember_session_scene(abm.session_id, "friend")
self._commit(abm)
# 收到 C2C 消息
@@ -76,9 +81,11 @@ class botClient(Client):
MessageType.FRIEND_MESSAGE,
)
abm.session_id = abm.sender.user_id
self.platform.remember_session_scene(abm.session_id, "friend")
self._commit(abm)
def _commit(self, abm: AstrBotMessage) -> None:
self.platform.remember_session_message_id(abm.session_id, abm.message_id)
self.platform.commit_event(
QQOfficialMessageEvent(
abm.message_str,
@@ -124,6 +131,9 @@ class QQOfficialPlatformAdapter(Platform):
self.client.set_platform(self)
self._session_last_message_id: dict[str, str] = {}
self._session_scene: dict[str, str] = {}
self.test_mode = os.environ.get("TEST_MODE", "off") == "on"
async def send_by_session(
@@ -131,14 +141,191 @@ class QQOfficialPlatformAdapter(Platform):
session: MessageSesion,
message_chain: MessageChain,
) -> None:
raise NotImplementedError("QQ 机器人官方 API 适配器不支持 send_by_session")
await self._send_by_session_common(session, message_chain)
async def _send_by_session_common(
self,
session: MessageSesion,
message_chain: MessageChain,
) -> None:
(
plain_text,
image_base64,
image_path,
record_file_path,
video_file_source,
file_source,
file_name,
) = await QQOfficialMessageEvent._parse_to_qqofficial(message_chain)
if (
not plain_text
and not image_path
and not image_base64
and not record_file_path
and not video_file_source
and not file_source
):
return
msg_id = self._session_last_message_id.get(session.session_id)
if not msg_id:
logger.warning(
"[QQOfficial] No cached msg_id for session: %s, skip send_by_session",
session.session_id,
)
return
payload: dict[str, Any] = {"content": plain_text, "msg_id": msg_id}
ret: Any = None
send_helper = SimpleNamespace(bot=self.client)
if session.message_type == MessageType.GROUP_MESSAGE:
scene = self._session_scene.get(session.session_id)
if scene == "group":
payload["msg_seq"] = random.randint(1, 10000)
if image_base64:
media = await QQOfficialMessageEvent.upload_group_and_c2c_image(
send_helper, # type: ignore
image_base64,
QQOfficialMessageEvent.IMAGE_FILE_TYPE,
group_openid=session.session_id,
)
payload["media"] = media
payload["msg_type"] = 7
if record_file_path:
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
send_helper, # type: ignore
record_file_path,
QQOfficialMessageEvent.VOICE_FILE_TYPE,
group_openid=session.session_id,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
if video_file_source:
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
send_helper, # type: ignore
video_file_source,
QQOfficialMessageEvent.VIDEO_FILE_TYPE,
group_openid=session.session_id,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("msg_id", None)
if file_source:
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
send_helper, # type: ignore
file_source,
QQOfficialMessageEvent.FILE_FILE_TYPE,
file_name=file_name,
group_openid=session.session_id,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("msg_id", None)
ret = await self.client.api.post_group_message(
group_openid=session.session_id,
**payload,
)
else:
if image_path:
payload["file_image"] = image_path
ret = await self.client.api.post_message(
channel_id=session.session_id,
**payload,
)
elif session.message_type == MessageType.FRIEND_MESSAGE:
payload["msg_seq"] = random.randint(1, 10000)
if image_base64:
media = await QQOfficialMessageEvent.upload_group_and_c2c_image(
send_helper, # type: ignore
image_base64,
QQOfficialMessageEvent.IMAGE_FILE_TYPE,
openid=session.session_id,
)
payload["media"] = media
payload["msg_type"] = 7
if record_file_path:
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
send_helper, # type: ignore
record_file_path,
QQOfficialMessageEvent.VOICE_FILE_TYPE,
openid=session.session_id,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
if video_file_source:
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
send_helper, # type: ignore
video_file_source,
QQOfficialMessageEvent.VIDEO_FILE_TYPE,
openid=session.session_id,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
# QQ API rejects msg_id for media (video/file) messages sent
# via the proactive tool-call path; remove it to avoid 越权 error.
payload.pop("msg_id", None)
if file_source:
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
send_helper, # type: ignore
file_source,
QQOfficialMessageEvent.FILE_FILE_TYPE,
file_name=file_name,
openid=session.session_id,
)
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("msg_id", None)
ret = await QQOfficialMessageEvent.post_c2c_message(
send_helper, # type: ignore
openid=session.session_id,
**payload,
)
else:
logger.warning(
"[QQOfficial] Unsupported message type for send_by_session: %s",
session.message_type,
)
return
sent_message_id = self._extract_message_id(ret)
if sent_message_id:
self.remember_session_message_id(session.session_id, sent_message_id)
await super().send_by_session(session, message_chain)
def remember_session_message_id(self, session_id: str, message_id: str) -> None:
if not session_id or not message_id:
return
self._session_last_message_id[session_id] = message_id
def remember_session_scene(self, session_id: str, scene: str) -> None:
if not session_id or not scene:
return
self._session_scene[session_id] = scene
def _extract_message_id(self, ret: Any) -> str | None:
if isinstance(ret, dict):
message_id = ret.get("id")
return str(message_id) if message_id else None
message_id = getattr(ret, "id", None)
if message_id:
return str(message_id)
return None
def meta(self) -> PlatformMetadata:
return PlatformMetadata(
name="qq_official",
description="QQ 机器人官方 API 适配器",
id=cast(str, self.config.get("id")),
support_proactive_message=False,
support_proactive_message=True,
)
@staticmethod
@@ -158,7 +345,10 @@ class QQOfficialPlatformAdapter(Platform):
return
for attachment in attachments:
content_type = cast(str, getattr(attachment, "content_type", "") or "")
content_type = cast(
str,
getattr(attachment, "content_type", "") or "",
).lower()
url = QQOfficialPlatformAdapter._normalize_attachment_url(
cast(str | None, getattr(attachment, "url", None))
)
@@ -174,7 +364,32 @@ class QQOfficialPlatformAdapter(Platform):
or getattr(attachment, "name", None)
or "attachment",
)
msg.append(File(name=filename, file=url, url=url))
ext = os.path.splitext(filename)[1].lower()
image_exts = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
audio_exts = {
".mp3",
".wav",
".ogg",
".m4a",
".amr",
".silk",
}
video_exts = {
".mp4",
".mov",
".avi",
".mkv",
".webm",
}
if content_type.startswith("audio") or ext in audio_exts:
msg.append(Record.fromURL(url))
elif content_type.startswith("video") or ext in video_exts:
msg.append(Video.fromURL(url))
elif content_type.startswith("image") or ext in image_exts:
msg.append(Image.fromURL(url))
else:
msg.append(File(name=filename, file=url, url=url))
@staticmethod
def _parse_from_qqofficial(
@@ -1,7 +1,5 @@
import asyncio
import logging
import random
from types import SimpleNamespace
from typing import Any, cast
import botpy
@@ -15,7 +13,6 @@ from astrbot.core.platform.astr_message_event import MessageSesion
from astrbot.core.utils.webhook_utils import log_webhook_info
from ...register import register_platform_adapter
from ..qqofficial.qqofficial_message_event import QQOfficialMessageEvent
from ..qqofficial.qqofficial_platform_adapter import QQOfficialPlatformAdapter
from .qo_webhook_event import QQOfficialWebhookMessageEvent
from .qo_webhook_server import QQOfficialWebhook
@@ -123,95 +120,11 @@ class QQOfficialWebhookPlatformAdapter(Platform):
session: MessageSesion,
message_chain: MessageChain,
) -> None:
(
plain_text,
image_base64,
image_path,
record_file_path,
) = await QQOfficialMessageEvent._parse_to_qqofficial(message_chain)
if not plain_text and not image_path:
return
msg_id = self._session_last_message_id.get(session.session_id)
if not msg_id:
logger.warning(
"[QQOfficialWebhook] No cached msg_id for session: %s, skip send_by_session",
session.session_id,
)
return
payload: dict[str, Any] = {"content": plain_text, "msg_id": msg_id}
ret: Any = None
send_helper = SimpleNamespace(bot=self.client)
if session.message_type == MessageType.GROUP_MESSAGE:
scene = self._session_scene.get(session.session_id)
if scene == "group":
payload["msg_seq"] = random.randint(1, 10000)
if image_base64:
media = await QQOfficialMessageEvent.upload_group_and_c2c_image(
send_helper, # type: ignore
image_base64,
1,
group_openid=session.session_id,
)
payload["media"] = media
payload["msg_type"] = 7
if record_file_path:
media = await QQOfficialMessageEvent.upload_group_and_c2c_record(
send_helper, # type: ignore
record_file_path,
3,
group_openid=session.session_id,
)
payload["media"] = media
payload["msg_type"] = 7
ret = await self.client.api.post_group_message(
group_openid=session.session_id,
**payload,
)
else:
if image_path:
payload["file_image"] = image_path
ret = await self.client.api.post_message(
channel_id=session.session_id,
**payload,
)
elif session.message_type == MessageType.FRIEND_MESSAGE:
payload["msg_seq"] = random.randint(1, 10000)
if image_base64:
media = await QQOfficialMessageEvent.upload_group_and_c2c_image(
send_helper, # type: ignore
image_base64,
1,
openid=session.session_id,
)
payload["media"] = media
payload["msg_type"] = 7
if record_file_path:
media = await QQOfficialMessageEvent.upload_group_and_c2c_record(
send_helper, # type: ignore
record_file_path,
3,
openid=session.session_id,
)
payload["media"] = media
payload["msg_type"] = 7
ret = await QQOfficialMessageEvent.post_c2c_message(
send_helper, # type: ignore
openid=session.session_id,
**payload,
)
else:
logger.warning(
"[QQOfficialWebhook] Unsupported message type for send_by_session: %s",
session.message_type,
)
return
sent_message_id = self._extract_message_id(ret)
if sent_message_id:
self.remember_session_message_id(session.session_id, sent_message_id)
await super().send_by_session(session, message_chain)
await QQOfficialPlatformAdapter._send_by_session_common(
cast(Any, self),
session,
message_chain,
)
def remember_session_message_id(self, session_id: str, message_id: str) -> None:
if not session_id or not message_id:
@@ -278,7 +278,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
try:
md_text = telegramify_markdown.markdownify(
chunk,
normalize_whitespace=False,
)
await client.send_message(
text=md_text,
@@ -456,7 +455,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
try:
markdown_text = telegramify_markdown.markdownify(
delta,
normalize_whitespace=False,
)
await self.client.send_message(
text=markdown_text,
@@ -537,7 +535,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
try:
md = telegramify_markdown.markdownify(
draft_text,
normalize_whitespace=False,
)
await self._send_message_draft(
user_name,
@@ -695,7 +692,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
try:
markdown_text = telegramify_markdown.markdownify(
delta,
normalize_whitespace=False,
)
await self.client.edit_message_text(
text=markdown_text,
+23 -17
View File
@@ -21,8 +21,8 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
DEFAULT_MCP_CONFIG = {"mcpServers": {}}
DEFAULT_MCP_INIT_TIMEOUT_SECONDS = 20.0
DEFAULT_ENABLE_MCP_TIMEOUT_SECONDS = 30.0
DEFAULT_MCP_INIT_TIMEOUT_SECONDS = 180.0
DEFAULT_ENABLE_MCP_TIMEOUT_SECONDS = 180.0
MCP_INIT_TIMEOUT_ENV = "ASTRBOT_MCP_INIT_TIMEOUT"
ENABLE_MCP_TIMEOUT_ENV = "ASTRBOT_MCP_ENABLE_TIMEOUT"
MAX_MCP_TIMEOUT_SECONDS = 300.0
@@ -417,9 +417,11 @@ class FunctionToolManager:
for (name, cfg, _), result in zip(active_configs, results, strict=False):
if isinstance(result, Exception):
if isinstance(result, MCPInitTimeoutError):
logger.error(f"MCP 服务 {name} 初始化超时({timeout_display}秒)")
logger.error(
f"Connected to MCP server {name} timeout ({timeout_display} seconds)"
)
else:
logger.error(f"MCP 服务 {name} 初始化失败: {result}")
logger.error(f"Failed to initialize MCP server {name}: {result}")
self._log_safe_mcp_debug_config(cfg)
failed_services.append(name)
async with self._runtime_lock:
@@ -430,16 +432,18 @@ class FunctionToolManager:
if failed_services:
logger.warning(
f"以下 MCP 服务初始化失败: {', '.join(failed_services)}"
f"请检查配置文件 mcp_server.json 和服务器可用性。"
f"The following MCP services failed to initialize: {', '.join(failed_services)}. "
f"Please check the mcp_server.json file and server availability."
)
summary = MCPInitSummary(
total=len(active_configs), success=success_count, failed=failed_services
)
logger.info(f"MCP 服务初始化完成: {summary.success}/{summary.total} 成功")
logger.info(
f"MCP services initialization completed: {summary.success}/{summary.total} successful, {len(summary.failed)} failed."
)
if summary.total > 0 and summary.success == 0:
msg = "全部 MCP 服务初始化失败,请检查 mcp_server.json 配置和服务器可用性。"
msg = "All MCP services failed to initialize, please check the mcp_server.json and server availability."
if raise_on_all_failed:
raise MCPAllServicesFailedError(msg)
logger.error(msg)
@@ -461,7 +465,7 @@ class FunctionToolManager:
async with self._runtime_lock:
if name in self._mcp_server_runtime or name in self._mcp_starting:
logger.warning(
f"MCP 服务 {name} 已在运行,忽略本次启用请求(timeout={timeout:g})。"
f"Connected to MCP server {name}, ignoring this startup request (timeout={timeout:g})."
)
self._log_safe_mcp_debug_config(cfg)
return
@@ -478,10 +482,10 @@ class FunctionToolManager:
)
except asyncio.TimeoutError as exc:
raise MCPInitTimeoutError(
f"MCP 服务 {name} 初始化超时({timeout:g} 秒)"
f"Connected to MCP server {name} timeout ({timeout:g} seconds)"
) from exc
except Exception:
logger.error(f"初始化 MCP 客户端 {name} 失败", exc_info=True)
logger.error(f"Failed to initialize MCP client {name}", exc_info=True)
raise
finally:
if mcp_client is None:
@@ -491,9 +495,9 @@ class FunctionToolManager:
async def lifecycle() -> None:
try:
await shutdown_event.wait()
logger.info(f"收到 MCP 客户端 {name} 终止信号")
logger.info(f"Received shutdown signal for MCP client {name}")
except asyncio.CancelledError:
logger.debug(f"MCP 客户端 {name} 任务被取消")
logger.debug(f"MCP client {name} task was cancelled")
raise
finally:
await self._terminate_mcp_client(name)
@@ -545,7 +549,7 @@ class FunctionToolManager:
if strict:
raise MCPShutdownTimeoutError(pending_names, timeout)
logger.warning(
"MCP 服务关闭超时(%s 秒),以下服务未完全关闭:%s",
"MCP server shutdown timeout (%s seconds), the following servers were not fully closed: %s",
f"{timeout:g}",
", ".join(pending_names),
)
@@ -568,7 +572,9 @@ class FunctionToolManager:
try:
await mcp_client.cleanup()
except Exception as cleanup_exc: # noqa: BLE001 - only log here
logger.error(f"清理 MCP 客户端资源 {name} 失败: {cleanup_exc}")
logger.error(
f"Failed to cleanup MCP client resources {name}: {cleanup_exc}"
)
async def _init_mcp_client(self, name: str, config: dict) -> MCPClient:
"""初始化单个MCP客户端"""
@@ -602,7 +608,7 @@ class FunctionToolManager:
)
self.func_list.append(func_tool)
logger.info(f"已连接 MCP 服务 {name}, Tools: {tool_names}")
logger.info(f"Connected to MCP server {name}, Tools: {tool_names}")
return mcp_client
async def _terminate_mcp_client(self, name: str) -> None:
@@ -622,7 +628,7 @@ class FunctionToolManager:
async with self._runtime_lock:
self._mcp_server_runtime.pop(name, None)
self._mcp_starting.discard(name)
logger.info(f"已关闭 MCP 服务 {name}")
logger.info(f"Disconnected from MCP server {name}")
return
# Runtime missing but stale tools may still exist after failed flows.
+18 -18
View File
@@ -79,6 +79,7 @@ class ProviderManager:
self._provider_change_hooks: list[
Callable[[str, ProviderType, str | None], None]
] = []
self._mcp_init_task: asyncio.Task | None = None
def set_provider_change_callback(
self,
@@ -330,24 +331,16 @@ class ProviderManager:
if not self.curr_tts_provider_inst and self.tts_provider_insts:
self.curr_tts_provider_inst = self.tts_provider_insts[0]
# 初始化 MCP Client 连接(等待完成以确保工具可用)
strict_mcp_init = os.getenv("ASTRBOT_MCP_INIT_STRICT", "").strip().lower() in {
"1",
"true",
"yes",
"on",
}
mcp_init_summary = await self.llm_tools.init_mcp_clients(
raise_on_all_failed=strict_mcp_init
)
if (
mcp_init_summary.total > 0
and mcp_init_summary.success == 0
and not strict_mcp_init
):
logger.warning(
"MCP 服务全部初始化失败,系统将继续启动(可设置 "
"ASTRBOT_MCP_INIT_STRICT=1 以在此场景下中止启动)。"
async def _init_mcp_clients_bg() -> None:
try:
await self.llm_tools.init_mcp_clients()
except Exception:
logger.error("MCP init background task failed", exc_info=True)
if self._mcp_init_task is None or self._mcp_init_task.done():
self._mcp_init_task = asyncio.create_task(
_init_mcp_clients_bg(),
name="provider-manager:mcp-init",
)
def dynamic_import_provider(self, type: str) -> None:
@@ -817,6 +810,13 @@ class ProviderManager:
await self.load_provider(new_config)
async def terminate(self) -> None:
if self._mcp_init_task and not self._mcp_init_task.done():
self._mcp_init_task.cancel()
try:
await self._mcp_init_task
except asyncio.CancelledError:
pass
for provider_inst in self.provider_insts:
if hasattr(provider_inst, "terminate"):
await provider_inst.terminate() # type: ignore
+18 -1
View File
@@ -281,7 +281,24 @@ class TTSProvider(AbstractProvider):
accumulated_text += text_part
async def test(self) -> None:
await self.get_audio("hi")
audio_path = await self.get_audio("hi")
# 检查生成的音频文件是否有效
if not os.path.exists(audio_path):
raise Exception("TTS test failed: audio file was not created")
file_size = os.path.getsize(audio_path)
if file_size == 0:
raise Exception(
"TTS test failed: generated audio file is empty (0 bytes). "
"Please check your TTS provider configuration, especially required parameters like group_id for MiniMax."
)
# 清理测试文件
try:
os.remove(audio_path)
except Exception:
pass
class EmbeddingProvider(AbstractProvider):
@@ -20,6 +20,7 @@ from ..register import register_provider_adapter
TEMP_DIR = Path(get_astrbot_temp_path()) / "azure_tts"
TEMP_DIR.mkdir(parents=True, exist_ok=True)
AZURE_TTS_SUBSCRIPTION_KEY_PATTERN = r"^(?:[a-zA-Z0-9]{32}|[a-zA-Z0-9]{84})$"
class OTTSProvider:
@@ -116,7 +117,7 @@ class AzureNativeProvider(TTSProvider):
"azure_tts_subscription_key",
"",
).strip()
if not re.fullmatch(r"^[a-zA-Z0-9]{32}$", self.subscription_key):
if not re.fullmatch(AZURE_TTS_SUBSCRIPTION_KEY_PATTERN, self.subscription_key):
raise ValueError("无效的Azure订阅密钥")
self.region = provider_config.get("azure_tts_region", "eastus").strip()
self.endpoint = (
@@ -235,9 +236,9 @@ class AzureTTSProvider(TTSProvider):
raise ValueError(error_msg) from e
except KeyError as e:
raise ValueError(f"配置错误: 缺少必要参数 {e}") from e
if re.fullmatch(r"^[a-zA-Z0-9]{32}$", key_value):
if re.fullmatch(AZURE_TTS_SUBSCRIPTION_KEY_PATTERN, key_value):
return AzureNativeProvider(config, self.provider_settings)
raise ValueError("订阅密钥格式无效,应为32位字母数字或other[...]格式")
raise ValueError("订阅密钥格式无效,应为32位或84位字母数字或other[...]格式")
async def get_audio(self, text: str) -> str:
if isinstance(self.provider, OTTSProvider):
@@ -154,6 +154,14 @@ class ProviderMiniMaxTTSAPI(TTSProvider):
audio_stream = self._call_tts_stream(text)
audio = await self._audio_play(audio_stream)
# 检查音频数据是否为空
if not audio or len(audio) == 0:
raise Exception(
"MiniMax TTS API returned empty audio data. "
"Please verify your configuration, especially the 'group_id' parameter. "
"You can find your group_id in Account Management -> Basic Information on the MiniMax platform."
)
# 结果保存至文件
with open(path, "wb") as file:
file.write(audio)
@@ -161,4 +169,4 @@ class ProviderMiniMaxTTSAPI(TTSProvider):
return path
except aiohttp.ClientError as e:
raise e
raise Exception(f"MiniMax TTS API request failed: {e!s}")
+82 -7
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import json
import os
import re
import shlex
import shutil
import tempfile
import zipfile
@@ -79,7 +80,59 @@ def _parse_frontmatter_description(text: str) -> str:
# Regex for sanitizing paths used in prompt examples — only allow
# safe path characters to prevent prompt injection via crafted skill paths.
_SAFE_PATH_RE = re.compile(r"[^A-Za-z0-9_./ -]")
_SAFE_PATH_RE = re.compile(r"[^\w./ ,()'\-]", re.UNICODE)
_WINDOWS_DRIVE_PATH_RE = re.compile(r"^[A-Za-z]:(?:/|\\)")
_WINDOWS_UNC_PATH_RE = re.compile(r"^(//|\\\\)[^/\\]+[/\\][^/\\]+")
_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1F\x7F]")
def _is_windows_prompt_path(path: str) -> bool:
if os.name != "nt":
return False
return bool(_WINDOWS_DRIVE_PATH_RE.match(path) or _WINDOWS_UNC_PATH_RE.match(path))
def _sanitize_prompt_path_for_prompt(path: str) -> str:
if not path:
return ""
if _WINDOWS_DRIVE_PATH_RE.match(path) or _WINDOWS_UNC_PATH_RE.match(path):
path = path.replace("\\", "/")
drive_prefix = ""
if _WINDOWS_DRIVE_PATH_RE.match(path):
drive_prefix = path[:2]
path = path[2:]
path = path.replace("`", "")
path = _CONTROL_CHARS_RE.sub("", path)
sanitized = _SAFE_PATH_RE.sub("", path)
return f"{drive_prefix}{sanitized}"
def _sanitize_prompt_description(description: str) -> str:
description = description.replace("`", "")
description = _CONTROL_CHARS_RE.sub(" ", description)
description = " ".join(description.split())
return description
def _sanitize_skill_display_name(name: str) -> str:
if _SKILL_NAME_RE.fullmatch(name):
return name
return "<invalid_skill_name>"
def _build_skill_read_command_example(path: str) -> str:
if path == "<skills_root>/<skill_name>/SKILL.md":
return f"cat {path}"
if _is_windows_prompt_path(path):
command = "type"
path_arg = f'"{path}"'
else:
command = "cat"
path_arg = shlex.quote(path)
return f"{command} {path_arg}"
def build_skills_prompt(skills: list[SkillInfo]) -> str:
@@ -92,16 +145,37 @@ def build_skills_prompt(skills: list[SkillInfo]) -> str:
skills_lines: list[str] = []
example_path = ""
for skill in skills:
display_name = _sanitize_skill_display_name(skill.name)
description = skill.description or "No description"
if skill.source_type == "sandbox_only":
description = _sanitize_prompt_description(description)
if not description:
description = "Read SKILL.md for details."
if skill.source_type == "sandbox_only":
rendered_path = (
f"{str(SANDBOX_WORKSPACE_ROOT)}/{str(SANDBOX_SKILLS_ROOT)}/"
f"{display_name}/SKILL.md"
)
else:
rendered_path = _sanitize_prompt_path_for_prompt(skill.path)
if not rendered_path:
rendered_path = "<skills_root>/<skill_name>/SKILL.md"
skills_lines.append(
f"- **{skill.name}**: {description}\n File: `{skill.path}`"
f"- **{display_name}**: {description}\n File: `{rendered_path}`"
)
if not example_path:
example_path = skill.path
example_path = rendered_path
skills_block = "\n".join(skills_lines)
# Sanitize example_path — it may originate from sandbox cache (untrusted)
example_path = _SAFE_PATH_RE.sub("", example_path) if example_path else ""
example_path = example_path or "<skills_root>/<skill_name>/SKILL.md"
if example_path == "<skills_root>/<skill_name>/SKILL.md":
example_path = "<skills_root>/<skill_name>/SKILL.md"
else:
example_path = _sanitize_prompt_path_for_prompt(example_path)
example_path = example_path or "<skills_root>/<skill_name>/SKILL.md"
example_command = _build_skill_read_command_example(example_path)
return (
"## Skills\n\n"
@@ -119,8 +193,9 @@ def build_skills_prompt(skills: list[SkillInfo]) -> str:
"*Never silently skip a matching skill* — either use it or briefly "
"explain why you chose not to.\n"
"3. **Mandatory grounding** — Before executing any skill you MUST "
"first read its `SKILL.md` by running a shell command with the "
f"**absolute path** shown above (e.g. `cat {example_path}`). "
"first read its `SKILL.md` by running a shell command compatible "
"with the current runtime shell and using the **absolute path** "
f"shown above (e.g. `{example_command}`). "
"Never rely on memory or assumptions about a skill's content.\n"
"4. **Progressive disclosure** — Load only what is directly "
"referenced from `SKILL.md`:\n"
+147 -9
View File
@@ -1,12 +1,14 @@
"""插件的重载、启停、安装、卸载等操作。"""
import asyncio
import contextlib
import functools
import inspect
import json
import logging
import os
import sys
import tempfile
import traceback
from types import ModuleType
@@ -14,7 +16,12 @@ import yaml
from packaging.specifiers import InvalidSpecifier, SpecifierSet
from packaging.version import InvalidVersion, Version
from astrbot.core import logger, pip_installer, sp
from astrbot.core import (
DependencyConflictError,
logger,
pip_installer,
sp,
)
from astrbot.core.agent.handoff import FunctionTool, HandoffTool
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.config.default import VERSION
@@ -24,9 +31,13 @@ from astrbot.core.utils.astrbot_path import (
get_astrbot_config_path,
get_astrbot_path,
get_astrbot_plugin_path,
get_astrbot_temp_path,
)
from astrbot.core.utils.io import remove_dir
from astrbot.core.utils.metrics import Metric
from astrbot.core.utils.requirements_utils import (
plan_missing_requirements_install,
)
from . import StarMetadata
from .command_management import sync_command_configs
@@ -48,6 +59,97 @@ class PluginVersionIncompatibleError(Exception):
"""Raised when plugin astrbot_version is incompatible with current AstrBot."""
class PluginDependencyInstallError(Exception):
"""Raised when plugin dependency installation fails."""
def __init__(
self,
*,
plugin_label: str,
requirements_path: str,
error: Exception,
) -> None:
message = f"插件 {plugin_label} 依赖安装失败: {error!s}"
super().__init__(message)
self.plugin_label = plugin_label
self.requirements_path = requirements_path
self.error = error
@contextlib.contextmanager
def _temporary_filtered_requirements_file(
*,
install_lines: tuple[str, ...],
):
filtered_requirements_path: str | None = None
temp_dir = get_astrbot_temp_path()
try:
os.makedirs(temp_dir, exist_ok=True)
with tempfile.NamedTemporaryFile(
mode="w",
suffix="_plugin_requirements.txt",
delete=False,
dir=temp_dir,
encoding="utf-8",
) as filtered_requirements_file:
filtered_requirements_file.write("\n".join(install_lines) + "\n")
filtered_requirements_path = filtered_requirements_file.name
yield filtered_requirements_path
finally:
if filtered_requirements_path and os.path.exists(filtered_requirements_path):
try:
os.remove(filtered_requirements_path)
except OSError as exc:
logger.warning(
"删除临时插件依赖文件失败:%s(路径:%s",
exc,
filtered_requirements_path,
)
async def _install_requirements_with_precheck(
*,
plugin_label: str,
requirements_path: str,
) -> None:
install_plan = plan_missing_requirements_install(requirements_path)
if install_plan is None:
logger.info(
f"正在安装插件 {plugin_label} 的依赖库(缺失依赖预检查不可裁剪,回退到完整安装): "
f"{requirements_path}"
)
await pip_installer.install(requirements_path=requirements_path)
return
if not install_plan.missing_names:
logger.info(f"插件 {plugin_label} 的依赖已满足,跳过安装。")
return
if not install_plan.install_lines:
fallback_reason = install_plan.fallback_reason or "unknown reason"
logger.info(
"检测到插件 %s 缺失依赖,但无法安全裁剪 requirements,回退到完整安装: %s (%s)",
plugin_label,
requirements_path,
fallback_reason,
)
await pip_installer.install(requirements_path=requirements_path)
return
logger.info(
f"检测到插件 {plugin_label} 缺失依赖,正在按 requirements.txt 安装: "
f"{requirements_path} -> {sorted(install_plan.missing_names)}"
)
with _temporary_filtered_requirements_file(
install_lines=install_plan.install_lines,
) as filtered_requirements_path:
await pip_installer.install(requirements_path=filtered_requirements_path)
class PluginManager:
def __init__(self, context: Context, config: AstrBotConfig) -> None:
from .star_tools import StarTools
@@ -198,15 +300,37 @@ class PluginManager:
to_update.append(p.root_dir_name)
for p in to_update:
plugin_path = os.path.join(plugin_dir, p)
if os.path.exists(os.path.join(plugin_path, "requirements.txt")):
pth = os.path.join(plugin_path, "requirements.txt")
logger.info(f"正在安装插件 {p} 所需的依赖库: {pth}")
try:
await pip_installer.install(requirements_path=pth)
except Exception as e:
logger.error(f"更新插件 {p} 的依赖失败。Code: {e!s}")
await self._ensure_plugin_requirements(plugin_path, p)
return True
async def _ensure_plugin_requirements(
self,
plugin_dir_path: str,
plugin_label: str,
) -> None:
requirements_path = os.path.join(plugin_dir_path, "requirements.txt")
if not os.path.exists(requirements_path):
return
try:
await _install_requirements_with_precheck(
plugin_label=plugin_label,
requirements_path=requirements_path,
)
except asyncio.CancelledError:
raise
except DependencyConflictError as e:
logger.error(f"插件 {plugin_label} 依赖冲突: {e!s}")
raise
except Exception as e:
dependency_error = PluginDependencyInstallError(
plugin_label=plugin_label,
requirements_path=requirements_path,
error=e,
)
logger.exception(str(dependency_error))
raise dependency_error from e
async def _import_plugin_with_dependency_recovery(
self,
path: str,
@@ -422,7 +546,7 @@ class PluginManager:
root_dir_name: str,
plugin_dir_path: str,
reserved: bool,
error: Exception | str,
error: BaseException | str,
error_trace: str,
) -> dict:
record: dict = {
@@ -495,6 +619,9 @@ class PluginManager:
self._cleanup_plugin_state(dir_name)
plugin_path = os.path.join(self.plugin_store_path, dir_name)
await self._ensure_plugin_requirements(plugin_path, dir_name)
success, error = await self.load(specified_dir_name=dir_name)
if success:
self.failed_plugin_dict.pop(dir_name, None)
@@ -1078,6 +1205,10 @@ class PluginManager:
# reload the plugin
dir_name = os.path.basename(plugin_path)
await self._ensure_plugin_requirements(
plugin_path,
dir_name,
)
success, error_message = await self.load(
specified_dir_name=dir_name,
ignore_version_check=ignore_version_check,
@@ -1317,6 +1448,12 @@ class PluginManager:
raise Exception("该插件是 AstrBot 保留插件,无法更新。")
await self.updator.update(plugin, proxy=proxy)
if plugin.root_dir_name:
plugin_dir_path = os.path.join(self.plugin_store_path, plugin.root_dir_name)
await self._ensure_plugin_requirements(
plugin_dir_path,
plugin_name,
)
await self.reload(plugin_name)
async def turn_off_plugin(self, plugin_name: str) -> None:
@@ -1488,6 +1625,7 @@ class PluginManager:
os.remove(zip_file_path)
except BaseException as e:
logger.warning(f"删除插件压缩包失败: {e!s}")
await self._ensure_plugin_requirements(desti_dir, dir_name)
# await self.reload()
success, error_message = await self.load(
specified_dir_name=dir_name,
+1 -1
View File
@@ -30,7 +30,7 @@ class CreateActiveCronTool(FunctionTool[AstrAgentContext]):
"properties": {
"cron_expression": {
"type": "string",
"description": "Cron expression defining recurring schedule (e.g., '0 8 * * *').",
"description": "Cron expression defining recurring schedule (e.g., '0 8 * * *' or '0 23 * * mon-fri'). Prefer named weekdays like 'mon-fri' or 'sat,sun' instead of numeric day-of-week ranges such as '1-5' to avoid ambiguity across cron implementations.",
},
"run_at": {
"type": "string",
+121
View File
@@ -0,0 +1,121 @@
import contextlib
import functools
import importlib.metadata as importlib_metadata
import logging
import os
from collections.abc import Iterator
from packaging.requirements import Requirement
from astrbot.core.utils.requirements_utils import (
canonicalize_distribution_name,
collect_installed_distribution_versions,
get_requirement_check_paths,
)
logger = logging.getLogger("astrbot")
def _resolve_core_dist_name(core_dist_name: str | None) -> str | None:
if core_dist_name:
try:
importlib_metadata.distribution(core_dist_name)
return core_dist_name
except importlib_metadata.PackageNotFoundError:
return None
try:
importlib_metadata.distribution("AstrBot")
return "AstrBot"
except importlib_metadata.PackageNotFoundError:
pass
if not __package__:
return None
top_pkg = __package__.split(".")[0]
for dist in importlib_metadata.distributions():
try:
top_level = dist.read_text("top_level.txt") or ""
except Exception:
continue
if top_pkg in top_level.splitlines():
if "Name" in dist.metadata:
return dist.metadata["Name"]
return None
@functools.cache
def _get_core_constraints(core_dist_name: str | None) -> tuple[str, ...]:
try:
resolved_core_dist_name = _resolve_core_dist_name(core_dist_name)
except Exception as exc:
logger.warning("解析核心分发名称失败: %s", exc)
return ()
if not resolved_core_dist_name:
return ()
try:
dist = importlib_metadata.distribution(resolved_core_dist_name)
except importlib_metadata.PackageNotFoundError:
return ()
except Exception as exc:
logger.warning("读取核心分发元数据失败 (%s): %s", resolved_core_dist_name, exc)
return ()
if not dist or not dist.requires:
return ()
installed = collect_installed_distribution_versions(get_requirement_check_paths())
if not installed:
return ()
constraints: list[str] = []
for req_str in dist.requires:
try:
req = Requirement(req_str)
if req.marker and not req.marker.evaluate():
continue
name = canonicalize_distribution_name(req.name)
if name in installed:
constraints.append(f"{name}=={installed[name]}")
except Exception:
continue
return tuple(constraints)
class CoreConstraintsProvider:
def __init__(self, core_dist_name: str | None) -> None:
self._core_dist_name = core_dist_name
@contextlib.contextmanager
def constraints_file(self) -> Iterator[str | None]:
constraints = _get_core_constraints(self._core_dist_name)
if not constraints:
yield None
return
path: str | None = None
try:
import tempfile
with tempfile.NamedTemporaryFile(
mode="w", suffix="_constraints.txt", delete=False, encoding="utf-8"
) as f:
f.write("\n".join(constraints))
path = f.name
logger.info("已启用核心依赖版本保护 (%d 个约束)", len(constraints))
except Exception as exc:
logger.warning("创建临时约束文件失败: %s", exc)
yield None
return
try:
yield path
finally:
if path and os.path.exists(path):
with contextlib.suppress(Exception):
os.remove(path)
+428 -96
View File
@@ -7,21 +7,71 @@ import io
import logging
import os
import re
import shlex
import sys
import threading
from collections import deque
from dataclasses import dataclass
from urllib.parse import urlparse
from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path
from astrbot.core.utils.core_constraints import CoreConstraintsProvider
from astrbot.core.utils.requirements_utils import (
canonicalize_distribution_name as _canonicalize_distribution_name,
)
from astrbot.core.utils.requirements_utils import (
extract_requirement_name,
extract_requirement_names,
parse_package_install_input,
)
from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime
logger = logging.getLogger("astrbot")
_DISTLIB_FINDER_PATCH_ATTEMPTED = False
_SITE_PACKAGES_IMPORT_LOCK = threading.RLock()
_PIP_FAILURE_PATTERNS = {
"error_prefix": re.compile(r"^\s*error:", re.IGNORECASE),
"user_requested": re.compile(r"\bthe user requested\b", re.IGNORECASE),
"resolution_impossible": re.compile(r"\bresolutionimpossible\b", re.IGNORECASE),
"cannot_install": re.compile(r"\bcannot install\b", re.IGNORECASE),
"conflict": re.compile(r"\bconflict(?:ing|s)?\b", re.IGNORECASE),
"constraint": re.compile(r"\(constraint\)", re.IGNORECASE),
"dependency_detail": re.compile(r"\bdepends on\b", re.IGNORECASE),
}
_SENSITIVE_PIP_VALUE_KEYS = frozenset(
{"password", "passwd", "pass", "api_token", "token", "auth_token"}
)
_MAX_PIP_OUTPUT_LINES = 200
def _canonicalize_distribution_name(name: str) -> str:
return re.sub(r"[-_.]+", "-", name).strip("-").lower()
class DependencyConflictError(Exception):
"""Raised when pip encounters a dependency conflict."""
def __init__(
self, message: str, errors: list[str], *, is_core_conflict: bool
) -> None:
super().__init__(message)
self.errors = errors
self.is_core_conflict = is_core_conflict
class PipInstallError(Exception):
"""Raised when pip install fails without a classified dependency conflict."""
def __init__(self, message: str, *, code: int) -> None:
super().__init__(message)
self.code = code
@dataclass
class PipConflictContext:
relevant_lines: list[str]
requested_lines: list[str]
dependency_detail_lines: list[str]
constraint_lines: list[str]
has_strong_conflict_signal: bool
has_contextual_conflict_signal: bool
def _get_pip_main():
@@ -41,11 +91,12 @@ def _get_pip_main():
return pip_main
def _run_pip_main_with_output(pip_main, args: list[str]) -> tuple[int, str]:
stream = io.StringIO()
with contextlib.redirect_stdout(stream), contextlib.redirect_stderr(stream):
result_code = pip_main(args)
return result_code, stream.getvalue()
def _prepend_sys_path(path: str) -> None:
normalized_target = os.path.realpath(path)
sys.path[:] = [
item for item in sys.path if os.path.realpath(item) != normalized_target
]
sys.path.insert(0, normalized_target)
def _cleanup_added_root_handlers(original_handlers: list[logging.Handler]) -> None:
@@ -59,76 +110,258 @@ def _cleanup_added_root_handlers(original_handlers: list[logging.Handler]) -> No
handler.close()
def _prepend_sys_path(path: str) -> None:
normalized_target = os.path.realpath(path)
sys.path[:] = [
item for item in sys.path if os.path.realpath(item) != normalized_target
]
sys.path.insert(0, normalized_target)
def _get_trusted_host_for_index_url(index_url: str) -> str | None:
parsed = urlparse(index_url if "://" in index_url else f"//{index_url}")
host = parsed.hostname
if host == "mirrors.aliyun.com":
return host
return None
def _module_exists_in_site_packages(module_name: str, site_packages_path: str) -> bool:
base_path = os.path.join(site_packages_path, *module_name.split("."))
package_init = os.path.join(base_path, "__init__.py")
module_file = f"{base_path}.py"
return os.path.isfile(package_init) or os.path.isfile(module_file)
def _normalize_sensitive_pip_key(raw_key: str) -> str:
return raw_key.lstrip("-").replace("-", "_").lower()
def _is_module_loaded_from_site_packages(
module_name: str,
site_packages_path: str,
) -> bool:
module = sys.modules.get(module_name)
if module is None:
try:
module = importlib.import_module(module_name)
except Exception:
return False
def _is_sensitive_pip_value_key(raw_key: str) -> bool:
return _normalize_sensitive_pip_key(raw_key) in _SENSITIVE_PIP_VALUE_KEYS
module_file = getattr(module, "__file__", None)
if not module_file:
return False
module_path = os.path.realpath(module_file)
site_packages_real = os.path.realpath(site_packages_path)
try:
return (
os.path.commonpath([module_path, site_packages_real]) == site_packages_real
def _redact_url_credentials(raw_value: str) -> str:
"""Redact URL credentials and known inline secret values for safe logging."""
parsed = urlparse(raw_value)
if parsed.netloc and "@" in parsed.netloc:
hostname = parsed.hostname or ""
port = f":{parsed.port}" if parsed.port else ""
return parsed._replace(netloc=f"<redacted>@{hostname}{port}").geturl()
if raw_value.startswith("--"):
option, separator, _ = raw_value.partition("=")
if separator and _is_sensitive_pip_value_key(option):
return f"{option}=****"
return raw_value
key, separator, _ = raw_value.partition("=")
if separator and _is_sensitive_pip_value_key(key):
return f"{key}=****"
return raw_value
def _redact_pip_args_for_logging(args: list[str]) -> list[str]:
redacted_args: list[str] = []
redact_next_value = False
for arg in args:
if redact_next_value:
redacted_args.append("****")
redact_next_value = False
continue
if arg.startswith("--") and "=" in arg:
option, value = arg.split("=", 1)
if _is_sensitive_pip_value_key(option):
redacted_args.append(f"{option}=****")
else:
redacted_args.append(f"{option}={_redact_url_credentials(value)}")
continue
if arg.startswith("-i") and arg != "-i":
redacted_args.append(f"-i{_redact_url_credentials(arg[2:])}")
continue
if _is_sensitive_pip_value_key(arg):
redacted_args.append(arg)
redact_next_value = True
continue
redacted_args.append(_redact_url_credentials(arg))
return redacted_args
def _package_specs_override_index(package_specs: list[str]) -> bool:
for index, spec in enumerate(package_specs):
if spec == "--no-index":
return True
if spec in {"-i", "--index-url"}:
if index + 1 < len(package_specs):
return True
continue
if spec.startswith("--index-url="):
return True
if spec.startswith("-i") and spec != "-i":
return True
return False
class _StreamingLogWriter(io.TextIOBase):
def __init__(self, log_func, *, max_lines: int | None = None) -> None:
self._log_func = log_func
self._lines = deque(maxlen=max_lines or _MAX_PIP_OUTPUT_LINES)
self._buffer = ""
def write(self, text: str) -> int:
if not text:
return 0
self._buffer += text.replace("\r\n", "\n").replace("\r", "\n")
while "\n" in self._buffer:
raw_line, self._buffer = self._buffer.split("\n", 1)
line = raw_line.rstrip("\r\n")
self._log_func(line)
self._lines.append(line)
return len(text)
def flush(self) -> None:
line = self._buffer.rstrip("\r\n")
if line:
self._log_func(line)
self._lines.append(line)
self._buffer = ""
@property
def lines(self) -> list[str]:
return list(self._lines)
def _run_pip_main_streaming(pip_main, args: list[str]) -> tuple[int, list[str]]:
stream = _StreamingLogWriter(logger.info, max_lines=_MAX_PIP_OUTPUT_LINES)
with (
contextlib.redirect_stdout(stream),
contextlib.redirect_stderr(stream),
):
result_code = pip_main(args)
stream.flush()
return result_code, stream.lines
def _matches_pip_failure_pattern(line: str, *pattern_names: str) -> bool:
names = pattern_names or tuple(_PIP_FAILURE_PATTERNS)
return any(_PIP_FAILURE_PATTERNS[name].search(line) for name in names)
def _normalize_conflict_detail_line(line: str) -> str:
stripped = line.strip()
if _matches_pip_failure_pattern(stripped, "user_requested"):
return re.sub(
r"^\s*The user requested\s+",
"",
stripped,
flags=re.IGNORECASE,
)
except ValueError:
return False
return stripped
def _extract_requirement_name(raw_requirement: str) -> str | None:
line = raw_requirement.split("#", 1)[0].strip()
if not line:
return None
if line.startswith(("-r", "--requirement", "-c", "--constraint")):
return None
if line.startswith("-"):
def _build_pip_conflict_context(output_lines: list[str]) -> PipConflictContext | None:
matched_indices = [
index
for index, line in enumerate(output_lines)
if _matches_pip_failure_pattern(line)
]
if matched_indices:
relevant_index_set: set[int] = set()
for index in matched_indices:
start = max(0, index - 1)
end = min(len(output_lines), index + 2)
relevant_index_set.update(range(start, end))
relevant_output_lines = [
line
for index, line in enumerate(output_lines)
if index in relevant_index_set
]
else:
relevant_output_lines = output_lines[-5:]
if not relevant_output_lines:
return None
egg_match = re.search(r"#egg=([A-Za-z0-9_.-]+)", raw_requirement)
if egg_match:
return _canonicalize_distribution_name(egg_match.group(1))
dependency_detail_lines = [
line.strip()
for line in relevant_output_lines
if _matches_pip_failure_pattern(line, "dependency_detail")
]
requested_lines = [
line.strip()
for line in relevant_output_lines
if _matches_pip_failure_pattern(line, "user_requested")
and not _matches_pip_failure_pattern(line, "constraint")
]
if not requested_lines:
requested_lines = [
line
for line in dependency_detail_lines
if not _matches_pip_failure_pattern(line, "constraint")
]
constraint_lines = [
line.strip()
for line in relevant_output_lines
if _matches_pip_failure_pattern(line, "constraint")
]
candidate = re.split(r"[<>=!~;\s\[]", line, maxsplit=1)[0].strip()
if not candidate:
has_strong_conflict_signal = any(
_matches_pip_failure_pattern(
line,
"resolution_impossible",
"cannot_install",
)
for line in relevant_output_lines
)
has_contextual_conflict_signal = any(
_matches_pip_failure_pattern(line, "conflict") for line in relevant_output_lines
) and bool(dependency_detail_lines or requested_lines or constraint_lines)
return PipConflictContext(
relevant_lines=relevant_output_lines,
requested_lines=requested_lines,
dependency_detail_lines=dependency_detail_lines,
constraint_lines=constraint_lines,
has_strong_conflict_signal=has_strong_conflict_signal,
has_contextual_conflict_signal=has_contextual_conflict_signal,
)
def _classify_pip_failure(output_lines: list[str]) -> DependencyConflictError | None:
context = _build_pip_conflict_context(output_lines)
if context is None:
return None
return _canonicalize_distribution_name(candidate)
if (
not context.has_strong_conflict_signal
and not context.has_contextual_conflict_signal
and not (context.requested_lines and context.constraint_lines)
):
return None
def _extract_requirement_names(requirements_path: str) -> set[str]:
names: set[str] = set()
try:
with open(requirements_path, encoding="utf-8") as requirements_file:
for line in requirements_file:
requirement_name = _extract_requirement_name(line)
if requirement_name:
names.add(requirement_name)
except Exception as exc:
logger.warning("读取依赖文件失败,跳过冲突检测: %s", exc)
return names
is_core_conflict = bool(context.constraint_lines)
detail = ""
if context.constraint_lines and context.requested_lines:
detail = (
" 冲突详情: "
f"{_normalize_conflict_detail_line(context.requested_lines[0])} vs "
f"{_normalize_conflict_detail_line(context.constraint_lines[0])}"
)
elif len(context.dependency_detail_lines) >= 2:
detail = (
" 冲突详情: "
f"{_normalize_conflict_detail_line(context.dependency_detail_lines[0])} vs "
f"{_normalize_conflict_detail_line(context.dependency_detail_lines[1])}"
)
if is_core_conflict:
message = (
f"检测到核心依赖版本保护冲突。{detail}插件要求的依赖版本与 AstrBot 核心不兼容,"
"为了系统稳定,已阻止该降级行为。请联系插件作者或调整 requirements.txt。"
)
else:
message = f"检测到依赖冲突。{detail}"
return DependencyConflictError(
message,
context.relevant_lines,
is_core_conflict=is_core_conflict,
)
def _extract_top_level_modules(
@@ -155,7 +388,11 @@ def _collect_candidate_modules(
by_name: dict[str, list[importlib_metadata.Distribution]] = {}
try:
for distribution in importlib_metadata.distributions(path=[site_packages_path]):
distribution_name = distribution.metadata.get("Name")
distribution_name = (
distribution.metadata["Name"]
if "Name" in distribution.metadata
else None
)
if not distribution_name:
continue
canonical_name = _canonicalize_distribution_name(distribution_name)
@@ -173,7 +410,7 @@ def _collect_candidate_modules(
for distribution in by_name.get(requirement_name, []):
for dependency_line in distribution.requires or []:
dependency_name = _extract_requirement_name(dependency_line)
dependency_name = extract_requirement_name(dependency_line)
if not dependency_name:
continue
if dependency_name in expanded_requirement_names:
@@ -230,6 +467,38 @@ def _ensure_preferred_modules(
raise RuntimeError(conflict_message)
def _module_exists_in_site_packages(module_name: str, site_packages_path: str) -> bool:
base_path = os.path.join(site_packages_path, *module_name.split("."))
package_init = os.path.join(base_path, "__init__.py")
module_file = f"{base_path}.py"
return os.path.isfile(package_init) or os.path.isfile(module_file)
def _is_module_loaded_from_site_packages(
module_name: str,
site_packages_path: str,
) -> bool:
module = sys.modules.get(module_name)
if module is None:
try:
module = importlib.import_module(module_name)
except Exception:
return False
module_file = getattr(module, "__file__", None)
if not module_file:
return False
module_path = os.path.realpath(module_file)
site_packages_real = os.path.realpath(site_packages_path)
try:
return (
os.path.commonpath([module_path, site_packages_real]) == site_packages_real
)
except ValueError:
return False
def _prefer_module_from_site_packages(
module_name: str, site_packages_path: str
) -> bool:
@@ -531,9 +800,63 @@ def _patch_distlib_finder_for_frozen_runtime() -> None:
class PipInstaller:
def __init__(self, pip_install_arg: str, pypi_index_url: str | None = None) -> None:
def __init__(
self,
pip_install_arg: str,
pypi_index_url: str | None = None,
core_dist_name: str | None = "AstrBot",
) -> None:
self.pip_install_arg = pip_install_arg
self.pypi_index_url = pypi_index_url
self.core_dist_name = core_dist_name
self._core_constraints = CoreConstraintsProvider(core_dist_name)
def _build_pip_args(
self,
package_name: str | None,
requirements_path: str | None,
mirror: str | None,
) -> tuple[list[str], set[str]]:
args: list[str] = []
requested_requirements: set[str] = set()
normalized_requirements_path = (
requirements_path.strip() if requirements_path else ""
)
if package_name and normalized_requirements_path:
raise ValueError(
"package_name and requirements_path cannot be used together"
)
if package_name:
parsed_package = parse_package_install_input(package_name)
if parsed_package.specs:
args = ["install", *parsed_package.specs]
requested_requirements = set(parsed_package.requirement_names)
elif normalized_requirements_path:
args = ["install", "-r", normalized_requirements_path]
requested_requirements = extract_requirement_names(
normalized_requirements_path
)
if not args:
return [], requested_requirements
pip_install_args = (
shlex.split(self.pip_install_arg) if self.pip_install_arg else []
)
if not _package_specs_override_index([*args[1:], *pip_install_args]):
index_url = mirror or self.pypi_index_url or "https://pypi.org/simple"
trusted_host = _get_trusted_host_for_index_url(index_url)
if trusted_host:
args.extend(["--trusted-host", trusted_host])
args.extend(["-i", index_url])
if pip_install_args:
args.extend(pip_install_args)
return args, requested_requirements
async def install(
self,
@@ -541,36 +864,37 @@ class PipInstaller:
requirements_path: str | None = None,
mirror: str | None = None,
) -> None:
args = ["install"]
requested_requirements: set[str] = set()
if package_name:
args.append(package_name)
requirement_name = _extract_requirement_name(package_name)
if requirement_name:
requested_requirements.add(requirement_name)
elif requirements_path:
args.extend(["-r", requirements_path])
requested_requirements = _extract_requirement_names(requirements_path)
index_url = mirror or self.pypi_index_url or "https://pypi.org/simple"
args.extend(["--trusted-host", "mirrors.aliyun.com", "-i", index_url])
args, requested_requirements = self._build_pip_args(
package_name, requirements_path, mirror
)
if not args:
logger.info("Pip 包管理器跳过安装:未提供有效的包名或 requirements 文件。")
return
target_site_packages = None
if is_packaged_desktop_runtime():
target_site_packages = get_astrbot_site_packages_path()
os.makedirs(target_site_packages, exist_ok=True)
_prepend_sys_path(target_site_packages)
args.extend(["--target", target_site_packages])
args.extend(["--upgrade", "--force-reinstall"])
args.extend(
[
"--target",
target_site_packages,
"--upgrade",
"--upgrade-strategy",
"only-if-needed",
]
)
if self.pip_install_arg:
args.extend(self.pip_install_arg.split())
with self._core_constraints.constraints_file() as constraints_file_path:
if constraints_file_path:
args.extend(["-c", constraints_file_path])
logger.info(f"Pip 包管理器: pip {' '.join(args)}")
result_code = await self._run_pip_in_process(args)
if result_code != 0:
raise Exception(f"安装失败,错误码:{result_code}")
logger.info(
"Pip 包管理器 argv: %s",
["pip", *_redact_pip_args_for_logging(args)],
)
await self._run_pip_with_classification(args)
if target_site_packages:
_prepend_sys_path(target_site_packages)
@@ -589,7 +913,7 @@ class PipInstaller:
if not os.path.isdir(target_site_packages):
return
requested_requirements = _extract_requirement_names(requirements_path)
requested_requirements = extract_requirement_names(requirements_path)
if not requested_requirements:
return
@@ -605,13 +929,21 @@ class PipInstaller:
_patch_distlib_finder_for_frozen_runtime()
original_handlers = list(logging.getLogger().handlers)
result_code, output = await asyncio.to_thread(
_run_pip_main_with_output, pip_main, args
)
for line in output.splitlines():
line = line.strip()
if line:
logger.info(line)
try:
result_code, output_lines = await asyncio.to_thread(
_run_pip_main_streaming, pip_main, args
)
finally:
_cleanup_added_root_handlers(original_handlers)
if result_code != 0:
conflict = _classify_pip_failure(output_lines)
if conflict:
raise conflict
_cleanup_added_root_handlers(original_handlers)
return result_code
async def _run_pip_with_classification(self, args: list[str]) -> None:
result_code = await self._run_pip_in_process(args)
if result_code != 0:
raise PipInstallError(f"安装失败,错误码:{result_code}", code=result_code)
+486
View File
@@ -0,0 +1,486 @@
import importlib.metadata as importlib_metadata
import logging
import os
import re
import shlex
import sys
from collections.abc import Iterable, Iterator, Sequence
from dataclasses import dataclass
from packaging.requirements import InvalidRequirement, Requirement
from packaging.specifiers import SpecifierSet
from packaging.version import InvalidVersion, Version
from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path
from astrbot.core.utils.runtime_env import is_packaged_desktop_runtime
logger = logging.getLogger("astrbot")
class RequirementsPrecheckFailed(Exception):
"""Raised when the pre-check of requirements fails."""
pass
@dataclass(frozen=True)
class ParsedPackageInput:
specs: tuple[str, ...]
requirement_names: frozenset[str]
@dataclass(frozen=True)
class MissingRequirementsPlan:
missing_names: frozenset[str]
install_lines: tuple[str, ...]
fallback_reason: str | None = None
def canonicalize_distribution_name(name: str) -> str:
return re.sub(r"[-_.]+", "-", name).strip("-").lower()
def strip_inline_requirement_comment(raw_input: str) -> str:
if raw_input.lstrip().startswith("#"):
return ""
return re.split(r"[ \t]+#", raw_input, maxsplit=1)[0].strip()
def _specifier_contains_version(specifier: SpecifierSet, version: str) -> bool:
try:
parsed_version = Version(version)
except InvalidVersion:
return False
return specifier.contains(parsed_version, prereleases=True)
def _looks_like_local_path_reference(token: str) -> bool:
candidate = token.strip()
if not candidate:
return False
return candidate in {".", ".."} or candidate.startswith(
("./", "../", "/", "~/", ".\\", "..\\", "\\")
)
def looks_like_direct_reference(token: str) -> bool:
candidate = token.strip()
if not candidate:
return False
return (
_looks_like_local_path_reference(candidate)
or candidate.startswith("git+")
or "://" in candidate
)
def extract_requirement_name(raw_requirement: str) -> str | None:
line = raw_requirement.split("#", 1)[0].strip()
if not line:
return None
if line.startswith(("-r", "--requirement", "-c", "--constraint")):
return None
egg_match = re.search(r"#egg=([A-Za-z0-9_.-]+)", raw_requirement)
if egg_match:
return canonicalize_distribution_name(egg_match.group(1))
if line.startswith("-"):
return None
candidate = re.split(r"[<>=!~;\s\[]", line, maxsplit=1)[0].strip()
if not candidate:
return None
return canonicalize_distribution_name(candidate)
def _parse_editable_or_direct_name(target: str) -> str | None:
name = extract_requirement_name(target)
if not name:
return None
if "#egg=" in target or not looks_like_direct_reference(target):
return name
return None
def _parse_requirement_name_and_spec(
line: str,
) -> tuple[str | None, SpecifierSet | None]:
if line.startswith(("-c", "--constraint")):
return None, None
try:
req = Requirement(line)
except InvalidRequirement:
tokens = shlex.split(line)
if not tokens:
return None, None
editable_target: str | None = None
if tokens[0] in {"-e", "--editable"} and len(tokens) > 1:
editable_target = tokens[1]
elif tokens[0].startswith("--editable="):
editable_target = tokens[0].split("=", 1)[1]
if editable_target:
name = _parse_editable_or_direct_name(editable_target)
return (name, None) if name else (None, None)
name = _parse_editable_or_direct_name(line)
return (name, None) if name else (None, None)
if req.marker and not req.marker.evaluate():
return None, None
return canonicalize_distribution_name(req.name), (req.specifier or None)
def _parse_requirement_line(
line: str,
) -> tuple[str, SpecifierSet | None] | None:
name, specifier = _parse_requirement_name_and_spec(line)
return (name, specifier) if name else None
def _extract_requirement_names_from_package_tokens(tokens: list[str]) -> frozenset[str]:
requirement_names: set[str] = set()
skip_next_for: str | None = None
for token in tokens:
if skip_next_for:
if skip_next_for == "editable":
name = _parse_editable_or_direct_name(token)
if name:
requirement_names.add(name)
skip_next_for = None
continue
if token in {"-e", "--editable"}:
skip_next_for = "editable"
continue
if token in {
"-i",
"--index-url",
"--extra-index-url",
"-f",
"--find-links",
"--trusted-host",
"-r",
"--requirement",
"-c",
"--constraint",
}:
skip_next_for = "option-value"
continue
if token.startswith(("--editable=",)):
editable_target = token.split("=", 1)[1]
name = _parse_editable_or_direct_name(editable_target)
if name:
requirement_names.add(name)
continue
if token.startswith(
(
"--index-url=",
"--extra-index-url=",
"--find-links=",
"--trusted-host=",
"--requirement=",
"--constraint=",
)
):
continue
if (
(token.startswith("-i") and token != "-i")
or (token.startswith("-f") and token != "-f")
or token == "--no-index"
):
continue
if token.startswith("-"):
continue
name, _ = _parse_requirement_name_and_spec(token)
if name:
requirement_names.add(name)
return frozenset(requirement_names)
def parse_package_install_input(raw_input: str) -> ParsedPackageInput:
specs: list[str] = []
requirement_names: set[str] = set()
normalized = raw_input.strip()
if not normalized:
return ParsedPackageInput(specs=(), requirement_names=frozenset())
for raw_line in normalized.splitlines():
line = strip_inline_requirement_comment(raw_line)
if not line:
continue
try:
Requirement(line)
except InvalidRequirement:
tokens = shlex.split(line)
if not tokens:
continue
specs.extend(tokens)
requirement_names.update(
_extract_requirement_names_from_package_tokens(tokens)
)
continue
specs.append(line)
name, _ = _parse_requirement_name_and_spec(line)
if name:
requirement_names.add(name)
return ParsedPackageInput(
specs=tuple(specs),
requirement_names=frozenset(requirement_names),
)
def _iter_requirement_lines(
requirements_path: str,
_visited: set[str] | None = None,
) -> Iterator[str]:
visited = _visited or set()
resolved_path = os.path.realpath(requirements_path)
if resolved_path in visited:
logger.warning(
"检测到循环依赖的 requirements 包含: %s,将跳过该文件", resolved_path
)
return
visited.add(resolved_path)
with open(resolved_path, encoding="utf-8") as f:
for raw_line in f:
line = strip_inline_requirement_comment(raw_line)
if not line:
continue
tokens = shlex.split(line)
if not tokens:
continue
nested: str | None = None
if tokens[0] in {"-r", "--requirement"} and len(tokens) > 1:
nested = tokens[1]
elif tokens[0].startswith("--requirement="):
nested = tokens[0].split("=", 1)[1]
if nested:
if not os.path.isabs(nested):
nested = os.path.join(os.path.dirname(resolved_path), nested)
yield from _iter_requirement_lines(nested, _visited=visited)
continue
yield line
def iter_requirements(
requirements_path: str | None = None,
lines: Iterable[str] | None = None,
) -> Iterator[tuple[str, SpecifierSet | None]]:
if lines is None:
if requirements_path is None:
raise ValueError("Either requirements_path or lines must be provided")
lines = _iter_requirement_lines(requirements_path)
for line in lines:
parsed = _parse_requirement_line(line)
if parsed is not None:
yield parsed
def extract_requirement_names(requirements_path: str) -> set[str]:
try:
return {
name for name, _ in iter_requirements(requirements_path=requirements_path)
}
except Exception as exc:
logger.warning("读取依赖文件失败,跳过冲突检测: %s", exc)
return set()
def get_requirement_check_paths() -> list[str]:
paths = list(sys.path)
if is_packaged_desktop_runtime():
target_site_packages = get_astrbot_site_packages_path()
if os.path.isdir(target_site_packages):
paths.insert(0, target_site_packages)
return paths
def _canonical_distribution_identity(distribution) -> tuple[str | None, str | None]:
distribution_name = (
distribution.metadata["Name"] if "Name" in distribution.metadata else None
)
if not distribution_name:
return None, None
return canonicalize_distribution_name(distribution_name), distribution.version
def collect_installed_distribution_versions(paths: list[str]) -> dict[str, str] | None:
installed: dict[str, str] = {}
try:
for distribution in importlib_metadata.distributions(path=paths):
distribution_name, version = _canonical_distribution_identity(distribution)
if not distribution_name or not version:
continue
installed.setdefault(distribution_name, version)
except Exception as exc:
logger.warning("读取已安装依赖失败,跳过缺失依赖预检查: %s", exc)
return None
return installed
def _load_requirement_lines_for_precheck(
requirements_path: str,
) -> tuple[bool, list[str] | None]:
try:
requirement_lines = list(_iter_requirement_lines(requirements_path))
except Exception as exc:
logger.warning(
"预检查缺失依赖失败,将回退到完整安装: %s (%s)",
requirements_path,
exc,
)
return False, None
fallback_line = next(
(
line
for line in requirement_lines
if (
(
line.startswith(("-e ", "--editable ", "--editable="))
and "#egg=" not in line
)
or (
_parse_requirement_line(line) is None
and looks_like_direct_reference(line)
)
)
),
None,
)
if fallback_line is not None:
logger.info(
"缺失依赖预检查发现无法安全裁剪的 option/direct-reference 行,将回退到完整安装: %s (%s)",
requirements_path,
fallback_line,
)
return False, None
return True, requirement_lines
def find_missing_requirements(requirements_path: str) -> set[str] | None:
can_precheck, requirement_lines = _load_requirement_lines_for_precheck(
requirements_path
)
if not can_precheck or requirement_lines is None:
return None
return find_missing_requirements_from_lines(requirement_lines)
def find_missing_requirements_from_lines(
requirement_lines: Sequence[str],
) -> set[str] | None:
required = list(iter_requirements(lines=requirement_lines))
if not required:
return set()
installed = collect_installed_distribution_versions(get_requirement_check_paths())
if installed is None:
return None
missing: set[str] = set()
for name, specifier in required:
installed_version = installed.get(name)
if not installed_version:
missing.add(name)
continue
if specifier and not _specifier_contains_version(specifier, installed_version):
missing.add(name)
return missing
def build_missing_requirements_install_lines(
requirements_path: str,
requirement_lines: Sequence[str],
missing_names: set[str] | frozenset[str],
) -> tuple[str, ...] | None:
wanted_names = set(missing_names)
install_lines: list[str] = []
for line in requirement_lines:
parsed = _parse_requirement_line(line)
if parsed is None:
if looks_like_direct_reference(line) or line.startswith(("-", "--")):
logger.debug(
"缺失依赖行筛选回退到完整安装:requirements 中包含无法安全裁剪的 option/direct-reference 行: %s (%s)",
requirements_path,
line,
)
return None
continue
name, _specifier = parsed
if name in wanted_names:
install_lines.append(line)
return tuple(install_lines)
def plan_missing_requirements_install(
requirements_path: str,
) -> MissingRequirementsPlan | None:
can_precheck, requirement_lines = _load_requirement_lines_for_precheck(
requirements_path
)
if not can_precheck or requirement_lines is None:
return None
missing = find_missing_requirements_from_lines(requirement_lines)
if missing is None:
return None
install_lines = build_missing_requirements_install_lines(
requirements_path,
requirement_lines,
missing,
)
if install_lines is None:
return None
if missing and not install_lines:
logger.warning(
"预检查缺失依赖成功,但无法映射到可安装 requirement 行,将回退到完整安装: %s -> %s",
requirements_path,
sorted(missing),
)
return MissingRequirementsPlan(
missing_names=frozenset(missing),
install_lines=(),
fallback_reason="unmapped missing requirement names",
)
return MissingRequirementsPlan(
missing_names=frozenset(missing),
install_lines=install_lines,
)
def find_missing_requirements_or_raise(requirements_path: str) -> set[str]:
missing = find_missing_requirements(requirements_path)
if missing is None:
raise RequirementsPrecheckFailed(f"预检查失败: {requirements_path}")
return missing
+11 -1
View File
@@ -977,7 +977,17 @@ class BackupRoute(Route):
if not jwt_secret:
return Response().error("服务器配置错误").__dict__
jwt.decode(token, jwt_secret, algorithms=["HS256"])
# Verify JWT token with strict security options
jwt.decode(
token,
jwt_secret,
algorithms=["HS256"],
options={
"require": ["exp"], # Require expiration claim
"verify_signature": True, # Explicitly verify signature
"verify_exp": True, # Verify expiration
},
)
except jwt.ExpiredSignatureError:
return Response().error("Token 已过期,请刷新页面后重试").__dict__
except jwt.InvalidTokenError:
+31 -1
View File
@@ -5,7 +5,8 @@ import os
import ssl
import traceback
from dataclasses import dataclass
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
import aiohttp
import certifi
@@ -352,6 +353,34 @@ class PluginRoute(Route):
logger.warning(f"获取插件 Logo 失败: {e}")
return None
def _resolve_plugin_dir(self, plugin) -> Path | None:
if not plugin.root_dir_name:
return None
base_dir = Path(
self.plugin_manager.reserved_plugin_path
if plugin.reserved
else self.plugin_manager.plugin_store_path
)
plugin_dir = base_dir / plugin.root_dir_name
if not plugin_dir.is_dir():
return None
return plugin_dir
def _get_plugin_installed_at(self, plugin) -> str | None:
plugin_dir = self._resolve_plugin_dir(plugin)
if plugin_dir is None:
return None
try:
return datetime.fromtimestamp(
plugin_dir.stat().st_mtime,
timezone.utc,
).isoformat()
except OSError as exc:
logger.warning(f"获取插件安装时间失败 {plugin.name}: {exc!s}")
return None
async def get_plugins(self):
_plugin_resp = []
plugin_name = request.args.get("name")
@@ -377,6 +406,7 @@ class PluginRoute(Route):
"logo": f"/api/file/{logo_url}" if logo_url else None,
"support_platforms": plugin.support_platforms,
"astrbot_version": plugin.astrbot_version,
"installed_at": self._get_plugin_installed_at(plugin),
}
# 检查是否为全空的幽灵插件
if not any(
+43
View File
@@ -0,0 +1,43 @@
## What's Changed
### 新增
- Lark 适配器支持 CardKit 流式输出(飞书)([#5777](https://github.com/AstrBotDevs/AstrBot/pull/5777))。
- WebUI 已安装插件列表新增筛选与排序功能 ([#5923](https://github.com/AstrBotDevs/AstrBot/pull/5923))。
### 优化
- 启动时后台加载 MCP Server,不阻塞加载流程 ([#5993](https://github.com/AstrBotDevs/AstrBot/pull/5993))。
### 修复
- 部分情况下 MCP 页报错 500 导致查看不了 MCP 服务器 ([#5993](https://github.com/AstrBotDevs/AstrBot/pull/5993))。
- 修复 TTS Provider 测试:增加文件大小校验,并补充 MiniMax 空音频检测 ([#5999](https://github.com/AstrBotDevs/AstrBot/pull/5999))。
- 修复前端切换到 Chat 后又回到 Welcome 时,页面切换配置未正确持久化的问题 ([#5792](https://github.com/AstrBotDevs/AstrBot/pull/5792))。
- 修复 Azure TTS 不支持 84 位订阅密钥的问题 ([#5813](https://github.com/AstrBotDevs/AstrBot/pull/5813))。
### 文档
- 文档仓库迁移:将 `AstrBotDevs/AstrBot-docs` 内容迁移至 `AstrBotDevs/AstrBot` ([#5960](https://github.com/AstrBotDevs/AstrBot/pull/5960))。
---
## What's Changed (EN)
### New Features
- Added CardKit streaming output support for the Lark/Feishu adapter ([#5777](https://github.com/AstrBotDevs/AstrBot/pull/5777)).
- Added filtering and sorting for installed plugins in the WebUI ([#5923](https://github.com/AstrBotDevs/AstrBot/pull/5923)).
### Impprovement
- MCP Server now loads in the background during startup without blocking the loading process ([#5993](https://github.com/AstrBotDevs/AstrBot/pull/5993)).
### Bug Fixes
- Added file size validation in TTS provider tests and MiniMax empty-audio detection ([#5999](https://github.com/AstrBotDevs/AstrBot/pull/5999)).
- Fixed frontend state persistence when switching from Chat back to Welcome ([#5792](https://github.com/AstrBotDevs/AstrBot/pull/5792)).
- Fixed Azure TTS support for 84-character subscription keys ([#5813](https://github.com/AstrBotDevs/AstrBot/pull/5813)).
- Reverted the MCP stdio missing-command error wording change after the previous fix ([#5992](https://github.com/AstrBotDevs/AstrBot/pull/5992)).
### Documentation
- Migrated documentation content from `AstrBotDevs/AstrBot-docs` into `AstrBotDevs/AstrBot` ([#5960](https://github.com/AstrBotDevs/AstrBot/pull/5960)).
+64
View File
@@ -0,0 +1,64 @@
## What's Changed
### 新增
- 新增俄语翻译([#6081](https://github.com/AstrBotDevs/AstrBot/pull/6081))。
- QQ 官方 Bot 新增文件、语音、视频消息支持(含 WebSocket 模式)([#6063](https://github.com/AstrBotDevs/AstrBot/pull/6063))。
### 优化
- 优化 QQ 官方 Bot 的流式消息投递可靠性与主动媒体发送能力([#6131](https://github.com/AstrBotDevs/AstrBot/pull/6131))。
- 优化边界场景下 booter 选择逻辑与消息发送工具([#6064](https://github.com/AstrBotDevs/AstrBot/pull/6064))。
### 修复
- 修复 Dashboard README 对话框锚点导航失效([#6083](https://github.com/AstrBotDevs/AstrBot/pull/6083))。
- 优先使用具名 weekday 的 cron 示例,避免歧义([#6091](https://github.com/AstrBotDevs/AstrBot/pull/6091))。
- 修复插件市场安装后状态未及时刷新的问题([#6124](https://github.com/AstrBotDevs/AstrBot/pull/6124))。
- 修复插件依赖安装逻辑:仅安装缺失依赖([#6088](https://github.com/AstrBotDevs/AstrBot/pull/6088))。
- 移除 Telegram 适配器中已废弃的 `normalize_whitespace` 参数([#6044](https://github.com/AstrBotDevs/AstrBot/pull/6044))。
- 修复 Windows 本地 skill 文件读取问题([#6028](https://github.com/AstrBotDevs/AstrBot/pull/6028))。
- 修复 Discord pre-ack emoji 配置重启后不持久化的问题([#6031](https://github.com/AstrBotDevs/AstrBot/pull/6031))。
- 统一 WebUI 搜索框清空行为([#6017](https://github.com/AstrBotDevs/AstrBot/pull/6017))。
- 优化插件依赖自动安装流程与 Dashboard 安装体验([#5954](https://github.com/AstrBotDevs/AstrBot/pull/5954))。
### 文档
- 新增 Astrbook 和玖帕喵社区链接([#6135](https://github.com/AstrBotDevs/AstrBot/pull/6135))。
- 修正文档 `docker.md``napcat.md` 中的拼写错误([#6048](https://github.com/AstrBotDevs/AstrBot/pull/6048))。
- 在多语言 README 中补充官方开发群号,并改进配置元数据中的正则说明。
- 更新编辑链接模式并移除过时仓库引用。
---
## What's Changed (EN)
### New Features
- Added Russian translation support ([#6081](https://github.com/AstrBotDevs/AstrBot/pull/6081)).
- Added file, voice, and video message support for QQ Official Bot (including WebSocket mode) ([#6063](https://github.com/AstrBotDevs/AstrBot/pull/6063)).
### Improvements
- Improved streaming message delivery reliability and proactive media sending for QQ Official API ([#6131](https://github.com/AstrBotDevs/AstrBot/pull/6131)).
- Optimized booter selection logic in edge cases and message sending tooling ([#6064](https://github.com/AstrBotDevs/AstrBot/pull/6064)).
### Bug Fixes
- Fixed broken README dialog anchor navigation in the Dashboard ([#6083](https://github.com/AstrBotDevs/AstrBot/pull/6083)).
- Preferred named weekday cron examples to reduce ambiguity ([#6091](https://github.com/AstrBotDevs/AstrBot/pull/6091)).
- Fixed plugin market install-state refresh after installation ([#6124](https://github.com/AstrBotDevs/AstrBot/pull/6124)).
- Fixed plugin dependency installation logic to install only missing packages ([#6088](https://github.com/AstrBotDevs/AstrBot/pull/6088)).
- Removed deprecated `normalize_whitespace` parameter in the Telegram adapter ([#6044](https://github.com/AstrBotDevs/AstrBot/pull/6044)).
- Fixed local skill file reading issues on Windows ([#6028](https://github.com/AstrBotDevs/AstrBot/pull/6028)).
- Fixed Discord pre-ack emoji config not being persisted across restarts ([#6031](https://github.com/AstrBotDevs/AstrBot/pull/6031)).
- Unified WebUI search input clear behavior ([#6017](https://github.com/AstrBotDevs/AstrBot/pull/6017)).
- Improved plugin dependency auto-install flow and Dashboard installation experience ([#5954](https://github.com/AstrBotDevs/AstrBot/pull/5954)).
### Documentation
- Added Astrbook and Jiupa Miao community links ([#6135](https://github.com/AstrBotDevs/AstrBot/pull/6135)).
- Fixed typos in `docker.md` and `napcat.md` ([#6048](https://github.com/AstrBotDevs/AstrBot/pull/6048)).
- Added official developer group IDs to multilingual READMEs and improved regex description in config metadata.
- Updated edit-link patterns and removed obsolete repository references.
+13 -7
View File
@@ -17,17 +17,17 @@
"@tiptap/starter-kit": "2.1.7",
"@tiptap/vue-3": "2.1.7",
"apexcharts": "3.42.0",
"axios": ">=1.6.2 <1.10.0 || >1.10.0 <2.0.0",
"axios": "1.13.5",
"axios-mock-adapter": "^1.22.0",
"chance": "1.1.11",
"date-fns": "2.30.0",
"dompurify": "^3.3.1",
"dompurify": "^3.3.2",
"event-source-polyfill": "^1.0.31",
"highlight.js": "^11.11.1",
"js-md5": "^0.8.3",
"katex": "^0.16.27",
"lodash": "4.17.21",
"markdown-it": "^14.1.0",
"lodash": "4.17.23",
"markdown-it": "^14.1.1",
"markstream-vue": "^0.0.6",
"mermaid": "^11.12.2",
"monaco-editor": "^0.52.2",
@@ -38,7 +38,7 @@
"stream-markdown": "^0.0.13",
"stream-monaco": "^0.0.17",
"vee-validate": "4.11.3",
"vite-plugin-vuetify": "1.0.2",
"vite-plugin-vuetify": "2.1.3",
"vue": "3.3.4",
"vue-i18n": "^11.1.5",
"vue-router": "4.2.4",
@@ -54,7 +54,7 @@
"@types/dompurify": "^3.0.5",
"@types/markdown-it": "^14.1.2",
"@types/node": "^20.5.7",
"@vitejs/plugin-vue": "4.3.3",
"@vitejs/plugin-vue": "5.2.4",
"@vue/eslint-config-prettier": "8.0.0",
"@vue/eslint-config-typescript": "11.0.3",
"@vue/tsconfig": "^0.4.0",
@@ -64,9 +64,15 @@
"sass": "1.66.1",
"sass-loader": "13.3.2",
"typescript": "5.1.6",
"vite": "4.4.9",
"vite": "6.4.1",
"vue-cli-plugin-vuetify": "2.5.8",
"vue-tsc": "1.8.8",
"vuetify-loader": "^2.0.0-alpha.9"
},
"pnpm": {
"overrides": {
"immutable": "4.3.8",
"lodash-es": "4.17.23"
}
}
}
+597 -267
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,97 @@
<script setup>
const props = defineProps({
modelValue: {
type: String,
required: true,
},
items: {
type: Array,
required: true,
},
label: {
type: String,
required: true,
},
order: {
type: String,
default: "desc",
},
ascendingLabel: {
type: String,
default: "Ascending",
},
descendingLabel: {
type: String,
default: "Descending",
},
showOrder: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:modelValue", "update:order"]);
const updateSortBy = (value) => {
emit("update:modelValue", value);
};
const toggleOrder = () => {
emit("update:order", props.order === "desc" ? "asc" : "desc");
};
</script>
<template>
<div class="plugin-sort-control">
<v-select
:model-value="modelValue"
:items="items"
density="compact"
variant="outlined"
hide-details
:label="label"
class="plugin-sort-control__select"
@update:model-value="updateSortBy"
>
<template #prepend-inner>
<v-icon size="small">mdi-sort</v-icon>
</template>
</v-select>
<v-btn
v-if="showOrder"
icon
variant="text"
density="compact"
@click="toggleOrder"
>
<v-icon>{{
order === "desc" ? "mdi-arrow-down-thin" : "mdi-arrow-up-thin"
}}</v-icon>
<v-tooltip activator="parent" location="top">
{{ order === "desc" ? descendingLabel : ascendingLabel }}
</v-tooltip>
</v-btn>
</div>
</template>
<style scoped>
.plugin-sort-control {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.plugin-sort-control__select {
min-width: 180px;
max-width: 220px;
}
.plugin-sort-control__select :deep(.v-field__input),
.plugin-sort-control__select :deep(.v-field-label),
.plugin-sort-control__select :deep(.v-select__selection-text),
.plugin-sort-control__select :deep(.v-field__prepend-inner) {
font-size: 0.875rem;
}
</style>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import { normalizeTextInput } from '@/utils/inputValue';
const { tm } = useModuleI18n('features/command');
@@ -52,6 +53,7 @@ const statusItems = [
{ title: tm('filters.disabled'), value: 'disabled' },
{ title: tm('filters.conflict'), value: 'conflict' }
];
</script>
<template>
@@ -108,10 +110,11 @@ const statusItems = [
<div style="min-width: 200px; max-width: 350px; flex: 1; border: 1px solid #B9B9B9; border-radius: 16px;">
<v-text-field
:model-value="searchQuery"
@update:model-value="emit('update:searchQuery', $event)"
@update:model-value="emit('update:searchQuery', normalizeTextInput($event))"
density="compact"
:label="tm('search.placeholder')"
prepend-inner-icon="mdi-magnify"
clearable
variant="solo-filled"
flat
hide-details
@@ -3,6 +3,7 @@
*/
import { ref, computed, type Ref } from 'vue';
import type { CommandItem, FilterState } from '../types';
import { normalizeTextInput } from '@/utils/inputValue';
export function useCommandFilters(commands: Ref<CommandItem[]>) {
// 过滤状态
@@ -95,7 +96,7 @@ export function useCommandFilters(commands: Ref<CommandItem[]>) {
*
*/
const filteredCommands = computed(() => {
const query = searchQuery.value.toLowerCase();
const query = normalizeTextInput(searchQuery.value).toLowerCase();
const conflictCmds: CommandItem[] = [];
const normalCmds: CommandItem[] = [];
@@ -184,4 +185,3 @@ export function useCommandFilters(commands: Ref<CommandItem[]>) {
isGroupExpanded
};
}
@@ -15,6 +15,7 @@
import { computed, onActivated, onMounted, ref, watch} from 'vue';
import axios from 'axios';
import { useModuleI18n } from '@/i18n/composables';
import { normalizeTextInput } from '@/utils/inputValue';
// Composables
import { useComponentData } from './composables/useComponentData';
@@ -83,7 +84,7 @@ const {
} = useCommandActions(toast, () => fetchCommands(tm('messages.loadFailed')));
const filteredTools = computed(() => {
const query = toolSearch.value.trim().toLowerCase();
const query = normalizeTextInput(toolSearch.value).trim().toLowerCase();
if (!query) return tools.value;
return tools.value.filter(tool =>
tool.name?.toLowerCase().includes(query) ||
@@ -253,7 +254,8 @@ watch(viewMode, async (mode) => {
<div class="d-flex flex-wrap align-center ga-3 mb-4">
<div style="min-width: 240px; max-width: 380px; flex: 1;">
<v-text-field
v-model="toolSearch"
:model-value="toolSearch"
@update:model-value="toolSearch = normalizeTextInput($event)"
prepend-inner-icon="mdi-magnify"
:label="tmTool('functionTools.search')"
variant="outlined"
@@ -7,6 +7,7 @@
v-model="modelSearchProxy"
density="compact"
prepend-inner-icon="mdi-magnify"
clearable
hide-details
variant="solo-filled"
flat
@@ -161,6 +162,7 @@
<script setup>
import { computed } from 'vue'
import { normalizeTextInput } from '@/utils/inputValue'
const props = defineProps({
entries: {
@@ -222,7 +224,7 @@ const emit = defineEmits([
const modelSearchProxy = computed({
get: () => props.modelSearch,
set: (val) => emit('update:modelSearch', val)
set: (val) => emit('update:modelSearch', normalizeTextInput(val))
})
const isProviderTesting = (providerId) => props.testingProviders.includes(providerId)
@@ -48,6 +48,24 @@ const loading = ref(false);
const isEmpty = ref(false);
const copyFeedbackTimer = ref(null);
const lastRequestId = ref(0);
const scrollContainer = ref(null);
function slugifyHeading(text, slugCounts) {
const base = (text || "")
.trim()
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^\p{Letter}\p{Number}\s-]/gu, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-");
if (!base) return "";
const count = slugCounts.get(base) || 0;
slugCounts.set(base, count + 1);
return count === 0 ? base : `${base}-${count}`;
}
onUnmounted(() => {
if (copyFeedbackTimer.value) clearTimeout(copyFeedbackTimer.value);
@@ -153,6 +171,18 @@ const renderedHtml = computed(() => {
// 3.
const tempDiv = document.createElement("div");
tempDiv.innerHTML = cleanHtml;
const slugCounts = new Map();
tempDiv.querySelectorAll("h1, h2, h3, h4, h5, h6").forEach((heading) => {
if (heading.id) {
slugCounts.set(heading.id, (slugCounts.get(heading.id) || 0) + 1);
return;
}
const slug = slugifyHeading(heading.textContent, slugCounts);
if (slug) heading.id = slug;
});
tempDiv.querySelectorAll("a").forEach((link) => {
const href = link.getAttribute("href");
// 使 _blank
@@ -251,18 +281,35 @@ watch(
function handleContainerClick(event) {
const btn = event.target.closest(".copy-code-btn");
if (!btn) return;
const code = btn.closest(".code-block-wrapper")?.querySelector("code");
if (code) {
if (navigator.clipboard?.writeText) {
navigator.clipboard
.writeText(code.textContent)
.then(() => showCopyFeedback(btn, true))
.catch(() => tryFallbackCopy(code.textContent, btn));
} else {
tryFallbackCopy(code.textContent, btn);
if (btn) {
const code = btn.closest(".code-block-wrapper")?.querySelector("code");
if (code) {
if (navigator.clipboard?.writeText) {
navigator.clipboard
.writeText(code.textContent)
.then(() => showCopyFeedback(btn, true))
.catch(() => tryFallbackCopy(code.textContent, btn));
} else {
tryFallbackCopy(code.textContent, btn);
}
}
return;
}
const anchor = event.target.closest('a[href^="#"]');
if (!anchor) return;
const rawHref = anchor.getAttribute("href");
const targetId = rawHref ? decodeURIComponent(rawHref.slice(1)) : "";
if (!targetId) return;
const target = scrollContainer.value?.querySelector(
`#${CSS.escape(targetId)}`,
);
if (!target) return;
event.preventDefault();
target.scrollIntoView({ behavior: "smooth", block: "start" });
}
function tryFallbackCopy(text, btn) {
@@ -326,7 +373,7 @@ const showActionArea = computed(() => {
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text style="overflow-y: auto">
<v-card-text ref="scrollContainer" style="overflow-y: auto">
<div v-if="showActionArea" class="d-flex justify-space-between mb-4">
<v-btn
v-if="modeConfig.showGithubButton && repoUrl"
@@ -436,6 +483,7 @@ const showActionArea = computed(() => {
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
scroll-margin-top: 12px;
}
:deep(.markdown-body h1) {
@@ -2,6 +2,7 @@ import { ref, computed, onMounted, nextTick, watch } from 'vue'
import axios from 'axios'
import { getProviderIcon } from '@/utils/providerUtils'
import { askForConfirmation as askForConfirmationDialog, useConfirmDialog } from '@/utils/confirmDialog'
import { normalizeTextInput } from '@/utils/inputValue'
export interface UseProviderSourcesOptions {
defaultTab?: string
@@ -157,7 +158,7 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
})
const filteredMergedModelEntries = computed(() => {
const term = modelSearch.value.trim().toLowerCase()
const term = normalizeTextInput(modelSearch.value).trim().toLowerCase()
if (!term) return mergedModelEntries.value
return mergedModelEntries.value.filter((entry: any) => {
+27 -26
View File
@@ -11,7 +11,7 @@ const translations = ref<Record<string, any>>({});
*/
export async function initI18n(locale: Locale = 'zh-CN') {
currentLocale.value = locale;
// 加载静态翻译数据
loadTranslations(locale);
}
@@ -50,7 +50,7 @@ export function useI18n() {
const t = (key: string, params?: Record<string, string | number>): string => {
const keys = key.split('.');
let value: any = translations.value;
// 遍历键路径
for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
@@ -61,35 +61,35 @@ export function useI18n() {
return `[MISSING: ${key}]`;
}
}
if (typeof value !== 'string') {
console.warn(`Translation value is not string: ${key}`, value);
// 返回带括号的键名,便于在开发时识别类型错误的翻译
return `[INVALID: ${key}]`;
}
// 此时value确定是string类型
let result: string = value;
// 处理参数插值
if (params) {
result = result.replace(/\{(\w+)\}/g, (match: string, paramKey: string) => {
return params[paramKey]?.toString() || match;
});
}
return result;
};
// 切换语言
const setLocale = async (newLocale: Locale) => {
if (newLocale !== currentLocale.value) {
currentLocale.value = newLocale;
loadTranslations(newLocale);
// 保存到localStorage
localStorage.setItem('astrbot-locale', newLocale);
// 触发自定义事件,通知相关页面重新加载配置数据
// 这是因为插件适配器的 i18n 数据是通过后端 API 注入的,
// 需要根据 Accept-Language 头重新获取
@@ -98,16 +98,16 @@ export function useI18n() {
}));
}
};
// 获取当前语言
const locale = computed(() => currentLocale.value);
// 获取可用语言列表
const availableLocales: Locale[] = ['zh-CN', 'en-US'];
const availableLocales: Locale[] = ['zh-CN', 'en-US', 'ru-RU'];
// 检查是否已加载
const isLoaded = computed(() => Object.keys(translations.value).length > 0);
return {
t,
locale,
@@ -122,13 +122,13 @@ export function useI18n() {
*/
export function useModuleI18n(moduleName: string) {
const { t } = useI18n();
const tm = (key: string, params?: Record<string, string | number>): string => {
// 将斜杠转换为点号以匹配嵌套对象结构
const normalizedModuleName = moduleName.replace(/\//g, '.');
return t(`${normalizedModuleName}.${key}`, params);
};
// 获取原始翻译值(可能是字符串、数组或对象)
const getRaw = (key: string): any => {
const normalizedModuleName = moduleName.replace(/\//g, '.');
@@ -143,10 +143,10 @@ export function useModuleI18n(moduleName: string) {
return null;
}
}
return value;
};
return { tm, getRaw };
}
@@ -155,20 +155,21 @@ export function useModuleI18n(moduleName: string) {
*/
export function useLanguageSwitcher() {
const { locale, setLocale, availableLocales } = useI18n();
const languageOptions = computed(() => [
{ value: 'zh-CN', label: '简体中文', flag: '🇨🇳' },
{ value: 'en-US', label: 'English', flag: '🇺🇸' }
{ value: 'en-US', label: 'English', flag: '🇺🇸' },
{ value: 'ru-RU', label: 'Русский', flag: '🇷🇺' }
]);
const currentLanguage = computed(() => {
return languageOptions.value.find(lang => lang.value === locale.value);
});
const switchLanguage = async (newLocale: Locale) => {
await setLocale(newLocale);
};
return {
locale,
languageOptions,
@@ -220,9 +221,9 @@ function deepMerge(target: Record<string, any>, source: Record<string, any>) {
export async function setupI18n() {
// 从localStorage获取保存的语言设置
const savedLocale = localStorage.getItem('astrbot-locale') as Locale;
const initialLocale = savedLocale && ['zh-CN', 'en-US'].includes(savedLocale)
? savedLocale
const initialLocale = savedLocale && ['zh-CN', 'en-US', 'ru-RU'].includes(savedLocale)
? savedLocale
: 'zh-CN';
await initI18n(initialLocale);
}
@@ -78,6 +78,7 @@
},
"persona": {
"description": "Persona",
"hint": "Set the default persona for AI conversations. Personas can be managed in the Persona tab.",
"provider_settings": {
"default_personality": {
"description": "Default Persona"
@@ -873,7 +874,8 @@
]
},
"regex": {
"description": "Segmentation Regular Expression"
"description": "Segmentation Regular Expression",
"hint": "Used to identify split points with a regular expression. Prefer patterns that match separators."
},
"split_words": {
"description": "Split Word List",
@@ -1521,4 +1523,4 @@
"helpMiddle": "or",
"helpSuffix": "."
}
}
}
@@ -23,6 +23,9 @@
"placeholder": "Search extensions...",
"marketPlaceholder": "Search market extensions..."
},
"filters": {
"all": "All"
},
"views": {
"card": "Card View",
"list": "List View"
@@ -122,10 +125,14 @@
"sourceSafetyWarning": "Even with the default source, plugin stability and security cannot be fully guaranteed. Please verify carefully before use."
},
"sort": {
"by": "Sort by",
"default": "Default",
"installTime": "Last Modified",
"name": "Name",
"stars": "Stars",
"author": "Author",
"updated": "Last Updated",
"updateStatus": "Update Status",
"ascending": "Ascending",
"descending": "Descending"
},
@@ -0,0 +1,24 @@
{
"create": "Создать",
"read": "Чтение",
"update": "Обновить",
"delete": "Удалить",
"search": "Поиск",
"filter": "Фильтр",
"sort": "Сортировка",
"export": "Экспорт",
"import": "Импорт",
"backup": "Резервное копирование",
"restore": "Восстановление",
"copy": "Копировать",
"paste": "Вставить",
"cut": "Вырезать",
"undo": "Отменить",
"redo": "Повторить",
"refresh": "Обновить",
"submit": "Отправить",
"reset": "Сбросить",
"clear": "Очистить",
"save": "Сохранить",
"close": "Закрыть"
}
@@ -0,0 +1,133 @@
{
"save": "Сохранить",
"cancel": "Отмена",
"close": "Закрыть",
"copy": "Копировать",
"copied": "Скопировано",
"copyFailed": "Ошибка копирования",
"delete": "Удалить",
"edit": "Редактировать",
"add": "Добавить",
"confirm": "Подтвердить",
"loading": "Загрузка...",
"success": "Успешно",
"error": "Ошибка",
"warning": "Внимание",
"info": "Информация",
"name": "Имя",
"description": "Описание",
"author": "Автор",
"status": "Статус",
"actions": "Действия",
"enable": "Включить",
"disable": "Выключить",
"enabled": "Включено",
"disabled": "Выключено",
"reload": "Перезагрузить",
"configure": "Настроить",
"install": "Установить",
"uninstall": "Удалить",
"update": "Обновить",
"language": "Язык",
"settings": "Настройки",
"locale": "JSON",
"type": "Тип",
"press": "Нажмите",
"longPress": "Долгое нажатие",
"yes": "Да",
"no": "Нет",
"imagePreview": "Предпросмотр изображения",
"autoDetect": "Автоопределение",
"dialog": {
"confirmTitle": "Подтверждение",
"confirmMessage": "Вы уверены, что хотите выполнить это действие?",
"confirmButton": "ОК",
"cancelButton": "Отмена"
},
"restart": {
"waiting": "Ожидание перезагрузки AstrBot...",
"maxRetriesReached": "Превышено количество попыток проверки статуса. Пожалуйста, проверьте вручную."
},
"readme": {
"title": "Документация плагина",
"buttons": {
"viewOnGithub": "Открыть репозиторий на GitHub",
"refresh": "Обновить"
},
"loading": "Загрузка README...",
"errors": {
"fetchFailed": "Не удалось загрузить README",
"fetchError": "Произошла ошибка при загрузке README"
},
"empty": {
"title": "У этого плагина нет ссылки на документацию или репозиторий GitHub.",
"subtitle": "Пожалуйста, посетите магазин плагинов или свяжитесь с автором для получения дополнительной информации."
}
},
"changelog": {
"title": "Журнал изменений",
"loading": "Загрузка журнала изменений...",
"empty": {
"title": "У этого плагина нет журнала изменений",
"subtitle": "Разработчики могут добавить файл CHANGELOG.md в директорию плагина"
}
},
"editor": {
"fullscreen": "На весь экран",
"editingTitle": "Редактирование содержимого"
},
"templateList": {
"addEntry": "Добавить запись",
"empty": "Записей нет, выберите шаблон для добавления",
"missingTemplate": "Шаблон не найден, пожалуйста, удалите и добавьте заново.",
"unknownTemplate": "Неизвестный шаблон"
},
"list": {
"addItemPlaceholder": "Добавьте новый элемент и нажмите Enter",
"addButton": "Добавить",
"addMore": "Добавить еще",
"batchImport": "Массовый импорт",
"batchImportTitle": "Массовый импорт",
"batchImportLabel": "Один элемент на строку",
"batchImportPlaceholder": "Например:\nЭлемент 1\nЭлемент 2\nЭлемент 3",
"batchImportHint": "Каждая строка будет считаться отдельным элементом. Пустые строки игнорируются.",
"batchImportButton": "Импортировать {count} эл.",
"noItems": "Список пуст",
"noItemsHint": "Элементов нет. Напишите что-нибудь выше и нажмите Enter.",
"inputPlaceholder": "Введите текст и нажмите Enter",
"editTitle": "Изменить элемент",
"modifyButton": "Изменить"
},
"itemCard": {
"enabled": "Включено",
"disabled": "Выключено",
"delete": "Удалить",
"edit": "Изменить",
"copy": "Копировать",
"noData": "Нет данных"
},
"objectEditor": {
"dialogTitle": "Изменение пар ключ-значение",
"noItems": "Нет элементов",
"noParams": "Нет параметров",
"presets": "Пресеты",
"newKeyLabel": "Имя ключа",
"valueTypeLabel": "Тип значения",
"keyExists": "Ключ уже существует",
"invalidJson": "Некорректный формат JSON",
"placeholders": {
"keyName": "Ключ",
"stringValue": "Строка",
"numberValue": "Число",
"jsonValue": "JSON"
}
},
"firstNotice": {
"title": "Первичная информация",
"loading": "Загрузка информации...",
"empty": {
"title": "Нет информации для отображения",
"subtitle": "Файл FIRST_NOTICE.md не найден или пуст."
}
}
}
@@ -0,0 +1,108 @@
{
"logoTitle": "Панель управления AstrBot",
"version": {
"hasNewVersion": "Доступна новая версия AstrBot!",
"dashboardHasNewVersion": "Доступна новая версия WebUI!"
},
"buttons": {
"update": "Обновить",
"account": "Аккаунт",
"theme": {
"light": "Светлая тема",
"dark": "Темная тема"
}
},
"updateDialog": {
"title": "Обновить AstrBot",
"currentVersion": "Текущая версия",
"status": {
"checking": "Проверка обновлений...",
"switching": "Переключение версии...",
"updating": "Обновление..."
},
"tabs": {
"release": "😊 Релиз"
},
"updateToLatest": "Обновить до последней версии",
"preRelease": "Предварительная версия",
"preReleaseWarning": {
"title": "Внимание: предварительная версия",
"description": "Версии с меткой Pre-release могут содержать неизвестные ошибки. Не рекомендуется использовать в рабочих средах. Если вы обнаружили ошибку, пожалуйста, сообщите о ней в ",
"issueLink": "GitHub Issues"
},
"tip": "💡 ПОДСКАЗКА: ",
"tipContinue": "По умолчанию при переключении версии загружаются соответствующие файлы WebUI. Код WebUI находится в директории dashboard, вы можете собрать его самостоятельно с помощью npm.",
"dockerTip": "При переключении версии будет предпринята попытка обновить как основной процесс бота, так и панель управления. Если вы используете Docker, вы также можете обновить образ или использовать",
"dockerTipLink": "watchtower",
"dockerTipContinue": "для автоматического мониторинга и обновления.",
"table": {
"tag": "Тег",
"publishDate": "Дата публикации",
"content": "Содержание",
"sourceUrl": "Исходный код",
"actions": "Действия",
"view": "Просмотр",
"switch": "Переключить"
},
"releaseNotes": {
"title": "Журнал изменений"
},
"redirectConfirm": {
"title": "Переход по ссылке",
"message": "Вы будете перенаправлены на страницу GitHub Releases. Продолжить?",
"latestLabel": "Последняя версия",
"targetVersion": "Целевая версия:",
"currentVersion": "Текущая версия:",
"guideTitle": "Рекомендации после перехода:",
"guideStep1": "Загрузите пакет, соответствующий архитектуре вашей системы.",
"guideStep2": "После завершения установки перезапустите AstrBot.",
"guideStep3": "Если вы используете Docker, отдайте приоритет обновлению через образ."
},
"desktopApp": {
"title": "Обновить десктопное приложение",
"message": "Проверка и обновление десктопной версии AstrBot.",
"currentVersion": "Текущая версия:",
"latestVersion": "Последняя версия:",
"checking": "Проверка обновлений десктопного приложения...",
"hasNewVersion": "Найдена новая версия. Нажмите для подтверждения обновления.",
"isLatest": "Установлена последняя версия",
"installing": "Загрузка и установка обновления... Приложение будет перезапущено автоматически.",
"checkFailed": "Ошибка проверки обновлений. Попробуйте позже.",
"installFailed": "Ошибка обновления. Попробуйте позже."
},
"dashboardUpdate": {
"title": "Обновить только панель управления",
"currentVersion": "Текущая версия",
"hasNewVersion": "Доступна новая версия!",
"isLatest": "Установлена последняя версия.",
"downloadAndUpdate": "Скачать и обновить"
}
},
"accountDialog": {
"title": "Изменить аккаунт",
"securityWarning": "Безопасность: Пожалуйста, смените пароль по умолчанию для защиты аккаунта",
"form": {
"currentPassword": "Текущий пароль",
"newPassword": "Новый пароль",
"confirmPassword": "Подтвердите новый пароль",
"newUsername": "Новое имя пользователя (опционально)",
"passwordHint": "Пароль должен быть не менее 8 символов",
"confirmPasswordHint": "Введите новый пароль еще раз",
"usernameHint": "Оставьте пустым, если не хотите менять имя пользователя",
"defaultCredentials": "Логин и пароль по умолчанию: astrbot"
},
"validation": {
"passwordRequired": "Введите пароль",
"passwordMinLength": "Пароль должен быть не менее 8 символов",
"passwordMatch": "Паролы не совпадают",
"usernameMinLength": "Имя пользователя должно быть не менее 3 символов"
},
"actions": {
"save": "Сохранить изменения",
"cancel": "Отмена"
},
"messages": {
"updateFailed": "Ошибка обновления, попробуйте еще раз"
}
}
}
@@ -0,0 +1,49 @@
{
"welcome": "Добро пожаловать",
"dashboard": "Статистика",
"platforms": "Боты",
"providers": "Провайдеры моделей",
"commands": "Команды",
"persona": "Персонажи",
"subagent": "Субагенты",
"toolUse": "Инструменты MCP",
"extension": "Плагины",
"extensionTabs": {
"installed": "Плагины AstrBot",
"market": "Магазин плагинов",
"mcp": "Серверы MCP",
"skills": "Навыки",
"components": "Управление поведением"
},
"config": "Конфигурация",
"chat": "Чат",
"cron": "Запланированные задачи",
"conversation": "Данные диалогов",
"sessionManagement": "Пользовательские правила",
"console": "Логи платформы",
"trace": "Трассировка",
"alkaid": "Alkaid Lab",
"knowledgeBase": "База знаний",
"about": "О программе",
"settings": "Настройки",
"changelog": "Журнал изменений",
"documentation": "Документация",
"faq": "FAQ",
"github": "GitHub",
"drag": "Перетащить",
"groups": {
"more": "Дополнительно"
},
"changelogDialog": {
"title": "Журнал изменений",
"loading": "Загрузка...",
"error": "Ошибка загрузки",
"notFound": "Журнал изменений для этой версии не найден",
"selectVersion": "Выберите версию",
"current": "Текущая"
},
"configTabs": {
"normal": "Обычная конфигурация",
"system": "Системная конфигурация"
}
}
@@ -0,0 +1,111 @@
{
"knowledgeBaseSelector": {
"notSelected": "Не выбрано",
"buttonText": "Выбрать базу знаний...",
"dialogTitle": "Выбор базы знаний",
"loading": "Загрузка...",
"noKnowledgeBases": "Базы знаний не найдены",
"createKnowledgeBase": "Создать базу знаний",
"selectedCount": "Выбрано баз знаний: {count}",
"confirmSelection": "ОК",
"cancelSelection": "Отмена",
"noDescription": "Нет описания",
"documentCount": "Документов: {count}",
"chunkCount": "Фрагментов: {count}"
},
"pluginSetSelector": {
"notSelected": "Плагины не включены",
"allPlugins": "Включить все плагины (*)",
"selectedCount": "Выбрано плагинов: {count}",
"buttonText": "Выбрать набор плагинов...",
"dialogTitle": "Выбор набора плагинов",
"loading": "Загрузка...",
"enableAll": "Включить все",
"enableNone": "Ничего не включать",
"customSelect": "Настроить выбор",
"noPlugins": "Доступных плагинов нет",
"confirmSelection": "ОК",
"cancelSelection": "Отмена",
"noDescription": "Нет описания",
"notActivated": "Не активирован",
"note": "*Системные и уже выключенные в настройках плагины не отображаются.",
"selectedPluginsLabel": "Выбранные плагины:",
"allPluginsLabel": "Все плагины"
},
"providerSelector": {
"notSelected": "Не выбрано",
"buttonText": "Выбрать провайдера...",
"dialogTitle": "Выбор провайдера",
"loading": "Загрузка...",
"noProviders": "Доступных провайдеров нет",
"confirmSelection": "ОК",
"cancelSelection": "Отмена",
"clearSelection": "Сбросить выбор",
"clearSelectionSubtitle": "Очистить текущий выбор",
"unknownType": "Неизвестный тип",
"createProvider": "Создать провайдера",
"manageProviders": "Управление провайдерами",
"selectProviderPool": "Выбрать пул провайдеров...",
"selectedCount": "Выбрано провайдеров: {count}"
},
"personaSelector": {
"notSelected": "Не выбрано",
"defaultPersona": "Персонаж по умолчанию",
"buttonText": "Выбрать персонажа...",
"editPersona": "Изменить текущего персонажа",
"dialogTitle": "Выбор персонажа",
"noDescription": "Нет описания",
"noPersonas": "Доступных персонажей нет",
"createPersona": "Создать персонажа",
"cancelSelection": "Отмена",
"confirmSelection": "ОК",
"selectPersonaPool": "Выбрать пул персонажей...",
"rootFolder": "Все персонажи",
"emptyFolder": "Папка пуста"
},
"personaQuickPreview": {
"title": "Быстрый просмотр",
"loading": "Загрузка...",
"noPersonaSelected": "Персонаж не выбран",
"personaNotFound": "Информация о персонаже не найдена",
"systemPromptLabel": "Системный промпт",
"toolsLabel": "Инструменты",
"skillsLabel": "Навыки (Skills)",
"originLabel": "Источник",
"originNameLabel": "Имя источника",
"toolInactive": "Выключено",
"toolInactiveTooltip": "Этот инструмент выключен. Включите его в Плагины -> Управление поведением -> Функции.",
"allTools": "Доступны все инструменты",
"allToolsWithCount": "Доступны все инструменты ({count})",
"noTools": "Инструменты не настроены",
"allSkills": "Доступны все навыки (Skills)",
"allSkillsWithCount": "Доступны все навыки ({count})",
"noSkills": "Навыки (Skills) не настроены"
},
"t2iTemplateEditor": {
"buttonText": "Настроить T2I шаблон",
"dialogTitle": "Настройка HTML шаблона Text-to-Image",
"newTemplateNameLabel": "Введите имя нового шаблона",
"nameRequired": "Имя обязательно для заполнения",
"selectTemplateLabel": "Выбрать шаблон",
"applied": "Применено",
"apply": "Применить",
"templateEditor": "Редактор шаблона",
"new": "Создать",
"resetBase": "Сбросить 'base'",
"delete": "Удалить",
"save": "Сохранить",
"livePreview": "Предпросмотр (может отличаться)",
"refreshPreview": "Обновить",
"syntaxHint": "Поддерживается синтаксис jinja2. Переменные: text | safe (текст для рендеринга), version (версия AstrBot)",
"saveAndApply": "Сохранить и применить текущий шаблон",
"confirmReset": "Подтверждение сброса",
"confirmResetMessage": "Вы уверены, что хотите сбросить шаблон 'base' до значений по умолчанию? Все несохраненные изменения будут потеряны. Это действие необратимо.",
"confirmResetButton": "Сбросить",
"confirmDelete": "Подтверждение удаления",
"confirmDeleteMessage": "Вы уверены, что хотите удалить шаблон '{name}'? Это действие необратимо.",
"confirmDeleteButton": "Удалить",
"confirmAction": "Подтверждение действия",
"confirmApplyMessage": "Вы уверены, что хотите сохранить изменения в '{name}' и сделать его активным шаблоном?"
}
}
@@ -0,0 +1,22 @@
{
"loading": "Загрузка",
"success": "Успешно",
"error": "Ошибка",
"warning": "Внимание",
"info": "Информация",
"pending": "В ожидании",
"processing": "В процессе",
"completed": "Завершено",
"failed": "Ошибка",
"cancelled": "Отменено",
"timeout": "Тайм-аут",
"connecting": "Подключение",
"connected": "Подключено",
"disconnected": "Отключено",
"online": "В сети",
"offline": "Не в сети",
"active": "Активен",
"inactive": "Неактивен",
"ready": "Готов",
"busy": "Занят"
}
@@ -0,0 +1,17 @@
{
"hero": {
"title": "AstrBot",
"subtitle": "Проект, рожденный из интереса и любви ❤️",
"starButton": "Star этот проект! 🌟",
"issueButton": "Сообщить об ошибке"
},
"contributors": {
"title": "Контрибьюторы",
"description": "Этот проект поддерживается участниками open-source сообщества. Спасибо каждому за вклад!",
"viewLink": "Посмотреть всех участников"
},
"stats": {
"title": "Глобальное развертывание",
"license": "AstrBot распространяется по лицензии AGPL v3"
}
}
@@ -0,0 +1,44 @@
{
"title": "Лаборатория Alkaid",
"subtitle": "Исследуйте передовые возможности AI",
"comingSoon": "Этот мир еще впереди, заходите позже!",
"page": {
"title": "Проект Alkaid.",
"subtitle": "AstrBot Alpha Project",
"navigation": {
"knowledgeBase": "База знаний (Плагин)",
"longTermMemory": "Долгосрочная память",
"other": "..."
}
},
"features": {
"knowledgeBase": "База знаний",
"longTermMemory": "Долгосрочная память",
"advancedChat": "Продвинутый чат",
"multiModal": "Мультимодальность"
},
"status": {
"experimental": "Экспериментально",
"beta": "Бета",
"stable": "Стабильно",
"deprecated": "Устарело"
},
"sigma": {
"subtitle": "Экспериментальный проект AstrBot",
"visualization": "Визуализация",
"filterUserId": "Фильтр по User ID",
"filter": "Фильтр",
"resetFilter": "Сброс",
"refreshGraph": "Обновить граф",
"nodeDetails": "Детали узла",
"id": "ID",
"type": "Тип",
"name": "Имя",
"userId": "ID пользователя",
"timestamp": "Метка времени",
"graphStats": "Статистика графа",
"nodeCount": "Узлов",
"edgeCount": "Связей",
"inDevelopment": "В разработке"
}
}
@@ -0,0 +1,155 @@
{
"title": "База знаний",
"subtitle": "Управление контентом базы знаний и поиск",
"documents": {
"title": "Список документов",
"name": "Имя файла",
"size": "Размер",
"uploadTime": "Дата загрузки",
"status": "Статус",
"actions": "Действия"
},
"management": {
"delete": "Удалить",
"preview": "Предпросмотр",
"download": "Скачать",
"reindex": "Переиндексировать"
},
"notInstalled": {
"title": "Плагин базы знаний не установлен",
"install": "Установить сейчас"
},
"empty": {
"title": "База знаний пуста. Создайте свою первую базу! 🙂",
"create": "Создать базу знаний"
},
"list": {
"title": "Список баз знаний",
"create": "Создать базу знаний",
"config": "Настройка",
"checkUpdate": "Проверить обновления плагина",
"updatePlugin": "Обновить плагин до версии {version}",
"knowledgeCount": "записей",
"tips": "Совет: используйте команду /kb в чате, чтобы узнать, как пользоваться базой!"
},
"createDialog": {
"title": "Создание базы знаний",
"nameLabel": "Название",
"descriptionLabel": "Описание",
"descriptionPlaceholder": "Краткое описание...",
"embeddingModelLabel": "Embedding модель",
"rerankModelLabel": "Rerank модель",
"providerInfo": "Провайдер: {id} | Размерность: {dimensions}",
"rerankProviderInfo": "Провайдер: {id}",
"tips": "Совет: после выбора Embedding модели не рекомендуется менять провайдера или размерность векторов, так как это сделает текущий индекс нечитаемым.",
"cancel": "Отмена",
"create": "Создать"
},
"emojiPicker": {
"title": "Выберите иконку",
"close": "Закрыть",
"categories": {
"emotions": "Смайлы",
"animals": "Животные и природа",
"food": "Еда и напитки",
"activities": "Занятия и вещи",
"travel": "Места и путешествия",
"symbols": "Символы и флаги"
}
},
"contentDialog": {
"title": "Управление базой знаний",
"embeddingModel": "Embedding модель",
"vectorDimension": "Размерность",
"usage": "Использование: введите «/kb use {name}» в чате",
"tabs": {
"upload": "Загрузка файлов",
"search": "Поиск",
"fromURL": "Импорт из URL"
}
},
"upload": {
"title": "Загрузка файлов",
"subtitle": "Поддерживаются форматы txt, pdf, word, excel и др.",
"dropzone": "Перетащите файлы сюда или нажмите для выбора",
"chunkSettings": {
"title": "Настройка фрагментации (Chunking)",
"tooltip": "Размер фрагмента определяет объем текста в одном блоке. Перекрытие позволяет сохранить контекст между соседними блоками.\nМаленькие фрагменты точнее, но увеличивают объем базы.",
"chunkSizeLabel": "Размер фрагмента",
"chunkSizeHint": "Длина текста в одном блоке (пусто = по умолчанию)",
"overlapLabel": "Перекрытие",
"overlapHint": "Нахлест между соседними блоками (пусто = по умолчанию)"
},
"upload": "Начать загрузку",
"uploading": "Загрузка..."
},
"search": {
"queryLabel": "Поиск по базе знаний",
"queryPlaceholder": "Введите ключевые слова...",
"resultCountLabel": "Количество результатов",
"searching": "Поиск...",
"resultsTitle": "Результаты поиска",
"relevance": "Релевантность",
"noResults": "Совпадений не найдено"
},
"deleteDialog": {
"title": "Подтверждение удаления",
"confirmText": "Вы уверены, что хотите удалить базу знаний «{name}»?",
"warning": "Это действие необратимо. Весь контент базы знаний будет навсегда удален.",
"cancel": "Отмена",
"delete": "Удалить"
},
"messages": {
"pluginNotAvailable": "Плагин не установлен или недоступен",
"pluginNotActivated": "Плагин astrbot_plugin_knowledge_base не включен. Пожалуйста, активируйте его в разделе плагинов и перезапустите AstrBot.",
"checkPluginFailed": "Не удалось проверить плагин",
"installFailed": "Ошибка установки",
"installPluginFailed": "Не удалось установить плагин",
"getKnowledgeBaseListFailed": "Ошибка получения списка баз знаний",
"knowledgeBaseCreated": "База знаний создана",
"createFailed": "Ошибка создания",
"createKnowledgeBaseFailed": "Не удалось создать базу знаний",
"pleaseEnterKnowledgeBaseName": "Укажите название базы знаний",
"pleaseSelectFile": "Пожалуйста, сначала выберите файл",
"operationSuccess": "Успешно: {message}",
"uploadFailed": "Ошибка загрузки",
"fileUploadFailed": "Не удалось загрузить файл",
"pleaseEnterSearchContent": "Введите текст для поиска",
"noMatchingContent": "Ничего не найдено",
"searchFailed": "Ошибка поиска",
"searchKnowledgeBaseFailed": "Не удалось выполнить поиск",
"deleteTargetNotExists": "Объект для удаления не найден",
"knowledgeBaseDeleted": "База знаний удалена",
"deleteFailed": "Ошибка удаления",
"deleteKnowledgeBaseFailed": "Не удалось удалить базу знаний",
"getEmbeddingModelListFailed": "Не удалось загрузить список Embedding моделей",
"updateAvailable": "Доступна новая версия: {current} -> {latest}",
"pluginUpToDate": "У вас последняя версия плагина",
"pluginNotFoundInMarket": "Плагин не найден в магазине",
"checkUpdateFailed": "Ошибка проверки обновлений",
"updateSuccess": "Плагин успешно обновлен",
"updateFailed": "Ошибка обновления",
"updatePluginFailed": "Не удалось обновить плагин"
},
"importFromUrl": {
"title": "Импорт из URL",
"urlLabel": "Адрес страницы",
"urlPlaceholder": "Введите URL для извлечения знаний",
"optionsTitle": "Настройки импорта",
"tooltip": "Эти параметры управляют извлечением текста из URL.\nЕсли оставить пустыми, будут использованы настройки по умолчанию.\nТекстовая очистка через LLM может занять время.",
"useLlmRepairLabel": "Исправление текста через LLM",
"useClusteringSummaryLabel": "Кластеризация и суммаризация",
"repairLlmProviderIdLabel": "Модель для очистки",
"summarizeLlmProviderIdLabel": "Модель для суммаризации",
"embeddingProviderIdLabel": "Embedding модель",
"chunkSizeLabel": "Размер фрагмента",
"chunkOverlapLabel": "Перекрытие",
"startImport": "Начать импорт",
"importing": "Импорт...",
"importSuccess": "Импортировано успешно",
"importFailed": "Ошибка импорта",
"uploadingChunks": "Текст извлечен, загрузка фрагментов...",
"preRequisite": "Примечание: сначала установите плагин astrbot_plugin_url_2_knowledge_base и выполните установку playwright согласно документации.",
"allChunksUploaded": "Все фрагменты успешно загружены"
}
}
@@ -0,0 +1,97 @@
{
"title": "Долгосрочная память",
"subtitle": "Управление памятью вашего AI-помощника",
"memories": {
"title": "Список воспоминаний",
"content": "Содержание",
"importance": "Важность",
"createTime": "Дата создания",
"lastAccess": "Последнее обращение",
"category": "Категория"
},
"categories": {
"personal": "Личное",
"preferences": "Предпочтения",
"conversations": "История диалогов",
"facts": "Факты",
"skills": "Навыки"
},
"importance": {
"high": "Высокая",
"medium": "Средняя",
"low": "Низкая"
},
"actions": {
"view": "Детали",
"edit": "Изменить",
"delete": "Удалить",
"pin": "Закрепить",
"unpin": "Открепить"
},
"filters": {
"all": "Все",
"category": "По категории",
"importance": "По важности",
"dateRange": "По периоду",
"title": "Фильтр",
"userIdLabel": "Фильтр по User ID",
"filterButton": "Применить",
"resetButton": "Сбросить",
"refreshButton": "Обновить граф"
},
"search": {
"title": "Поиск по памяти",
"userIdLabel": "ID пользователя",
"queryLabel": "Ключевое слово",
"searchButton": "Поиск",
"resultsTitle": "Результаты поиска",
"noResults": "Ничего не найдено",
"similarity": "Сходство",
"noTextContent": "Нет текста"
},
"addMemory": {
"title": "Добавить данные в память",
"textLabel": "Текст воспоминания",
"userIdLabel": "ID пользователя",
"summarizeLabel": "Нужна суммаризация",
"addButton": "Добавить"
},
"nodeDetails": {
"title": "Детали узла",
"id": "ID",
"type": "Тип",
"name": "Имя",
"userId": "ID пользователя",
"timestamp": "Метка времени"
},
"graphStats": {
"title": "Статистика графа",
"nodeCount": "Узлов",
"edgeCount": "Связей"
},
"factDialog": {
"title": "Факт из памяти",
"id": "ID",
"docId": "ID документа",
"createdAt": "Создано",
"updatedAt": "Обновлено",
"metadata": "Метаданные",
"metadataKey": "Ключ",
"metadataValue": "Значение",
"loading": "Загрузка...",
"close": "Закрыть",
"noValue": "нет",
"unknown": "неизвестно"
},
"messages": {
"searchQueryRequired": "Пожалуйста, введите запрос",
"searchSuccess": "Найдено записей: {count}",
"searchNoResults": "В памяти ничего не найдено",
"searchError": "Ошибка поиска",
"addSuccess": "Данные успешно добавлены в память!",
"addError": "Не удалось добавить данные",
"factDetailsError": "Ошибка загрузки деталей",
"metadataParseError": "Не удалось разобрать метаданные",
"relationNoMemoryData": "У этой связи нет ассоциированных данных"
}
}
@@ -0,0 +1,14 @@
{
"login": "Вход",
"username": "Имя пользователя",
"password": "Пароль",
"defaultHint": "Логин и пароль по умолчанию: astrbot",
"logo": {
"title": "Панель управления AstrBot",
"subtitle": "Добро пожаловать"
},
"theme": {
"switchToDark": "Перейти на темную тему",
"switchToLight": "Перейти на светлую тему"
}
}
@@ -0,0 +1,4 @@
{
"messageCount": "Количество сообщений",
"time": "Время"
}
@@ -0,0 +1,146 @@
{
"title": "Давай пообщаемся!",
"subtitle": "Общение с AI-помощником",
"input": {
"placeholder": "Введите сообщение...",
"send": "Отправить",
"clear": "Очистить",
"upload": "Загрузить файл",
"voice": "Голосовой ввод",
"recordingPrompt": "Запись... говорите",
"chatPrompt": "Давай пообщаемся!",
"dropToUpload": "Отпустите, чтобы загрузить файл",
"stopGenerating": "Остановить генерацию"
},
"message": {
"user": "Вы",
"assistant": "Ассистент",
"system": "Система",
"error": "Ошибка в сообщении",
"loading": "Думаю..."
},
"voice": {
"start": "Начать запись",
"stop": "Стоп",
"recording": "Запись",
"processing": "Обработка...",
"error": "Ошибка записи",
"listening": "Слушаю...",
"speaking": "Говорю",
"startRecording": "Начать голосовой ввод",
"liveMode": "Общение в реальном времени"
},
"welcome": {
"title": "Добро пожаловать в AstrBot",
"subtitle": "Ваш умный помощник",
"quickActions": "Быстрые действия",
"examples": "Примеры вопросов"
},
"actions": {
"copy": "Копировать",
"regenerate": "Перегенерировать",
"like": "Нравится",
"dislike": "Не нравится",
"share": "Поделиться",
"newChat": "Новый чат",
"deleteChat": "Удалить чат",
"editTitle": "Изменить заголовок",
"fullscreen": "На весь экран",
"exitFullscreen": "Выход из полноэкранного режима",
"reply": "Ответить",
"providerConfig": "Настройки AI",
"toolsUsed": "Использованные инструменты",
"toolCallUsed": "Использован инструмент {name}",
"pythonCodeAnalysis": "Использован анализ кода Python"
},
"ipython": {
"output": "Вывод"
},
"conversation": {
"newConversation": "Новый чат",
"noHistory": "История диалогов пуста",
"systemStatus": "Статус системы",
"llmService": "Сервис LLM",
"speechToText": "Преобразование речи",
"editDisplayName": "Изменить имя чата",
"displayName": "Имя чата",
"displayNameUpdated": "Имя чата обновлено",
"displayNameUpdateFailed": "Не удалось обновить имя чата",
"confirmDelete": "Вы уверены, что хотите удалить «{name}»? Это действие необратимо."
},
"modes": {
"darkMode": "Темная тема",
"lightMode": "Светлая тема"
},
"shortcuts": {
"help": "Справка",
"voiceRecord": "Запись голоса",
"pasteImage": "Вставить изображение"
},
"streaming": {
"enabled": "Потоковый ответ включен",
"disabled": "Потоковый ответ выключен",
"on": "Поток",
"off": "Обычный"
},
"transport": {
"title": "Протокол передачи",
"sse": "SSE",
"websocket": "WebSocket"
},
"config": {
"title": "Конфигурация"
},
"reasoning": {
"thinking": "Рассуждение"
},
"reply": {
"replyTo": "В ответ на",
"notFound": "Сообщение не найдено"
},
"project": {
"title": "Проект",
"create": "Создать проект",
"edit": "Изменить проект",
"name": "Имя проекта",
"emoji": "Иконка (Emoji)",
"description": "Описание проекта (опционально)",
"noSessions": "В этом проекте пока нет диалогов",
"confirmDelete": "Вы уверены, что хотите удалить проект «{title}»? Диалоги внутри проекта не будут удалены."
},
"time": {
"today": "Сегодня",
"yesterday": "Вчера"
},
"stats": {
"tokens": "Токены",
"inputTokens": "Входящие",
"outputTokens": "Исходящие",
"cachedTokens": "Кэшированные",
"duration": "Время",
"ttft": "Время до первого токена"
},
"refs": {
"title": "Ссылки",
"sources": "Источники"
},
"connection": {
"title": "Статус подключения",
"message": "Системе необходимо переустановить соединение с чатом.",
"reasons": "Это может быть вызвано следующими причинами:",
"reasonWindowResize": "Изменение размера окна (нормально)",
"reasonMultipleTabs": "Страница чата открыта в другой вкладке",
"reasonNetworkIssue": "Временная проблема с сетью",
"notice": "Примечание: для стабильной работы допускается только одно активное соединение. Если вы используете чат в нескольких вкладках, рекомендуем оставить только одну.",
"understand": "Понятно",
"status": {
"reconnecting": "Переподключение...",
"reconnected": "Соединение восстановлено",
"failed": "Ошибка подключения, обновите страницу"
}
},
"errors": {
"sendMessageFailed": "Ошибка отправки сообщения, попробуйте еще раз",
"createSessionFailed": "Ошибка создания сессии, обновите страницу"
}
}
@@ -0,0 +1,95 @@
{
"title": "Управление командами",
"summary": {
"total": "Всего команд",
"disabled": "Отключено",
"conflicts": "Конфликты"
},
"conflictAlert": {
"title": "Обнаружены конфликты команд",
"description": "Сейчас конфликтуют {count} пары команд. Это может привести к одновременному срабатыванию нескольких плагинов и непредсказуемому поведению.",
"hint": "Нажмите «Переименовать», чтобы изменить название конфликтующей команды."
},
"table": {
"headers": {
"command": "Команда",
"type": "Тип",
"plugin": "Плагин",
"description": "Описание",
"permission": "Доступ",
"status": "Статус",
"actions": "Действия"
}
},
"type": {
"command": "Команда",
"group": "Группа команд",
"subCommand": "Под-команда"
},
"status": {
"enabled": "Активна",
"disabled": "Отключена",
"conflict": "Конфликт"
},
"permission": {
"everyone": "Все",
"admin": "Админ"
},
"tooltips": {
"enable": "Включить",
"disable": "Выключить",
"rename": "Переименовать",
"viewDetails": "Подробности"
},
"dialogs": {
"rename": {
"title": "Переименование команды",
"newName": "Новое название",
"aliases": "Управление алиасами",
"addAlias": "Добавить алиас",
"cancel": "Отмена",
"confirm": "Подтвердить"
},
"details": {
"title": "Детали команды",
"type": "Тип команды",
"handler": "Обработчик (Handler)",
"module": "Путь к модулю",
"originalCommand": "Исходная команда",
"effectiveCommand": "Действующая команда",
"parentGroup": "Родительская группа",
"subCommands": "Под-команды",
"aliases": "Алиасы (Синонимы)",
"permission": "Требования прав",
"conflictStatus": "Статус конфликта"
}
},
"messages": {
"toggleSuccess": "Статус команды обновлен",
"toggleFailed": "Не удалось изменить статус команды",
"renameSuccess": "Команда переименована",
"renameFailed": "Ошибка переименования",
"loadFailed": "Ошибка загрузки списка команд",
"updateSuccess": "Обновлено успешно",
"updateFailed": "Ошибка обновления"
},
"search": {
"placeholder": "Поиск команд..."
},
"empty": {
"noCommands": "Команд не найдено",
"noCommandsDesc": "По вашему запросу не найдено ни одной команды"
},
"filters": {
"all": "Все",
"enabled": "Активные",
"disabled": "Отключенные",
"conflict": "Конфликтующие",
"byPlugin": "По плагину",
"byType": "По типу",
"byPermission": "По правам",
"byStatus": "По статусу",
"showSystemPlugins": "Показывать системные плагины",
"systemPluginConflictHint": "Конфликт затрагивает системный плагин, его нельзя скрыть до разрешения конфликта"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,129 @@
{
"title": "Конфигурация",
"subtitle": "Управление системными настройками",
"editor": {
"visual": "Визуальный редактор",
"code": "Редактор кода",
"revertCode": "Отменить изменения",
"applyConfig": "Применить",
"applyTip": "Кнопка «Применить» временно фиксирует изменения в визуальном редакторе. Чтобы сохранить их на постоянной основе, нажмите кнопку «Сохранить» в правом нижнем углу."
},
"actions": {
"save": "Сохранить",
"delete": "Удалить",
"add": "Добавить",
"reset": "Сбросить настройки",
"export": "Экспорт",
"import": "Импорт",
"validate": "Проверить"
},
"help": {
"documentation": "Документация",
"support": "Поддержка",
"helpText": "Нужна помощь? См. {documentation} или обратитесь в {support}.",
"helpPrefix": "Нужна помощь? См.",
"helpMiddle": "или обратитесь в",
"helpSuffix": "."
},
"messages": {
"configApplied": "Настройки применены образно. Нажмите «Сохранить» для окончательной записи.",
"configApplyError": "Ошибка применения: некорректный формат JSON.",
"unsavedChangesNotice": "Есть несохраненные изменения. Пожалуйста, нажмите «Сохранить», чтобы они вступили в силу.",
"saveSuccess": "Настройки успешно сохранены",
"saveError": "Ошибка при сохранении",
"loadError": "Ошибка при загрузке настроек",
"deleteSuccess": "Удалено",
"deleteError": "Ошибка удаления",
"updateSuccess": "Обновлено",
"updateError": "Ошибка обновления"
},
"sections": {
"general": "Основные",
"advanced": "Расширенные",
"security": "Безопасность",
"appearance": "Внешний вид",
"notification": "Уведомления"
},
"general": {
"botName": "Имя бота",
"language": "Язык интерфейса",
"timezone": "Часовой пояс",
"autoSave": "Автосохранение",
"debugMode": "Режим отладки"
},
"advanced": {
"logLevel": "Уровень логирования",
"maxConnections": "Макс. соединений",
"timeout": "Тайм-аут",
"retryAttempts": "Попытки повтора",
"cacheSize": "Размер кэша"
},
"security": {
"apiKey": "Ключ API",
"allowedHosts": "Разрешенные хосты",
"rateLimit": "Лимит запросов",
"encryption": "Шифрование"
},
"configSelection": {
"selectConfig": "Выбор конфигурации",
"normalConfig": "Обычная",
"systemConfig": "Системная"
},
"search": {
"placeholder": "Поиск по настройкам (поле/описание/подсказка)",
"noResult": "Совпадений не найдено"
},
"configManagement": {
"title": "Управление конфигурациями",
"description": "AstrBot поддерживает несколько конфигураций для разных ботов. По умолчанию используется «default».",
"newConfig": "Новая конфигурация",
"editConfig": "Изменить конфигурацию",
"manageConfigs": "Управление файлами...",
"configName": "Имя",
"fillConfigName": "Введите имя конфигурации",
"confirmDelete": "Вы уверены, что хотите удалить конфигурацию «{name}»? Это действие необратимо.",
"pleaseEnterName": "Пожалуйста, введите имя",
"createFailed": "Ошибка создания конфигурации",
"deleteFailed": "Ошибка удаления",
"updateFailed": "Ошибка обновления"
},
"buttons": {
"cancel": "Отмена",
"create": "Создать",
"update": "Обновить"
},
"codeEditor": {
"title": "Редактирование файла"
},
"fileUpload": {
"button": "Файлы",
"dialogTitle": "Загруженные файлы",
"dropzone": "Загрузить файлы",
"allowedTypes": "Разрешенные типы: {types}",
"empty": "Файлов нет",
"statusMissing": "Файл отсутствует",
"statusUnconfigured": "Не в конфиге",
"uploadSuccess": "Загружено файлов: {count}",
"uploadFailed": "Ошибка загрузки",
"loadFailed": "Ошибка получения списка файлов",
"fileTooLarge": "Файл слишком велик (макс. {max} МБ): {name}",
"deleteSuccess": "Файл удален",
"deleteFailed": "Ошибка удаления",
"addToConfig": "Добавлено в конфигурацию",
"fileCount": "Файлов: {count}",
"done": "Готово"
},
"unsavedChangesWarning": {
"dialogTitle": "Несохраненные изменения",
"leavePage": "У вас есть несохраненные изменения. Сохранить перед уходом?",
"switchConfig": "Переключение конфигурации приведет к потере несохраненных изменений. Сохранить?",
"options": {
"save": "Сохранить",
"saveAndSwitch": "Сохранить и переключить",
"discardAndSwitch": "Сбросить и переключить",
"closeCard": "Закрыть",
"confirm": "ОК",
"cancel": "Отмена"
}
}
}
@@ -0,0 +1,18 @@
{
"title": "Логи платформы",
"autoScroll": {
"enabled": "Автопрокрутка включена",
"disabled": "Автопрокрутка выключена"
},
"pipInstall": {
"button": "Установить pip-пакет",
"dialogTitle": "Установка Pip-пакета",
"packageLabel": "*Имя пакета, например: llmtuner",
"mirrorLabel": "Использовать зеркало PyPI (опционально)",
"mirrorHint": "Приоритет зеркала PyPI > настройки «Зеркало репозитория PyPI»",
"installButton": "Установить"
},
"debugHint": {
"text": "Для отображения Debug-логов необходимо установить соответствующий уровень в «Конфигурация → Система → Уровень логирования»"
}
}
@@ -0,0 +1,102 @@
{
"title": "Управление диалогами",
"subtitle": "Просмотр и управление историей сообщений",
"filters": {
"title": "Фильтры",
"platform": "ID бота",
"type": "Тип",
"search": "Поиск по ключевым словам",
"reset": "Сбросить"
},
"history": {
"title": "История",
"refresh": "Обновить"
},
"batch": {
"deleteSelected": "Удалить выбранные ({count})",
"exportSelected": "Экспорт выбранных ({count})"
},
"pagination": {
"itemsPerPage": "на странице",
"showingItems": "Показано {start}-{end} из {total}"
},
"table": {
"headers": {
"title": "Заголовок диалога",
"platform": "ID бота",
"type": "Тип сообщения",
"cid": "ID диалога",
"umo": "Источник сообщения",
"sessionId": "ID сессии",
"createdAt": "Создан",
"updatedAt": "Обновлен",
"actions": "Действия"
}
},
"actions": {
"view": "Просмотр",
"edit": "Редактировать",
"delete": "Удалить"
},
"messageTypes": {
"group": "Группа",
"friend": "ЛС",
"unknown": "Неизвестно"
},
"status": {
"noTitle": "Без заголовка",
"unknown": "Неизвестно",
"noData": "История диалогов пуста",
"emptyContent": "Содержимое диалога пусто",
"audioNotSupported": "Ваш браузер не поддерживает воспроизведение аудио."
},
"dialogs": {
"view": {
"title": "Детали диалога",
"editMode": "Режим редактирования",
"previewMode": "Режим просмотра",
"saveChanges": "Сохранить изменения",
"close": "Закрыть",
"confirmClose": "У вас есть несохраненные изменения. Вы уверены, что хотите закрыть?"
},
"edit": {
"title": "Изменить информацию",
"titleLabel": "Заголовок диалога",
"titlePlaceholder": "Введите заголовок",
"cancel": "Отмена",
"save": "Сохранить"
},
"delete": {
"title": "Подтверждение удаления",
"message": "Вы уверены, что хотите удалить диалог «{title}»? Это действие необратимо.",
"cancel": "Отмена",
"confirm": "Удалить"
},
"batchDelete": {
"title": "Массовое удаление",
"message": "Вы уверены, что хотите удалить {count} выбранных диалогов? Это действие необратимо!",
"andMore": "и еще {count}",
"cancel": "Отмена",
"confirm": "Удалить всё",
"warning": "Внимание: удаление нельзя будет отменить!"
}
},
"messages": {
"fetchError": "Не удалось загрузить список диалогов",
"saveSuccess": "Сохранено",
"saveError": "Ошибка сохранения",
"deleteSuccess": "Удалено",
"deleteError": "Ошибка удаления",
"historyError": "Не удалось загрузить историю диалога",
"historySaveSuccess": "История сохранена",
"historySaveError": "Ошибка сохранения истории",
"invalidJson": "Некорректный формат JSON",
"noItemSelected": "Сначала выберите диалоги для удаления",
"batchDeleteSuccess": "Успешно удалено {count} диалогов",
"batchDeleteError": "Ошибка массового удаления",
"batchDeletePartial": "Удаление завершено: успешно {deleted}, ошибок {failed}",
"exportSuccess": "Экспорт завершен",
"exportError": "Ошибка экспорта",
"noItemSelectedForExport": "Сначала выберите диалоги для экспорта"
}
}
@@ -0,0 +1,66 @@
{
"page": {
"title": "Запланированные задачи",
"beta": "Экспериментальные функции",
"subtitle": "Управление будущими задачами AstrBot. Бот автоматически проснется, выполнит задачу и отправит результат. Требуется включить «Проактивные способности» в конфигурации.",
"proactive": {
"supported": "Отправка результатов поддерживается на платформах: {platforms}",
"unsupported": "Нет платформ, поддерживающих проактивные сообщения. Включите их в настройках платформ."
}
},
"actions": {
"create": "Новая задача",
"refresh": "Обновить",
"delete": "Удалить",
"cancel": "Отмена",
"submit": "Создать"
},
"table": {
"title": "Список задач",
"empty": "Задач пока нет.",
"headers": {
"name": "Имя",
"type": "Тип",
"cron": "Cron",
"session": "ID сессии",
"nextRun": "Следующий запуск",
"lastRun": "Последний запуск",
"note": "Описание",
"actions": "Действия"
},
"type": {
"once": "Разовая",
"recurring": "Повторяющаяся",
"activeAgent": "Активный агент",
"workflow": "Рабочий процесс",
"unknown": "{type}"
},
"timezoneLocal": "Местное время",
"notAvailable": "—"
},
"form": {
"title": "Создать задачу",
"chatHint": "Вы можете ставить задачи прямо в чате, AstrBot создаст их автоматически без заполнения этой формы.",
"runOnce": "Разовая задача",
"name": "Имя задачи",
"note": "Описание",
"cron": "Cron-выражения",
"cronPlaceholder": "0 9 * * *",
"runAt": "Время запуска",
"session": "Целевая сессия (platform_id:message_type:session_id)",
"timezone": "Часовой пояс (опционально, напр. Europe/Moscow)",
"enabled": "Включено"
},
"messages": {
"loadFailed": "Ошибка загрузки задач",
"updateFailed": "Ошибка обновления",
"deleteSuccess": "Удалено",
"deleteFailed": "Ошибка удаления",
"sessionRequired": "Укажите сессию",
"noteRequired": "Заполните описание",
"cronRequired": "Укажите Cron-выражение",
"runAtRequired": "Выберите время запуска",
"createSuccess": "Задача создана",
"createFailed": "Ошибка создания"
}
}
@@ -0,0 +1,65 @@
{
"title": "Логи платформы",
"subtitle": "Мониторинг и статистика в реальном времени",
"lastUpdate": "Последнее обновление",
"status": {
"loading": "Загрузка...",
"dataError": "Ошибка получения данных",
"noticeError": "Ошибка получения объявлений",
"online": "В сети",
"uptime": "Время работы",
"memoryUsage": "Память"
},
"stats": {
"totalMessage": {
"title": "Всего сообщений",
"subtitle": "Все сообщения со всех платформ"
},
"onlinePlatform": {
"title": "Платформы",
"subtitle": "Количество подключенных платформ"
},
"runningTime": {
"title": "Время работы",
"subtitle": "Общее время работы системы",
"format": "{hours} ч. {minutes} мин. {seconds} сек."
},
"memoryUsage": {
"title": "Память",
"subtitle": "Использование оперативной памяти",
"cpuLoad": "Загрузка CPU",
"status": {
"good": "Отлично",
"normal": "Нормально",
"high": "Высокая"
}
}
},
"charts": {
"messageTrend": {
"title": "Тренды сообщений",
"subtitle": "Изменение количества сообщений во времени",
"totalMessages": "Всего сообщений",
"dailyAverage": "В среднем за день",
"growthRate": "Скорость роста",
"timeLabel": "Время",
"messageCount": "Кол-во сообщений",
"timeRanges": {
"1day": "За 1 день",
"3days": "За 3 дня",
"1week": "За 7 дней",
"1month": "За 30 дней"
}
},
"platformStat": {
"title": "Статистика по платформам",
"subtitle": "Распределение сообщений по платформам",
"total": "Всего",
"noData": "Нет данных по платформам",
"messageUnit": "шт.",
"platformCount": "Кол-во платформ",
"mostActive": "Самый активный",
"totalPercentage": "Доля от общего числа"
}
}
}
@@ -0,0 +1,358 @@
{
"title": "Плагины",
"subtitle": "Управление и настройка расширений системы",
"tabs": {
"installedPlugins": "Плагины AstrBot",
"market": "Магазин плагинов",
"installedMcpServers": "MCP",
"skills": "Навыки",
"handlersOperation": "Управление поведением"
},
"titles": {
"installedAstrBotPlugins": "Установленные плагины AstrBot"
},
"failedPlugins": {
"title": "Ошибка загрузки ({count})",
"hint": "Эти плагины не удалось загрузить. Вы можете попробовать перезагрузить их или удалить.",
"columns": {
"plugin": "Плагин",
"error": "Ошибка"
}
},
"search": {
"placeholder": "Поиск плагинов...",
"marketPlaceholder": "Поиск в магазине..."
},
"filters": {
"all": "Все"
},
"views": {
"card": "Плитка",
"list": "Список"
},
"buttons": {
"showSystemPlugins": "Показать системные",
"hideSystemPlugins": "Скрыть системные",
"install": "Установить",
"uninstall": "Удалить",
"update": "Обновить",
"reload": "Перезагрузить",
"enable": "Включить",
"disable": "Выключить",
"configure": "Настроить",
"viewInfo": "Детали",
"viewDocs": "Документация",
"viewRepo": "Репозиторий",
"close": "Закрыть",
"save": "Сохранить",
"saveAndClose": "Сохранить и закрыть",
"cancel": "Отмена",
"actions": "Действия",
"back": "Назад",
"selectFile": "Выбрать файл",
"refresh": "Обновить",
"updateAll": "Обновить все",
"deleteSource": "Удалить источник",
"reshuffle": "Мне повезет!"
},
"status": {
"enabled": "Включен",
"disabled": "Выключен",
"system": "Системный",
"loading": "Загрузка...",
"installed": "Установлен",
"unknown": "Неизвестно"
},
"tooltips": {
"enable": "Включить",
"disable": "Выключить",
"reload": "Перезагрузить",
"configure": "Настроить",
"viewInfo": "Просмотр поведения",
"viewDocs": "Документация",
"update": "Обновить",
"uninstall": "Удалить"
},
"table": {
"headers": {
"name": "Имя",
"description": "Описание",
"version": "Версия",
"author": "Автор",
"status": "Статус",
"actions": "Действия",
"stars": "Звезды",
"lastUpdate": "Обновлен",
"tags": "Теги",
"eventType": "Тип события",
"specificType": "Тип",
"trigger": "Триггер"
}
},
"empty": {
"noPlugins": "Плагины не найдены",
"noPluginsDesc": "Попробуйте установить новые плагины или включите отображение системных."
},
"market": {
"recommended": "🥳 Рекомендуем",
"allPlugins": "📦 Все плагины",
"showFullName": "Полное имя",
"devDocs": "Документация для разработчиков",
"submitRepo": "Добавить репозиторий",
"customSource": "Свои источники",
"source": "Источник",
"availableSources": "Доступные источники",
"sourceManagement": "Управление источниками",
"addSource": "Добавить источник",
"sourceName": "Имя",
"sourceUrl": "Исходный URL",
"defaultSource": "Источник по умолчанию",
"removeSource": "Удалить источник",
"confirmRemoveSource": "Вы уверены, что хотите удалить этот источник плагинов?",
"sourceAdded": "Источник успешно добавлен",
"sourceRemoved": "Источник удален",
"sourceError": "Ошибка операции",
"selectSource": "Выбрать источник",
"currentSource": "Текущий источник",
"editSource": "Изменить источник",
"sourceUpdated": "Источник обновлен",
"defaultOfficialSource": "Официальный источник",
"sourceExists": "Этот источник уже есть в списке",
"installPlugin": "Установить плагин",
"randomPlugins": "🎲 Случайные плагины",
"showRandomPlugins": "Показать случайные",
"hideRandomPlugins": "Скрыть случайные",
"sourceSafetyWarning": "Даже при использовании источников по умолчанию мы не можем гарантировать 100% безопасность и стабильность сторонних плагинов. Пожалуйста, будьте внимательны."
},
"sort": {
"by": "Сортировать по",
"default": "По умолчанию",
"installTime": "Дате установки",
"name": "Имени",
"stars": "Звездам",
"author": "Автору",
"updated": "Дате обновления",
"updateStatus": "Статусу обновления",
"ascending": "По возрастанию",
"descending": "По убыванию"
},
"tags": {
"danger": "Опасно"
},
"dialogs": {
"error": {
"title": "Ошибка",
"checkConsole": "Подробности смотрите в логах платформы"
},
"config": {
"title": "Настройка плагина",
"noConfig": "У этого плагина нет настраиваемых параметров"
},
"loading": {
"title": "Загрузка...",
"logs": "Логи"
},
"uninstall": {
"title": "Подтверждение удаления",
"message": "Вы уверены, что хотите удалить этот плагин?",
"deleteConfig": "Удалить файл конфигурации плагина",
"deleteData": "Удалить сохраненные данные плагина",
"configHint": "Конфиг находится в data/config",
"dataHint": "Данные находятся в data/plugin_data и data/plugins_data"
},
"install": {
"title": "Установка плагина",
"fromFile": "Из файла",
"fromUrl": "По ссылке",
"supportPlatformsCount": "Поддерживает платформ: {count}"
},
"danger_warning": {
"title": "Внимание!",
"message": "Этот плагин может содержать небезопасный код или функции, которые могут привести к нестабильности системы или потере данных. Вы уверены, что хотите продолжить установку?",
"confirm": "Продолжить",
"cancel": "Отмена"
},
"versionCompatibility": {
"title": "Предупреждение о версии",
"message": "Требуемая плагином версия AstrBot не совпадает с вашей текущей версией. Вы можете продолжить установку на свой страх и риск.",
"confirm": "Игнорировать и установить",
"cancel": "Отмена"
},
"forceUpdate": {
"title": "Новых версий не найдено",
"message": "Новых версий не обнаружено. Выполнить принудительную переустановку из удаленного репозитория?",
"confirm": "Принудительно"
},
"updateAllConfirm": {
"title": "Обновить всё",
"message": "Обновить все плагины ({count} шт.)? Это может занять некоторое время.",
"confirm": "Подтвердить"
}
},
"messages": {
"uninstalling": "Удаление",
"refreshing": "Обновление списка плагинов...",
"refreshSuccess": "Список плагинов обновлен",
"refreshFailed": "Ошибка при обновлении списка",
"operationFailed": "Ошибка операции",
"reloadSuccess": "Перезагрузка завершена",
"reloadFailed": "Ошибка перезагрузки",
"updateSuccess": "Обновление завершено",
"addSuccess": "Успешно добавлено",
"saveSuccess": "Сохранено",
"deleteSuccess": "Удалено",
"installing": "Установка из файла...",
"installingFromUrl": "Установка по ссылке...",
"installFailed": "Ошибка установки:",
"getMarketDataFailed": "Ошибка получения данных магазина:",
"hasUpdate": "Доступно обновление:",
"confirmDelete": "Вы уверены, что хотите удалить плагин?",
"fillUrlOrFile": "Укажите ссылку или выберите файл",
"dontFillBoth": "Пожалуйста, используйте либо ссылку, либо файл, но не оба сразу",
"supportedFormats": "Поддерживаются файлы плагинов в формате .zip",
"updateAllSuccess": "Все плагины успешно обновлены",
"updateAllFailed": "Ошибок при обновлении: {failed} из {total}:",
"fillSourceNameAndUrl": "Пожалуйста, введите имя и адрес источника",
"invalidUrl": "Введите корректный URL",
"enterJsonUrl": "Введите URL, возвращающий список плагинов в формате JSON"
},
"upload": {
"fromFile": "Загрузить файл",
"fromUrl": "Указать ссылку",
"selectFile": "Выбрать файл",
"enterUrl": "Ссылка на репозиторий"
},
"skills": {
"modeLocal": "Локальные навыки",
"modeNeo": "Навыки Neo",
"actions": "Действия",
"upload": "Загрузить навыки",
"refresh": "Обновить",
"empty": "Навыки не найдены",
"emptyHint": "Пожалуйста, загрузите архив с навыками",
"uploadDialogTitle": "Загрузка навыков",
"uploadHint": "Поддерживается массовая загрузка zip-архивов. Вы также можете перетащить файлы в это окно. Система автоматически проверит структуру каждого архива.",
"structureRequirement": "Архив должен содержать одну корневую папку (например, `skillname/`), внутри которой обязательно должен находиться файл `SKILL.md`.",
"abilityMultiple": "Поддержка массовой загрузки",
"abilityValidate": "Автопроверка `SKILL.md`",
"abilitySkip": "Пропуск дубликатов",
"selectFile": "Выбрать файл",
"selectFiles": "Выбрать файлы",
"dropzoneTitle": "Перетащите zip-файлы сюда",
"dropzoneAction": "или нажмите, чтобы выбрать файлы на компьютере",
"dropzoneHint": "Система проверит структуру архивов перед загрузкой",
"fileListTitle": "Очередь загрузки",
"fileListEmpty": "Здесь будет отображаться статус проверки и загрузки файлов",
"uploading": "Загрузка...",
"batchResultTitle": "Результаты загрузки",
"batchResultSummary": "Всего: {total}, успешно: {success}",
"batchSuccessList": "Успешно загружено",
"batchFailedList": "Ошибка загрузки",
"confirm": "ОК",
"confirmUpload": "Начать загрузку",
"cancel": "Отмена",
"statusWaiting": "В очереди",
"statusUploading": "Загрузка...",
"statusSuccess": "Готово",
"statusError": "Ошибка структуры",
"statusSkipped": "Пропущено",
"summaryTotal": "Всего: {count}",
"summaryReady": "Готовы: {count}",
"summarySuccess": "Успешно: {count}",
"summaryFailed": "Ошибок: {count}",
"summarySkipped": "Дубликатов: {count}",
"validationReady": "Ожидает загрузки (проверка структуры будет выполнена автоматически)",
"validationZipOnly": "Допускаются только zip-архивы",
"validationDuplicate": "Файл уже есть в списке, пропуск",
"validationUploading": "Проверка и загрузка...",
"validationUploadFailed": "Ошибка загрузки, попробуйте еще раз",
"validationUploadedAs": "Установлено как {name}",
"validationNoResult": "Результат не получен, проверьте логи платформы",
"noDescription": "Нет описания",
"path": "Путь",
"uploadSuccess": "Успешно загружено",
"uploadFailed": "Ошибка загрузки",
"download": "Скачать",
"downloadSuccess": "Скачивание начато",
"downloadFailed": "Ошибка скачивания",
"loadFailed": "Не удалось загрузить навыки",
"updateSuccess": "Обновлено",
"updateFailed": "Ошибка обновления",
"deleteTitle": "Подтверждение удаления",
"deleteMessage": "Вы уверены, что хотите удалить этот навык?",
"deleteSuccess": "Удалено",
"deleteFailed": "Ошибка удаления",
"neoSkillKey": "Фильтр по ключу",
"neoStatus": "Статус кандидата",
"neoStage": "Этап публикации",
"neoFilterHint": "Фильтрация записей о публикации",
"neoAll": "Все",
"neoCandidates": "Кандидаты Neo",
"neoReleases": "Релизы Neo",
"neoLoadFailed": "Ошибка загрузки данных Neo Skills",
"neoPass": "Одобрить",
"neoReject": "Отклонить",
"neoEvaluateSuccess": "Оценка обновлена",
"neoEvaluateFailed": "Ошибка обновления оценки",
"neoPromoteSuccess": "Опубликовано",
"neoPromoteFailed": "Ошибка публикации",
"neoRollback": "Откат",
"neoRollbackSuccess": "Откат выполнен",
"neoRollbackFailed": "Ошибка отката",
"neoDeactivate": "Деактивация",
"neoDeactivateSuccess": "Деактивировано",
"neoDeactivateFailed": "Ошибка деактивации",
"neoSync": "Синхронизация",
"neoSyncSuccess": "Синхронизировано",
"neoSyncFailed": "Ошибка синхронизации",
"neoDelete": "Удалить",
"neoDeleteSuccess": "Удалено",
"neoDeleteFailed": "Ошибка удаления",
"neoPayloadTitle": "Детали Neo Payload",
"neoPayloadFailed": "Ошибка чтения Payload",
"runtimeNoneWarning": "Среда выполнения Computer Use не задана. Навыки могут не работать, так как нет активного окружения.",
"runtimeHint": "Установите среду выполнения в «local» или «sandbox» в настройках способностей использования компьютера.",
"neoRuntimeRequired": "Neo Skills доступны только в среде sandbox с драйвером shipyard_neo.",
"sourceLocalOnly": "Локальный навык",
"sourceSandboxOnly": "Предустановленный Sandbox навык",
"sourceBoth": "Локальный + Sandbox",
"sandboxDiscoveryPending": "Предустановленные Sandbox навыки не найдены. Запустите сессию Sandbox хотя бы один раз.",
"sandboxPresetReadonly": "Предустановленные навыки Sandbox доступны только для чтения и не могут быть удалены здесь."
},
"card": {
"actions": {
"pluginConfig": "Настройки",
"uninstallPlugin": "Удалить",
"reloadPlugin": "Перезагрузить",
"togglePlugin": "Плагин",
"viewHandlers": "Действия",
"updateTo": "Обновить до",
"reinstall": "Переустановить"
},
"status": {
"hasUpdate": "Доступно обновление",
"disabled": "Плагин выключен",
"handlersCount": "действий",
"supportPlatform": "Платформы",
"supportPlatformsCount": "Платформ: {count}",
"astrbotVersion": "Требуемая версия AstrBot"
},
"alt": {
"logo": "логотип",
"extensionIcon": "иконка расширения"
},
"errors": {
"confirmNotRegistered": "$confirm не зарегистрирован"
}
},
"conflicts": {
"title": "Конфликт команд",
"message": "Обнаружены конфликтующие команды. Это может привести к некорректной работе. Рекомендуется разрешить конфликты в панели «Управление командами».",
"pairs": "конфликтующих пар",
"goToManage": "Управление",
"later": "Позже"
},
"pluginChangelog": {
"menuTitle": "Журнал изменений"
}
}
@@ -0,0 +1,118 @@
{
"title": "Детали базы знаний",
"backToList": "К списку",
"tabs": {
"overview": "Обзор",
"documents": "Документы",
"retrieval": "Поиск",
"sessions": "Сессии",
"settings": "Настройки"
},
"overview": {
"title": "Информация",
"name": "Название",
"description": "Описание",
"emoji": "Иконка",
"createdAt": "Создана",
"updatedAt": "Обновлена",
"stats": "Статистика",
"docCount": "Количество документов",
"chunkCount": "Количество фрагментов",
"embeddingModel": "Embedding модель",
"rerankModel": "Rerank модель",
"notSet": "не выбрано"
},
"documents": {
"title": "Список документов",
"upload": "Загрузить",
"empty": "Документов нет",
"name": "Имя файла",
"type": "Тип",
"size": "Размер",
"chunks": "Фрагменты",
"createdAt": "Дата загрузки",
"actions": "Действия",
"view": "Смотреть",
"delete": "Удалить",
"deleteConfirm": "Вы уверены, что хотите удалить «{name}»?",
"deleteWarning": "Это удалит файл и все его фрагменты из индекса.",
"uploading": "Загрузка...",
"uploadSuccess": "Файл успешно загружен",
"uploadFailed": "Ошибка загрузки",
"deleteSuccess": "Файл удален",
"deleteFailed": "Ошибка удаления"
},
"upload": {
"title": "Добавление контента",
"selectFile": "Файл",
"dropzone": "Нажмите или перетащите файл сюда",
"supportedFormats": "Форматы: ",
"maxSize": "Максимум: 128MB",
"chunkSettings": "Фрагментация",
"batchSettings": "Пакетная обработка",
"cleaningSettings": "Очистка данных",
"enableCleaning": "Включить очистку контента",
"cleaningProvider": "Сервис для очистки",
"cleaningProviderHint": "LLM провайдер для суммаризации и извлечения смыслов из веб-страниц",
"chunkSize": "Размер чанка",
"chunkSizeHint": "Символов в блоке (по умолчанию: 512)",
"chunkOverlap": "Перекрытие",
"chunkOverlapHint": "Перекрытие между блоками (по умолчанию: 50)",
"batchSize": "Размер пакета",
"batchSizeHint": "Блоков за один запрос (по умолчанию: 32)",
"tasksLimit": "Лимит задач",
"tasksLimitHint": "Макс. параллельных потоков (по умолчанию: 3)",
"maxRetries": "Попытки",
"maxRetriesHint": "Повторов при сбое (по умолчанию: 3)",
"cancel": "Отмена",
"submit": "Загрузить",
"fileRequired": "Пожалуйста, выберите файл",
"fileUpload": "Загрузка файла",
"fromUrl": "Из URL",
"urlPlaceholder": "Ссылка на веб-страницу",
"urlRequired": "Введите URL",
"urlHint": "Контент будет автоматически извлечен со страницы. Убедитесь, что сайт разрешает доступ роботам.",
"beta": "Бета-версия"
},
"retrieval": {
"title": "Поиск и проверка",
"subtitle": "Проверьте качество поиска (Dense & Sparse) по вашей базе знаний",
"query": "Тестовый запрос",
"queryPlaceholder": "Что вы хотите найти?",
"search": "Найти",
"searching": "Ищем...",
"results": "Результаты поиска",
"noResults": "Релевантный контент не найден",
"tryDifferentQuery": "Попробуйте изменить формулировку запроса",
"settings": "Параметры поиска",
"topK": "Количество результатов",
"topKHint": "Сколько фрагментов возвращать",
"enableRerank": "Включить Rerank",
"enableRerankHint": "Применить переранжирование для повышения точности",
"score": "Вес (Score)",
"document": "Документ",
"chunk": "Фрагмент #{index}",
"content": "Текст",
"charCount": "{count} симв.",
"searchSuccess": "Поиск завершен, найдено: {count}",
"searchFailed": "Ошибка выполнения поиска",
"queryRequired": "Введите поисковый запрос"
},
"settings": {
"title": "Общие настройки базы",
"basic": "Основные",
"retrieval": "Поиск",
"chunkSize": "Размер чанка",
"chunkOverlap": "Перекрытие",
"topKDense": "Вернуть (Dense)",
"topKSparse": "Вернуть (Sparse)",
"topMFinal": "Итоговый результат",
"enableRerank": "Включить Rerank",
"embeddingProvider": "Провайдер Embedding",
"rerankProvider": "Провайдер Rerank",
"save": "Сохранить",
"saveSuccess": "Настройки сохранены",
"saveFailed": "Ошибка сохранения",
"tips": "Внимание! Изменение этих параметров повлияет на будущую выдачу базы знаний."
}
}
@@ -0,0 +1,55 @@
{
"title": "Просмотр документа",
"backToKB": "К базе знаний",
"info": {
"title": "Информация о документе",
"name": "Имя файла",
"type": "Формат",
"size": "Размер",
"chunkCount": "Количество фрагментов",
"createdAt": "Загружен"
},
"chunks": {
"title": "Фрагменты текста",
"empty": "Фрагменты не найдены",
"index": "Индекс",
"content": "Текст",
"charCount": "Символов",
"actions": "Действия",
"view": "Детали",
"edit": "Изменить",
"delete": "Удалить",
"preview": "Обзор",
"search": "Поиск по документу",
"searchPlaceholder": "Найти во фрагментах...",
"showing": "Показано",
"deleteConfirm": "Удалить этот фрагмент?",
"deleteSuccess": "Фрагмент удален",
"deleteFailed": "Ошибка удаления"
},
"edit": {
"title": "Редактирование фрагмента",
"content": "Текст",
"cancel": "Отмена",
"save": "Сохранить",
"saveSuccess": "Фрагмент обновлен",
"saveFailed": "Ошибка сохранения"
},
"delete": {
"title": "Удаление",
"confirmText": "Вы уверены?",
"warning": "Удаление фрагмента может ухудшить качество ответов AI по этой теме.",
"cancel": "Отмена",
"confirm": "Удалить",
"deleteSuccess": "Удаление выполнено",
"deleteFailed": "Ошибка удаления"
},
"view": {
"title": "Детальный просмотр",
"index": "Индекс",
"content": "Текст",
"charCount": "Символов",
"vecDocId": "ID вектора",
"close": "Закрыть"
}
}
@@ -0,0 +1,67 @@
{
"title": "Управление базами знаний",
"subtitle": "Централизованное управление всеми знаниями AstrBot",
"list": {
"title": "Мои базы знаний",
"subtitle": "Все доступные коллекции знаний",
"create": "Создать базу",
"refresh": "Обновить",
"empty": "Баз знаний пока нет",
"loading": "Загрузка...",
"documents": "док.",
"chunks": "фрагм.",
"sessionConfig": "Профиль"
},
"card": {
"edit": "Изменить",
"delete": "Удалить",
"open": "Открыть",
"docCount": "Документов: {count}",
"chunkCount": "Фрагментов: {count}"
},
"create": {
"title": "Создание базы знаний",
"nameLabel": "Название",
"namePlaceholder": "Придумайте имя для базы",
"descriptionLabel": "Описание",
"descriptionPlaceholder": "Для чего нужна эта база?",
"emojiLabel": "Иконка",
"embeddingModelLabel": "Embedding модель",
"rerankModelLabel": "Rerank модель (опционально)",
"providerInfo": "Провайдер: {id} | Размерность: {dimensions}",
"rerankProviderInfo": "Провайдер: {id}",
"cancel": "Отмена",
"submit": "Создать",
"nameRequired": "Введите название базы знаний"
},
"edit": {
"title": "Редактирование",
"submit": "Сохранить"
},
"delete": {
"title": "Удаление",
"confirmText": "Вы уверены, что хотите удалить базу знаний «{name}»?",
"warning": "Это действие необратимо. Все документы, фрагменты и настройки будут навсегда удалены.",
"cancel": "Отмена",
"confirm": "Удалить"
},
"emoji": {
"title": "Выберите иконку",
"close": "Закрыть",
"categories": {
"books": "Книги и документы",
"emotions": "Эмоции",
"objects": "Вещи",
"symbols": "Символы"
}
},
"messages": {
"createSuccess": "База знаний создана",
"createFailed": "Ошибка создания",
"updateSuccess": "Обновлено успешно",
"updateFailed": "Ошибка обновления",
"deleteSuccess": "Удалено успешно",
"deleteFailed": "Ошибка удаления",
"loadError": "Не удалось загрузить список"
}
}
@@ -0,0 +1,18 @@
{
"dialog": {
"title": "Помощник по миграции",
"warning": "👋 Добро пожаловать в v4.0.0! В этой версии мы оптимизировали формат хранения данных. Обнаружена необходимость миграции базы данных.",
"loading": "Загрузка списка платформ...",
"loadError": "Ошибка загрузки, попробуйте еще раз",
"noPlatforms": "Конфигурации платформ не найдены",
"retry": "Повторить",
"startMigration": "Начать миграцию",
"migrating": "Выполняется миграция...",
"migratingSubtitle": "Пожалуйста, подождите. Не закрывайте это окно до завершения процесса.",
"migrationError": "Ошибка миграции",
"success": "Миграция успешно завершена!",
"completed": "Миграция выполнена",
"restartRecommended": "Рекомендуется перезапустить приложение, чтобы все изменения вступили в силу.",
"restartNow": "Перезапустить сейчас"
}
}
@@ -0,0 +1,146 @@
{
"page": {
"description": "Управление настройками и поведением персонажей"
},
"buttons": {
"create": "Создать персонажа",
"createFirst": "Создать первого персонажа",
"edit": "Изменить",
"delete": "Удалить",
"cancel": "Отмена",
"save": "Сохранить",
"move": "Переместить",
"addDialogPair": "Добавить пример диалога"
},
"labels": {
"presetDialogs": "Примеры диалогов ({count})",
"createdAt": "Создан",
"updatedAt": "Обновлен"
},
"form": {
"personaId": "ID персонажа",
"systemPrompt": "Системный промпт",
"customErrorMessage": "Свое сообщение об ошибке (опционально)",
"customErrorMessageHelp": "Это сообщение будет отправлено пользователю при сбое запроса к LLM. Если оставить пустым, будет использовано системное сообщение по умолчанию.",
"presetDialogs": "Примеры диалогов",
"presetDialogsHelp": "Добавьте примеры взаимодействия, чтобы помочь AI лучше понять свою роль и стиль общения.",
"userMessage": "Сообщение пользователя",
"assistantMessage": "Ответ AI",
"tools": "Инструменты / MCP серверы",
"toolsHelp": "Выберите инструменты, доступные этому персонажу. Инструменты позволяют AI взаимодействовать с внешним миром: искать в интернете, выполнять расчеты и т.д.",
"toolsSelection": "Выбор инструментов",
"selectAllTools": "Выбрать все",
"clearAllTools": "Очистить всё",
"allSelected": "Выбрано всё",
"mcpServersQuickSelect": "Быстрый выбор MCP серверов",
"searchTools": "Поиск инструментов",
"selectedTools": "Выбранные инструменты",
"noToolsAvailable": "Нет доступных инструментов",
"noToolsFound": "Инструменты не найдены",
"loadingTools": "Загрузка инструментов...",
"allToolsAvailable": "Использовать все доступные инструменты",
"noToolsSelected": "Инструменты не выбраны",
"skills": "Навыки (Skills)",
"skillsHelp": "Выберите навыки, доступные этому персонажу. Навыки предоставляют AI готовые сценарии и правила работы.",
"skillsAllAvailable": "По умолчанию использовать все навыки",
"skillsSelectSpecific": "Выбрать определенные навыки",
"searchSkills": "Поиск навыков",
"selectedSkills": "Выбранные навыки",
"noSkillsAvailable": "Нет доступных навыков",
"noSkillsFound": "Навыки не найдены",
"loadingSkills": "Загрузка навыков...",
"allSkillsAvailable": "Использовать все доступные навыки",
"noSkillsSelected": "Навыки не выбраны",
"skillsRuntimeNoneWarning": "Среда выполнения Computer Use не задана. Навыки могут не работать, так как нет активного окружения.",
"createInFolder": "Будет создан в папке «{folder}»",
"rootFolder": "Все персонажи"
},
"dialog": {
"create": {
"title": "Создание персонажа"
},
"edit": {
"title": "Редактирование персонажа"
}
},
"empty": {
"title": "Персонажи не настроены",
"description": "Самое время создать одного!",
"folderEmpty": "Папка пуста",
"folderEmptyDescription": "Создайте нового персонажа или папку, чтобы начать"
},
"validation": {
"required": "Это поле обязательно для заполнения",
"minLength": "Минимум {min} символов",
"alphanumeric": "Разрешены только латинские буквы, цифры, подчёркивания и дефисы",
"dialogRequired": "{type} не может быть пустым",
"personaIdExists": "Персонаж с таким ID уже существует"
},
"messages": {
"loadError": "Не удалось загрузить список персонажей",
"saveSuccess": "Сохранено",
"saveError": "Ошибка сохранения",
"deleteConfirm": "Вы уверены, что хотите удалить персонажа «{id}»? Это действие необратимо.",
"deleteSuccess": "Удалено",
"deleteError": "Ошибка удаления"
},
"persona": {
"personasTitle": "Персонаж",
"toolsCount": "инстр.",
"skillsCount": "навыков",
"contextMenu": {
"moveTo": "Переместить в..."
},
"messages": {
"moveSuccess": "Персонаж перемещен",
"moveError": "Не удалось переместить персонажа"
}
},
"folder": {
"sidebarTitle": "Папки",
"rootFolder": "Корень",
"foldersTitle": "Папки",
"noFolders": "Папок нет",
"createButton": "Новая папка",
"searchPlaceholder": "Поиск папок...",
"form": {
"name": "Имя папки",
"description": "Описание (опционально)"
},
"validation": {
"nameRequired": "Имя папки не может быть пустым"
},
"contextMenu": {
"open": "Открыть",
"rename": "Переименовать",
"moveTo": "Переместить в...",
"delete": "Удалить"
},
"createDialog": {
"title": "Создать папку",
"createButton": "Создать"
},
"renameDialog": {
"title": "Переименовать папку"
},
"deleteDialog": {
"title": "Удаление папки",
"message": "Вы уверены, что хотите удалить папку «{name}»?",
"warning": "Все персонажи из этой папки будут перемещены в корневой каталог."
},
"messages": {
"createSuccess": "Папка создана",
"createError": "Ошибка создания папки",
"renameSuccess": "Папка переименована",
"renameError": "Ошибка переименования папки",
"deleteSuccess": "Папка удалена",
"deleteError": "Ошибка удаления папки"
}
},
"moveDialog": {
"title": "Перемещение",
"description": "Выберите папку для «{name}»",
"success": "Объект перемещен",
"error": "Ошибка перемещения"
}
}
@@ -0,0 +1,135 @@
{
"title": "Боты",
"subtitle": "Управление адаптерами платформ для подключения к мессенджерам",
"adapters": "Адаптеры платформ",
"addAdapter": "Создать бота",
"emptyText": "Боты не настроены. Нажмите «Создать бота», чтобы начать.",
"viewWebhook": "Показать Webhook",
"webhookCopied": "URL скопирован в буфер обмена",
"webhookCopyFailed": "Не удалось скопировать, сделайте это вручную",
"webhookDialog": {
"title": "Адрес Webhook",
"description": "Используйте этот адрес для обратных вызовов. Убедитесь, что ваш AstrBot доступен из интернета. Рекомендуется указать «Внешний URL для Webhook» в Конфигурация -> Система.",
"close": "Закрыть"
},
"details": {
"adapterType": "Тип адаптера",
"token": "Токен",
"description": "Описание"
},
"logs": {
"title": "Логи платформы",
"expand": "Развернуть",
"collapse": "Свернуть"
},
"dialog": {
"add": "Добавить",
"edit": "Изменить",
"adapter": "Бот",
"refresh": "Обновить",
"cancel": "Отмена",
"save": "Сохранить",
"addPlatform": "Создать бота",
"connectTitle": "Подключение к {name}",
"viewTutorial": "Открыть руководство",
"noTemplates": "Шаблоны не найдены",
"idConflict": {
"title": "Конфликт ID",
"message": "Бот с ID «{id}» уже существует. Пожалуйста, используйте уникальный ID.",
"confirm": "Понятно"
},
"securityWarning": {
"title": "Безопасность",
"aiocqhttpTokenMissing": "Для защиты соединения крайне рекомендуется установить ws_reverse_token. Работа без токена небезопасна.",
"learnMore": "Подробнее"
},
"invalidPlatformId": "ID платформы не может содержать символы ':' или '!'."
},
"createDialog": {
"step1Title": "Выберите мессенджер",
"step1Hint": "Куда вы хотите подключить бота? (QQ, Telegram, Discord, WeChat и др.)",
"platformTypeLabel": "Платформа",
"configFileTitle": "Файл конфигурации",
"optional": "опционально",
"configHint": "Как настроить бота? Конфиг содержит модель, персонажа, базу знаний и набор плагинов.",
"configDefaultHint": "По умолчанию используется профиль «default». Вы сможете изменить его позже.",
"useExistingConfig": "Использовать существующий конфиг",
"selectConfigLabel": "Выберите профиль",
"createNewConfig": "Создать новый профиль",
"newConfigNameLabel": "Имя нового профиля",
"newConfigTitle": "Создание нового профиля",
"newConfigLoadFailed": "Не удалось загрузить шаблон конфигурации",
"addRouteRule": "Добавить правило маршрутизации",
"viewMode": "Просмотр",
"editMode": "Редактирование",
"noRouteRules": "Правила маршрутизации не заданы, будет использоваться профиль по умолчанию",
"sessionIdPlaceholder": "ID сессии или *",
"allSessions": "Все сессии",
"configMissing": "Файл конфигурации не найден",
"routeHint": "* При получении сообщения AstrBot ищет первое совпадение в списке сверху вниз. Используйте слэш-команду /sid, чтобы узнать ID текущей сессии. Если совпадений нет, используется профиль по умолчанию.",
"warningContinue": "Игнорировать и создать",
"warningEditAgain": "Вернуться к редактированию",
"configDrawerTitle": "Управление профилями",
"configDrawerIdLabel": "ID",
"configTableHeaders": {
"configId": "ID связанного профиля",
"scope": "Область применения"
},
"routeTableHeaders": {
"source": "Источник (тип:ID)",
"config": "Файл конфига",
"actions": "Действия"
},
"messageTypeOptions": {
"all": "Все сообщения",
"group": "Групповые (GroupMessage)",
"friend": "Личные (FriendMessage)"
},
"messageTypeLabels": {
"all": "Все",
"group": "Группа",
"friend": "ЛС"
}
},
"messages": {
"updateSuccess": "Обновлено!",
"addSuccess": "Добавлено!",
"deleteSuccess": "Удалено!",
"statusUpdateSuccess": "Статус обновлен!",
"deleteConfirm": "Вы уверены, что хотите удалить этого бота?",
"configNotFoundOpenConfig": "Целевой конфиг не найден. Открыта страница настроек для проверки.",
"updateMissingPlatformId": "Ошибка обновления: отсутствует ID платформы.",
"platformUpdateFailed": "Не удалось обновить платформу.",
"addSuccessWithConfig": "Бот успешно добавлен, профиль обновлен",
"configIdMissing": "Не удалось получить ID конфигурации.",
"routingUpdateFailed": "Ошибка обновления маршрутов: {message}",
"createConfigFailed": "Ошибка создания профиля: {message}",
"platformIdMissing": "Не удалось получить ID платформы.",
"routingSaveFailed": "Ошибка сохранения маршрутов: {message}"
},
"status": {
"enabled": "Включен",
"disabled": "Выключен",
"connecting": "Подключение",
"connected": "Подключен",
"disconnected": "Отключен",
"error": "Ошибка"
},
"runtimeStatus": {
"running": "Работает",
"error": "Ошибка",
"pending": "Ожидание",
"stopped": "Остановлен",
"unknown": "Неизвестно",
"errors": "ошибок"
},
"errorDialog": {
"title": "Детали ошибки",
"platformId": "ID платформы",
"errorCount": "Кол-во ошибок",
"lastError": "Последняя ошибка",
"occurredAt": "Время",
"traceback": "Стек вызовов",
"close": "Закрыть"
}
}
@@ -0,0 +1,151 @@
{
"title": "Провайдеры моделей",
"subtitle": "Настройка AI моделей для диалогов. Также поддерживает Dify, Coze, а также внешние Agent-сервисы.",
"providers": {
"title": "Сервис-провайдеры",
"settings": "Настройки",
"addProvider": "Добавить провайдера",
"providerType": "Тип провайдера",
"tabs": {
"all": "Все",
"chatCompletion": "Диалоги",
"agentRunner": "Агенты",
"speechToText": "STT (Речь -> Текст)",
"textToSpeech": "TTS (Текст -> Речь)",
"embedding": "Эмбеддинги",
"rerank": "Rerank (Ранжирование)"
},
"empty": {
"all": "Провайдеры не добавлены. Нажмите «Добавить провайдера», чтобы начать.",
"typed": "Провайдеры типа «{type}» не найдены."
},
"description": {
"openai": "Поддерживаются все провайдеры, совместимые с OpenAI API.",
"vllm_rerank": "Также поддерживает Jina AI, Cohere, PPIO и другие.",
"default": "Преобразование речи в текст"
}
},
"availability": {
"title": "Доступность провайдеров",
"subtitle": "Статус определяется путем выполнения тестового запроса. Может взиматься плата согласно тарифу API.",
"refresh": "Проверить статус",
"noData": "Нажмите «Проверить статус», чтобы узнать доступность моделей",
"available": "Доступен",
"unavailable": "Недоступен",
"pending": "Проверка...",
"errorMessage": "Ошибка",
"test": "Тест"
},
"logs": {
"title": "Логи сервиса",
"expand": "Развернуть",
"collapse": "Свернуть"
},
"dialogs": {
"addProvider": {
"title": "Новый провайдер",
"tabs": {
"basic": "Диалоги",
"agentRunner": "Агенты",
"speechToText": "Преобразование текста в речь",
"textToSpeech": "Переранжирование",
"embedding": "Эмбеддинги",
"rerank": "API Key"
},
"noTemplates": "Шаблоны для этого типа не найдены"
},
"config": {
"addTitle": "Добавить",
"editTitle": "Изменить",
"provider": "Провайдер",
"cancel": "Отмена",
"save": "Сохранить"
},
"settings": {
"title": "Общие настройки провайдеров",
"sessionSeparation": {
"title": "Изоляция провайдеров по сессиям",
"description": "Позволяет выбирать независимых провайдеров для генерации текста, TTS и STT в каждой конкретной сессии."
},
"close": "Закрыть"
}
},
"messages": {
"success": {
"update": "Обновлено!",
"add": "Добавлено!",
"delete": "Удалено!",
"statusUpdate": "Статус обновлен!",
"sessionSeparation": "Настройки изоляции сохранены"
},
"error": {
"sessionSeparation": "Не удалось загрузить настройки изоляции",
"fetchStatus": "Не удалось получить статус провайдеров",
"testError": "Тест {id} провален: {error}"
},
"confirm": {
"delete": "Вы уверены, что хотите удалить провайдера «{id}»?"
}
},
"providerTypes": {
"title": "Тип провайдера"
},
"providerSources": {
"title": "Источник провайдера",
"add": "Добавить",
"empty": "Источники не найдены",
"selectHint": "Пожалуйста, выберите источник провайдера",
"selectCreated": "Выбрать существующий источник",
"save": "Сохранить конфиг",
"saveAndFetchModels": "Сохранить и загрузить модели",
"fetchModels": "Загрузить список моделей",
"saveSuccess": "Источник успешно сохранен",
"saveError": "Ошибка сохранения источника",
"deleteConfirm": "Вы уверены, что хотите удалить источник «{id}»? Все связанные конфигурации моделей будут удалены.",
"deleteSuccess": "Источник удален",
"deleteError": "Ошибка удаления",
"enabled": "Включен",
"disabled": "Выключен",
"advancedConfig": "Расширенные настройки...",
"fields": {
"name": "Имя",
"apiKey": "Base URL",
"baseUrl": "Base URL"
},
"hints": {
"id": "Уникальный ID источника",
"key": "Ваш серетный API-ключ",
"apiBase": "Адрес API точки входа (Endpoint URL)",
"proxy": "Прокси сервер (HTTP/HTTPS), напр. http://127.0.0.1:7890. Используется только для запросов к этому провайдеру."
},
"labels": {
"proxy": "Прокси"
}
},
"models": {
"available": "Доступные модели",
"configured": "Настроенные модели",
"empty": "Модели не настроены. Нажмите «Загрузить список моделей» выше.",
"noModelsFound": "Модели не найдены",
"fetchError": "Не удалось получить список моделей",
"addSuccess": "Модель {model} успешно добавлена",
"deleteConfirm": "Вы уверены, что хотите удалить модель «{id}»?",
"deleteSuccess": "Модель удалена",
"deleteError": "Ошибка удаления модели",
"testSuccess": "Тест модели «{id}» пройден успешно",
"testError": "Тест модели провален",
"searchPlaceholder": "Поиск по имени или ID",
"manualAddButton": "Добавить вручную",
"manualDialogTitle": "Произвольная модель",
"manualDialogModelLabel": "Код модели (напр. gpt-4o-mini)",
"manualDialogPreviewLabel": "Отображаемый ID (авто)",
"manualDialogPreviewHint": "Будет выглядеть как: SourceID/ModelID",
"manualModelRequired": "Укажите ID модели",
"manualModelExists": "Эта модель уже добавлена",
"configure": "Настроить",
"tooltips": {
"providerId": "ID провайдера",
"modelId": "ID модели"
}
}
}
@@ -0,0 +1,130 @@
{
"title": "Управление сессиями",
"subtitle": "Настройка индивидуальных правил для конкретных диалогов. Эти правила имеют приоритет над глобальной конфигурацией.",
"buttons": {
"refresh": "Обновить",
"edit": "Изменить",
"editRule": "Редактировать правило",
"deleteAllRules": "Удалить все правила",
"addRule": "Добавить правило",
"save": "Сохранить",
"cancel": "Отмена",
"delete": "Удалить",
"clear": "Очистить",
"next": "Далее",
"editCustomName": "Изменить заметку",
"batchDelete": "Массовое удаление"
},
"customRules": {
"title": "Пользовательские правила",
"rulesCount": "правил",
"hasRules": "Настроено",
"noRules": "Индивидуальных правил нет",
"noRulesDesc": "Нажмите «Добавить правило», чтобы задать настройки для конкретного диалога",
"serviceConfig": "Сервис",
"pluginConfig": "Плагины",
"kbConfig": "База знаний",
"providerConfig": "Модель",
"configured": "Настроено",
"noCustomName": "Без заметки"
},
"quickEditName": {
"title": "Редактирование заметки"
},
"search": {
"placeholder": "Поиск сессии..."
},
"table": {
"headers": {
"umoInfo": "Источник (UMO)",
"rulesOverview": "Обзор правил",
"actions": "Действия"
}
},
"persona": {
"none": "Из конфигурации"
},
"provider": {
"followConfig": "Из конфигурации"
},
"addRule": {
"title": "Добавление правила",
"description": "Выберите источник сообщения (UMO) для настройки. Индивидуальные правила приоритетнее глобальных. Используйте команду /sid в чате, чтобы узнать информацию об источнике.",
"selectUmo": "Выберите сессию",
"noUmos": "Нет доступных сессий"
},
"ruleEditor": {
"title": "Редактор правил",
"description": "Настройте поведение для этой сессии. Настройки ниже перекроют глобальный конфиг.",
"serviceConfig": {
"title": "Сервисные настройки",
"sessionEnabled": "Обрабатывать сообщения",
"llmEnabled": "Использовать LLM",
"ttsEnabled": "Использовать TTS",
"customName": "Заметка для сессии"
},
"providerConfig": {
"title": "Выбор моделей",
"chatProvider": "Чат-модель",
"sttProvider": "STT (Распознавание)",
"ttsProvider": "TTS (Озвучка)"
},
"personaConfig": {
"title": "Персона",
"selectPersona": "Выберите Persona",
"hint": "При выборе Persona все диалоги из этого источника будут использовать именно её."
},
"pluginConfig": {
"title": "Плагины",
"disabledPlugins": "Отключенные плагины",
"hint": "Выберите плагины, которые нужно ОТКЛЮЧИТЬ в этой сессии. Остальные останутся активными."
},
"kbConfig": {
"title": "База знаний",
"selectKbs": "Выбор баз знаний",
"topK": "Количество результатов (Top K)",
"enableRerank": "Использовать Rerank"
}
},
"deleteConfirm": {
"title": "Подтверждение",
"message": "Удалить все настройки для этой сессии? Будут применены глобальные настройки."
},
"batchDeleteConfirm": {
"title": "Массовое удаление",
"message": "Удалить {count} выбранных правил? Будут применены глобальные настройки."
},
"batchOperations": {
"title": "Массовые операции",
"hint": "Быстрое изменение настроек для группы сессий",
"scope": "Область применения",
"scopeSelected": "Выбранные",
"scopeAll": "Все сессии",
"scopeGroup": "Все группы",
"scopePrivate": "Личные диалоги",
"llmStatus": "Статус LLM",
"ttsStatus": "Статус TTS",
"chatProvider": "Чат-модель",
"ttsProvider": "TTS-модель",
"apply": "Применить"
},
"status": {
"enabled": "Включено",
"disabled": "Выключено"
},
"messages": {
"refreshSuccess": "Данные обновлены",
"loadError": "Ошибка загрузки",
"saveSuccess": "Настройки сохранены",
"saveError": "Ошибка сохранения",
"clearSuccess": "Очищено",
"clearError": "Ошибка очистки",
"deleteSuccess": "Удалено",
"deleteError": "Ошибка удаления",
"noChanges": "Изменений не обнаружено",
"batchDeleteSuccess": "Массовое удаление выполнено",
"batchDeleteError": "Ошибка массового удаления",
"batchUpdateError": "Ошибка пакетного обновления",
"batchUpdateSuccess": "Пакетное обновление успешно выполнено"
}
}
@@ -0,0 +1,180 @@
{
"network": {
"title": "Сеть",
"githubProxy": {
"title": "Зеркало GitHub",
"subtitle": "Адрес для ускорения загрузки плагинов и обновлений AstrBot. Особенно актуально для пользователей из Китая. Все адреса предоставляются как есть, если обновление не удается — проверьте доступность выбранного зеркала.",
"label": "Выбрать ускоритель GitHub"
},
"proxySelector": {
"title": "Ускорение GitHub",
"noProxy": "Не использовать",
"useProxy": "Включить",
"testConnection": "Проверить соединение",
"available": "Доступен",
"unavailable": "Недоступен",
"custom": "Свой вариант"
}
},
"theme": {
"title": "Тема оформления",
"subtitle": "Настройка основных и дополнительных цветов. Изменения вступают в силу немедленно и сохраняются в браузере.",
"customize": {
"title": "Цвета темы",
"primary": "Основной",
"secondary": "Дополнительный",
"reset": "Сбросить"
}
},
"system": {
"title": "Система",
"restart": {
"title": "Перезапуск",
"subtitle": "Выполнить мягкий перезапуск AstrBot",
"button": "Перезагрузить"
},
"migration": {
"title": "Миграция данных в v4.0.0",
"subtitle": "Если у вас возникли проблемы с совместимостью данных после обновления, запустите помощник вручную.",
"button": "Запустить миграцию"
},
"backup": {
"title": "Резервное копирование",
"subtitle": "Важнейший инструмент для безопасного переноса данных между серверами.",
"button": "Управление бэкапами"
}
},
"sidebar": {
"title": "Боковая панель",
"customize": {
"title": "Настройка меню",
"subtitle": "Перетаскивайте элементы, чтобы изменить их порядок или скрыть в группе «Дополнительно». Настройки сохраняются локально в браузере.",
"reset": "Сбросить порядок",
"mainItems": "Основные разделы",
"moreItems": "Дополнительно"
}
},
"backup": {
"dialog": {
"title": "Резервное копирование"
},
"tabs": {
"export": "Экспорт",
"import": "Импорт",
"list": "Список копий"
},
"export": {
"title": "Создать резервную копию",
"description": "Экспорт всех данных в ZIP-архив, включая базы данных, базу знаний, конфигурации и вложения.",
"includes": "Включает: основную БД, векторные индексы знаний, файлы конфигурации, медиа-вложения.",
"button": "Начать экспорт",
"processing": "Экспорт...",
"wait": "Пожалуйста, подождите, мы упаковываем данные...",
"completed": "Готово!",
"download": "Скачать архив",
"another": "Создать новый",
"failed": "Ошибка экспорта",
"retry": "Повторить"
},
"import": {
"title": "Восстановление из копии",
"warning": "⚠️ Внимание! Импорт полностью удалит и перезапишет текущие данные! Убедитесь, что у вас есть копия текущего состояния.",
"selectFile": "Выберите ZIP-архив",
"uploadAndCheck": "Загрузить и проверить",
"uploading": "Загрузка...",
"uploadWait": "Файл передается на сервер...",
"uploadInit": "Инициализация...",
"uploadingChunks": "Передача фрагментов...",
"uploadComplete": "Загружено, идет сборка...",
"checking": "Проверка структуры...",
"invalidBackup": "Некорректный файл резервной копии",
"backupContents": "Состав архива",
"tables": "таблиц БД",
"knowledgeBases": "баз знаний",
"configFiles": "конфигов",
"confirmImport": "Подтвердите импорт",
"button": "Начать восстановление",
"processing": "Восстановление...",
"wait": "Идет процесс развертывания данных...",
"completed": "Восстановление успешно завершено!",
"restartRequired": "Данные восстановлены. Необходимо немедленно перезапустить AstrBot для вступления изменений в силу.",
"restartNow": "Перезапустить сейчас",
"failed": "Ошибка импорта",
"retry": "Повторить",
"version": {
"backupVersion": "Версия бэкапа",
"currentVersion": "Текущая версия",
"backupTime": "Дата создания",
"matchTitle": "✅ Версии совпадают",
"matchMessage": "Импорт перезапишет все текущие данные, включая:\n• Основную БД (чаты, настройки)\n• Базы знаний\n• Плагины и их данные\n• Файлы конфигурации\n\nЭто действие необратимо! Продолжить?",
"minorDiffTitle": "⚠️ Разница в минорной версии",
"minorDiffMessage": "Разница в минорных версиях обычно допустима, но структура данных могла немного измениться. Все текущие данные будут удалены!\n\nПродолжить импорт?",
"majorDiffTitle": "⛔ Импорт невозможен",
"majorDiffMessage": "Версии основного выпуска различаются. Импорт между мажорными версиями может привести к фатальному повреждению данных.\nИспользуйте AstrBot той же основной версии."
}
},
"list": {
"empty": "Резервные копии не найдены",
"refresh": "Обновить список",
"confirmDelete": "Вы уверены, что хотите безвозвратно удалить эту копию?",
"uploaded": "Загружено",
"restore": "Восстановить из этого файла",
"rename": "Переименовать",
"renameTitle": "Переименование файла",
"newName": "Новое имя",
"renameHint": "Разрешены буквы, цифры, точки, дефисы и подчеркивания",
"renameRequired": "Введите имя файла",
"renameInvalidChars": "Имя содержит недопустимые символы",
"renameFailed": "Ошибка переименования",
"ftpHint": "Для больших архивов вы можете загружать их напрямую в папку data/backups через FTP/SFTP."
}
},
"apiKey": {
"title": "API Keys",
"manageTitle": "Ключи доступа разработчика",
"subtitle": "Управление токенами для доступа к открытому HTTP API AstrBot.",
"name": "Имя ключа",
"expiresInDays": "Срок действия",
"expiryOptions": {
"day1": "1 день",
"day7": "7 дней",
"day30": "30 дней",
"day90": "90 дней",
"permanent": "Бессрочно"
},
"permanentWarning": "Бессрочные ключи менее безопасны. Пожалуйста, храните их в надежном месте.",
"scopes": "Область доступа (Scopes)",
"create": "Создать API Key",
"revoke": "Отозвать",
"delete": "Удалить",
"copy": "Копировать",
"docsLink": "Документация API",
"plaintextHint": "Обязательно сохраните ключ сейчас. После закрытия окна вы больше не сможете увидеть его значение.",
"empty": "Ключи не созданы",
"status": {
"active": "Активен",
"inactive": "Неактивен"
},
"table": {
"name": "Имя",
"prefix": "Префикс",
"scopes": "Права",
"status": "Статус",
"lastUsed": "Использован",
"createdAt": "Создан",
"actions": "Действия"
},
"messages": {
"loadFailed": "Не удалось загрузить ключи",
"scopeRequired": "Выберите хотя бы одну область доступа",
"createSuccess": "API Key создан",
"createFailed": "Ошибка создания ключа",
"revokeSuccess": "Ключ отозван",
"revokeFailed": "Ошибка отзыва ключа",
"deleteSuccess": "Ключ удален",
"deleteFailed": "Ошибка удаления ключа",
"copySuccess": "Ключ скопирован",
"copyFailed": "Ошибка копирования"
}
}
}
@@ -0,0 +1,65 @@
{
"page": {
"title": "Оркестрация SubAgent",
"beta": "Экспериментально",
"subtitle": "Основной LLM может напрямую использовать свои инструменты или делегировать задачи SubAgent через handoff."
},
"actions": {
"refresh": "Обновить",
"save": "Сохранить",
"add": "Добавить SubAgent",
"delete": "Удалить",
"close": "Закрыть"
},
"switches": {
"enable": "Включить оркестрацию SubAgent",
"enableHint": "Включить функциональность под-агентов",
"dedupe": "Дедупликация инструментов основного LLM (скрывать инструменты, дублируемые SubAgent)",
"dedupeHint": "Удалить дублирующиеся инструменты из основного агента"
},
"description": {
"disabled": "Выключено: SubAgent отключен; основной LLM подключает инструменты согласно правилам персонажа (все по умолчанию) и вызывает их напрямую.",
"enabled": "Включено: основной LLM сохраняет свои инструменты и подключает инструменты делегирования transfer_to_*. При дедупликации инструменты, пересекающиеся с SubAgent, удаляются из основного набора."
},
"section": {
"title": "Субагенты",
"globalSettings": "Глобальные настройки"
},
"cards": {
"statusEnabled": "Включено",
"statusDisabled": "Отключено",
"unnamed": "Безымянный SubAgent",
"transferPrefix": "передать_{name}",
"switchLabel": "Включить",
"previewTitle": "Предпросмотр: инструмент handoff, видимый основному LLM",
"personaChip": "Персонаж: {id}",
"personaPreview": "ПРЕДПРОСМОТР ПЕРСОНАЖА"
},
"form": {
"nameLabel": "Имя агента (используется для transfer_to_{name})",
"nameHint": "Используйте строчные латинские буквы и подчеркивания; имя должно быть глобально уникальным.",
"providerLabel": "Chat Provider (опционально)",
"providerHint": "Оставьте пустым, чтобы использовать глобальный провайдер по умолчанию.",
"personaLabel": "Выберите персонажа",
"personaHint": "SubAgent наследует системные настройки и инструменты выбранного персонажа.",
"descriptionLabel": "Описание для основного LLM (используется для принятия решения о handoff)",
"descriptionHint": "Отображается как описание инструмента transfer_to_* — будьте кратки и ясны."
},
"messages": {
"loadConfigFailed": "Не удалось загрузить конфигурацию",
"loadPersonaFailed": "Не удалось загрузить список персонажей",
"nameMissing": "У SubAgent отсутствует имя",
"nameInvalid": "Недопустимое имя SubAgent: только строчные латинские буквы/цифры/подчеркивания, должно начинаться с буквы",
"nameDuplicate": "Дублирующееся имя SubAgent: {name}",
"personaMissing": "У SubAgent {name} не выбран персонаж",
"saveSuccess": "Успешно сохранено",
"saveFailed": "Ошибка сохранения",
"nameRequired": "Имя обязательно",
"namePattern": "Только строчные буквы, цифры и подчеркивание"
},
"empty": {
"title": "Агенты не настроены",
"subtitle": "Добавьте первого под-агента, чтобы начать",
"action": "Создать первого агента"
}
}
@@ -0,0 +1,195 @@
{
"title": "Инструменты и функции",
"subtitle": "Управление MCP-серверами и доступными функциями",
"tooltip": {
"info": "Что такое Function Calling и MCP?",
"marketplace": "Обзор и установка MCP-серверов от сообщества",
"serverConfig": "Конфигурация MCP-серверов (stdio) поддерживает следующие поля:\ncommand: имя команды (например, python или uv)\nargs: массив аргументов (например, [\"run\", \"server.py\"])\nenv: объект переменных окружения (например, {\"api_key\": \"abc\"})\ncwd: рабочий каталог (например, /path/to/server)\nencoding: кодировка вывода (по умолчанию utf-8)\nПодробности см. в документации MCP.\n⚠️ Если вы используете Docker, устанавливайте сервера в смонтированную директорию data."
},
"tabs": {
"local": "Локальные сервера",
"marketplace": "Магазин MCP"
},
"mcpServers": {
"title": "MCP Сервера",
"buttons": {
"refresh": "Обновить",
"add": "Добавить сервер",
"useTemplateStdio": "Шаблон Stdio",
"useTemplateStreamableHttp": "Шаблон Streamable HTTP",
"useTemplateSse": "Шаблон SSE",
"sync": "Синхронизировать"
},
"empty": "MCP-сервера не найдены. Нажмите «Добавить сервер».",
"status": {
"noTools": "Нет доступных инструментов",
"availableTools": "Доступные инструменты",
"configSummary": "Конфигурация: {keys}",
"noConfig": "Конфигурация не задана"
}
},
"functionTools": {
"title": "Функции (Tools)",
"buttons": {
"view": "Показать инструменты"
},
"search": "Поиск по функциям",
"empty": "Доступные инструменты не найдены",
"description": "Описание функции",
"parameters": "Параметры",
"noParameters": "У этого инструмента нет параметров",
"table": {
"paramName": "Параметр",
"type": "Тип",
"description": "Описание",
"required": "Обяз.",
"origin": "Источник",
"originName": "Имя источника",
"actions": "Действия"
}
},
"marketplace": {
"title": "Магазин MCP-серверов",
"search": "Поиск по магазину",
"buttons": {
"refresh": "Обновить",
"detail": "Инфо",
"import": "Импорт"
},
"loading": "Загрузка списка серверов...",
"empty": "Доступных MCP-серверов не найдено",
"status": {
"availableTools": "Инструментов: {count}",
"noToolsInfo": "Нет данных об инструментах"
}
},
"dialogs": {
"addServer": {
"title": "Добавление MCP-сервера",
"editTitle": "Редактирование MCP-сервера",
"fields": {
"name": "Название сервера",
"nameRequired": "Название обязательно",
"enable": "Включить сервер",
"config": "Конфигурация сервера"
},
"errors": {
"configEmpty": "Конфигурация не может быть пустой",
"jsonFormat": "Ошибка формата JSON: {error}",
"jsonParse": "Ошибка разбора JSON: {error}"
},
"buttons": {
"cancel": "Отмена",
"save": "Сохранить",
"testConnection": "Тест связи",
"sync": "Синхронизировать"
},
"tips": {
"timeoutConfig": "Тайм-аут вызова инструментов настраивается отдельно на странице конфигурации"
}
},
"serverDetail": {
"title": "Детали сервера",
"installConfig": "Конфигурация установки",
"availableTools": "Список инструментов",
"buttons": {
"close": "Закрыть",
"importConfig": "Импортировать конфиг"
}
},
"confirmDelete": "Вы уверены, что хотите удалить сервер «{name}»?",
"syncProvider": {
"title": "Синхронизация MCP",
"subtitle": "Загрузка конфигурации MCP-серверов от провайдера",
"steps": {
"selectProvider": "Шаг 1: Провайдер",
"configureAuth": "Шаг 2: Авторизация",
"syncServers": "Шаг 3: Синхронизация"
},
"providers": {
"modelscope": "ModelScope",
"description": "ModelScope — это сообщество моделей с открытым исходным кодом, предоставляющее различные MCP-сервера для AI-сервисов"
},
"fields": {
"provider": "Выберите провайдера",
"accessToken": "Токен доступа",
"tokenRequired": "Токен обязателен",
"tokenHint": "Введите ваш токен доступа ModelScope"
},
"buttons": {
"cancel": "Отмена",
"previous": "Назад",
"next": "Далее",
"sync": "Начать",
"getToken": "Получить токен"
},
"status": {
"selectProvider": "Пожалуйста, выберите провайдера MCP-серверов",
"enterToken": "Введите токен для продолжения",
"readyToSync": "Готов к синхронизации"
},
"messages": {
"syncSuccess": "MCP-сервера успешно синхронизированы!",
"syncError": "Ошибка синхронизации: {error}",
"tokenHelp": "Как получить токен ModelScope? Нажмите кнопку справа для инструкции"
}
}
},
"messages": {
"getServersError": "Ошибка получения списка серверов: {error}",
"getToolsError": "Ошибка получения списка инструментов: {error}",
"saveSuccess": "Настройки сохранены!",
"saveError": "Ошибка сохранения: {error}",
"deleteSuccess": "Сервер удален успешно!",
"deleteError": "Ошибка удаления: {error}",
"updateSuccess": "Обновлено успешно!",
"updateError": "Ошибка обновления: {error}",
"getMarketError": "Не удалось загрузить магазин MCP: {error}",
"importError": {
"noConfig": "У этого сервера нет доступной конфигурации",
"invalidFormat": "Неверный формат конфигурации",
"failed": "Импорт не удался: {error}"
},
"configParseError": "Ошибка разбора конфигурации: {error}",
"noAvailableConfig": "Конфигурация отсутствует",
"toggleToolSuccess": "Статус инструмента изменен!",
"toggleToolError": "Не удалось изменить статус: {error}",
"testError": "Ошибка теста связи: {error}"
},
"syncProvider": {
"title": "Синхронизация серверов MCP",
"subtitle": "Синхронизировать конфигурации серверов MCP от провайдеров с локальными",
"steps": {
"selectProvider": "Шаг 1: Выберите провайдер",
"configureAuth": "Шаг 2: Настройте аутентификацию",
"syncServers": "Шаг 3: Синхронизируйте серверы"
},
"providers": {
"modelscope": "ModelScope",
"description": "ModelScope — это сообщество открытых моделей, предоставляющее серверы MCP для различных сервисов машинного обучения и ИИ"
},
"fields": {
"provider": "Выберите провайдер",
"accessToken": "Токен доступа",
"tokenRequired": "Требуется токен доступа",
"tokenHint": "Введите ваш токен доступа ModelScope"
},
"buttons": {
"cancel": "Отмена",
"previous": "Назад",
"next": "Далее",
"sync": "Начать синхронизацию",
"getToken": "Получить токен"
},
"status": {
"selectProvider": "Пожалуйста, выберите провайдер сервера MCP",
"enterToken": "Введите токен доступа для продолжения",
"readyToSync": "Готово к синхронизации конфигураций серверов"
},
"messages": {
"syncSuccess": "Серверы MCP успешно синхронизированы!",
"syncError": "Ошибка синхронизации: {error}",
"tokenHelp": "Как получить токен доступа ModelScope? Нажмите кнопку справа для получения инструкций"
}
}
}
@@ -0,0 +1,10 @@
{
"title": "Трассировка (Trace)",
"autoScroll": {
"enabled": "Автопрокрутка: ВКЛ",
"disabled": "Автопрокрутка: ВЫКЛ"
},
"hint": "В данный момент записываются только вызовы моделей основного агента AstrBot. Система будет совершенствоваться.",
"recording": "Запись...",
"paused": "Пауза"
}
@@ -0,0 +1,37 @@
{
"greeting": {
"morning": "Доброе утро, добро пожаловать в AstrBot",
"afternoon": "Добрый день, добро пожаловать в AstrBot",
"evening": "Добрый вечер, добро пожаловать в AstrBot",
"newYear": "С Новым Годом!"
},
"subtitle": "Сначала пройдите базовое руководство. Настройку платформ и провайдеров моделей можно завершить позже.",
"announcement": {
"title": "Объявление"
},
"onboard": {
"title": "Быстрый старт",
"subtitle": "Вы можете выполнить первичную настройку прямо здесь.",
"step1Title": "Настройка платформ",
"step1Desc": "Подключите AstrBot к QQ, Lark, WeChat, Telegram и другим мессенджерам.",
"step2Title": "Настройка AI моделей",
"step2Desc": "Выберите и настройте AI провайдеров для AstrBot.",
"configure": "Настроить",
"skip": "Пропустить",
"pending": "Ожидает",
"completed": "Готово",
"skipped": "Пропущено",
"platformLoadFailed": "Ошибка загрузки конфигурации платформ",
"providerLoadFailed": "Ошибка загрузки конфигурации провайдеров",
"providerUpdateFailed": "Ошибка обновления провайдера по умолчанию в файле default",
"providerDefaultUpdated": "Провайдер {id} установлен по умолчанию в файле default"
},
"resources": {
"title": "Ресурсы",
"githubDesc": "Поставьте нам звезду на GitHub!",
"docsTitle": "Документация",
"docsDesc": "Официальная документация AstrBot.",
"afdianTitle": "Afdian",
"afdianDesc": "Поддержите команду AstrBot через Afdian."
}
}
@@ -0,0 +1,39 @@
{
"network": {
"timeout": "Время ожидания запроса истекло, попробуйте позже",
"connection": "Ошибка сетевого соединения. Проверьте интернет",
"server": "Внутренняя ошибка сервера. Обратитесь в поддержку",
"unavailable": "Сервис временно недоступен",
"forbidden": "Доступ запрещен"
},
"validation": {
"required": "Это поле обязательно для заполнения",
"invalid": "Неверный формат ввода",
"tooLong": "Введено слишком много символов",
"tooShort": "Введено слишком мало символов",
"email": "Укажите корректный email",
"url": "Укажите корректный URL",
"number": "Введите числовое значение"
},
"auth": {
"unauthorized": "Авторизация не выполнена, войдите снова",
"forbidden": "Недостаточно прав для выполнения операции",
"tokenExpired": "Сессия истекла, пожалуйста, войдите заново",
"invalidCredentials": "Неверное имя пользователя или пароль"
},
"file": {
"uploadFailed": "Загрузка файла не удалась",
"invalidFormat": "Неподдерживаемый формат файла",
"tooLarge": "Файл слишком большой",
"notFound": "Файл не найден"
},
"operation": {
"failed": "Операция не удалась",
"cancelled": "Операция отменена",
"notSupported": "Действие не поддерживается",
"conflict": "Конфликт операций, попробуйте позже"
},
"browser": {
"audioNotSupported": "Ваш браузер не поддерживает воспроизведение аудио."
}
}
@@ -0,0 +1,23 @@
{
"operation": {
"saved": "Сохранено",
"created": "Создано",
"updated": "Обновлено успешно",
"deleted": "Удалено",
"uploaded": "Загружено",
"downloaded": "Скачано",
"imported": "Импорт завершен",
"exported": "Экспорт завершен",
"copied": "Скопировано в буфер",
"sent": "Отправлено"
},
"connection": {
"connected": "Подключено",
"authenticated": "Вход выполнен",
"synchronized": "Синхронизация завершена"
},
"validation": {
"valid": "Проверка пройдена",
"completed": "Готово"
}
}
@@ -0,0 +1,25 @@
{
"required": "Это поле обязательно",
"email": "Введите корректный email",
"url": "Введите корректный URL",
"number": "Введите число",
"min": "Минимальное значение: {min}",
"max": "Максимальное значение: {max}",
"minLength": "Минимум {length} симв.",
"maxLength": "Максимум {length} симв.",
"pattern": "Неверный формат",
"unique": "Такое значение уже существует",
"confirm": "Значения не совпадают",
"fileSize": "Размер файла не должен превышать {size}MB",
"fileType": "Неподдерживаемый тип файла",
"required_field": "Заполните обязательные поля",
"invalid_format": "Некорректный формат",
"password_too_short": "Пароль должен быть не менее 8 символов",
"password_too_weak": "Пароль слишком слабый",
"invalid_phone": "Некорректный номер телефона",
"invalid_date": "Некорректная дата",
"date_range": "Неверный диапазон дат",
"upload_failed": "Загрузка не удалась",
"network_error": "Ошибка сети, попробуйте снова",
"operation_cannot_be_undone": "⚠️ Это действие нельзя отменить, будьте осторожны!"
}
@@ -876,7 +876,8 @@
]
},
"regex": {
"description": "分段正则表达式"
"description": "分段正则表达式",
"hint": "用于按正则规则识别分段点。建议使用能匹配分隔符的表达式。"
},
"split_words": {
"description": "分段词列表",
@@ -1524,4 +1525,4 @@
"helpMiddle": "或",
"helpSuffix": "。"
}
}
}
@@ -23,6 +23,9 @@
"placeholder": "搜索插件...",
"marketPlaceholder": "搜索市场插件..."
},
"filters": {
"all": "全部"
},
"views": {
"card": "卡片视图",
"list": "列表视图"
@@ -122,10 +125,14 @@
"sourceSafetyWarning": "即使是默认插件源,我们也不能完全保证插件的稳定性和安全性,使用前请谨慎核查。"
},
"sort": {
"by": "排序方式",
"default": "默认排序",
"installTime": "最后修改时间",
"name": "名称",
"stars": "Star数",
"author": "作者名",
"updated": "更新时间",
"updateStatus": "更新状态",
"ascending": "升序",
"descending": "降序"
},
+91 -1
View File
@@ -42,7 +42,7 @@ import zhCNErrors from './locales/zh-CN/messages/errors.json';
import zhCNSuccess from './locales/zh-CN/messages/success.json';
import zhCNValidation from './locales/zh-CN/messages/validation.json';
// 英文翻译
// English translation
import enUSCommon from './locales/en-US/core/common.json';
import enUSActions from './locales/en-US/core/actions.json';
import enUSStatus from './locales/en-US/core/status.json';
@@ -83,6 +83,47 @@ import enUSErrors from './locales/en-US/messages/errors.json';
import enUSSuccess from './locales/en-US/messages/success.json';
import enUSValidation from './locales/en-US/messages/validation.json';
// Russian translation
import ruRUCommon from './locales/ru-RU/core/common.json';
import ruRUActions from './locales/ru-RU/core/actions.json';
import ruRUStatus from './locales/ru-RU/core/status.json';
import ruRUNavigation from './locales/ru-RU/core/navigation.json';
import ruRUHeader from './locales/ru-RU/core/header.json';
import ruRUShared from './locales/ru-RU/core/shared.json';
import ruRUChat from './locales/ru-RU/features/chat.json';
import ruRUExtension from './locales/ru-RU/features/extension.json';
import ruRUConversation from './locales/ru-RU/features/conversation.json';
import ruRUSessionManagement from './locales/ru-RU/features/session-management.json';
import ruRUToolUse from './locales/ru-RU/features/tool-use.json';
import ruRUProvider from './locales/ru-RU/features/provider.json';
import ruRUPlatform from './locales/ru-RU/features/platform.json';
import ruRUConfig from './locales/ru-RU/features/config.json';
import ruRUConfigMetadata from './locales/ru-RU/features/config-metadata.json';
import ruRUConsole from './locales/ru-RU/features/console.json';
import ruRUTrace from './locales/ru-RU/features/trace.json';
import ruRUAbout from './locales/ru-RU/features/about.json';
import ruRUSettings from './locales/ru-RU/features/settings.json';
import ruRUAuth from './locales/ru-RU/features/auth.json';
import ruRUChart from './locales/ru-RU/features/chart.json';
import ruRUDashboard from './locales/ru-RU/features/dashboard.json';
import ruRUCron from './locales/ru-RU/features/cron.json';
import ruRUAlkaidIndex from './locales/ru-RU/features/alkaid/index.json';
import ruRUAlkaidKnowledgeBase from './locales/ru-RU/features/alkaid/knowledge-base.json';
import ruRUAlkaidMemory from './locales/ru-RU/features/alkaid/memory.json';
import ruRUKnowledgeBaseIndex from './locales/ru-RU/features/knowledge-base/index.json';
import ruRUKnowledgeBaseDetail from './locales/ru-RU/features/knowledge-base/detail.json';
import ruRUKnowledgeBaseDocument from './locales/ru-RU/features/knowledge-base/document.json';
import ruRUPersona from './locales/ru-RU/features/persona.json';
import ruRUMigration from './locales/ru-RU/features/migration.json';
import ruRUCommand from './locales/ru-RU/features/command.json';
import ruRUSubagent from './locales/ru-RU/features/subagent.json';
import ruRUWelcome from './locales/ru-RU/features/welcome.json';
import ruRUErrors from './locales/ru-RU/messages/errors.json';
import ruRUSuccess from './locales/ru-RU/messages/success.json';
import ruRUValidation from './locales/ru-RU/messages/validation.json';
// 组装翻译对象
export const translations = {
'zh-CN': {
@@ -182,6 +223,55 @@ export const translations = {
success: enUSSuccess,
validation: enUSValidation
}
},
'ru-RU': {
core: {
common: ruRUCommon,
actions: ruRUActions,
status: ruRUStatus,
navigation: ruRUNavigation,
header: ruRUHeader,
shared: ruRUShared
},
features: {
chat: ruRUChat,
extension: ruRUExtension,
conversation: ruRUConversation,
'session-management': ruRUSessionManagement,
tooluse: ruRUToolUse,
provider: ruRUProvider,
platform: ruRUPlatform,
config: ruRUConfig,
'config-metadata': ruRUConfigMetadata,
console: ruRUConsole,
trace: ruRUTrace,
about: ruRUAbout,
settings: ruRUSettings,
auth: ruRUAuth,
chart: ruRUChart,
dashboard: ruRUDashboard,
cron: ruRUCron,
alkaid: {
index: ruRUAlkaidIndex,
'knowledge-base': ruRUAlkaidKnowledgeBase,
memory: ruRUAlkaidMemory
},
'knowledge-base': {
index: ruRUKnowledgeBaseIndex,
detail: ruRUKnowledgeBaseDetail,
document: ruRUKnowledgeBaseDocument
},
persona: ruRUPersona,
migration: ruRUMigration,
command: ruRUCommand,
subagent: ruRUSubagent,
welcome: ruRUWelcome
},
messages: {
errors: ruRUErrors,
success: ruRUSuccess,
validation: ruRUValidation
}
}
};
@@ -27,6 +27,7 @@ const customizer = useCustomizerStore();
const theme = useTheme();
const { t } = useI18n();
const route = useRoute();
const LAST_BOT_ROUTE_KEY = 'astrbot:last_bot_route';
let dialog = ref(false);
let accountWarning = ref(false)
let updateStatusDialog = ref(false);
@@ -402,15 +403,32 @@ const viewMode = computed({
});
// viewMode bot
watch(() => customizer.viewMode, (newMode, oldMode) => {
if (newMode === 'bot' && oldMode === 'chat') {
// chat bot
if (route.path !== '/') {
router.push('/');
// bot
// route bot
watch(() => route.fullPath, (newPath) => {
if (customizer.viewMode === 'bot' && typeof window !== 'undefined') {
try {
localStorage.setItem(LAST_BOT_ROUTE_KEY, newPath);
} catch (e) {
console.error('Failed to save last bot route to localStorage:', e);
}
}
});
// viewMode
watch(() => customizer.viewMode, (newMode, oldMode) => {
if (newMode === 'bot' && oldMode === 'chat' && typeof window !== 'undefined') {
// chat bot bot
let lastBotRoute = '/';
try {
lastBotRoute = localStorage.getItem(LAST_BOT_ROUTE_KEY) || '/';
} catch (e) {
console.error('Failed to read last bot route from localStorage:', e);
}
router.push(lastBotRoute);
}
});
// Merry Christmas! 🎄
const isChristmas = computed(() => {
const today = new Date();
+2
View File
@@ -0,0 +1,2 @@
export const normalizeTextInput = (value: unknown): string =>
typeof value === 'string' ? value : '';
+7 -1
View File
@@ -13,9 +13,11 @@
</v-select>
<v-text-field
class="config-search-input"
v-model="configSearchKeyword"
:model-value="configSearchKeyword"
@update:model-value="onConfigSearchInput"
prepend-inner-icon="mdi-magnify"
:label="tm('search.placeholder')"
clearable
hide-details
density="compact"
rounded="md"
@@ -211,6 +213,7 @@ import {
useConfirmDialog
} from '@/utils/confirmDialog';
import UnsavedChangesConfirmDialog from '@/components/config/UnsavedChangesConfirmDialog.vue';
import { normalizeTextInput } from '@/utils/inputValue';
export default {
name: 'ConfigPage',
@@ -419,6 +422,9 @@ export default {
},
methods: {
onConfigSearchInput(value) {
this.configSearchKeyword = normalizeTextInput(value);
},
extractConfigTypeFromHash(hash) {
const rawHash = String(hash || '');
const lastHashIndex = rawHash.lastIndexOf('#');
+10 -4
View File
@@ -353,10 +353,11 @@
<v-window-item value="search">
<div class="search-container pa-4">
<v-form @submit.prevent="searchKnowledgeBase" class="d-flex align-center">
<v-text-field v-model="searchQuery" :label="tm('search.queryLabel')"
<v-text-field :model-value="searchQuery"
@update:model-value="onSearchQueryInput" :label="tm('search.queryLabel')"
append-icon="mdi-magnify" variant="outlined" class="flex-grow-1 me-2"
@click:append="searchKnowledgeBase" @keyup.enter="searchKnowledgeBase"
:placeholder="tm('search.queryPlaceholder')" hide-details></v-text-field>
:placeholder="tm('search.queryPlaceholder')" hide-details clearable></v-text-field>
<v-select v-model="topK" :items="[3, 5, 10, 20]"
:label="tm('search.resultCountLabel')" variant="outlined"
@@ -434,6 +435,7 @@
import axios from 'axios';
import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
import { useModuleI18n } from '@/i18n/composables';
import { normalizeTextInput } from '@/utils/inputValue';
export default {
name: 'KnowledgeBase',
@@ -580,6 +582,9 @@ export default {
this.getProviderList();
},
methods: {
onSearchQueryInput(value) {
this.searchQuery = normalizeTextInput(value);
},
getSelectedGitHubProxy() {
if (typeof window === "undefined" || !window.localStorage) return "";
return localStorage.getItem("githubProxyRadioValue") === "1"
@@ -903,7 +908,8 @@ export default {
},
searchKnowledgeBase() {
if (!this.searchQuery.trim()) {
const query = normalizeTextInput(this.searchQuery).trim();
if (!query) {
this.showSnackbar(this.tm('messages.pleaseEnterSearchContent'), 'warning');
return;
}
@@ -914,7 +920,7 @@ export default {
axios.get(`/api/plug/alkaid/kb/collection/search`, {
params: {
collection_name: this.currentKB.collection_name,
query: this.searchQuery,
query,
top_k: this.topK
}
})
+19 -8
View File
@@ -37,10 +37,12 @@
<h3>{{ tm('search.title') }}</h3>
<v-card variant="outlined" class="mt-2 pa-3">
<div>
<v-text-field v-model="searchMemoryUserId" :label="tm('search.userIdLabel')" variant="outlined" density="compact" hide-details
class="mb-2"></v-text-field>
<v-text-field v-model="searchQuery" :label="tm('search.queryLabel')" variant="outlined" density="compact" hide-details
@keyup.enter="searchMemory" class="mb-2"></v-text-field>
<v-text-field :model-value="searchMemoryUserId"
@update:model-value="onSearchMemoryUserIdInput" :label="tm('search.userIdLabel')" variant="outlined" density="compact" hide-details
class="mb-2" clearable></v-text-field>
<v-text-field :model-value="searchQuery"
@update:model-value="onSearchQueryInput" :label="tm('search.queryLabel')" variant="outlined" density="compact" hide-details
@keyup.enter="searchMemory" class="mb-2" clearable></v-text-field>
<v-btn color="info" @click="searchMemory" :loading="isSearching" variant="tonal">
<v-icon start>mdi-text-search</v-icon>
{{ tm('search.searchButton') }}
@@ -254,6 +256,7 @@
import axios from 'axios';
// import * as d3 from "d3"; // npm install d3
import { useModuleI18n } from '@/i18n/composables';
import { normalizeTextInput } from '@/utils/inputValue';
export default {
name: 'LongTermMemory',
@@ -336,9 +339,16 @@ export default {
this.searchResults = [];
},
methods: {
onSearchMemoryUserIdInput(value) {
this.searchMemoryUserId = normalizeTextInput(value);
},
onSearchQueryInput(value) {
this.searchQuery = normalizeTextInput(value);
},
//
searchMemory() {
if (!this.searchQuery.trim()) {
const query = normalizeTextInput(this.searchQuery).trim();
if (!query) {
this.$toast.warning(this.tm('messages.searchQueryRequired'));
return;
}
@@ -349,12 +359,13 @@ export default {
//
const params = {
query: this.searchQuery
query
};
// ID
if (this.searchMemoryUserId) {
params.user_id = this.searchMemoryUserId;
const normalizedUserId = normalizeTextInput(this.searchMemoryUserId).trim();
if (normalizedUserId) {
params.user_id = normalizedUserId;
}
axios.get('/api/plug/alkaid/ltm/graph/search', { params })
@@ -1,7 +1,9 @@
<script setup>
import PluginSortControl from "@/components/extension/PluginSortControl.vue";
import ExtensionCard from "@/components/shared/ExtensionCard.vue";
import StyledMenu from "@/components/shared/StyledMenu.vue";
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
import { normalizeTextInput } from "@/utils/inputValue";
const props = defineProps({
state: {
@@ -48,6 +50,9 @@ const {
getInitialListViewMode,
isListView,
pluginSearch,
installedStatusFilter,
installedSortBy,
installedSortOrder,
loading_,
currentPage,
dangerConfirmDialog,
@@ -82,6 +87,8 @@ const {
toPinyinText,
toInitials,
plugin_handler_info_headers,
installedSortItems,
installedSortUsesOrder,
pluginHeaders,
filteredExtensions,
filteredPlugins,
@@ -158,10 +165,12 @@ const {
<div class="d-flex align-center flex-wrap ml-auto" style="gap: 8px">
<v-text-field
v-model="pluginSearch"
:model-value="pluginSearch"
@update:model-value="pluginSearch = normalizeTextInput($event)"
density="compact"
:label="tm('search.placeholder')"
prepend-inner-icon="mdi-magnify"
clearable
variant="solo-filled"
flat
hide-details
@@ -185,30 +194,64 @@ const {
</div>
<v-row class="mb-4">
<v-col cols="12" class="d-flex align-center flex-wrap ga-2">
<v-btn variant="tonal" @click="toggleShowReserved">
<v-icon>{{
showReserved ? "mdi-eye-off" : "mdi-eye"
}}</v-icon>
{{
showReserved
? tm("buttons.hideSystemPlugins")
: tm("buttons.showSystemPlugins")
}}
</v-btn>
<v-col cols="12">
<div class="installed-toolbar">
<div class="installed-toolbar__actions">
<v-btn variant="tonal" @click="toggleShowReserved">
<v-icon>{{
showReserved ? "mdi-eye-off" : "mdi-eye"
}}</v-icon>
{{
showReserved
? tm("buttons.hideSystemPlugins")
: tm("buttons.showSystemPlugins")
}}
</v-btn>
<v-btn
class="ml-2"
color="warning"
variant="tonal"
:disabled="updatableExtensions.length === 0"
:loading="updatingAll"
@click="showUpdateAllConfirm"
>
<v-icon>mdi-update</v-icon>
{{ tm("buttons.updateAll") }}
</v-btn>
<v-btn
color="warning"
variant="tonal"
:disabled="updatableExtensions.length === 0"
:loading="updatingAll"
@click="showUpdateAllConfirm"
>
<v-icon>mdi-update</v-icon>
{{ tm("buttons.updateAll") }}
</v-btn>
</div>
<div class="installed-toolbar__controls">
<v-btn-toggle
v-model="installedStatusFilter"
mandatory
divided
density="compact"
color="primary"
class="installed-status-toggle"
>
<v-btn value="all" prepend-icon="mdi-filter-variant">
{{ tm("filters.all") }}
</v-btn>
<v-btn value="enabled" prepend-icon="mdi-play-circle-outline">
{{ tm("status.enabled") }}
</v-btn>
<v-btn value="disabled" prepend-icon="mdi-pause-circle-outline">
{{ tm("status.disabled") }}
</v-btn>
</v-btn-toggle>
<PluginSortControl
v-model="installedSortBy"
:items="installedSortItems"
:label="tm('sort.by')"
:order="installedSortOrder"
:ascending-label="tm('sort.ascending')"
:descending-label="tm('sort.descending')"
:show-order="installedSortUsesOrder"
@update:order="installedSortOrder = $event"
/>
</div>
</div>
</v-col>
</v-row>
@@ -654,6 +697,32 @@ const {
</template>
<style scoped>
.installed-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.installed-toolbar__actions,
.installed-toolbar__controls {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.installed-toolbar__controls {
margin-left: auto;
justify-content: flex-end;
}
.installed-status-toggle :deep(.v-btn) {
min-height: 34px;
text-transform: none;
}
.view-mode-toggle :deep(.v-btn) {
min-width: 30px;
height: 28px;
@@ -684,6 +753,14 @@ const {
}
}
@media (max-width: 960px) {
.installed-toolbar__controls {
margin-left: 0;
width: 100%;
justify-content: flex-start;
}
}
.fab-button {
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
@@ -1,7 +1,9 @@
<script setup>
import MarketPluginCard from "@/components/extension/MarketPluginCard.vue";
import PluginSortControl from "@/components/extension/PluginSortControl.vue";
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
import { computed } from "vue";
import { normalizeTextInput } from "@/utils/inputValue";
const props = defineProps({
state: {
@@ -157,6 +159,13 @@ const currentSourceName = computed(() => {
const matched = customSources.value.find((s) => s.url === selectedSource.value);
return matched?.name || tm("market.defaultSource");
});
const marketSortItems = computed(() => [
{ title: tm("sort.default"), value: "default" },
{ title: tm("sort.stars"), value: "stars" },
{ title: tm("sort.author"), value: "author" },
{ title: tm("sort.updated"), value: "updated" },
]);
</script>
<template>
@@ -204,11 +213,13 @@ const currentSourceName = computed(() => {
</div>
<v-text-field
v-model="marketSearch"
:model-value="marketSearch"
@update:model-value="marketSearch = normalizeTextInput($event)"
class="ml-auto"
density="compact"
:label="tm('search.marketPlaceholder')"
prepend-inner-icon="mdi-magnify"
clearable
variant="solo-filled"
flat
hide-details
@@ -327,44 +338,16 @@ const currentSourceName = computed(() => {
class="d-flex align-center"
style="gap: 8px; flex-wrap: wrap"
>
<v-select
<PluginSortControl
v-model="sortBy"
:items="[
{ title: tm('sort.default'), value: 'default' },
{ title: tm('sort.stars'), value: 'stars' },
{ title: tm('sort.author'), value: 'author' },
{ title: tm('sort.updated'), value: 'updated' },
]"
density="compact"
variant="outlined"
hide-details
style="max-width: 150px"
>
<template v-slot:prepend-inner>
<v-icon size="small">mdi-sort</v-icon>
</template>
</v-select>
<v-btn
icon
v-if="sortBy !== 'default'"
@click="sortOrder = sortOrder === 'desc' ? 'asc' : 'desc'"
variant="text"
density="compact"
>
<v-icon>{{
sortOrder === "desc"
? "mdi-sort-descending"
: "mdi-sort-ascending"
}}</v-icon>
<v-tooltip activator="parent" location="top">
{{
sortOrder === "desc"
? tm("sort.descending")
: tm("sort.ascending")
}}
</v-tooltip>
</v-btn>
:items="marketSortItems"
:label="tm('sort.by')"
:order="sortOrder"
:ascending-label="tm('sort.ascending')"
:descending-label="tm('sort.descending')"
:show-order="sortBy !== 'default'"
@update:order="sortOrder = $event"
/>
</div>
</div>
+126 -14
View File
@@ -186,6 +186,9 @@ export const useExtensionPage = () => {
};
const isListView = ref(getInitialListViewMode());
const pluginSearch = ref("");
const installedStatusFilter = ref("all");
const installedSortBy = ref("default");
const installedSortOrder = ref("desc");
const loading_ = ref(false);
// 分页相关
@@ -253,6 +256,18 @@ export const useExtensionPage = () => {
{ title: tm("table.headers.specificType"), key: "type" },
{ title: tm("table.headers.trigger"), key: "cmd" },
]);
const installedSortItems = computed(() => [
{ title: tm("sort.default"), value: "default" },
{ title: tm("sort.installTime"), value: "install_time" },
{ title: tm("sort.name"), value: "name" },
{ title: tm("sort.author"), value: "author" },
{ title: tm("sort.updateStatus"), value: "update_status" },
]);
const installedSortUsesOrder = computed(
() => installedSortBy.value !== "default",
);
// 插件表格的表头定义
const showAuthorColumn = computed(() => width.value >= 1280);
@@ -261,16 +276,19 @@ export const useExtensionPage = () => {
{
title: tm("table.headers.name"),
key: "name",
sortable: false,
width: showAuthorColumn.value ? "24%" : "26%",
},
{
title: tm("table.headers.description"),
key: "desc",
sortable: false,
width: showAuthorColumn.value ? "32%" : "36%",
},
{
title: tm("table.headers.version"),
key: "version",
sortable: false,
width: showAuthorColumn.value ? "12%" : "14%",
},
];
@@ -279,6 +297,7 @@ export const useExtensionPage = () => {
headers.push({
title: tm("table.headers.author"),
key: "author",
sortable: false,
width: "10%",
});
}
@@ -301,33 +320,120 @@ export const useExtensionPage = () => {
}
return data;
});
const sortPluginsByName = (plugins) => {
const compareInstalledPluginNames = (left, right) =>
normalizeStr(left?.name ?? "").localeCompare(
normalizeStr(right?.name ?? ""),
undefined,
{
sensitivity: "base",
},
);
const compareInstalledPluginAuthors = (left, right) =>
normalizeStr(left?.author ?? "").localeCompare(
normalizeStr(right?.author ?? ""),
undefined,
{ sensitivity: "base" },
);
const getInstalledAtTimestamp = (plugin) => {
const parsed = Date.parse(plugin?.installed_at ?? "");
return Number.isFinite(parsed) ? parsed : null;
};
const sortInstalledPlugins = (plugins) => {
return plugins
.map((plugin, index) => ({ plugin, index }))
.sort((a, b) => {
const nameA = String(a.plugin?.name ?? "");
const nameB = String(b.plugin?.name ?? "");
const nameCompare = nameA.localeCompare(nameB, undefined, {
sensitivity: "base",
});
if (nameCompare !== 0) {
return nameCompare;
.map((plugin, index) => ({
plugin,
index,
installedAtTimestamp: getInstalledAtTimestamp(plugin),
}))
.sort((left, right) => {
const fallbackNameCompare = compareInstalledPluginNames(
left.plugin,
right.plugin,
);
const fallbackResult =
fallbackNameCompare !== 0 ? fallbackNameCompare : left.index - right.index;
if (installedSortBy.value === "install_time") {
const leftTimestamp = left.installedAtTimestamp;
const rightTimestamp = right.installedAtTimestamp;
if (leftTimestamp == null && rightTimestamp == null) {
return fallbackResult;
}
if (leftTimestamp == null) {
return 1;
}
if (rightTimestamp == null) {
return -1;
}
const timeDiff =
installedSortOrder.value === "desc"
? rightTimestamp - leftTimestamp
: leftTimestamp - rightTimestamp;
return timeDiff !== 0 ? timeDiff : fallbackResult;
}
return a.index - b.index;
if (installedSortBy.value === "name") {
const nameCompare = compareInstalledPluginNames(left.plugin, right.plugin);
if (nameCompare !== 0) {
return installedSortOrder.value === "desc"
? -nameCompare
: nameCompare;
}
return left.index - right.index;
}
if (installedSortBy.value === "author") {
const authorCompare = compareInstalledPluginAuthors(
left.plugin,
right.plugin,
);
if (authorCompare !== 0) {
return installedSortOrder.value === "desc"
? -authorCompare
: authorCompare;
}
return fallbackResult;
}
if (installedSortBy.value === "update_status") {
const leftHasUpdate = left.plugin?.has_update ? 1 : 0;
const rightHasUpdate = right.plugin?.has_update ? 1 : 0;
const updateDiff =
installedSortOrder.value === "desc"
? rightHasUpdate - leftHasUpdate
: leftHasUpdate - rightHasUpdate;
return updateDiff !== 0 ? updateDiff : fallbackResult;
}
return fallbackResult;
})
.map((item) => item.plugin);
};
// 通过搜索过滤插件
const filteredPlugins = computed(() => {
const plugins = filteredExtensions.value;
const plugins = filteredExtensions.value.filter((plugin) => {
if (installedStatusFilter.value === "enabled") {
return !!plugin.activated;
}
if (installedStatusFilter.value === "disabled") {
return !plugin.activated;
}
return true;
});
const query = buildSearchQuery(pluginSearch.value);
const filtered = query
? plugins.filter((plugin) => matchesPluginSearch(plugin, query))
: plugins;
return sortPluginsByName([...filtered]);
return sortInstalledPlugins(filtered);
});
// 过滤后的插件市场数据(带搜索)
@@ -1203,6 +1309,7 @@ export const useExtensionPage = () => {
onLoadingDialogResult(1, resData.message);
dialog.value = false;
await getExtensions();
checkAlreadyInstalled();
viewReadme({
name: resData.data.name,
@@ -1481,6 +1588,9 @@ export const useExtensionPage = () => {
getInitialListViewMode,
isListView,
pluginSearch,
installedStatusFilter,
installedSortBy,
installedSortOrder,
loading_,
currentPage,
dangerConfirmDialog,
@@ -1516,6 +1626,8 @@ export const useExtensionPage = () => {
toPinyinText,
toInitials,
plugin_handler_info_headers,
installedSortItems,
installedSortUsesOrder,
pluginHeaders,
filteredExtensions,
filteredPlugins,

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