Compare commits

..

73 Commits

Author SHA1 Message Date
shuiping233 f5ba1a026a perf: Implement Pydantic data models for the KOOK adapter to enhance data retrieval and message schema validation (#5719)
* refactor: 给kook适配器添加kook事件数据类

* format: 使用StrEnum替换kook适配器中的(str,enum)
2026-03-17 18:05:58 +08:00
jnMetaCode dcffb5269a fix: only pass dimensions when explicitly configured in embedding config (#6432)
* fix: only pass dimensions param when explicitly configured

Models like bge-m3 don't support the dimensions parameter in the
embedding API, causing HTTP 400 errors. Previously dimensions was
always sent with a default value of 1024, even when the user never
configured it. Now dimensions is only included in the request when
embedding_dimensions is explicitly set in provider config.

Closes #6421

Signed-off-by: JiangNan <1394485448@qq.com>

* fix: handle invalid dimensions config and align get_dim return

- Add try-except around int() conversion in _embedding_kwargs to
  gracefully handle invalid embedding_dimensions config values
- Update get_dim() to return 0 when embedding_dimensions is not
  explicitly configured, so callers know dimensions weren't specified
  and can handle it accordingly
- Both methods now share consistent logic for reading the config

Signed-off-by: JiangNan <1394485448@qq.com>

* fix: improve logging for invalid embedding_dimensions configuration

---------

Signed-off-by: JiangNan <1394485448@qq.com>
Co-authored-by: Soulter <905617992@qq.com>
2026-03-17 17:53:03 +08:00
whatevertogo ebd232ec8e fix: register_agent decorator NameError (#5765)
* fix: 修改 register_agent 以避免运行时导入 AstrAgentContext

* test: improve register_agent test robustness

- Add fixture for llm_tools cleanup to avoid test interference
- Use multiple import patterns to make guard more robust to refactors
- Add assertion to verify decorated coroutine is wired as handoff handler

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

* 删除测试文件: 移除 register_agent 装饰器的运行时行为测试

---------

Co-authored-by: whatevertogo <whatevertogo@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Soulter <905617992@qq.com>
2026-03-17 16:07:30 +08:00
whatevertogo 1fd3d4ce0e fix: subagent lookup failure when using default persona (#5672)
* fix: resolve subagent persona lookup for 'default' and unify resolution logic

- Add PersonaManager.get_persona_v3_by_id() to centralize v3 persona resolution
- Handle 'default' persona_id mapping to DEFAULT_PERSONALITY in subagent orchestrator
- Fix HandoffTool.default_description using agent_name parameter correctly
- Add tests for default persona in subagent config and tool deduplication

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

* refactor: simplify get_default_persona_v3 using get_persona_v3_by_id

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

---------

Co-authored-by: whatevertogo <whatevertogo@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2026-03-17 15:42:15 +08:00
linzhengtian 26d69c96d1 fix: reading skills on Windows (#6490)
There is an issue with reading the skill directory on the Windows system, which results in a high probability of files under the skill directory being unrecognizable, now fix it.
2026-03-17 15:12:02 +08:00
YYMa 3dcdb8b29c chore: remove deprecated version field from compose.yml (#5495)
The version field is no longer required in Docker Compose v2 and has been deprecated.
2026-03-17 14:20:35 +08:00
dependabot[bot] 437adead28 chore(deps): bump the github-actions group with 2 updates (#6461)
Bumps the github-actions group with 2 updates: [ncipollo/release-action](https://github.com/ncipollo/release-action) and [actions/github-script](https://github.com/actions/github-script).


Updates `ncipollo/release-action` from 1.20.0 to 1.21.0
- [Release notes](https://github.com/ncipollo/release-action/releases)
- [Commits](https://github.com/ncipollo/release-action/compare/v1.20.0...v1.21.0)

Updates `actions/github-script` from 7 to 8
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v8)

---
updated-dependencies:
- dependency-name: ncipollo/release-action
  dependency-version: 1.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: actions/github-script
  dependency-version: '8'
  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-17 13:53:37 +08:00
Rhonin Wang d5b98b353c fix: parse multiline frontmatter description in SKILL.md (#6460)
* fix(skills): support multiline frontmatter descriptions

* fix(skills): 修复多行 frontmatter 描述解析

* style(skills): clean up frontmatter parser follow-ups

---------

Co-authored-by: RhoninSeiei <RhoninSeiei@users.noreply.github.com>
2026-03-17 13:53:16 +08:00
Yufeng He acbc5150cf fix: SQLite 'database is locked' by adding busy timeout (#6474)
The async engine is created without a busy timeout, so concurrent
writes (agent responses, metrics, session updates) fail instantly
with 'database is locked' instead of waiting for the lock.

Add connect_args={'timeout': 30} for SQLite engines so the driver
waits up to 30 seconds for the write lock. Combined with the existing
WAL journal mode, this handles the typical concurrent write bursts
from agent + metrics + session operations.

Fixes #6443
2026-03-17 12:56:34 +08:00
Ruochen Pan 85cfd62014 feat: localize session management group & interval method texts (#6471)
* fix(ui): localize session management group texts

Replace hardcoded Chinese strings in SessionManagementPage with i18n
lookups for group management labels, dialogs, and action feedback.

Add and align translation keys in en-US, ru-RU, and zh-CN for group
management and batch operation messages to ensure consistent multilingual
UI behavior.

* fix(ui): localize interval method hint text
2026-03-17 10:21:55 +08:00
LIghtJUNction 1c7c2ee0cd chore: Delete .github/workflows/pr-checklist-check.yml 2026-03-17 10:18:08 +08:00
Soulter ed47420678 ci: add pr check 2026-03-17 01:07:22 +08:00
Soulter 6d687691a2 chore: bump version to 4.20.1 2026-03-17 00:35:57 +08:00
Soulter 0c71d351ee chore: revise PULL_REQUEST_TEMPLATE 2026-03-16 22:20:48 +08:00
LIghtJUNction f00ba5adc6 chore(github): 更新 PR 模板以区分 dev 和 master 提交规则 2026-03-16 21:43:14 +08:00
LIghtJUNction d3d4e1db7b Merge branch 'master' of https://github.com/AstrBotDevs/AstrBot 2026-03-16 19:17:42 +08:00
LIghtJUNction 78b3e12c66 chore: update astrbot.service configuration 2026-03-16 19:15:44 +08:00
Futureppo c42ac87ee1 feat: Add OpenRouter chat completion provider adapter with custom headers. (#6436) 2026-03-16 19:11:43 +08:00
QuietStars 3fbd16b211 docs: update rainyun.md with backup access instructions (#6427)
Added a note about using a backup address if the management panel cannot be accessed.
2026-03-16 15:38:01 +08:00
qingyun e77500ff69 fix(provider): sync providers_config after creating new provider (#6388)
Fixes #6283

When adding a new embedding provider, the knowledge base creation page
did not show the new provider until restart.

Root cause: create_provider() did not update self.providers_config,
which is used by get_provider_config_list() to return provider lists.

This fix syncs the in-memory config after loading the new provider,
consistent with how reload() handles config updates.

Co-authored-by: ccsang <ccsang@users.noreply.github.com>
2026-03-16 15:29:51 +08:00
lppsuixn 2c49ac0dcf Refactor _extract_session_id for chat type handling (#5775)
Update session ID extraction to handle group and single chat types.
2026-03-16 15:27:16 +08:00
Soulter 65decfbe87 chore: remove unused scripts for closing duplicate plugin publish issues and generating changelog 2026-03-16 12:39:39 +08:00
stevessr 92c31192de perf: enhance umo processing compatibility (#5996) 2026-03-16 12:34:21 +08:00
LIghtJUNction b795f804a7 更新 pr-checklist-check.yml 2026-03-16 02:51:39 +08:00
LIghtJUNction bc3b5e58a4 更新 pr-checklist-check.yml 2026-03-16 02:44:05 +08:00
LIghtJUNction 7e3c32b828 更新 pr-checklist-check.yml 2026-03-16 02:29:33 +08:00
LIghtJUNction ceb32dce9f 更新 pr-checklist-check.yml 2026-03-16 02:24:01 +08:00
LIghtJUNction 84e880af5f 更新 pr-checklist-check.yml 2026-03-16 02:21:05 +08:00
LIghtJUNction 9909d774ed Merge pull request #6400 from AstrBotDevs/copilot/implement-modifications-summary
feat: auto-close PRs when author checks "did not read" checklist item
2026-03-16 02:13:20 +08:00
LIghtJUNction 6b3868b4be Update pr-checklist-check.yml 2026-03-16 02:11:15 +08:00
LIghtJUNction 11c840953a 更新 pr-checklist-check.yml 2026-03-16 01:49:49 +08:00
LIghtJUNction 2bbca887ce Refine PR checklist validation and closure message
Updated the checklist validation script and modified the comment for PR closure.
2026-03-16 01:46:07 +08:00
copilot-swe-agent[bot] dd89a4b334 feat: add PR checklist enforcement workflow
Co-authored-by: LIghtJUNction <106986785+LIghtJUNction@users.noreply.github.com>
2026-03-15 17:30:29 +00:00
copilot-swe-agent[bot] a3fa8a5a7c Initial plan 2026-03-15 17:28:39 +00:00
LIghtJUNction aa60467782 Merge pull request #6399 from AstrBotDevs/LIghtJUNction-patch-1
Refactor checklist items in PR template
2026-03-16 01:24:30 +08:00
LIghtJUNction d936bb0a10 Refactor checklist items in PR template
Duplicated checklist items in the pull request template for clarity and emphasis.
2026-03-16 01:23:51 +08:00
Stable Genius 64e0183b55 fix: drop Groq reasoning_content from assistant history (#6065)
Co-authored-by: Stable Genius <259448942+stablegenius49@users.noreply.github.com>
2026-03-15 22:51:52 +08:00
Soulter 420d82df11 chore: ruff format 2026-03-15 22:43:29 +08:00
Yufeng He d87cf897da Fix TypeError when API returns null choices (#6313)
* Fix CreateSkillPayloadTool array schema missing items field

The payload parameter's anyOf array variant lacked an items field,
causing Gemini API to reject the tool declaration with 400 Bad Request:
'parameters.properties[payload].any_of[1].items: missing field.'

Add items: {type: object} to the array variant to satisfy the Gemini
API requirement for array type schemas.

Fixes #6279

* Fix TypeError when OpenAI-compatible API returns null choices

Some providers (e.g. OpenRouter) may return a completion where
choices is None rather than an empty list — for instance on rate
limiting, content filtering, or transient errors. The existing code
used len(completion.choices) which throws TypeError on None.

Replace all len(...choices) == 0 checks with 'not ... .choices' which
handles both None and empty list. Affects _query_stream, _parse_openai_completion,
and _extract_reasoning_content.

Fixes #6252
2026-03-15 22:28:26 +08:00
時壹 2f51916a73 fix: deduplicate repeated QQ webhook retry callbacks (#6320) 2026-03-15 22:18:37 +08:00
Rin b0e10cf479 fix: add null check for delta in streaming mode to prevent AttributeError when tool calls are returned (#6365) 2026-03-15 22:17:12 +08:00
Simon 20efaa5320 fix: revise link to model service configuration (#6296) 2026-03-15 22:03:52 +08:00
洛薇Lovie 3ccd70cd4e Fix: AI fails to send media files when tool-calling mode is set to "skills-like". (#6317)
* fix: improve send_message_to_user tool description for skills_like mode

* fix: enhance description for send_message_to_user tool to clarify usage

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-03-15 21:46:01 +08:00
xwsjjctz da520e573a feat(provider): add MiniMax (#6318)
* feat(provider): add MiniMax

* feat(provider): reintroduce MiniMax provider configuration and remove deprecated source

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-03-15 21:37:44 +08:00
Trainingcqy 6d055e81e9 fix: GIF sent as static image in Telegram adapter (#6329)
* fix(telegram): route GIF files to send_animation instead of send_photo

* fix: narrow exception in _is_gif to OSError

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

* refactor: simplify image send dispatch in send_with_client

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

* refactor: simplify image dispatch in _process_chain_items

* ruff format

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Soulter <905617992@qq.com>
2026-03-15 21:33:30 +08:00
Xial d41ccb70c5 fix: replace npm registry URLs with jsdelivr CDN for provider icons (#6340) 2026-03-15 21:15:04 +08:00
qingyun 18a99a25c2 fix(platform): parse QQ official face messages to readable text (#6355)
Fixes #6294

QQ official bot receives emoji/sticker messages as raw XML-like tags:
`<faceType=4,faceId="",ext="eyJ0ZXh0IjoiW+a7oeWktOmXruWPt10ifQ==">`

This made the LLM unable to understand the emoji content.

Changes:
- Added `_parse_face_message()` method to parse face message format
- Decode base64 `ext` field to get emoji description text
- Replace face tags with `[表情:描述]` format for readability

Example:
- Input: `<faceType=4,faceId="",ext="eyJ0ZXh0IjoiW+a7oeWktOmXruWPt10ifQ==">`
- Output: `[表情:[满头问号]]`

Co-authored-by: ccsang <ccsang@users.noreply.github.com>
2026-03-15 21:05:47 +08:00
LIghtJUNction 96cafe001d Merge pull request #6293 from AstrBotDevs/LIghtJUNction-patch-1
Update package.md
2026-03-15 03:15:10 +08:00
LIghtJUNction 29d100dd83 Update package.md 2026-03-15 02:55:34 +08:00
Soulter 14f3701c4a fix: update Discord invite link in community documentation
closes: #6188
2026-03-14 23:48:13 +08:00
Stable Genius 1044fc48ca fix: avoid webchat stream result crash on queue errors (#6123)
Co-authored-by: stablegenius49 <185121704+stablegenius49@users.noreply.github.com>
2026-03-14 23:41:28 +08:00
Soulter 693c2ca818 refactor: improve chat component behavior, use shiki to represent code block (#6286) 2026-03-14 23:37:17 +08:00
Soulter b1c486ba98 feat: add send shortcut configuration and localization support for chat input (#6272) 2026-03-14 21:25:12 +08:00
Soulter 9363fb824a chore: ruff format 2026-03-14 21:12:00 +08:00
Flartiny 044b361ac5 feat: add conversation batch deletion for webchat (#6160)
* feat: add conversation batch deletion for webchat

* fix: security issues in batch_delete_sessions and better handle batch select

* feat: enhance batch selection UI with animated checkbox visibility in ConversationSidebar

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-03-14 21:09:36 +08:00
Frank 06fd2d2428 fix: preserve subagent handoff tools during plugin filtering (#6155) 2026-03-14 20:55:15 +08:00
eason dd6bc1dcdb fix: add missing spaces in cron prompt and replace deprecated utcnow() (#6192)
1. Fix missing spaces in cron job wake prompt string concatenation.
   Python implicit string concatenation produced:
   "...scheduled taskProceed..." and "...conversation.After..."
   which sent garbled instructions to the LLM agent, causing unreliable
   cron job execution.

2. Replace deprecated datetime.utcnow() with
   datetime.now(datetime.timezone.utc) in JWT generation.
   utcnow() is deprecated since Python 3.12 and returns naive datetime
   which can cause incorrect token expiry on non-UTC systems.

Closes #6103
Closes #6165

Co-authored-by: easonysliu <easonysliu@tencent.com>
2026-03-14 20:52:00 +08:00
Rhonin Wang 52d5258b10 feat: display latency when testing model connection (#6258)
Co-authored-by: RhoninSeiei <RhoninSeiei@users.noreply.github.com>
2026-03-14 20:50:40 +08:00
Anima 91933bbd19 perf: webui theme color improvement (#6263)
* fix: update scrollbar styles to follow theme variables

* fix: update theme colors to use CSS variables for consistency

* fix: change login button color to primary for better visibility

* fix: update theme colors for Dark and Light themes; change login button color to secondary

* fix: update border and theme colors for consistency in DarkTheme

* fix: update sidebar list class to conditionally hide scrollbar in mini sidebar mode

* fix: simplify button visibility logic and remove unnecessary leftPadding style

* fix: refactor language switcher to use grouped menu for better UX

* fix: update theme colors to use primary color for consistency across components

* fix: add preview text for template output in multiple languages
2026-03-14 20:45:55 +08:00
Sakari f8d075b5d3 fix(telegram): avoid treating normal replies as topic threads (#6174) 2026-03-14 18:27:13 +08:00
eason 86ef758a9a fix: prevent ValueError when removing already-removed API key in retry loop (#6193)
In _handle_api_error(), when a 429 rate-limit is encountered, the code
calls available_api_keys.remove(chosen_key). If the same key was already
removed in a previous retry iteration (e.g. the key rotated back to the
same value), this raises ValueError which crashes the entire LLM request
with an opaque error instead of a proper retry/fallback.

Add a membership check before calling remove() to prevent the crash.

Co-authored-by: easonysliu <easonysliu@tencent.com>
2026-03-14 18:22:14 +08:00
Ann-Holmes 1a03180643 Add binding for local temp directory in YAML (#6191)
* Add binding for local temp directory in YAML

Bind the local temp directory to the sandbox for file access.

* Update compose-with-shipyard.yml

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

---------

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
2026-03-14 18:21:47 +08:00
DroidKali 326183a3fd fix: update startup command to 'astrbot run' in all README files (#6189)
Updated the quick start command from 'astrbot' to 'astrbot run' across all
language versions of README documentation for consistency and correctness.

Co-authored-by: DroidKali <DroidKali@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-03-14 18:20:48 +08:00
qingyun 08fc657755 fix: preserve whitespace in Plain.toDict() for @ mentions (#6244)
* fix: preserve whitespace in Plain.toDict() for @ mentions

- Remove .strip() from Plain.toDict() to match async to_dict() behavior
- Fixes #6237: QQ @mentions no longer lose trailing spaces
- This ensures '@user message' displays correctly instead of '@usermessage'

* refactor: remove redundant to_dict() from Plain class

- Let Plain inherit to_dict() from BaseMessageComponent
- BaseMessageComponent.to_dict() calls toDict() by default
- Reduces code duplication and prevents future divergence
- Addressed code review feedback from @gemini-code-assist and @sourcery-ai

* feat: add async to_dict method to Plain message component

* fix: add return type hint to Plain.toDict method

---------

Co-authored-by: ccsang <ccsang@users.noreply.github.com>
Co-authored-by: Soulter <905617992@qq.com>
2026-03-14 18:18:14 +08:00
Gao Jinzhe 0ff9539599 Merge pull request #6208 from nuomicici/master
更新(添加)部分文档中已过时的名词
2026-03-14 18:17:14 +08:00
lalala 38f5e077ee fix: remove duplicate dependencies (#6247)
remove duplicate `aiocqhttp` `aiodocker` `aiohttp` in requirements.txt
2026-03-14 18:15:06 +08:00
MousseC 89fbd75e7a perf(OneBot): add a whitespace after At component (#6238)
修复 At 组件后的空格在发送时被 strip 移除的问题。在消息解析时检测 At 组件并在其后额外插入空格。
2026-03-14 18:12:55 +08:00
Salman Chishti 493662524a ci: upgrade GitHub Actions to latest versions (#6251)
Signed-off-by: Salman Muin Kayser Chishti <13schishti@gmail.com>
2026-03-14 18:08:25 +08:00
糯米茨 1afbb357db Update docs/zh/platform/matrix.md
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-13 21:14:00 +08:00
糯米茨 8d2140f607 Update docs/zh/platform/wecom_ai_bot.md
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-13 21:13:45 +08:00
糯米茨 97732987d9 更新部分新版名称 2026-03-13 20:53:41 +08:00
糯米茨 a60a40bca3 更新部分新版本名称
Update the instructions for installing and configuring the Matrix adapter in AstrBot.
2026-03-13 20:51:39 +08:00
エイカク a8ff2b3d9c fix(dashboard): stabilize sidebar hash navigation on startup (#6159)
* fix(dashboard): stabilize sidebar hash navigation on startup

* fix(dashboard): reuse shared extension tab route helpers

* fix(dashboard): avoid leaking extension route query state

* fix(dashboard): preserve route params in tab locations

* fix(dashboard): harden hash tab routing fallbacks

* fix(dashboard): warn on tab route navigation failures

* fix(dashboard): harden extension tab startup routing
2026-03-13 11:53:50 +09:00
125 changed files with 3228 additions and 1039 deletions
+12 -5
View File
@@ -3,8 +3,8 @@
### Modifications / 改动点 ### Modifications / 改动点
<!--请总结你的改动:哪些核心文件被修改了?实现了什么功能?-->
<!--Please summarize your changes: What core files were modified? What functionality was implemented?--> <!--Please summarize your changes: What core files were modified? What functionality was implemented?-->
<!--请总结你的改动:哪些核心文件被修改了?实现了什么功能?-->
- [x] This is NOT a breaking change. / 这不是一个破坏性变更。 - [x] This is NOT a breaking change. / 这不是一个破坏性变更。
<!-- If your changes is a breaking change, please uncheck the checkbox above --> <!-- If your changes is a breaking change, please uncheck the checkbox above -->
@@ -21,7 +21,14 @@
<!--If merged, your code will serve tens of thousands of users! Please double-check the following items before submitting.--> <!--If merged, your code will serve tens of thousands of users! Please double-check the following items before submitting.-->
<!--如果分支被合并,您的代码将服务于数万名用户!在提交前,请核查一下几点内容。--> <!--如果分支被合并,您的代码将服务于数万名用户!在提交前,请核查一下几点内容。-->
- [ ] 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。/ If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc. - [ ] 😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
- [ ] 👀 我的更改经过了良好的测试,**并已在上方提供了“验证步骤”和“运行截图”**。/ My changes have been well-tested, **and "Verification Steps" and "Screenshots" have been provided above**. / 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。
- [ ] 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 `requirements.txt``pyproject.toml` 文件相应位置。/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in `requirements.txt` and `pyproject.toml`.
- [ ] 😮 我的更改没有引入恶意代码。/ My changes do not introduce malicious code. - [ ] 👀 My changes have been well-tested, **and "Verification Steps" and "Screenshots" have been provided above**.
/ 我的更改经过了良好的测试,**并已在上方提供了“验证步骤”和“运行截图”**。
- [ ] 🤓 I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in `requirements.txt` and `pyproject.toml`.
/ 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到 `requirements.txt``pyproject.toml` 文件相应位置。
- [ ] 😮 My changes do not introduce malicious code.
/ 我的更改没有引入恶意代码。
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
- name: Create GitHub Release - name: Create GitHub Release
if: github.event_name == 'push' if: github.event_name == 'push'
uses: ncipollo/release-action@v1.20.0 uses: ncipollo/release-action@v1.21.0
with: with:
tag: release-${{ github.sha }} tag: release-${{ github.sha }}
owner: AstrBotDevs owner: AstrBotDevs
+53
View File
@@ -0,0 +1,53 @@
name: PR Title Check
on:
pull_request_target:
types: [opened, edited, reopened, synchronize]
jobs:
title-format:
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
steps:
- name: Validate PR title
uses: actions/github-script@v8
with:
script: |
const title = (context.payload.pull_request.title || "").trim();
// allow only:
// feat: xxx
// feat(scope): xxx
const pattern = /^(feat)(\([a-z0-9-]+\))?:\s.+$/i;
const isValid = pattern.test(title);
const isSameRepo =
context.payload.pull_request.head.repo.full_name === context.payload.repository.full_name;
if (!isValid) {
if (isSameRepo) {
try {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: [
"⚠️ PR title format check failed.",
"Required formats:",
"- `feat: xxx`",
"- `feat(scope): xxx`",
"Please update your PR title and push again."
].join("\n")
});
} catch (e) {
core.warning(`Failed to post PR title comment: ${e.message}`);
}
} else {
core.warning("Fork PR: comment permission is restricted; skip posting review comment.");
}
}
if (!isValid) {
core.setFailed("Invalid PR title. Expected format: feat: xxx or feat(scope): xxx.");
}
+1 -1
View File
@@ -50,7 +50,7 @@ jobs:
echo "tag=$tag" >> "$GITHUB_OUTPUT" echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4.3.0 uses: pnpm/action-setup@v4.4.0
with: with:
version: 10.28.2 version: 10.28.2
+1 -1
View File
@@ -78,7 +78,7 @@ For users who want to quickly experience AstrBot, are familiar with command-line
```bash ```bash
uv tool install astrbot uv tool install astrbot
astrbot init # Only execute this command for the first time to initialize the environment astrbot init # Only execute this command for the first time to initialize the environment
astrbot astrbot run
``` ```
> Requires [uv](https://docs.astral.sh/uv/) to be installed. > Requires [uv](https://docs.astral.sh/uv/) to be installed.
+1 -1
View File
@@ -78,7 +78,7 @@ Pour les utilisateurs qui veulent découvrir AstrBot rapidement, qui sont famili
```bash ```bash
uv tool install astrbot uv tool install astrbot
astrbot init # Exécutez cette commande uniquement la première fois pour initialiser l'environnement astrbot init # Exécutez cette commande uniquement la première fois pour initialiser l'environnement
astrbot astrbot run
``` ```
> [uv](https://docs.astral.sh/uv/) doit être installé. > [uv](https://docs.astral.sh/uv/) doit être installé.
+1 -1
View File
@@ -78,7 +78,7 @@ AstrBot を素早く試したいユーザーで、コマンドラインに慣れ
```bash ```bash
uv tool install astrbot uv tool install astrbot
astrbot init # 初回のみ実行して環境を初期化します astrbot init # 初回のみ実行して環境を初期化します
astrbot astrbot run
``` ```
> [uv](https://docs.astral.sh/uv/) のインストールが必要です。 > [uv](https://docs.astral.sh/uv/) のインストールが必要です。
+1 -1
View File
@@ -78,7 +78,7 @@ AstrBot — это универсальная платформа Agent-чатб
```bash ```bash
uv tool install astrbot uv tool install astrbot
astrbot init # Выполните эту команду только при первом запуске для инициализации окружения astrbot init # Выполните эту команду только при первом запуске для инициализации окружения
astrbot astrbot run
``` ```
> Требуется установленный [uv](https://docs.astral.sh/uv/). > Требуется установленный [uv](https://docs.astral.sh/uv/).
+1 -1
View File
@@ -78,7 +78,7 @@ AstrBot 是一個開源的一站式 Agent 聊天機器人平台,可接入主
```bash ```bash
uv tool install astrbot uv tool install astrbot
astrbot init # 僅首次執行此命令以初始化環境 astrbot init # 僅首次執行此命令以初始化環境
astrbot astrbot run
``` ```
> 需要安裝 [uv](https://docs.astral.sh/uv/)。 > 需要安裝 [uv](https://docs.astral.sh/uv/)。
+1 -1
View File
@@ -78,7 +78,7 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
```bash ```bash
uv tool install astrbot uv tool install astrbot
astrbot init # 仅首次执行此命令以初始化环境 astrbot init # 仅首次执行此命令以初始化环境
astrbot astrbot run
``` ```
> 需要安装 [uv](https://docs.astral.sh/uv/)。 > 需要安装 [uv](https://docs.astral.sh/uv/)。
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.20.0" __version__ = "4.20.1"
+1 -1
View File
@@ -62,4 +62,4 @@ class HandoffTool(FunctionTool, Generic[TContext]):
def default_description(self, agent_name: str | None) -> str: def default_description(self, agent_name: str | None) -> str:
agent_name = agent_name or "another" agent_name = agent_name or "another"
return f"Delegate tasks to {self.name} agent to handle the request." return f"Delegate tasks to {agent_name} agent to handle the request."
+8 -8
View File
@@ -390,14 +390,9 @@ async def _ensure_persona_and_skills(
persona_tools = None persona_tools = None
pid = a.get("persona_id") pid = a.get("persona_id")
if pid: if pid:
persona_tools = next( persona = plugin_context.persona_manager.get_persona_v3_by_id(pid)
( if persona is not None:
p.get("tools") persona_tools = persona.get("tools")
for p in plugin_context.persona_manager.personas_v3
if p["name"] == pid
),
None,
)
tools = a.get("tools", []) tools = a.get("tools", [])
if persona_tools is not None: if persona_tools is not None:
tools = persona_tools tools = persona_tools
@@ -778,9 +773,14 @@ def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None:
continue continue
mp = tool.handler_module_path mp = tool.handler_module_path
if not mp: if not mp:
# 没有 plugin 归属信息的工具(如 subagent transfer_to_*
# 不应受到会话插件过滤影响。
new_tool_set.add_tool(tool)
continue continue
plugin = star_map.get(mp) plugin = star_map.get(mp)
if not plugin: if not plugin:
# 无法解析插件归属时,保守保留工具,避免误过滤。
new_tool_set.add_tool(tool)
continue continue
if plugin.name in event.plugins_name or plugin.reserved: if plugin.name in event.plugins_name or plugin.reserved:
new_tool_set.add_tool(tool) new_tool_set.add_tool(tool)
+6 -1
View File
@@ -188,7 +188,12 @@ class KnowledgeBaseQueryTool(FunctionTool[AstrAgentContext]):
@dataclass @dataclass
class SendMessageToUserTool(FunctionTool[AstrAgentContext]): class SendMessageToUserTool(FunctionTool[AstrAgentContext]):
name: str = "send_message_to_user" name: str = "send_message_to_user"
description: str = "Directly send message to the user. Only use this tool when you need to proactively message the user. Otherwise you can directly output the reply in the conversation." description: str = (
"Send message to the user. "
"Supports various message types including `plain`, `image`, `record`, `video`, `file`, and `mention_user`. "
"Use this tool to send media files (`image`, `record`, `video`, `file`), "
"or when you need to proactively message the user(such as cron job). For normal text replies, you can output directly."
)
parameters: dict = Field( parameters: dict = Field(
default_factory=lambda: { default_factory=lambda: {
+18 -7
View File
@@ -213,13 +213,24 @@ def parse_description(text: str) -> str:
break break
if end_idx is None: if end_idx is None:
return "" return ""
for line in lines[1:end_idx]:
if ":" not in line: frontmatter = "\n".join(lines[1:end_idx])
continue try:
key, value = line.split(":", 1) import yaml
if key.strip().lower() == "description": except ImportError:
return value.strip().strip('"').strip("'") return ""
return ""
try:
payload = yaml.safe_load(frontmatter) or dict()
except yaml.YAMLError:
return ""
if not isinstance(payload, dict):
return ""
description = payload.get("description", "")
if not isinstance(description, str):
return ""
return description.strip()
def load_managed_skills() -> list[str]: def load_managed_skills() -> list[str]:
+4 -1
View File
@@ -164,7 +164,10 @@ class CreateSkillPayloadTool(NeoSkillToolBase):
"type": "object", "type": "object",
"properties": { "properties": {
"payload": { "payload": {
"anyOf": [{"type": "object"}, {"type": "array"}], "anyOf": [
{"type": "object"},
{"type": "array", "items": {"type": "object"}},
],
"description": ( "description": (
"Skill payload JSON. Typical schema: {skill_markdown, inputs, outputs, meta}. " "Skill payload JSON. Typical schema: {skill_markdown, inputs, outputs, meta}. "
"This only stores content and returns payload_ref; it does not create a candidate or release." "This only stores content and returns payload_ref; it does not create a candidate or release."
+13 -7
View File
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.20.0" VERSION = "4.20.1"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db") DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [ WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -463,7 +463,6 @@ CONFIG_METADATA_2 = {
"type": "kook", "type": "kook",
"enable": False, "enable": False,
"kook_bot_token": "", "kook_bot_token": "",
"kook_bot_nickname": "",
"kook_reconnect_delay": 1, "kook_reconnect_delay": 1,
"kook_max_reconnect_delay": 60, "kook_max_reconnect_delay": 60,
"kook_max_retry_delay": 60, "kook_max_retry_delay": 60,
@@ -875,11 +874,6 @@ CONFIG_METADATA_2 = {
"type": "string", "type": "string",
"hint": "必填项。从 KOOK 开发者平台获取的机器人 Token。", "hint": "必填项。从 KOOK 开发者平台获取的机器人 Token。",
}, },
"kook_bot_nickname": {
"description": "Bot Nickname",
"type": "string",
"hint": "可选项。若发送者昵称与此值一致,将忽略该消息以避免广播风暴。",
},
"kook_reconnect_delay": { "kook_reconnect_delay": {
"description": "重连延迟", "description": "重连延迟",
"type": "int", "type": "int",
@@ -1132,6 +1126,18 @@ CONFIG_METADATA_2 = {
"proxy": "", "proxy": "",
"custom_headers": {}, "custom_headers": {},
}, },
"MiniMax": {
"id": "minimax",
"provider": "minimax",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.minimaxi.com/v1",
"timeout": 120,
"proxy": "",
"custom_headers": {},
},
"xAI": { "xAI": {
"id": "xai", "id": "xai",
"provider": "xai", "provider": "xai",
+2 -2
View File
@@ -332,9 +332,9 @@ class CronJobManager:
cron_job=cron_job_str cron_job=cron_job_str
) )
req.prompt = ( req.prompt = (
"You are now responding to a scheduled task" "You are now responding to a scheduled task. "
"Proceed according to your system instructions. " "Proceed according to your system instructions. "
"Output using same language as previous conversation." "Output using same language as previous conversation. "
"After completing your task, summarize and output your actions and results." "After completing your task, summarize and output your actions and results."
) )
if not req.func_tool: if not req.func_tool:
+15
View File
@@ -33,10 +33,18 @@ class BaseDatabase(abc.ABC):
DATABASE_URL = "" DATABASE_URL = ""
def __init__(self) -> None: def __init__(self) -> None:
# SQLite only supports a single writer at a time. Without a busy
# timeout the driver raises "database is locked" instantly when a
# second write is attempted. Setting timeout=30 tells SQLite to
# wait up to 30 s for the lock, which is enough to ride out brief
# write bursts from concurrent agent/metrics/session operations.
is_sqlite = "sqlite" in self.DATABASE_URL
connect_args = {"timeout": 30} if is_sqlite else {}
self.engine = create_async_engine( self.engine = create_async_engine(
self.DATABASE_URL, self.DATABASE_URL,
echo=False, echo=False,
future=True, future=True,
connect_args=connect_args,
) )
self.AsyncSessionLocal = async_sessionmaker( self.AsyncSessionLocal = async_sessionmaker(
self.engine, self.engine,
@@ -647,6 +655,13 @@ class BaseDatabase(abc.ABC):
"""Get a Platform session by its ID.""" """Get a Platform session by its ID."""
... ...
@abc.abstractmethod
async def get_platform_sessions_by_ids(
self, session_ids: list[str]
) -> list[PlatformSession]:
"""Get platform sessions by IDs."""
...
@abc.abstractmethod @abc.abstractmethod
async def get_platform_sessions_by_creator( async def get_platform_sessions_by_creator(
self, self,
+15
View File
@@ -1417,6 +1417,21 @@ class SQLiteDatabase(BaseDatabase):
result = await session.execute(query) result = await session.execute(query)
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def get_platform_sessions_by_ids(
self, session_ids: list[str]
) -> list[PlatformSession]:
"""Get platform sessions by IDs."""
if not session_ids:
return []
async with self.get_db() as session:
session: AsyncSession
query = select(PlatformSession).where(
col(PlatformSession.session_id).in_(session_ids)
)
result = await session.execute(query)
return list(result.scalars().all())
async def get_platform_sessions_by_creator( async def get_platform_sessions_by_creator(
self, self,
creator: str, creator: str,
+3 -3
View File
@@ -96,10 +96,10 @@ class Plain(BaseMessageComponent):
def __init__(self, text: str, convert: bool = True, **_) -> None: def __init__(self, text: str, convert: bool = True, **_) -> None:
super().__init__(text=text, convert=convert, **_) super().__init__(text=text, convert=convert, **_)
def toDict(self): def toDict(self) -> dict:
return {"type": "text", "data": {"text": self.text.strip()}} return {"type": "text", "data": {"text": self.text}}
async def to_dict(self): async def to_dict(self) -> dict:
return {"type": "text", "data": {"text": self.text}} return {"type": "text", "data": {"text": self.text}}
+17 -6
View File
@@ -44,6 +44,22 @@ class PersonaManager:
raise ValueError(f"Persona with ID {persona_id} does not exist.") raise ValueError(f"Persona with ID {persona_id} does not exist.")
return persona return persona
def get_persona_v3_by_id(self, persona_id: str | None) -> Personality | None:
"""Resolve a v3 persona object by id.
- None/empty id returns None.
- "default" maps to in-memory DEFAULT_PERSONALITY.
- Otherwise search in personas_v3 by persona name.
"""
if not persona_id:
return None
if persona_id == "default":
return DEFAULT_PERSONALITY
return next(
(persona for persona in self.personas_v3 if persona["name"] == persona_id),
None,
)
async def get_default_persona_v3( async def get_default_persona_v3(
self, self,
umo: str | MessageSession | None = None, umo: str | MessageSession | None = None,
@@ -54,12 +70,7 @@ class PersonaManager:
"default_personality", "default_personality",
"default", "default",
) )
if not default_persona_id or default_persona_id == "default": return self.get_persona_v3_by_id(default_persona_id) or DEFAULT_PERSONALITY
return DEFAULT_PERSONALITY
try:
return next(p for p in self.personas_v3 if p["name"] == default_persona_id)
except Exception:
return DEFAULT_PERSONALITY
async def resolve_selected_persona( async def resolve_selected_persona(
self, self,
@@ -6,6 +6,7 @@ from aiocqhttp import CQHttp, Event
from astrbot.api.event import AstrMessageEvent, MessageChain from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import ( from astrbot.api.message_components import (
At,
BaseMessageComponent, BaseMessageComponent,
File, File,
Image, Image,
@@ -70,11 +71,19 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
"""解析成 OneBot json 格式""" """解析成 OneBot json 格式"""
ret = [] ret = []
for segment in message_chain.chain: for segment in message_chain.chain:
if isinstance(segment, Plain): if isinstance(segment, At):
# At 组件后插入一个空格,避免与后续文本粘连
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
ret.append(d)
ret.append({"type": "text", "data": {"text": " "}})
elif isinstance(segment, Plain):
if not segment.text.strip(): if not segment.text.strip():
continue continue
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment) d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
ret.append(d) ret.append(d)
else:
d = await AiocqhttpMessageEvent._from_segment_to_dict(segment)
ret.append(d)
return ret return ret
@classmethod @classmethod
@@ -13,11 +13,28 @@ from astrbot.api.platform import (
PlatformMetadata, PlatformMetadata,
register_platform_adapter, register_platform_adapter,
) )
from astrbot.core.message.components import File, Record, Video
from astrbot.core.platform.astr_message_event import MessageSesion from astrbot.core.platform.astr_message_event import MessageSesion
from .kook_client import KookClient from .kook_client import KookClient
from .kook_config import KookConfig from .kook_config import KookConfig
from .kook_event import KookEvent from .kook_event import KookEvent
from .kook_types import (
ContainerModule,
FileModule,
HeaderModule,
ImageGroupModule,
KmarkdownElement,
KookCardMessageContainer,
KookChannelType,
KookMessageEventData,
KookMessageType,
KookModuleType,
PlainTextElement,
SectionModule,
)
KOOK_AT_SELECTOR_REGEX = re.compile(r"\(met\)([^()]+)\(met\)")
@register_platform_adapter( @register_platform_adapter(
@@ -57,35 +74,26 @@ class KookPlatformAdapter(Platform):
name="kook", description="KOOK 适配器", id=self.kook_config.id name="kook", description="KOOK 适配器", id=self.kook_config.id
) )
def _should_ignore_event_by_bot_nickname(self, payload: dict) -> bool: def _should_ignore_event_by_bot_nickname(self, author_id: str) -> bool:
bot_nickname = self.kook_config.bot_nickname.strip() return self.client.bot_id == author_id
if not bot_nickname:
return False
author = payload.get("extra", {}).get("author", {}) async def _on_received(self, event: KookMessageEventData):
if not isinstance(author, dict): logger.debug(
return False f'[KOOK] 收到来自"{event.channel_type.name}"渠道的消息, 消息类型为: {event.type.name}({event.type.value})'
)
author_nickname = author.get("nickname") or author.get("username") or "" event_type = event.type
if not isinstance(author_nickname, str): if event_type in (KookMessageType.KMARKDOWN, KookMessageType.CARD):
author_nickname = str(author_nickname) if self._should_ignore_event_by_bot_nickname(event.author_id):
logger.debug("[KOOK] 收到来自机器人自身的消息, 忽略此消息")
return author_nickname.strip().casefold() == bot_nickname.casefold() return
try:
async def _on_received(self, data: dict): abm = await self.convert_message(event)
logger.debug(f"KOOK 收到数据: {data}") await self.handle_msg(abm)
if "d" in data and data["s"] == 0: except Exception as e:
payload = data["d"] logger.error(f"[KOOK] 消息处理异常: {e}")
event_type = payload.get("type") elif event_type == KookMessageType.SYSTEM:
# 支持type=9(文本)和type=10(卡片) logger.debug(f'[KOOK] 消息为系统通知, 通知类型为: "{event.extra.type}"')
if event_type in (9, 10): logger.debug(f"[KOOK] 原始消息数据: {event.to_json()}")
if self._should_ignore_event_by_bot_nickname(payload):
return
try:
abm = await self.convert_message(payload)
await self.handle_msg(abm)
except Exception as e:
logger.error(f"[KOOK] 消息处理异常: {e}")
async def run(self): async def run(self):
"""主运行循环""" """主运行循环"""
@@ -184,18 +192,26 @@ class KookPlatformAdapter(Platform):
logger.info("[KOOK] 资源清理完成") logger.info("[KOOK] 资源清理完成")
def _parse_kmarkdown_text_message( def _parse_kmarkdown_text_message(
self, data: dict, self_id: str self, data: KookMessageEventData, self_id: str
) -> tuple[list, str]: ) -> tuple[list, str]:
kmarkdown = data.get("extra", {}).get("kmarkdown", {}) kmarkdown = data.extra.kmarkdown
content = data.get("content") or "" content = data.content or ""
raw_content = kmarkdown.get("raw_content") or content if kmarkdown is None:
logger.error(
f'[KOOK] 无法转换"{KookMessageType.KMARKDOWN.name}"消息, 消息中找不到kmarkdown字段'
)
logger.error(f"[KOOK] 原始消息内容: {data.to_json()}")
return [], ""
raw_content = kmarkdown.raw_content or content
if not isinstance(content, str): if not isinstance(content, str):
content = str(content) content = str(content)
if not isinstance(raw_content, str): if not isinstance(raw_content, str):
raw_content = str(raw_content) raw_content = str(raw_content)
# TODO 后面的pydantic类型替换,以后再来探索吧 :(
mention_name_map: dict[str, str] = {} mention_name_map: dict[str, str] = {}
mention_part = kmarkdown.get("mention_part", []) mention_part = kmarkdown.mention_part
if isinstance(mention_part, list): if isinstance(mention_part, list):
for item in mention_part: for item in mention_part:
if not isinstance(item, dict): if not isinstance(item, dict):
@@ -207,7 +223,7 @@ class KookPlatformAdapter(Platform):
components = [] components = []
cursor = 0 cursor = 0
for match in re.finditer(r"\(met\)([^()]+)\(met\)", content): for match in KOOK_AT_SELECTOR_REGEX.finditer(content):
if match.start() > cursor: if match.start() > cursor:
plain_text = content[cursor : match.start()] plain_text = content[cursor : match.start()]
if plain_text: if plain_text:
@@ -254,77 +270,109 @@ class KookPlatformAdapter(Platform):
return components, message_str return components, message_str
def _parse_card_message(self, data: dict) -> tuple[list, str]: def _parse_card_message(self, data: KookMessageEventData) -> tuple[list, str]:
content = data.get("content", "[]") content = data.content
if not isinstance(content, str): if not isinstance(content, str):
content = str(content) content = str(content)
card_list = json.loads(content)
card_list = KookCardMessageContainer.from_dict(json.loads(content))
text_parts: list[str] = [] text_parts: list[str] = []
images: list[str] = [] images: list[str] = []
files: list[tuple[KookModuleType, str, str]] = []
for card in card_list: for card in card_list:
if not isinstance(card, dict): for module in card.modules:
continue match module:
for module in card.get("modules", []): case SectionModule():
if not isinstance(module, dict): if content := self._handle_section_text(module):
continue text_parts.append(content)
module_type = module.get("type") case ContainerModule() | ImageGroupModule():
if module_type == "section": urls = self._handle_image_group(module)
section_text = module.get("text", {}).get("content", "") images.extend(urls)
if section_text: text_parts.append(" [image]" * len(urls))
text_parts.append(str(section_text))
continue
if module_type != "container": case HeaderModule():
continue text_parts.append(module.text.content)
for element in module.get("elements", []): case FileModule():
if not isinstance(element, dict): files.append((module.type, module.title, module.src))
continue text_parts.append(f" [{module.type.value}]")
if element.get("type") != "image":
continue
image_src = element.get("src") case _:
if not isinstance(image_src, str): logger.debug(f"[KOOK] 跳过或未处理模块: {module.type}")
logger.warning(
f'[KOOK] 处理卡片中的图片时发生错误,图片url "{image_src}" 应该为str类型, 而不是 "{type(image_src)}" '
)
continue
if not image_src.startswith(("http://", "https://")):
logger.warning(f"[KOOK] 屏蔽非http图片url: {image_src}")
continue
images.append(image_src)
text = "".join(text_parts) text = "".join(text_parts)
message = [] message = []
if text: if text:
for search in KOOK_AT_SELECTOR_REGEX.finditer(text):
search_text = search.group(1).strip()
if search_text == "all":
message.append(AtAll())
continue
message.append(At(qq=search_text))
text = text.replace(f"(met){search_text}(met)", "")
message.append(Plain(text=text)) message.append(Plain(text=text))
for img_url in images: for img_url in images:
message.append(Image(file=img_url)) message.append(Image(file=img_url))
for file in files:
file_type = file[0]
file_name = file[1]
file_url = file[2]
if file_type == KookModuleType.FILE:
message.append(File(name=file_name, file=file_url))
elif file_type == KookModuleType.VIDEO:
message.append(Video(file=file_url))
elif file_type == KookModuleType.AUDIO:
message.append(Record(file=file_url))
else:
logger.warning(f"[KOOK] 跳过未知文件类型: {file_type.name}")
return message, text return message, text
async def convert_message(self, data: dict) -> AstrBotMessage: def _handle_section_text(self, module: SectionModule) -> str:
"""专门处理 Section 里的文本提取"""
if isinstance(module.text, (KmarkdownElement, PlainTextElement)):
return module.text.content or ""
return ""
def _handle_image_group(
self, module: ContainerModule | ImageGroupModule
) -> list[str]:
"""专门处理图片组/容器里的合法 URL 提取"""
valid_urls = []
for el in module.elements:
image_src = el.src
if not el.src.startswith(("http://", "https://")):
logger.warning(f"[KOOK] 屏蔽非http图片url: {image_src}")
continue
valid_urls.append(el.src)
return valid_urls
async def convert_message(self, data: KookMessageEventData) -> AstrBotMessage:
abm = AstrBotMessage() abm = AstrBotMessage()
abm.raw_message = data abm.raw_message = data.to_dict()
abm.self_id = self.client.bot_id abm.self_id = self.client.bot_id
channel_type = data.get("channel_type") channel_type = data.channel_type
author_id = data.get("author_id", "unknown") author_id = data.author_id
# channel_type定义: https://developer.kookapp.cn/doc/event/event-introduction # channel_type定义: https://developer.kookapp.cn/doc/event/event-introduction
match channel_type: match channel_type:
case "GROUP": case KookChannelType.GROUP:
session_id = data.get("target_id") or "unknown" session_id = data.target_id or "unknown"
abm.type = MessageType.GROUP_MESSAGE abm.type = MessageType.GROUP_MESSAGE
abm.group_id = session_id abm.group_id = session_id
abm.session_id = session_id abm.session_id = session_id
case "PERSON": case KookChannelType.PERSON:
abm.type = MessageType.FRIEND_MESSAGE abm.type = MessageType.FRIEND_MESSAGE
abm.group_id = "" abm.group_id = ""
abm.session_id = data.get("author_id", "unknown") abm.session_id = data.author_id or "unknown"
case "BROADCAST": case KookChannelType.BROADCAST:
session_id = data.get("target_id") or "unknown" session_id = data.target_id or "unknown"
abm.type = MessageType.OTHER_MESSAGE abm.type = MessageType.OTHER_MESSAGE
abm.group_id = session_id abm.group_id = session_id
abm.session_id = session_id abm.session_id = session_id
@@ -333,28 +381,25 @@ class KookPlatformAdapter(Platform):
abm.sender = MessageMember( abm.sender = MessageMember(
user_id=author_id, user_id=author_id,
nickname=data.get("extra", {}).get("author", {}).get("username", ""), nickname=data.extra.author.username if data.extra.author else "unknown",
) )
abm.message_id = data.get("msg_id", "unknown") abm.message_id = data.msg_id or "unknown"
# 普通文本消息 if data.type == KookMessageType.KMARKDOWN:
if data.get("type") == 9: message, message_str = self._parse_kmarkdown_text_message(data, abm.self_id)
message, message_str = self._parse_kmarkdown_text_message(
data, str(abm.self_id)
)
abm.message = message abm.message = message
abm.message_str = message_str abm.message_str = message_str
# 卡片消息 elif data.type == KookMessageType.CARD:
elif data.get("type") == 10:
try: try:
abm.message, abm.message_str = self._parse_card_message(data) abm.message, abm.message_str = self._parse_card_message(data)
except Exception as exp: except Exception as exp:
logger.error(f"[KOOK] 卡片消息解析失败: {exp}") logger.error(f"[KOOK] 卡片消息解析失败: {exp}")
logger.error(f"[KOOK] 原始消息内容: {data.to_json()}")
abm.message_str = "[卡片消息解析失败]" abm.message_str = "[卡片消息解析失败]"
abm.message = [Plain(text="[卡片消息解析失败]")] abm.message = [Plain(text="[卡片消息解析失败]")]
else: else:
logger.warning(f'[KOOK] 不支持的kook消息类型: "{data.get("type")}"') logger.warning(f'[KOOK] 不支持的kook消息类型: "{data.type.name}"')
abm.message_str = "[不支持的消息类型]" abm.message_str = "[不支持的消息类型]"
abm.message = [Plain(text="[不支持的消息类型]")] abm.message = [Plain(text="[不支持的消息类型]")]
+103 -56
View File
@@ -1,6 +1,5 @@
import asyncio import asyncio
import base64 import base64
import json
import os import os
import random import random
import time import time
@@ -9,13 +8,23 @@ from pathlib import Path
import aiofiles import aiofiles
import aiohttp import aiohttp
import pydantic
import websockets import websockets
from astrbot import logger from astrbot import logger
from astrbot.core.platform.message_type import MessageType from astrbot.core.platform.message_type import MessageType
from .kook_config import KookConfig from .kook_config import KookConfig
from .kook_types import KookApiPaths, KookMessageType from .kook_types import (
KookApiPaths,
KookGatewayIndexResponse,
KookHelloEventData,
KookMessageSignal,
KookMessageType,
KookResumeAckEventData,
KookUserMeResponse,
KookWebsocketEvent,
)
class KookClient: class KookClient:
@@ -23,7 +32,8 @@ class KookClient:
# 数据字段 # 数据字段
self.config = config self.config = config
self._bot_id = "" self._bot_id = ""
self._bot_name = "" self._bot_username = ""
self._bot_nickname = ""
# 资源字段 # 资源字段
self._http_client = aiohttp.ClientSession( self._http_client = aiohttp.ClientSession(
@@ -48,37 +58,50 @@ class KookClient:
return self._bot_id return self._bot_id
@property @property
def bot_name(self): def bot_nickname(self):
return self._bot_name return self._bot_nickname
async def get_bot_info(self) -> str: @property
"""获取机器人账号ID""" def bot_username(self):
return self._bot_username
async def get_bot_info(self) -> None:
"""获取机器人账号信息"""
url = KookApiPaths.USER_ME url = KookApiPaths.USER_ME
try: try:
async with self._http_client.get(url) as resp: async with self._http_client.get(url) as resp:
if resp.status != 200: if resp.status != 200:
logger.error(f"[KOOK] 获取机器人账号ID失败,状态码: {resp.status}") logger.error(
return "" f"[KOOK] 获取机器人账号信息失败,状态码: {resp.status} , {await resp.text()}"
)
return
try:
resp_content = KookUserMeResponse.from_dict(await resp.json())
except pydantic.ValidationError as e:
logger.error(
f"[KOOK] 获取机器人账号信息失败, 响应数据格式错误: \n{e}"
)
logger.error(f"[KOOK] 响应内容: {await resp.text()}")
return
data = await resp.json() if not resp_content.success():
if data.get("code") != 0: logger.error(
logger.error(f"[KOOK] 获取机器人账号ID失败: {data}") f"[KOOK] 获取机器人账号信息失败: {resp_content.model_dump_json()}"
return "" )
return
bot_id: str = data["data"]["id"] bot_id: str = resp_content.data.id
self._bot_id = bot_id self._bot_id = bot_id
logger.info(f"[KOOK] 获取机器人账号ID成功: {bot_id}") logger.info(f"[KOOK] 获取机器人账号ID成功: {bot_id}")
bot_name: str = data["data"]["nickname"] or data["data"]["username"] self._bot_nickname = resp_content.data.nickname
self._bot_name = bot_name self._bot_username = resp_content.data.username
logger.info(f"[KOOK] 获取机器人名称成功: {self._bot_name}") logger.info(f"[KOOK] 获取机器人名称成功: {self._bot_nickname}")
return bot_id
except Exception as e: except Exception as e:
logger.error(f"[KOOK] 获取机器人账号ID异常: {e}") logger.error(f"[KOOK] 获取机器人账号信息异常: {e}")
return ""
async def get_gateway_url(self, resume=False, sn=0, session_id=None): async def get_gateway_url(self, resume=False, sn=0, session_id=None) -> str | None:
"""获取网关连接地址""" """获取网关连接地址"""
url = KookApiPaths.GATEWAY_INDEX url = KookApiPaths.GATEWAY_INDEX
@@ -96,14 +119,20 @@ class KookClient:
logger.error(f"[KOOK] 获取gateway失败,状态码: {resp.status}") logger.error(f"[KOOK] 获取gateway失败,状态码: {resp.status}")
return None return None
data = await resp.json() resp_content = KookGatewayIndexResponse.from_dict(await resp.json())
if data.get("code") != 0: if not resp_content.success():
logger.error(f"[KOOK] 获取gateway失败: {data}") logger.error(f"[KOOK] 获取gateway失败: {resp_content}")
return None return None
gateway_url: str = data["data"]["url"] gateway_url: str = resp_content.data.url
logger.info(f"[KOOK] 获取gateway成功: {gateway_url.split('?')[0]}") logger.info(f"[KOOK] 获取gateway成功: {gateway_url.split('?')[0]}")
return gateway_url return gateway_url
except pydantic.ValidationError as e:
logger.error(f"[KOOK] 获取gateway失败, 响应数据格式错误: \n{e}")
logger.error(f"[KOOK] 原始响应内容: {await resp.text()}")
return None
except Exception as e: except Exception as e:
logger.error(f"[KOOK] 获取gateway异常: {e}") logger.error(f"[KOOK] 获取gateway异常: {e}")
return None return None
@@ -156,7 +185,11 @@ class KookClient:
try: try:
while self.running: while self.running:
try: try:
msg = await asyncio.wait_for(self.ws.recv(), timeout=10) # type: ignore if self.ws is None:
logger.error("[KOOK] WebSocket 对象丢失,结束监听流程。")
break
msg = await asyncio.wait_for(self.ws.recv(), timeout=10)
if isinstance(msg, bytes): if isinstance(msg, bytes):
try: try:
@@ -166,10 +199,15 @@ class KookClient:
continue continue
msg = msg.decode("utf-8") msg = msg.decode("utf-8")
data = json.loads(msg) event = KookWebsocketEvent.from_json(msg)
# 处理不同类型的信令 # 处理不同类型的信令
await self._handle_signal(data) await self._handle_signal(event)
except pydantic.ValidationError as e:
logger.error(f"[KOOK] 解析WebSocket事件数据格式失败: \n{e}")
logger.error(f"[KOOK] 原始响应内容: {msg}")
continue
except asyncio.TimeoutError: except asyncio.TimeoutError:
# 超时检查,继续循环 # 超时检查,继续循环
@@ -187,38 +225,41 @@ class KookClient:
self.running = False self.running = False
self._stop_event.set() self._stop_event.set()
async def _handle_signal(self, data): async def _handle_signal(self, event: KookWebsocketEvent):
"""处理不同类型的信令""" """处理不同类型的信令"""
signal_type = data.get("s") data = event.data
if signal_type == 0: # 事件消息 match event.signal:
# 更新消息序号 case KookMessageSignal.MESSAGE:
if "sn" in data: if event.sn is not None:
self.last_sn = data["sn"] self.last_sn = event.sn
await self.event_callback(data) await self.event_callback(data)
elif signal_type == 1: # HELLO握手 case KookMessageSignal.HELLO:
await self._handle_hello(data) assert isinstance(data, KookHelloEventData)
await self._handle_hello(data)
elif signal_type == 3: # PONG心跳响应 case KookMessageSignal.RESUME_ACK:
await self._handle_pong(data) assert isinstance(data, KookResumeAckEventData)
await self._handle_resume_ack(data)
elif signal_type == 5: # RECONNECT重连指令 case KookMessageSignal.PONG:
await self._handle_reconnect(data) await self._handle_pong()
elif signal_type == 6: # RESUME ACK case KookMessageSignal.RECONNECT:
await self._handle_resume_ack(data) await self._handle_reconnect()
else: case _:
logger.debug(f"[KOOK] 未处理的信令类型: {signal_type}") logger.debug(
f"[KOOK] 未处理的信令类型: {event.signal.name}({event.signal.value})"
)
async def _handle_hello(self, data): async def _handle_hello(self, data: KookHelloEventData):
"""处理HELLO握手""" """处理HELLO握手"""
hello_data = data.get("d", {}) code = data.code
code = hello_data.get("code", 0)
if code == 0: if code == 0:
self.session_id = hello_data.get("session_id") self.session_id = data.session_id
logger.info(f"[KOOK] 握手成功,session_id: {self.session_id}") logger.info(f"[KOOK] 握手成功,session_id: {self.session_id}")
# TODO 重置重连延迟 # TODO 重置重连延迟
# self.reconnect_delay = 1 # self.reconnect_delay = 1
@@ -228,12 +269,12 @@ class KookClient:
logger.error("[KOOK] Token已过期,需要重新获取") logger.error("[KOOK] Token已过期,需要重新获取")
self.running = False self.running = False
async def _handle_pong(self, data): async def _handle_pong(self):
"""处理PONG心跳响应""" """处理PONG心跳响应"""
self.last_heartbeat_time = time.time() self.last_heartbeat_time = time.time()
self.heartbeat_failed_count = 0 self.heartbeat_failed_count = 0
async def _handle_reconnect(self, data): async def _handle_reconnect(self):
"""处理重连指令""" """处理重连指令"""
logger.warning("[KOOK] 收到重连指令") logger.warning("[KOOK] 收到重连指令")
# 清空本地状态 # 清空本地状态
@@ -241,10 +282,9 @@ class KookClient:
self.session_id = None self.session_id = None
self.running = False self.running = False
async def _handle_resume_ack(self, data): async def _handle_resume_ack(self, data: KookResumeAckEventData):
"""处理RESUME确认""" """处理RESUME确认"""
resume_data = data.get("d", {}) self.session_id = data.session_id
self.session_id = resume_data.get("session_id")
logger.info(f"[KOOK] Resume成功,session_id: {self.session_id}") logger.info(f"[KOOK] Resume成功,session_id: {self.session_id}")
async def _heartbeat_loop(self): async def _heartbeat_loop(self):
@@ -292,9 +332,16 @@ class KookClient:
async def _send_ping(self): async def _send_ping(self):
"""发送心跳PING""" """发送心跳PING"""
if self.ws is None:
logger.warning("[KOOK] 尚未连接kook WebSocket服务器, 跳过发送心跳包流程")
return
try: try:
ping_data = {"s": 2, "sn": self.last_sn} ping_data = KookWebsocketEvent(
await self.ws.send(json.dumps(ping_data)) # type: ignore signal=KookMessageSignal.PING,
data=None,
sn=self.last_sn,
)
await self.ws.send(ping_data.to_json())
except Exception as e: except Exception as e:
logger.error(f"[KOOK] 发送心跳失败: {e}") logger.error(f"[KOOK] 发送心跳失败: {e}")
@@ -9,7 +9,6 @@ class KookConfig:
# 基础配置 # 基础配置
token: str token: str
bot_nickname: str = ""
enable: bool = False enable: bool = False
id: str = "kook" id: str = "kook"
@@ -41,7 +40,6 @@ class KookConfig:
# id=config_dict.get("id", "kook"), # id=config_dict.get("id", "kook"),
enable=config_dict.get("enable", False), enable=config_dict.get("enable", False),
token=config_dict.get("kook_bot_token", ""), token=config_dict.get("kook_bot_token", ""),
bot_nickname=config_dict.get("kook_bot_nickname", ""),
reconnect_delay=config_dict.get( reconnect_delay=config_dict.get(
"kook_reconnect_delay", "kook_reconnect_delay",
KookConfig.reconnect_delay, KookConfig.reconnect_delay,
@@ -27,6 +27,7 @@ from .kook_types import (
KookCardMessage, KookCardMessage,
KookCardMessageContainer, KookCardMessageContainer,
KookMessageType, KookMessageType,
KookModuleType,
OrderMessage, OrderMessage,
) )
@@ -111,7 +112,7 @@ class KookEvent(AstrMessageEvent):
KookCardMessage( KookCardMessage(
modules=[ modules=[
FileModule( FileModule(
type="audio", type=KookModuleType.AUDIO,
title=title, title=title,
src=url, src=url,
) )
@@ -182,7 +183,7 @@ class KookEvent(AstrMessageEvent):
if item.reply_id: if item.reply_id:
reply_id = item.reply_id reply_id = item.reply_id
if not item.text: if not item.text:
logger.debug(f'[Kook] 跳过空消息,类型为"{item.type}"') logger.debug(f'[Kook] 跳过空消息,类型为"{item.type.name}"')
continue continue
try: try:
await self.client.send_text( await self.client.send_text(
+319 -55
View File
@@ -1,10 +1,8 @@
import json import json
from dataclasses import field from enum import IntEnum, StrEnum
from enum import IntEnum from typing import Annotated, Any, Literal
from typing import Literal
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic.dataclasses import dataclass
class KookApiPaths: class KookApiPaths:
@@ -25,8 +23,9 @@ class KookApiPaths:
DIRECT_MESSAGE_CREATE = f"{BASE_URL}{API_VERSION_PATH}/direct-message/create" DIRECT_MESSAGE_CREATE = f"{BASE_URL}{API_VERSION_PATH}/direct-message/create"
# 定义参见kook事件结构文档: https://developer.kookapp.cn/doc/event/event-introduction
class KookMessageType(IntEnum): class KookMessageType(IntEnum):
"""定义参见kook事件结构文档: https://developer.kookapp.cn/doc/event/event-introduction"""
TEXT = 1 TEXT = 1
IMAGE = 2 IMAGE = 2
VIDEO = 3 VIDEO = 3
@@ -37,6 +36,26 @@ class KookMessageType(IntEnum):
SYSTEM = 255 SYSTEM = 255
class KookModuleType(StrEnum):
PLAIN_TEXT = "plain-text"
KMARKDOWN = "kmarkdown"
IMAGE = "image"
BUTTON = "button"
HEADER = "header"
SECTION = "section"
IMAGE_GROUP = "image-group"
CONTAINER = "container"
ACTION_GROUP = "action-group"
CONTEXT = "context"
DIVIDER = "divider"
FILE = "file"
AUDIO = "audio"
VIDEO = "video"
COUNTDOWN = "countdown"
INVITE = "invite"
CARD = "card"
ThemeType = Literal[ ThemeType = Literal[
"primary", "success", "danger", "warning", "info", "secondary", "none", "invisible" "primary", "success", "danger", "warning", "info", "secondary", "none", "invisible"
] ]
@@ -48,43 +67,81 @@ SectionMode = Literal["left", "right"]
CountdownMode = Literal["day", "hour", "second"] CountdownMode = Literal["day", "hour", "second"]
class KookCardColor(str): class KookBaseDataClass(BaseModel):
"""16 进制色值""" model_config = ConfigDict(
extra="allow",
arbitrary_types_allowed=True,
populate_by_name=True,
)
@classmethod
def from_dict(cls, raw_data: dict):
return cls.model_validate(raw_data)
@classmethod
def from_json(cls, raw_data: str | bytes | bytearray):
return cls.model_validate_json(raw_data)
def to_dict(
self,
mode: Literal["json", "python"] | str = "python",
by_alias=True,
exclude_none=True,
exclude_unset=False,
) -> dict:
return self.model_dump(
by_alias=by_alias,
exclude_none=exclude_none,
mode=mode,
exclude_unset=exclude_unset,
)
def to_json(
self,
indent: int | None = None,
ensure_ascii=False,
by_alias=True,
exclude_none=True,
exclude_unset=False,
) -> str:
return self.model_dump_json(
indent=indent,
ensure_ascii=ensure_ascii,
by_alias=by_alias,
exclude_none=exclude_none,
exclude_unset=exclude_unset,
)
class KookCardModelBase: class KookCardModelBase(KookBaseDataClass):
"""卡片模块基类""" """卡片模块基类"""
type: str type: str
@dataclass
class PlainTextElement(KookCardModelBase): class PlainTextElement(KookCardModelBase):
content: str content: str
type: str = "plain-text" type: Literal[KookModuleType.PLAIN_TEXT] = KookModuleType.PLAIN_TEXT
emoji: bool = True emoji: bool = True
@dataclass
class KmarkdownElement(KookCardModelBase): class KmarkdownElement(KookCardModelBase):
content: str content: str
type: str = "kmarkdown" type: Literal[KookModuleType.KMARKDOWN] = KookModuleType.KMARKDOWN
@dataclass
class ImageElement(KookCardModelBase): class ImageElement(KookCardModelBase):
src: str src: str
type: str = "image" type: Literal[KookModuleType.IMAGE] = KookModuleType.IMAGE
alt: str = "" alt: str = ""
size: SizeType = "lg" size: SizeType = "lg"
circle: bool = False circle: bool = False
fallbackUrl: str | None = None fallbackUrl: str | None = None
@dataclass
class ButtonElement(KookCardModelBase): class ButtonElement(KookCardModelBase):
text: str text: str
type: str = "button" type: Literal[KookModuleType.BUTTON] = KookModuleType.BUTTON
theme: ThemeType = "primary" theme: ThemeType = "primary"
value: str = "" value: str = ""
"""当为 link 时,会跳转到 value 代表的链接; """当为 link 时,会跳转到 value 代表的链接;
@@ -96,93 +153,88 @@ class ButtonElement(KookCardModelBase):
AnyElement = PlainTextElement | KmarkdownElement | ImageElement | ButtonElement | str AnyElement = PlainTextElement | KmarkdownElement | ImageElement | ButtonElement | str
@dataclass
class ParagraphStructure(KookCardModelBase): class ParagraphStructure(KookCardModelBase):
fields: list[PlainTextElement | KmarkdownElement] fields: list[PlainTextElement | KmarkdownElement]
type: str = "paragraph" type: Literal["paragraph"] = "paragraph"
cols: int = 1 cols: int = 1
"""范围是 1-3 , 移动端忽略此参数""" """范围是 1-3 , 移动端忽略此参数"""
@dataclass
class HeaderModule(KookCardModelBase): class HeaderModule(KookCardModelBase):
text: PlainTextElement text: PlainTextElement
type: str = "header" type: Literal[KookModuleType.HEADER] = KookModuleType.HEADER
@dataclass
class SectionModule(KookCardModelBase): class SectionModule(KookCardModelBase):
text: PlainTextElement | KmarkdownElement | ParagraphStructure text: PlainTextElement | KmarkdownElement | ParagraphStructure
type: str = "section" type: Literal[KookModuleType.SECTION] = KookModuleType.SECTION
mode: SectionMode = "left" mode: SectionMode = "left"
accessory: ImageElement | ButtonElement | None = None accessory: ImageElement | ButtonElement | None = None
@dataclass
class ImageGroupModule(KookCardModelBase): class ImageGroupModule(KookCardModelBase):
"""1 到多张图片的组合""" """1 到多张图片的组合"""
elements: list[ImageElement] elements: list[ImageElement]
type: str = "image-group" type: Literal[KookModuleType.IMAGE_GROUP] = KookModuleType.IMAGE_GROUP
@dataclass
class ContainerModule(KookCardModelBase): class ContainerModule(KookCardModelBase):
"""1 到多张图片的组合,与图片组模块(ImageGroupModule)不同,图片并不会裁切为正方形。多张图片会纵向排列。""" """1 到多张图片的组合,与图片组模块(ImageGroupModule)不同,图片并不会裁切为正方形。多张图片会纵向排列。"""
elements: list[ImageElement] elements: list[ImageElement]
type: str = "container" type: Literal[KookModuleType.CONTAINER] = KookModuleType.CONTAINER
@dataclass
class ActionGroupModule(KookCardModelBase): class ActionGroupModule(KookCardModelBase):
"""用来放按钮的模块"""
elements: list[ButtonElement] elements: list[ButtonElement]
type: str = "action-group" type: Literal[KookModuleType.ACTION_GROUP] = KookModuleType.ACTION_GROUP
@dataclass
class ContextModule(KookCardModelBase): class ContextModule(KookCardModelBase):
elements: list[PlainTextElement | KmarkdownElement | ImageElement] elements: list[PlainTextElement | KmarkdownElement | ImageElement]
"""最多包含10个元素""" """最多包含10个元素"""
type: str = "context" type: Literal[KookModuleType.CONTEXT] = KookModuleType.CONTEXT
@dataclass
class DividerModule(KookCardModelBase): class DividerModule(KookCardModelBase):
type: str = "divider" """展示分割线用的"""
type: Literal[KookModuleType.DIVIDER] = KookModuleType.DIVIDER
@dataclass
class FileModule(KookCardModelBase): class FileModule(KookCardModelBase):
src: str src: str
title: str = "" title: str = ""
type: Literal["file", "audio", "video"] = "file" type: Literal[KookModuleType.FILE, KookModuleType.AUDIO, KookModuleType.VIDEO] = (
KookModuleType.FILE
)
cover: str | None = None cover: str | None = None
"""cover 仅音频有效, 是音频的封面图""" """cover 仅音频有效, 是音频的封面图"""
@dataclass
class CountdownModule(KookCardModelBase): class CountdownModule(KookCardModelBase):
"""startTime 和 endTime 为毫秒时间戳,startTime 和 endTime 不能小于服务器当前时间戳。""" """startTime 和 endTime 为毫秒时间戳,startTime 和 endTime 不能小于服务器当前时间戳。"""
endTime: int endTime: int
"""毫秒时间戳""" """毫秒时间戳"""
type: str = "countdown" type: Literal[KookModuleType.COUNTDOWN] = KookModuleType.COUNTDOWN
startTime: int | None = None startTime: int | None = None
"""毫秒时间戳, 仅当mode为second才有这个字段""" """毫秒时间戳, 仅当mode为second才有这个字段"""
mode: CountdownMode = "day" mode: CountdownMode = "day"
"""mode 主要是倒计时的样式""" """mode 主要是倒计时的样式"""
@dataclass
class InviteModule(KookCardModelBase): class InviteModule(KookCardModelBase):
code: str code: str
"""邀请链接或者邀请码""" """邀请链接或者邀请码"""
type: str = "invite" type: Literal[KookModuleType.INVITE] = KookModuleType.INVITE
# 所有模块的联合类型 # 所有模块的联合类型
AnyModule = ( AnyModule = Annotated[
HeaderModule HeaderModule
| SectionModule | SectionModule
| ImageGroupModule | ImageGroupModule
@@ -192,34 +244,29 @@ AnyModule = (
| DividerModule | DividerModule
| FileModule | FileModule
| CountdownModule | CountdownModule
| InviteModule | InviteModule,
) Field(discriminator="type"),
]
class KookCardMessage(BaseModel): class KookCardMessage(KookBaseDataClass):
"""卡片定义文档详见 : https://developer.kookapp.cn/doc/cardmessage """卡片定义文档详见 : https://developer.kookapp.cn/doc/cardmessage
此类型不能直接to_json后发送,因为kook要求卡片容器json顶层必须是**列表** 此类型不能直接to_json后发送,因为kook要求卡片容器json顶层必须是**列表**
若要发送卡片消息请使用KookCardMessageContainer 若要发送卡片消息请使用KookCardMessageContainer
""" """
model_config = ConfigDict(arbitrary_types_allowed=True) model_config = ConfigDict(arbitrary_types_allowed=True)
type: str = "card" type: Literal[KookModuleType.CARD] = KookModuleType.CARD
theme: ThemeType | None = None theme: ThemeType | None = None
size: SizeType | None = None size: SizeType | None = None
color: KookCardColor | None = None color: str | None = None
modules: list[AnyModule] = field(default_factory=list) """16 进制色值"""
modules: list[AnyModule] = Field(default_factory=list)
"""单个 card 模块数量不限制,但是一条消息中所有卡片的模块数量之和最多是 50""" """单个 card 模块数量不限制,但是一条消息中所有卡片的模块数量之和最多是 50"""
def add_module(self, module: AnyModule): def add_module(self, module: AnyModule):
self.modules.append(module) self.modules.append(module)
def to_dict(self, exclude_none: bool = True):
"""exclude_none:去掉值为 None 字段,保留结构"""
return self.model_dump(exclude_none=exclude_none)
def to_json(self, indent: int | None = None, ensure_ascii: bool = True):
return json.dumps(self.to_dict(), indent=indent, ensure_ascii=ensure_ascii)
class KookCardMessageContainer(list[KookCardMessage]): class KookCardMessageContainer(list[KookCardMessage]):
"""卡片消息容器(列表),此类型可以直接to_json后发送出去""" """卡片消息容器(列表),此类型可以直接to_json后发送出去"""
@@ -232,10 +279,227 @@ class KookCardMessageContainer(list[KookCardMessage]):
[i.to_dict() for i in self], indent=indent, ensure_ascii=ensure_ascii [i.to_dict() for i in self], indent=indent, ensure_ascii=ensure_ascii
) )
@classmethod
def from_dict(cls, raw_data: list[dict[str, Any]]):
return cls(KookCardMessage.from_dict(item) for item in raw_data)
@dataclass
class OrderMessage: class OrderMessage(BaseModel):
index: int index: int
text: str text: str
type: KookMessageType type: KookMessageType
reply_id: str | int = "" reply_id: str | int = ""
class KookMessageSignal(IntEnum):
"""KOOK WebSocket 信令类型
ws文档: https://developer.kookapp.cn/doc/websocket""" # noqa: W291
MESSAGE = 0
"""server->client 消息(s包含聊天和通知消息)"""
HELLO = 1
"""server->client 客户端连接 ws 时, 服务端返回握手结果"""
PING = 2
"""client->server 心跳,ping"""
PONG = 3
"""server->client 心跳,pong"""
RESUME = 4
"""client->server resume, 恢复会话"""
RECONNECT = 5
"""server->client reconnect, 要求客户端断开当前连接重新连接"""
RESUME_ACK = 6
"""server->client resume ack"""
class KookChannelType(StrEnum):
GROUP = "GROUP"
PERSON = "PERSON"
BROADCAST = "BROADCAST"
class KookAuthor(KookBaseDataClass):
id: str
username: str
identify_num: str
nickname: str
bot: bool
online: bool
avatar: str | None = None
vip_avatar: str | None = None
status: int
roles: list[int] = Field(default_factory=list)
class KookKMarkdown(KookBaseDataClass):
raw_content: str
mention_part: list[Any] = Field(default_factory=list)
mention_role_part: list[Any] = Field(default_factory=list)
class KookExtra(KookBaseDataClass):
type: int | str
code: str | None = None
body: dict[str, Any] | None = None
author: KookAuthor | None = None
kmarkdown: KookKMarkdown | None = None
last_msg_content: str | None = None
mention: list[str] = Field(default_factory=list)
mention_all: bool = False
mention_here: bool = False
class KookMessageEventData(KookBaseDataClass):
signal: Literal[KookMessageSignal.MESSAGE] = Field(
KookMessageSignal.MESSAGE, exclude=True
)
"""only for type hint"""
channel_type: KookChannelType
type: KookMessageType
target_id: str
author_id: str
content: str | dict[str, Any]
msg_id: str
msg_timestamp: int
nonce: str
from_type: int
extra: KookExtra
class KookHelloEventData(KookBaseDataClass):
signal: Literal[KookMessageSignal.HELLO] = Field(
KookMessageSignal.HELLO, exclude=True
)
"""only for type hint"""
code: int
session_id: str
class KookPingEventData(KookBaseDataClass):
signal: Literal[KookMessageSignal.PING] = Field(
KookMessageSignal.PING, exclude=True
)
"""only for type hint"""
class KookPongEventData(KookBaseDataClass):
signal: Literal[KookMessageSignal.PONG] = Field(
KookMessageSignal.PONG, exclude=True
)
"""only for type hint"""
class KookResumeEventData(KookBaseDataClass):
signal: Literal[KookMessageSignal.RESUME] = Field(
KookMessageSignal.RESUME, exclude=True
)
"""only for type hint"""
class KookReconnectEventData(KookBaseDataClass):
signal: Literal[KookMessageSignal.RECONNECT] = Field(
KookMessageSignal.RECONNECT, exclude=True
)
"""only for type hint"""
code: int
err: str
class KookResumeAckEventData(KookBaseDataClass):
signal: Literal[KookMessageSignal.RESUME_ACK] = Field(
KookMessageSignal.RESUME_ACK, exclude=True
)
"""only for type hint"""
session_id: str
class KookWebsocketEvent(KookBaseDataClass):
"""KOOK WebSocket 原始推送结构"""
signal: KookMessageSignal = Field(
..., validation_alias="s", serialization_alias="s"
)
"""信令类型"""
data: Annotated[
KookMessageEventData
| KookHelloEventData
| KookPingEventData
| KookPongEventData
| KookResumeEventData
| KookReconnectEventData
| KookResumeAckEventData
| None,
Field(discriminator="signal"),
] = Field(None, validation_alias="d", serialization_alias="d")
"""数据事件主体,对应原字段是'd'"""
sn: int | None = None
"""消息序号 , 用来确定消息顺序和ws重连时使用
详见ws连接流程文档: https://developer.kookapp.cn/doc/websocket#%E8%BF%9E%E6%8E%A5%E6%B5%81%E7%A8%8B""" # noqa: W291
@model_validator(mode="before")
@classmethod
def _inject_signal_into_data(cls, data: Any) -> Any:
"""在解析前,把外层的 s 同步到内层的 d 中,供 discriminator 使用"""
if isinstance(data, dict):
s_value = data.get("s")
d_value = data.get("d")
if s_value is not None and isinstance(d_value, dict):
d_value["signal"] = s_value
return data
class KookUserTag(KookBaseDataClass):
color: str
bg_color: str
text: str
class KookApiResponseBase(KookBaseDataClass):
code: int
message: str
data: Any
def success(self) -> bool:
return self.code == 0
class KookUserMeData(KookBaseDataClass):
"""USER_ME 接口返回的 'data' 字段主体"""
id: str
username: str
identify_num: str
nickname: str
bot: bool
online: bool
status: int
bot_status: int
avatar: str
vip_avatar: str | None = None
banner: str | None = None
roles: list[Any] = Field(default_factory=list)
is_vip: bool
vip_amp: bool
wealth_level: int
mobile_verified: bool
client_id: str
tag_info: KookUserTag | None = None
class KookUserMeResponse(KookApiResponseBase):
"""USER_ME 完整响应结构"""
data: KookUserMeData
class KookGatewayIndexData(KookBaseDataClass):
url: str
class KookGatewayIndexResponse(KookApiResponseBase):
"""USER_ME 完整响应结构"""
data: KookGatewayIndexData
@@ -391,6 +391,47 @@ class QQOfficialPlatformAdapter(Platform):
else: else:
msg.append(File(name=filename, file=url, url=url)) msg.append(File(name=filename, file=url, url=url))
@staticmethod
def _parse_face_message(content: str) -> str:
"""Parse QQ official face message format and convert to readable text.
QQ official face message format:
<faceType=4,faceId="",ext="eyJ0ZXh0IjoiW+a7oeWktOmXruWPt10ifQ==">
The ext field contains base64-encoded JSON with a 'text' field
describing the emoji (e.g., '[满头问号]').
Args:
content: The message content that may contain face tags.
Returns:
Content with face tags replaced by readable emoji descriptions.
"""
import base64
import json
import re
def replace_face(match):
face_tag = match.group(0)
# Extract ext field from the face tag
ext_match = re.search(r'ext="([^"]*)"', face_tag)
if ext_match:
try:
ext_encoded = ext_match.group(1)
# Decode base64 and parse JSON
ext_decoded = base64.b64decode(ext_encoded).decode("utf-8")
ext_data = json.loads(ext_decoded)
emoji_text = ext_data.get("text", "")
if emoji_text:
return f"[表情:{emoji_text}]"
except Exception:
pass
# Fallback if parsing fails
return "[表情]"
# Match face tags: <faceType=...>
return re.sub(r"<faceType=\d+[^>]*>", replace_face, content)
@staticmethod @staticmethod
def _parse_from_qqofficial( def _parse_from_qqofficial(
message: botpy.message.Message message: botpy.message.Message
@@ -416,7 +457,10 @@ class QQOfficialPlatformAdapter(Platform):
abm.group_id = message.group_openid abm.group_id = message.group_openid
else: else:
abm.sender = MessageMember(message.author.user_openid, "") abm.sender = MessageMember(message.author.user_openid, "")
abm.message_str = message.content.strip() # Parse face messages to readable text
abm.message_str = QQOfficialPlatformAdapter._parse_face_message(
message.content.strip()
)
abm.self_id = "unknown_selfid" abm.self_id = "unknown_selfid"
msg.append(At(qq="qq_official")) msg.append(At(qq="qq_official"))
msg.append(Plain(abm.message_str)) msg.append(Plain(abm.message_str))
@@ -432,10 +476,12 @@ class QQOfficialPlatformAdapter(Platform):
else: else:
abm.self_id = "" abm.self_id = ""
plain_content = message.content.replace( plain_content = QQOfficialPlatformAdapter._parse_face_message(
"<@!" + str(abm.self_id) + ">", message.content.replace(
"", "<@!" + str(abm.self_id) + ">",
).strip() "",
).strip()
)
QQOfficialPlatformAdapter._append_attachments(msg, message.attachments) QQOfficialPlatformAdapter._append_attachments(msg, message.attachments)
abm.message = msg abm.message = msg
@@ -1,5 +1,6 @@
import asyncio import asyncio
import logging import logging
import time
from typing import cast from typing import cast
import quart import quart
@@ -39,6 +40,9 @@ class QQOfficialWebhook:
self.client = botpy_client self.client = botpy_client
self.event_queue = event_queue self.event_queue = event_queue
self.shutdown_event = asyncio.Event() self.shutdown_event = asyncio.Event()
# Deduplication cache for webhook retry callbacks.
self._seen_event_ids: dict[str, float] = {}
self._dedup_ttl: int = 60 # seconds
async def initialize(self) -> None: async def initialize(self) -> None:
logger.info("正在登录到 QQ 官方机器人...") logger.info("正在登录到 QQ 官方机器人...")
@@ -106,6 +110,22 @@ class QQOfficialWebhook:
print(signed) print(signed)
return signed return signed
event_id = msg.get("id")
if event_id:
now = time.monotonic()
# Lazily evict expired entries to prevent unbounded growth.
expired = [
k
for k, ts in self._seen_event_ids.items()
if now - ts > self._dedup_ttl
]
for k in expired:
del self._seen_event_ids[k]
if event_id in self._seen_event_ids:
logger.debug(f"Duplicate webhook event {event_id!r}, skipping.")
return {"opcode": 12}
self._seen_event_ids[event_id] = now
if event and opcode == BotWebSocket.WS_DISPATCH_EVENT: if event and opcode == BotWebSocket.WS_DISPATCH_EVENT:
event = msg["t"].lower() event = msg["t"].lower()
try: try:
@@ -289,8 +289,8 @@ class TelegramPlatformAdapter(Platform):
else: else:
message.type = MessageType.GROUP_MESSAGE message.type = MessageType.GROUP_MESSAGE
message.group_id = str(update.message.chat.id) message.group_id = str(update.message.chat.id)
if update.message.message_thread_id: if update.message.is_topic_message and update.message.message_thread_id:
# Topic Group # Telegram Topic Group: include thread id to isolate per-topic sessions.
message.group_id += "#" + str(update.message.message_thread_id) message.group_id += "#" + str(update.message.message_thread_id)
message.session_id = message.group_id message.session_id = message.group_id
message.message_id = str(update.message.message_id) message.message_id = str(update.message.message_id)
@@ -25,6 +25,16 @@ from astrbot.api.platform import AstrBotMessage, MessageType, PlatformMetadata
from astrbot.core.utils.metrics import Metric from astrbot.core.utils.metrics import Metric
def _is_gif(path: str) -> bool:
if path.lower().endswith(".gif"):
return True
try:
with open(path, "rb") as f:
return f.read(6) in (b"GIF87a", b"GIF89a")
except OSError:
return False
class TelegramPlatformEvent(AstrMessageEvent): class TelegramPlatformEvent(AstrMessageEvent):
# Telegram 的最大消息长度限制 # Telegram 的最大消息长度限制
MAX_MESSAGE_LENGTH = 4096 MAX_MESSAGE_LENGTH = 4096
@@ -291,7 +301,13 @@ class TelegramPlatformEvent(AstrMessageEvent):
await client.send_message(text=chunk, **cast(Any, payload)) await client.send_message(text=chunk, **cast(Any, payload))
elif isinstance(i, Image): elif isinstance(i, Image):
image_path = await i.convert_to_file_path() image_path = await i.convert_to_file_path()
await client.send_photo(photo=image_path, **cast(Any, payload)) if _is_gif(image_path):
send_coro = client.send_animation
media_kwarg = {"animation": image_path}
else:
send_coro = client.send_photo
media_kwarg = {"photo": image_path}
await send_coro(**media_kwarg, **cast(Any, payload))
elif isinstance(i, File): elif isinstance(i, File):
path = await i.get_file() path = await i.get_file()
name = i.name or os.path.basename(path) name = i.name or os.path.basename(path)
@@ -406,12 +422,20 @@ class TelegramPlatformEvent(AstrMessageEvent):
on_text(i.text) on_text(i.text)
elif isinstance(i, Image): elif isinstance(i, Image):
image_path = await i.convert_to_file_path() image_path = await i.convert_to_file_path()
if _is_gif(image_path):
action = ChatAction.UPLOAD_VIDEO
send_coro = self.client.send_animation
media_kwarg = {"animation": image_path}
else:
action = ChatAction.UPLOAD_PHOTO
send_coro = self.client.send_photo
media_kwarg = {"photo": image_path}
await self._send_media_with_action( await self._send_media_with_action(
self.client, self.client,
ChatAction.UPLOAD_PHOTO, action,
self.client.send_photo, send_coro,
user_name=user_name, user_name=user_name,
photo=image_path, **media_kwarg,
**cast(Any, payload), **cast(Any, payload),
) )
elif isinstance(i, File): elif isinstance(i, File):
@@ -440,9 +440,16 @@ class WecomAIBotAdapter(Platform):
) )
def _extract_session_id(self, message_data: dict[str, Any]) -> str: def _extract_session_id(self, message_data: dict[str, Any]) -> str:
"""从消息数据中提取会话ID""" """从消息数据中提取会话ID
user_id = message_data.get("from", {}).get("userid", "default_user") 群聊使用 chatid单聊使用 userid
return format_session_id("wecomai", user_id) """
chattype = message_data.get("chattype", "single")
if chattype == "group":
chat_id = message_data.get("chatid", "default_group")
return format_session_id("wecomai", chat_id)
else:
user_id = message_data.get("from", {}).get("userid", "default_user")
return format_session_id("wecomai", user_id)
async def _enqueue_message( async def _enqueue_message(
self, self,
+2
View File
@@ -808,6 +808,8 @@ class ProviderManager:
config.save_config() config.save_config()
# load instance # load instance
await self.load_provider(new_config) await self.load_provider(new_config)
# sync in-memory config for API queries (e.g., embedding provider list)
self.providers_config = astrbot_config["provider"]
async def terminate(self) -> None: async def terminate(self) -> None:
if self._mcp_init_task and not self._mcp_init_task.done(): if self._mcp_init_task and not self._mcp_init_task.done():
@@ -13,3 +13,11 @@ class ProviderGroq(ProviderOpenAIOfficial):
) -> None: ) -> None:
super().__init__(provider_config, provider_settings) super().__init__(provider_config, provider_settings)
self.reasoning_key = "reasoning" self.reasoning_key = "reasoning"
def _finally_convert_payload(self, payloads: dict) -> None:
"""Groq rejects assistant history items that include reasoning_content."""
super()._finally_convert_payload(payloads)
for message in payloads.get("messages", []):
if message.get("role") == "assistant":
message.pop("reasoning_content", None)
message.pop("reasoning", None)
@@ -40,25 +40,46 @@ class OpenAIEmbeddingProvider(EmbeddingProvider):
async def get_embedding(self, text: str) -> list[float]: async def get_embedding(self, text: str) -> list[float]:
"""获取文本的嵌入""" """获取文本的嵌入"""
kwargs = self._embedding_kwargs()
embedding = await self.client.embeddings.create( embedding = await self.client.embeddings.create(
input=text, input=text,
model=self.model, model=self.model,
dimensions=self.get_dim(), **kwargs,
) )
return embedding.data[0].embedding return embedding.data[0].embedding
async def get_embeddings(self, text: list[str]) -> list[list[float]]: async def get_embeddings(self, text: list[str]) -> list[list[float]]:
"""批量获取文本的嵌入""" """批量获取文本的嵌入"""
kwargs = self._embedding_kwargs()
embeddings = await self.client.embeddings.create( embeddings = await self.client.embeddings.create(
input=text, input=text,
model=self.model, model=self.model,
dimensions=self.get_dim(), **kwargs,
) )
return [item.embedding for item in embeddings.data] return [item.embedding for item in embeddings.data]
def _embedding_kwargs(self) -> dict:
"""构建嵌入请求的可选参数"""
kwargs = {}
if "embedding_dimensions" in self.provider_config:
try:
kwargs["dimensions"] = int(self.provider_config["embedding_dimensions"])
except (ValueError, TypeError):
logger.warning(
f"embedding_dimensions in embedding configs is not a valid integer: '{self.provider_config['embedding_dimensions']}', ignored."
)
return kwargs
def get_dim(self) -> int: def get_dim(self) -> int:
"""获取向量的维度""" """获取向量的维度"""
return int(self.provider_config.get("embedding_dimensions", 1024)) if "embedding_dimensions" in self.provider_config:
try:
return int(self.provider_config["embedding_dimensions"])
except (ValueError, TypeError):
logger.warning(
f"embedding_dimensions in embedding configs is not a valid integer: '{self.provider_config['embedding_dimensions']}', ignored."
)
return 0
async def terminate(self): async def terminate(self):
if self.client: if self.client:
@@ -311,7 +311,7 @@ class ProviderOpenAIOfficial(Provider):
state.handle_chunk(chunk) state.handle_chunk(chunk)
except Exception as e: except Exception as e:
logger.warning("Saving chunk state error: " + str(e)) logger.warning("Saving chunk state error: " + str(e))
if len(chunk.choices) == 0: if not chunk.choices:
continue continue
delta = chunk.choices[0].delta delta = chunk.choices[0].delta
# logger.debug(f"chunk delta: {delta}") # logger.debug(f"chunk delta: {delta}")
@@ -322,7 +322,7 @@ class ProviderOpenAIOfficial(Provider):
if reasoning: if reasoning:
llm_response.reasoning_content = reasoning llm_response.reasoning_content = reasoning
_y = True _y = True
if delta.content: if delta and delta.content:
# Don't strip streaming chunks to preserve spaces between words # Don't strip streaming chunks to preserve spaces between words
completion_text = self._normalize_content(delta.content, strip=False) completion_text = self._normalize_content(delta.content, strip=False)
llm_response.result_chain = MessageChain( llm_response.result_chain = MessageChain(
@@ -345,7 +345,7 @@ class ProviderOpenAIOfficial(Provider):
) -> str: ) -> str:
"""Extract reasoning content from OpenAI ChatCompletion if available.""" """Extract reasoning content from OpenAI ChatCompletion if available."""
reasoning_text = "" reasoning_text = ""
if len(completion.choices) == 0: if not completion.choices:
return reasoning_text return reasoning_text
if isinstance(completion, ChatCompletion): if isinstance(completion, ChatCompletion):
choice = completion.choices[0] choice = completion.choices[0]
@@ -468,7 +468,7 @@ class ProviderOpenAIOfficial(Provider):
"""Parse OpenAI ChatCompletion into LLMResponse""" """Parse OpenAI ChatCompletion into LLMResponse"""
llm_response = LLMResponse("assistant") llm_response = LLMResponse("assistant")
if len(completion.choices) == 0: if not completion.choices:
raise Exception("API 返回的 completion 为空。") raise Exception("API 返回的 completion 为空。")
choice = completion.choices[0] choice = completion.choices[0]
@@ -629,7 +629,8 @@ class ProviderOpenAIOfficial(Provider):
# 最后一次不等待 # 最后一次不等待
if retry_cnt < max_retries - 1: if retry_cnt < max_retries - 1:
await asyncio.sleep(1) await asyncio.sleep(1)
available_api_keys.remove(chosen_key) if chosen_key in available_api_keys:
available_api_keys.remove(chosen_key)
if len(available_api_keys) > 0: if len(available_api_keys) > 0:
chosen_key = random.choice(available_api_keys) chosen_key = random.choice(available_api_keys)
return ( return (
@@ -16,4 +16,7 @@ class ProviderOpenRouter(ProviderOpenAIOfficial):
self.client._custom_headers["HTTP-Referer"] = ( # type: ignore self.client._custom_headers["HTTP-Referer"] = ( # type: ignore
"https://github.com/AstrBotDevs/AstrBot" "https://github.com/AstrBotDevs/AstrBot"
) )
self.client._custom_headers["X-TITLE"] = "AstrBot" # type: ignore self.client._custom_headers["X-OpenRouter-Title"] = "AstrBot" # type: ignore
self.client._custom_headers["X-OpenRouter-Categories"] = (
"general-chat,personal-agent" # type: ignore
)
+16 -8
View File
@@ -11,6 +11,8 @@ from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
import yaml
from astrbot.core.utils.astrbot_path import ( from astrbot.core.utils.astrbot_path import (
get_astrbot_data_path, get_astrbot_data_path,
get_astrbot_skills_path, get_astrbot_skills_path,
@@ -69,13 +71,19 @@ def _parse_frontmatter_description(text: str) -> str:
break break
if end_idx is None: if end_idx is None:
return "" return ""
for line in lines[1:end_idx]:
if ":" not in line: frontmatter = "\n".join(lines[1:end_idx])
continue try:
key, value = line.split(":", 1) payload = yaml.safe_load(frontmatter) or {}
if key.strip().lower() == "description": except yaml.YAMLError:
return value.strip().strip('"').strip("'") return ""
return "" if not isinstance(payload, dict):
return ""
description = payload.get("description", "")
if not isinstance(description, str):
return ""
return description.strip()
# Regex for sanitizing paths used in prompt examples — only allow # Regex for sanitizing paths used in prompt examples — only allow
@@ -128,7 +136,7 @@ def _build_skill_read_command_example(path: str) -> str:
return f"cat {path}" return f"cat {path}"
if _is_windows_prompt_path(path): if _is_windows_prompt_path(path):
command = "type" command = "type"
path_arg = f'"{path}"' path_arg = f'"{os.path.normpath(path)}"'
else: else:
command = "cat" command = "cat"
path_arg = shlex.quote(path) path_arg = shlex.quote(path)
+5 -8
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import re import re
from collections.abc import AsyncGenerator, Awaitable, Callable from collections.abc import AsyncGenerator, Awaitable, Callable
from typing import TYPE_CHECKING, Any from typing import Any
import docstring_parser import docstring_parser
@@ -15,9 +15,6 @@ from astrbot.core.message.message_event_result import MessageEventResult
from astrbot.core.provider.func_tool_manager import PY_TO_JSON_TYPE, SUPPORTED_TYPES from astrbot.core.provider.func_tool_manager import PY_TO_JSON_TYPE, SUPPORTED_TYPES
from astrbot.core.provider.register import llm_tools from astrbot.core.provider.register import llm_tools
if TYPE_CHECKING:
from astrbot.core.astr_agent_context import AstrAgentContext
from ..filter.command import CommandFilter from ..filter.command import CommandFilter
from ..filter.command_group import CommandGroupFilter from ..filter.command_group import CommandGroupFilter
from ..filter.custom_filter import CustomFilterAnd, CustomFilterOr from ..filter.custom_filter import CustomFilterAnd, CustomFilterOr
@@ -619,7 +616,7 @@ class RegisteringAgent:
kwargs["registering_agent"] = self kwargs["registering_agent"] = self
return register_llm_tool(*args, **kwargs) return register_llm_tool(*args, **kwargs)
def __init__(self, agent: Agent[AstrAgentContext]) -> None: def __init__(self, agent: Agent[Any]) -> None:
self._agent = agent self._agent = agent
@@ -627,7 +624,7 @@ def register_agent(
name: str, name: str,
instruction: str, instruction: str,
tools: list[str | FunctionTool] | None = None, tools: list[str | FunctionTool] | None = None,
run_hooks: BaseAgentRunHooks[AstrAgentContext] | None = None, run_hooks: BaseAgentRunHooks[Any] | None = None,
): ):
"""注册一个 Agent """注册一个 Agent
@@ -641,12 +638,12 @@ def register_agent(
tools_ = tools or [] tools_ = tools or []
def decorator(awaitable: Callable[..., Awaitable[Any]]): def decorator(awaitable: Callable[..., Awaitable[Any]]):
AstrAgent = Agent[AstrAgentContext] AstrAgent = Agent[Any]
agent = AstrAgent( agent = AstrAgent(
name=name, name=name,
instructions=instruction, instructions=instruction,
tools=tools_, tools=tools_,
run_hooks=run_hooks or BaseAgentRunHooks[AstrAgentContext](), run_hooks=run_hooks or BaseAgentRunHooks[Any](),
) )
handoff_tool = HandoffTool(agent=agent) handoff_tool = HandoffTool(agent=agent)
handoff_tool.handler = awaitable handoff_tool.handler = awaitable
+22 -16
View File
@@ -1,13 +1,16 @@
from __future__ import annotations from __future__ import annotations
from typing import Any import copy
from typing import TYPE_CHECKING, Any
from astrbot import logger from astrbot import logger
from astrbot.core.agent.agent import Agent from astrbot.core.agent.agent import Agent
from astrbot.core.agent.handoff import HandoffTool from astrbot.core.agent.handoff import HandoffTool
from astrbot.core.persona_mgr import PersonaManager
from astrbot.core.provider.func_tool_manager import FunctionToolManager from astrbot.core.provider.func_tool_manager import FunctionToolManager
if TYPE_CHECKING:
from astrbot.core.persona_mgr import PersonaManager
class SubAgentOrchestrator: class SubAgentOrchestrator:
"""Loads subagent definitions from config and registers handoff tools. """Loads subagent definitions from config and registers handoff tools.
@@ -43,15 +46,14 @@ class SubAgentOrchestrator:
continue continue
persona_id = item.get("persona_id") persona_id = item.get("persona_id")
persona_data = None if persona_id is not None:
if persona_id: persona_id = str(persona_id).strip() or None
try: persona_data = self._persona_mgr.get_persona_v3_by_id(persona_id)
persona_data = await self._persona_mgr.get_persona(persona_id) if persona_id and persona_data is None:
except StopIteration: logger.warning(
logger.warning( "SubAgent persona %s not found, fallback to inline prompt.",
"SubAgent persona %s not found, fallback to inline prompt.", persona_id,
persona_id, )
)
instructions = str(item.get("system_prompt", "")).strip() instructions = str(item.get("system_prompt", "")).strip()
public_description = str(item.get("public_description", "")).strip() public_description = str(item.get("public_description", "")).strip()
@@ -62,11 +64,15 @@ class SubAgentOrchestrator:
begin_dialogs = None begin_dialogs = None
if persona_data: if persona_data:
instructions = persona_data.system_prompt or instructions prompt = str(persona_data.get("prompt", "")).strip()
begin_dialogs = persona_data.begin_dialogs if prompt:
tools = persona_data.tools instructions = prompt
if public_description == "" and persona_data.system_prompt: begin_dialogs = copy.deepcopy(
public_description = persona_data.system_prompt[:120] persona_data.get("_begin_dialogs_processed")
)
tools = persona_data.get("tools")
if public_description == "" and prompt:
public_description = prompt[:120]
if tools is None: if tools is None:
tools = None tools = None
elif not isinstance(tools, list): elif not isinstance(tools, list):
+16 -6
View File
@@ -25,12 +25,22 @@ class UmopConfigRouter:
) )
self.umop_to_conf_id = sp_data self.umop_to_conf_id = sp_data
@staticmethod
def _split_umo(umo: str) -> tuple[str, str, str] | None:
"""将 UMO 拆分为 3 个部分,同时保留 session_id 中的 ':'"""
if not isinstance(umo, str):
return None
parts = umo.split(":", 2)
if len(parts) != 3:
return None
return parts[0], parts[1], parts[2]
def _is_umo_match(self, p1: str, p2: str) -> bool: def _is_umo_match(self, p1: str, p2: str) -> bool:
"""判断 p2 umo 是否逻辑包含于 p1 umo""" """判断 p2 umo 是否逻辑包含于 p1 umo"""
p1_ls = p1.split(":") p1_ls = self._split_umo(p1)
p2_ls = p2.split(":") p2_ls = self._split_umo(p2)
if len(p1_ls) != 3 or len(p2_ls) != 3: if p1_ls is None or p2_ls is None:
return False # 非法格式 return False # 非法格式
return all(p == "" or fnmatch.fnmatchcase(t, p) for p, t in zip(p1_ls, p2_ls)) return all(p == "" or fnmatch.fnmatchcase(t, p) for p, t in zip(p1_ls, p2_ls))
@@ -62,7 +72,7 @@ class UmopConfigRouter:
""" """
for part in new_routing: for part in new_routing:
if not isinstance(part, str) or len(part.split(":")) != 3: if self._split_umo(part) is None:
raise ValueError( raise ValueError(
"umop keys must be strings in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all", "umop keys must be strings in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
) )
@@ -81,7 +91,7 @@ class UmopConfigRouter:
ValueError: 如果 umo 格式不正确 ValueError: 如果 umo 格式不正确
""" """
if not isinstance(umo, str) or len(umo.split(":")) != 3: if self._split_umo(umo) is None:
raise ValueError( raise ValueError(
"umop must be a string in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all", "umop must be a string in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
) )
@@ -99,7 +109,7 @@ class UmopConfigRouter:
ValueError: umo 格式不正确时抛出 ValueError: umo 格式不正确时抛出
""" """
if not isinstance(umo, str) or len(umo.split(":")) != 3: if self._split_umo(umo) is None:
raise ValueError( raise ValueError(
"umop must be a string in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all", "umop must be a string in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
) )
+2 -1
View File
@@ -82,7 +82,8 @@ class AuthRoute(Route):
def generate_jwt(self, username): def generate_jwt(self, username):
payload = { payload = {
"username": username, "username": username,
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=7), "exp": datetime.datetime.now(datetime.timezone.utc)
+ datetime.timedelta(days=7),
} }
jwt_token = self.config["dashboard"].get("jwt_secret", None) jwt_token = self.config["dashboard"].get("jwt_secret", None)
if not jwt_token: if not jwt_token:
+85 -22
View File
@@ -36,6 +36,20 @@ async def track_conversation(convs: dict, conv_id: str):
convs.pop(conv_id, None) convs.pop(conv_id, None)
async def _poll_webchat_stream_result(back_queue, username: str):
try:
result = await asyncio.wait_for(back_queue.get(), timeout=1)
except asyncio.TimeoutError:
return None, False
except asyncio.CancelledError:
logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
return None, True
except Exception as e:
logger.error(f"WebChat stream error: {e}")
return None, False
return result, False
class ChatRoute(Route): class ChatRoute(Route):
def __init__( def __init__(
self, self,
@@ -51,6 +65,7 @@ class ChatRoute(Route):
"/chat/get_session": ("GET", self.get_session), "/chat/get_session": ("GET", self.get_session),
"/chat/stop": ("POST", self.stop_session), "/chat/stop": ("POST", self.stop_session),
"/chat/delete_session": ("GET", self.delete_webchat_session), "/chat/delete_session": ("GET", self.delete_webchat_session),
"/chat/batch_delete_sessions": ("POST", self.batch_delete_sessions),
"/chat/update_session_display_name": ( "/chat/update_session_display_name": (
"POST", "POST",
self.update_session_display_name, self.update_session_display_name,
@@ -342,16 +357,12 @@ class ChatRoute(Route):
async with track_conversation(self.running_convs, webchat_conv_id): async with track_conversation(self.running_convs, webchat_conv_id):
while True: while True:
try: result, should_break = await _poll_webchat_stream_result(
result = await asyncio.wait_for(back_queue.get(), timeout=1) back_queue, username
except asyncio.TimeoutError: )
continue if should_break:
except asyncio.CancelledError:
logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
client_disconnected = True client_disconnected = True
except Exception as e: break
logger.error(f"WebChat stream error: {e}")
if not result: if not result:
continue continue
@@ -578,19 +589,9 @@ class ChatRoute(Route):
return Response().ok(data={"stopped_count": stopped_count}).__dict__ return Response().ok(data={"stopped_count": stopped_count}).__dict__
async def delete_webchat_session(self): async def _delete_session_internal(self, session, username: str) -> None:
"""Delete a Platform session and all its related data.""" """Delete a single session and all its related data."""
session_id = request.args.get("session_id") session_id = session.session_id
if not session_id:
return Response().error("Missing key: session_id").__dict__
username = g.get("username", "guest")
# 验证会话是否存在且属于当前用户
session = await self.db.get_platform_session_by_id(session_id)
if not session:
return Response().error(f"Session {session_id} not found").__dict__
if session.creator != username:
return Response().error("Permission denied").__dict__
# 删除该会话下的所有对话 # 删除该会话下的所有对话
message_type = "GroupMessage" if session.is_group else "FriendMessage" message_type = "GroupMessage" if session.is_group else "FriendMessage"
@@ -632,8 +633,70 @@ class ChatRoute(Route):
# 删除会话 # 删除会话
await self.db.delete_platform_session(session_id) await self.db.delete_platform_session(session_id)
async def delete_webchat_session(self):
"""Delete a Platform session and all its related data."""
session_id = request.args.get("session_id")
if not session_id:
return Response().error("Missing key: session_id").__dict__
username = g.get("username", "guest")
session = await self.db.get_platform_session_by_id(session_id)
if not session:
return Response().error(f"Session {session_id} not found").__dict__
if session.creator != username:
return Response().error("Permission denied").__dict__
await self._delete_session_internal(session, username)
return Response().ok().__dict__ return Response().ok().__dict__
async def batch_delete_sessions(self):
"""Batch delete multiple Platform sessions."""
post_data = await request.json
if post_data is None:
return Response().error("Missing JSON body").__dict__
if not isinstance(post_data, dict):
return Response().error("Invalid JSON body: expected object").__dict__
session_ids = post_data.get("session_ids")
if not session_ids or not isinstance(session_ids, list):
return Response().error("Missing or invalid key: session_ids").__dict__
username = g.get("username", "guest")
sessions = await self.db.get_platform_sessions_by_ids(session_ids)
sessions_by_id = {session.session_id: session for session in sessions}
deleted_count = 0
failed_items = []
for sid in session_ids:
session = sessions_by_id.get(sid)
if not session:
failed_items.append({"session_id": sid, "reason": "not found"})
continue
if session.creator != username:
failed_items.append({"session_id": sid, "reason": "permission denied"})
continue
try:
await self._delete_session_internal(session, username)
deleted_count += 1
sessions_by_id.pop(sid, None)
except Exception:
logger.warning("Failed to delete session %s", sid)
failed_items.append({"session_id": sid, "reason": "internal_error"})
return (
Response()
.ok(
data={
"deleted_count": deleted_count,
"failed_count": len(failed_items),
"failed_items": failed_items,
}
)
.__dict__
)
def _extract_attachment_ids(self, history_list) -> list[str]: def _extract_attachment_ids(self, history_list) -> list[str]:
"""从消息历史中提取所有 attachment_id""" """从消息历史中提取所有 attachment_id"""
attachment_ids = [] attachment_ids = []
+93
View File
@@ -0,0 +1,93 @@
## What's Changed
### 新增
- 补充 MiniMax Provider。([#6318](https://github.com/AstrBotDevs/AstrBot/pull/6318)
- 新增 WebUI ChatUI 页面的会话批量删除功能。([#6160](https://github.com/AstrBotDevs/AstrBot/pull/6160)
- 新增 WebUI ChatUI 配置发送快捷键。([#6272](https://github.com/AstrBotDevs/AstrBot/pull/6272)
### 优化
- 优化 UMO 处理兼容性。([#5996](https://github.com/AstrBotDevs/AstrBot/pull/5996)
- 重构 `_extract_session_id`,改进聊天类型分支处理。(#5775
- 优化聊天组件行为,使用 `shiki` 进行代码块渲染。([#6286](https://github.com/AstrBotDevs/AstrBot/pull/6286)
- 优化 WebUI 主题配色与视觉体验。([#6263](https://github.com/AstrBotDevs/AstrBot/pull/6263)
- 优化 OneBot @ 组件后处理,避免消息文本解析空格问题。([#6238](https://github.com/AstrBotDevs/AstrBot/pull/6238)
### 修复
- 修复创建新 Provider 后未同步 `providers_config` 的问题。([#6388](https://github.com/AstrBotDevs/AstrBot/pull/6388)
- 修复 API 返回 `null choices` 时的 `TypeError`。([#6313](https://github.com/AstrBotDevs/AstrBot/pull/6313)
- 修复 QQ Webhook 重试回调重复触发的问题。([#6320](https://github.com/AstrBotDevs/AstrBot/pull/6320)
- 修复流式模式下 `delta``None` 导致工具调用时报错的问题。([#6365](https://github.com/AstrBotDevs/AstrBot/pull/6365)
- 修复模型服务链接说明文字错误。([#6296](https://github.com/AstrBotDevs/AstrBot/pull/6296)
- 修复 AI 在 tool-calling 模式设为 `skills-like` 时发送媒体失败的问题。([#6317](https://github.com/AstrBotDevs/AstrBot/pull/6317)
- 修复 Telegram 适配器中 GIF 被错误转成静态图的问题。([#6329](https://github.com/AstrBotDevs/AstrBot/pull/6329)
- 将 Provider 图标来源替换为 jsDelivr CDN 地址,修复部分环境下图标加载问题。([#6340](https://github.com/AstrBotDevs/AstrBot/pull/6340)
- 修复 QQ 官方表情消息未解析为可读文本的问题。([#6355](https://github.com/AstrBotDevs/AstrBot/pull/6355)
- 修复 WebChat 队列异常时流式结果页面崩溃的问题。([#6123](https://github.com/AstrBotDevs/AstrBot/pull/6123)
- 修复子代理 handoff 工具在插件过滤时丢失的问题。([#6155](https://github.com/AstrBotDevs/AstrBot/pull/6155)
- 修复 Cron 提示文案缺少空格及 `utcnow()` 的弃用警告问题。([#6192](https://github.com/AstrBotDevs/AstrBot/pull/6192)
- 修复 WebUI 启动时 Sidebar hash 导航抖动/定位问题。([#6159](https://github.com/AstrBotDevs/AstrBot/pull/6159)
- 修复启动重试过程中移除已移除 API Key 的 `ValueError` 报错。([#6193](https://github.com/AstrBotDevs/AstrBot/pull/6193)
- 修复 README 启动命令引用更新为 `astrbot run`。([#6189](https://github.com/AstrBotDevs/AstrBot/pull/6189)
- 修复 `Plain.toDict()``@` 提及场景下空白字符丢失的问题。([#6244](https://github.com/AstrBotDevs/AstrBot/pull/6244)
- 修复 provider 依赖重复定义问题。([#6247](https://github.com/AstrBotDevs/AstrBot/pull/6247)
- 修复 Telegram 中普通回复被误判为线程的处理问题。([#6174](https://github.com/AstrBotDevs/AstrBot/pull/6174)
### 其他
- 调整 `astrbot.service` 及 CI 配置,升级 GitHub Actions 版本。
---
## What's Changed (EN)
### New Features
- Added OpenRouter chat completion provider adapter with support for custom headers ([#6436](https://github.com/AstrBotDevs/AstrBot/pull/6436)).
- Added MiniMax provider ([#6318](https://github.com/AstrBotDevs/AstrBot/pull/6318)).
- Added batch conversation deletion in WebChat ([#6160](https://github.com/AstrBotDevs/AstrBot/pull/6160)).
- Added send shortcut settings and localization support for WebChat input ([#6272](https://github.com/AstrBotDevs/AstrBot/pull/6272)).
- Added local temporary directory binding in YAML config ([#6191](https://github.com/AstrBotDevs/AstrBot/pull/6191)).
### Improvements
- Improved UMO processing compatibility ([#5996](https://github.com/AstrBotDevs/AstrBot/pull/5996)).
- Refactored `_extract_session_id` for chat type handling (#5775).
- Improved chat component behavior and uses `shiki` for code-block rendering ([#6286](https://github.com/AstrBotDevs/AstrBot/pull/6286)).
- Improved WebUI theme color and visual behavior ([#6263](https://github.com/AstrBotDevs/AstrBot/pull/6263)).
- Improved OneBot `@` component spacing handling ([#6238](https://github.com/AstrBotDevs/AstrBot/pull/6238)).
- Improved PR checklist validation and closure messaging.
### Bug Fixes
- Fixed missing `providers_config` sync after creating new providers ([#6388](https://github.com/AstrBotDevs/AstrBot/pull/6388)).
- Fixed `TypeError` when API returns null choices ([#6313](https://github.com/AstrBotDevs/AstrBot/pull/6313)).
- Fixed repeated QQ webhook retry callbacks ([#6320](https://github.com/AstrBotDevs/AstrBot/pull/6320)).
- Fixed tool-calling streaming null `delta` handling to prevent `AttributeError` ([#6365](https://github.com/AstrBotDevs/AstrBot/pull/6365)).
- Fixed model service link wording in docs/config ([#6296](https://github.com/AstrBotDevs/AstrBot/pull/6296)).
- Fixed AI media sending failure when tool-calling mode is set to `skills-like` ([#6317](https://github.com/AstrBotDevs/AstrBot/pull/6317)).
- Fixed GIF being sent as static image in Telegram adapter ([#6329](https://github.com/AstrBotDevs/AstrBot/pull/6329)).
- Replaced npm registry URLs with jsDelivr CDN for provider icons ([#6340](https://github.com/AstrBotDevs/AstrBot/pull/6340)).
- Fixed QQ official face message parsing to readable text ([#6355](https://github.com/AstrBotDevs/AstrBot/pull/6355)).
- Fixed WebChat stream-result crash on queue errors ([#6123](https://github.com/AstrBotDevs/AstrBot/pull/6123)).
- Preserved subagent handoff tools during plugin filtering ([#6155](https://github.com/AstrBotDevs/AstrBot/pull/6155)).
- Fixed cron prompt spacing and deprecated `utcnow()` usage ([#6192](https://github.com/AstrBotDevs/AstrBot/pull/6192)).
- Fixed unstable sidebar hash navigation on startup ([#6159](https://github.com/AstrBotDevs/AstrBot/pull/6159)).
- Fixed `ValueError` in retry loop when removing an already removed API key ([#6193](https://github.com/AstrBotDevs/AstrBot/pull/6193)).
- Updated startup command to `astrbot run` across READMEs ([#6189](https://github.com/AstrBotDevs/AstrBot/pull/6189)).
- Preserved whitespace in `Plain.toDict()` for @ mentions ([#6244](https://github.com/AstrBotDevs/AstrBot/pull/6244)).
- Removed duplicate dependencies entries ([#6247](https://github.com/AstrBotDevs/AstrBot/pull/6247)).
- Fixed Telegram normal reply being treated as topic thread ([#6174](https://github.com/AstrBotDevs/AstrBot/pull/6174)).
### Documentation
- Updated `rainyun` backup/access documentation ([#6427](https://github.com/AstrBotDevs/AstrBot/pull/6427)).
- Updated `package.md` and platform docs, including Matrix and Wecom AI bot documentation.
- Fixed Discord invite link in community docs.
### Chores
- Updated PR templates/checklist workflow, repository service config, and automated checks.
- Refreshed repository automation and formatting maintenance, and removed obsolete changelog scripts.
+1
View File
@@ -37,6 +37,7 @@ services:
- DEFAULT_SHIP_MEMORY=512m - DEFAULT_SHIP_MEMORY=512m
volumes: volumes:
- ${PWD}/data/shipyard/bay_data:/app/data - ${PWD}/data/shipyard/bay_data:/app/data
- ${PWD}/data/temp:/AstrBot/data/temp # Bind the local temp directory to the sandbox so that the uploaded file can be accessed in the sandbox
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
networks: networks:
- astrbot_network - astrbot_network
-2
View File
@@ -1,5 +1,3 @@
version: '3.8'
# 当接入 QQ NapCat 时,请使用这个 compose 文件一键部署: https://github.com/NapNeko/NapCat-Docker/blob/main/compose/astrbot.yml # 当接入 QQ NapCat 时,请使用这个 compose 文件一键部署: https://github.com/NapNeko/NapCat-Docker/blob/main/compose/astrbot.yml
services: services:
-1
View File
@@ -36,7 +36,6 @@
"remixicon": "3.5.0", "remixicon": "3.5.0",
"shiki": "^3.20.0", "shiki": "^3.20.0",
"stream-markdown": "^0.0.13", "stream-markdown": "^0.0.13",
"stream-monaco": "^0.0.17",
"vee-validate": "4.11.3", "vee-validate": "4.11.3",
"vite-plugin-vuetify": "2.1.3", "vite-plugin-vuetify": "2.1.3",
"vue": "3.3.4", "vue": "3.3.4",
+4 -4
View File
@@ -81,9 +81,6 @@ importers:
stream-markdown: stream-markdown:
specifier: ^0.0.13 specifier: ^0.0.13
version: 0.0.13(shiki@3.22.0) version: 0.0.13(shiki@3.22.0)
stream-monaco:
specifier: ^0.0.17
version: 0.0.17(monaco-editor@0.52.2)
vee-validate: vee-validate:
specifier: 4.11.3 specifier: 4.11.3
version: 4.11.3(vue@3.3.4) version: 4.11.3(vue@3.3.4)
@@ -3300,6 +3297,7 @@ snapshots:
'@shikijs/core': 3.22.0 '@shikijs/core': 3.22.0
'@shikijs/types': 3.22.0 '@shikijs/types': 3.22.0
'@shikijs/vscode-textmate': 10.0.2 '@shikijs/vscode-textmate': 10.0.2
optional: true
'@shikijs/themes@3.22.0': '@shikijs/themes@3.22.0':
dependencies: dependencies:
@@ -3992,7 +3990,8 @@ snapshots:
json-schema-traverse: 1.0.0 json-schema-traverse: 1.0.0
require-from-string: 2.0.2 require-from-string: 2.0.2
alien-signals@2.0.8: {} alien-signals@2.0.8:
optional: true
ansi-regex@5.0.1: {} ansi-regex@5.0.1: {}
@@ -5443,6 +5442,7 @@ snapshots:
alien-signals: 2.0.8 alien-signals: 2.0.8
monaco-editor: 0.52.2 monaco-editor: 0.52.2
shiki: 3.22.0 shiki: 3.22.0
optional: true
stringify-entities@4.0.4: stringify-entities@4.0.4:
dependencies: dependencies:
+69 -3
View File
@@ -11,6 +11,7 @@
:currSessionId="currSessionId" :currSessionId="currSessionId"
:selectedProjectId="selectedProjectId" :selectedProjectId="selectedProjectId"
:transportMode="transportMode" :transportMode="transportMode"
:sendShortcut="sendShortcut"
:isDark="isDark" :isDark="isDark"
:chatboxMode="chatboxMode" :chatboxMode="chatboxMode"
:isMobile="isMobile" :isMobile="isMobile"
@@ -20,6 +21,7 @@
@selectConversation="handleSelectConversation" @selectConversation="handleSelectConversation"
@editTitle="showEditTitleDialog" @editTitle="showEditTitleDialog"
@deleteConversation="handleDeleteConversation" @deleteConversation="handleDeleteConversation"
@batchDeleteConversations="handleBatchDeleteConversations"
@closeMobileSidebar="closeMobileSidebar" @closeMobileSidebar="closeMobileSidebar"
@toggleTheme="toggleTheme" @toggleTheme="toggleTheme"
@toggleFullscreen="toggleFullscreen" @toggleFullscreen="toggleFullscreen"
@@ -28,6 +30,7 @@
@editProject="showEditProjectDialog" @editProject="showEditProjectDialog"
@deleteProject="handleDeleteProject" @deleteProject="handleDeleteProject"
@updateTransportMode="setTransportMode" @updateTransportMode="setTransportMode"
@updateSendShortcut="setSendShortcut"
/> />
<!-- 右侧聊天内容区域 --> <!-- 右侧聊天内容区域 -->
@@ -71,13 +74,14 @@
:stagedImagesUrl="stagedImagesUrl" :stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl" :stagedAudioUrl="stagedAudioUrl"
:stagedFiles="stagedNonImageFiles" :stagedFiles="stagedNonImageFiles"
:disabled="isStreaming" :disabled="false"
:is-running="isStreaming || isConvRunning" :is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming" :enableStreaming="enableStreaming"
:isRecording="isRecording" :isRecording="isRecording"
:session-id="currSessionId || null" :session-id="currSessionId || null"
:current-session="getCurrentSession" :current-session="getCurrentSession"
:replyTo="replyTo" :replyTo="replyTo"
:send-shortcut="sendShortcut"
@send="handleSendMessage" @send="handleSendMessage"
@stop="handleStopMessage" @stop="handleStopMessage"
@toggleStreaming="toggleStreaming" @toggleStreaming="toggleStreaming"
@@ -102,13 +106,14 @@
:stagedImagesUrl="stagedImagesUrl" :stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl" :stagedAudioUrl="stagedAudioUrl"
:stagedFiles="stagedNonImageFiles" :stagedFiles="stagedNonImageFiles"
:disabled="isStreaming" :disabled="false"
:is-running="isStreaming || isConvRunning" :is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming" :enableStreaming="enableStreaming"
:isRecording="isRecording" :isRecording="isRecording"
:session-id="currSessionId || null" :session-id="currSessionId || null"
:current-session="getCurrentSession" :current-session="getCurrentSession"
:replyTo="replyTo" :replyTo="replyTo"
:send-shortcut="sendShortcut"
@send="handleSendMessage" @send="handleSendMessage"
@stop="handleStopMessage" @stop="handleStopMessage"
@toggleStreaming="toggleStreaming" @toggleStreaming="toggleStreaming"
@@ -132,13 +137,14 @@
:stagedImagesUrl="stagedImagesUrl" :stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl" :stagedAudioUrl="stagedAudioUrl"
:stagedFiles="stagedNonImageFiles" :stagedFiles="stagedNonImageFiles"
:disabled="isStreaming" :disabled="false"
:is-running="isStreaming || isConvRunning" :is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming" :enableStreaming="enableStreaming"
:isRecording="isRecording" :isRecording="isRecording"
:session-id="currSessionId || null" :session-id="currSessionId || null"
:current-session="getCurrentSession" :current-session="getCurrentSession"
:replyTo="replyTo" :replyTo="replyTo"
:send-shortcut="sendShortcut"
@send="handleSendMessage" @send="handleSendMessage"
@stop="handleStopMessage" @stop="handleStopMessage"
@toggleStreaming="toggleStreaming" @toggleStreaming="toggleStreaming"
@@ -220,10 +226,13 @@ import { useMediaHandling } from '@/composables/useMediaHandling';
import { useProjects } from '@/composables/useProjects'; import { useProjects } from '@/composables/useProjects';
import type { Project } from '@/components/chat/ProjectList.vue'; import type { Project } from '@/components/chat/ProjectList.vue';
import { useRecording } from '@/composables/useRecording'; import { useRecording } from '@/composables/useRecording';
import { useToast } from '@/utils/toast';
interface Props { interface Props {
chatboxMode?: boolean; chatboxMode?: boolean;
} }
type SendShortcut = 'enter' | 'shift_enter';
const SEND_SHORTCUT_STORAGE_KEY = 'chat_send_shortcut';
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
chatboxMode: false chatboxMode: false
@@ -233,6 +242,7 @@ const router = useRouter();
const route = useRoute(); const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
const { tm } = useModuleI18n('features/chat'); const { tm } = useModuleI18n('features/chat');
const { warning: toastWarning } = useToast();
const theme = useTheme(); const theme = useTheme();
const customizer = useCustomizerStore(); const customizer = useCustomizerStore();
@@ -257,6 +267,7 @@ const {
getSessions, getSessions,
newSession, newSession,
deleteSession: deleteSessionFn, deleteSession: deleteSessionFn,
batchDeleteSessions,
showEditTitleDialog, showEditTitleDialog,
saveTitle, saveTitle,
updateSessionTitle, updateSessionTitle,
@@ -330,6 +341,18 @@ interface ReplyInfo {
const replyTo = ref<ReplyInfo | null>(null); const replyTo = ref<ReplyInfo | null>(null);
const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark'); const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark');
const sendShortcut = ref<SendShortcut>('shift_enter');
function setSendShortcut(mode: SendShortcut) {
sendShortcut.value = mode;
localStorage.setItem(SEND_SHORTCUT_STORAGE_KEY, mode);
}
function focusChatInput() {
nextTick(() => {
chatInputRef.value?.focusInput?.();
});
}
// //
function checkMobile() { function checkMobile() {
@@ -488,6 +511,7 @@ async function handleSelectConversation(sessionIds: string[]) {
nextTick(() => { nextTick(() => {
messageList.value?.scrollToBottom(); messageList.value?.scrollToBottom();
}); });
focusChatInput();
} }
function handleNewChat() { function handleNewChat() {
@@ -497,6 +521,7 @@ function handleNewChat() {
// 退 // 退
selectedProjectId.value = null; selectedProjectId.value = null;
projectSessions.value = []; projectSessions.value = [];
focusChatInput();
} }
async function handleDeleteConversation(sessionId: string) { async function handleDeleteConversation(sessionId: string) {
@@ -510,6 +535,33 @@ async function handleDeleteConversation(sessionId: string) {
} }
} }
async function handleBatchDeleteConversations(sessionIds: string[]) {
try {
const result = await batchDeleteSessions(sessionIds);
//
if (result.currentSessionDeleted) {
messages.value = [];
}
//
if (result.failed_count > 0) {
toastWarning(
tm('batch.partialFailure', { failed: result.failed_count, total: sessionIds.length })
);
}
//
if (selectedProjectId.value) {
const sessions = await getProjectSessions(selectedProjectId.value);
projectSessions.value = sessions;
}
} catch (err) {
console.error('Batch delete sessions failed:', err);
toastWarning(tm('batch.requestFailed'));
}
}
async function handleSelectProject(projectId: string) { async function handleSelectProject(projectId: string) {
selectedProjectId.value = projectId; selectedProjectId.value = projectId;
const sessions = await getProjectSessions(projectId); const sessions = await getProjectSessions(projectId);
@@ -627,6 +679,11 @@ async function handleSendMessage() {
const selectedProviderId = selection?.providerId || ''; const selectedProviderId = selection?.providerId || '';
const selectedModelName = selection?.modelName || ''; const selectedModelName = selection?.modelName || '';
//
nextTick(() => {
messageList.value?.scrollToBottom();
});
await sendMsg( await sendMsg(
promptToSend, promptToSend,
filesToSend, filesToSend,
@@ -636,6 +693,11 @@ async function handleSendMessage() {
replyToSend replyToSend
); );
//
nextTick(() => {
messageList.value?.scrollToBottom();
});
// //
if (isCreatingNewSession && currentProjectId && currSessionId.value) { if (isCreatingNewSession && currentProjectId && currSessionId.value) {
await addSessionToProject(currSessionId.value, currentProjectId); await addSessionToProject(currSessionId.value, currentProjectId);
@@ -694,6 +756,10 @@ watch(sessions, (newSessions) => {
}); });
onMounted(() => { onMounted(() => {
const storedShortcut = localStorage.getItem(SEND_SHORTCUT_STORAGE_KEY);
if (storedShortcut === 'enter' || storedShortcut === 'shift_enter') {
sendShortcut.value = storedShortcut;
}
checkMobile(); checkMobile();
window.addEventListener('resize', checkMobile); window.addEventListener('resize', checkMobile);
getSessions(); getSessions();
+44 -29
View File
@@ -15,7 +15,7 @@
<transition name="fade"> <transition name="fade">
<div v-if="isDragging" class="drop-overlay"> <div v-if="isDragging" class="drop-overlay">
<div class="drop-overlay-content"> <div class="drop-overlay-content">
<v-icon size="48" color="deep-purple">mdi-cloud-upload</v-icon> <v-icon size="48" color="primary">mdi-cloud-upload</v-icon>
<span class="drop-text">{{ tm('input.dropToUpload') }}</span> <span class="drop-text">{{ tm('input.dropToUpload') }}</span>
</div> </div>
</div> </div>
@@ -41,7 +41,7 @@
<!-- Settings Menu --> <!-- Settings Menu -->
<StyledMenu offset="8" location="top start" :close-on-content-click="false"> <StyledMenu offset="8" location="top start" :close-on-content-click="false">
<template v-slot:activator="{ props: activatorProps }"> <template v-slot:activator="{ props: activatorProps }">
<v-btn v-bind="activatorProps" icon="mdi-plus" variant="text" color="deep-purple" /> <v-btn v-bind="activatorProps" icon="mdi-plus" variant="text" color="primary" />
</template> </template>
<!-- Upload Files --> <!-- Upload Files -->
@@ -87,7 +87,7 @@
{{ tm('voice.liveMode') }} {{ tm('voice.liveMode') }}
</v-tooltip> </v-tooltip>
</v-btn> --> </v-btn> -->
<v-btn @click="handleRecordClick" icon variant="text" :color="isRecording ? 'error' : 'deep-purple'" <v-btn @click="handleRecordClick" icon variant="text" :color="isRecording ? 'error' : 'primary'"
class="record-btn"> class="record-btn">
<v-icon :icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'" variant="text" <v-icon :icon="isRecording ? 'mdi-stop-circle' : 'mdi-microphone'" variant="text"
plain></v-icon> plain></v-icon>
@@ -95,13 +95,13 @@
{{ isRecording ? tm('voice.speaking') : tm('voice.startRecording') }} {{ isRecording ? tm('voice.speaking') : tm('voice.startRecording') }}
</v-tooltip> </v-tooltip>
</v-btn> </v-btn>
<v-btn icon v-if="isRunning" @click="$emit('stop')" variant="tonal" color="deep-purple" class="send-btn"> <v-btn icon v-if="isRunning && !canSend" @click="$emit('stop')" variant="tonal" color="primary" class="send-btn">
<v-icon icon="mdi-stop" variant="text" plain></v-icon> <v-icon icon="mdi-stop" variant="text" plain></v-icon>
<v-tooltip activator="parent" location="top"> <v-tooltip activator="parent" location="top">
{{ tm('input.stopGenerating') }} {{ tm('input.stopGenerating') }}
</v-tooltip> </v-tooltip>
</v-btn> </v-btn>
<v-btn v-else @click="$emit('send')" icon="mdi-send" variant="tonal" color="deep-purple" <v-btn v-else @click="$emit('send')" icon="mdi-send" variant="tonal" color="primary"
:disabled="!canSend" class="send-btn" /> :disabled="!canSend" class="send-btn" />
</div> </div>
</div> </div>
@@ -117,7 +117,7 @@
</div> </div>
<div v-if="stagedAudioUrl" class="audio-preview"> <div v-if="stagedAudioUrl" class="audio-preview">
<v-chip color="deep-purple-lighten-4" class="audio-chip"> <v-chip color="primary" variant="tonal" class="audio-chip">
<v-icon start icon="mdi-microphone" size="small"></v-icon> <v-icon start icon="mdi-microphone" size="small"></v-icon>
{{ tm('voice.recording') }} {{ tm('voice.recording') }}
</v-chip> </v-chip>
@@ -126,7 +126,7 @@
</div> </div>
<div v-for="(file, index) in stagedFiles" :key="'file-' + index" class="file-preview"> <div v-for="(file, index) in stagedFiles" :key="'file-' + index" class="file-preview">
<v-chip color="blue-grey-lighten-4" class="file-chip"> <v-chip color="primary" variant="tonal" class="file-chip">
<v-icon start icon="mdi-file-document-outline" size="small"></v-icon> <v-icon start icon="mdi-file-document-outline" size="small"></v-icon>
<span class="file-name-preview">{{ file.original_name }}</span> <span class="file-name-preview">{{ file.original_name }}</span>
</v-chip> </v-chip>
@@ -173,6 +173,7 @@ interface Props {
currentSession?: Session | null; currentSession?: Session | null;
configId?: string | null; configId?: string | null;
replyTo?: ReplyInfo | null; replyTo?: ReplyInfo | null;
sendShortcut?: 'enter' | 'shift_enter';
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -180,7 +181,8 @@ const props = withDefaults(defineProps<Props>(), {
currentSession: null, currentSession: null,
configId: null, configId: null,
stagedFiles: () => [], stagedFiles: () => [],
replyTo: null replyTo: null,
sendShortcut: 'shift_enter'
}); });
const emit = defineEmits<{ const emit = defineEmits<{
@@ -253,9 +255,29 @@ watch(localPrompt, () => {
}); });
function handleKeyDown(e: KeyboardEvent) { function handleKeyDown(e: KeyboardEvent) {
// Enter const isEnter = e.key === 'Enter';
// Shift+Enter Ctrl+Enter / Cmd+Enter if (!isEnter) {
if (e.keyCode === 13 && (e.shiftKey || e.ctrlKey || e.metaKey)) { // Ctrl+B
if (e.ctrlKey && e.keyCode === 66) {
e.preventDefault();
if (ctrlKeyDown.value) return;
ctrlKeyDown.value = true;
ctrlKeyTimer.value = window.setTimeout(() => {
if (ctrlKeyDown.value && !props.isRecording) {
emit('startRecording');
}
}, ctrlKeyLongPressThreshold);
}
return;
}
const isSendHotkey =
e.ctrlKey ||
e.metaKey ||
(props.sendShortcut === 'enter' ? !e.shiftKey : e.shiftKey);
if (isSendHotkey) {
e.preventDefault(); e.preventDefault();
if (localPrompt.value.trim() === '/astr_live_dev') { if (localPrompt.value.trim() === '/astr_live_dev') {
emit('openLiveMode'); emit('openLiveMode');
@@ -267,19 +289,6 @@ function handleKeyDown(e: KeyboardEvent) {
} }
return; return;
} }
// Ctrl+B
if (e.ctrlKey && e.keyCode === 66) {
e.preventDefault();
if (ctrlKeyDown.value) return;
ctrlKeyDown.value = true;
ctrlKeyTimer.value = window.setTimeout(() => {
if (ctrlKeyDown.value && !props.isRecording) {
emit('startRecording');
}
}, ctrlKeyLongPressThreshold);
}
} }
function handleKeyUp(e: KeyboardEvent) { function handleKeyUp(e: KeyboardEvent) {
@@ -364,6 +373,11 @@ function getCurrentSelection() {
return providerModelMenuRef.value?.getCurrentSelection(); return providerModelMenuRef.value?.getCurrentSelection();
} }
function focusInput() {
if (!inputField.value) return;
inputField.value.focus();
}
onMounted(() => { onMounted(() => {
if (inputField.value) { if (inputField.value) {
inputField.value.addEventListener('paste', handlePaste); inputField.value.addEventListener('paste', handlePaste);
@@ -379,7 +393,8 @@ onBeforeUnmount(() => {
}); });
defineExpose({ defineExpose({
getCurrentSelection getCurrentSelection,
focusInput
}); });
</script> </script>
@@ -399,8 +414,8 @@ defineExpose({
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background-color: rgba(103, 58, 183, 0.15); background-color: rgba(var(--v-theme-primary), 0.12);
border: 2px dashed rgba(103, 58, 183, 0.5); border: 2px dashed rgba(var(--v-theme-primary), 0.45);
border-radius: 24px; border-radius: 24px;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -419,7 +434,7 @@ defineExpose({
.drop-text { .drop-text {
font-size: 16px; font-size: 16px;
font-weight: 500; font-weight: 500;
color: #673ab7; color: rgb(var(--v-theme-primary));
} }
/* Fade transition for drop overlay */ /* Fade transition for drop overlay */
@@ -439,7 +454,7 @@ defineExpose({
justify-content: space-between; justify-content: space-between;
padding: 8px 16px; padding: 8px 16px;
margin: 8px 8px 0 8px; margin: 8px 8px 0 8px;
background-color: rgba(103, 58, 183, 0.06); background-color: rgba(var(--v-theme-primary), 0.06);
border-radius: 12px; border-radius: 12px;
gap: 8px; gap: 8px;
max-height: 500px; max-height: 500px;
@@ -5,7 +5,7 @@
'mobile-sidebar-open': isMobile && mobileMenuOpen, 'mobile-sidebar-open': isMobile && mobileMenuOpen,
'mobile-sidebar': isMobile 'mobile-sidebar': isMobile
}" }"
:style="{ 'background-color': isDark ? sidebarCollapsed ? '#1e1e1e' : '#2d2d2d' : sidebarCollapsed ? '#ffffff' : '#f1f4f9' }"> :style="{ backgroundColor: sidebarCollapsed && !isMobile ? 'rgb(var(--v-theme-surface))' : 'rgb(var(--v-theme-mcpCardBg))' }">
<div class="sidebar-collapse-btn-container" v-if="!isMobile"> <div class="sidebar-collapse-btn-container" v-if="!isMobile">
<v-btn icon class="sidebar-collapse-btn" @click="toggleSidebar" variant="text" color="deep-purple"> <v-btn icon class="sidebar-collapse-btn" @click="toggleSidebar" variant="text" color="deep-purple">
@@ -21,12 +21,31 @@
</div> </div>
<div style="padding: 8px; opacity: 0.6;"> <div style="padding: 8px; opacity: 0.6;">
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId" <div class="new-chat-row" v-if="!sidebarCollapsed || isMobile">
v-if="!sidebarCollapsed || isMobile" prepend-icon="mdi-square-edit-outline">{{ tm('actions.newChat') }}</v-btn> <v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
<v-btn icon="mdi-square-edit-outline" rounded="xl" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId" prepend-icon="mdi-square-edit-outline">{{ tm('actions.newChat') }}</v-btn>
<v-btn v-if="sessions.length > 0" icon size="small" variant="text" @click="toggleBatchMode"
:color="batchMode ? 'primary' : undefined">
<v-icon>mdi-checkbox-multiple-marked-outline</v-icon>
</v-btn>
</div>
<v-btn icon="mdi-square-edit-outline" rounded="xl" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn> v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn>
</div> </div>
<!-- Batch action bar -->
<div v-if="batchMode && (!sidebarCollapsed || isMobile)" class="batch-action-bar">
<v-btn size="x-small" variant="text" @click="toggleSelectAll">
{{ isAllSelected ? tm('batch.deselectAll') : tm('batch.selectAll') }}
</v-btn>
<span class="batch-selected-count">{{ tm('batch.selected', { count: batchSelected.length }) }}</span>
<v-spacer />
<v-btn size="x-small" variant="text" color="error" :disabled="batchSelected.length === 0"
@click="handleBatchDelete">
{{ tm('batch.delete') }}
</v-btn>
</div>
<!-- 项目列表组件 --> <!-- 项目列表组件 -->
<ProjectList <ProjectList
v-if="!sidebarCollapsed || isMobile" v-if="!sidebarCollapsed || isMobile"
@@ -41,19 +60,34 @@
v-if="!sidebarCollapsed || isMobile"> v-if="!sidebarCollapsed || isMobile">
<v-card v-if="sessions.length > 0" flat style="background-color: transparent;"> <v-card v-if="sessions.length > 0" flat style="background-color: transparent;">
<v-list density="compact" nav class="conversation-list" <v-list density="compact" nav class="conversation-list"
style="background-color: transparent;" :selected="selectedSessions" style="background-color: transparent;" :selected="batchMode ? [] : selectedSessions"
@update:selected="$emit('selectConversation', $event)"> @update:selected="handleListSelect">
<v-list-item v-for="item in sessions" :key="item.session_id" :value="item.session_id" <v-list-item v-for="item in sessions" :key="item.session_id" :value="item.session_id"
rounded="lg" class="conversation-item" active-color="secondary"> rounded="lg" class="conversation-item" active-color="secondary"
@click="batchMode ? toggleBatchItem(item.session_id) : undefined">
<template v-slot:prepend>
<div class="batch-checkbox-slot" :class="{ 'batch-checkbox-slot--active': batchMode }">
<v-checkbox-btn
:model-value="batchSelected.includes(item.session_id)"
@update:model-value="toggleBatchItem(item.session_id)"
@click.stop
density="compact"
hide-details
class="batch-checkbox"
/>
</div>
</template>
<v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title" <v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title"
:style="{ color: isDark ? '#ffffff' : '#000000' }"> :style="{ color: 'rgb(var(--v-theme-primaryText))' }">
{{ item.display_name || tm('conversation.newConversation') }} {{ item.display_name || tm('conversation.newConversation') }}
</v-list-item-title> </v-list-item-title>
<!-- <v-list-item-subtitle v-if="!sidebarCollapsed || isMobile" class="timestamp"> <!-- <v-list-item-subtitle v-if="!sidebarCollapsed || isMobile" class="timestamp">
{{ new Date(item.updated_at).toLocaleString() }} {{ new Date(item.updated_at).toLocaleString() }}
</v-list-item-subtitle> --> </v-list-item-subtitle> -->
<template v-if="!sidebarCollapsed || isMobile" v-slot:append> <template v-if="!batchMode && (!sidebarCollapsed || isMobile)" v-slot:append>
<div class="conversation-actions"> <div class="conversation-actions">
<v-btn icon="mdi-pencil" size="x-small" variant="text" <v-btn icon="mdi-pencil" size="x-small" variant="text"
class="edit-title-btn" class="edit-title-btn"
@@ -98,16 +132,52 @@
</v-btn> </v-btn>
</template> </template>
<!-- 语言切换 --> <!-- 语言切换分组 -->
<v-list-item class="styled-menu-item"> <v-menu
<template v-slot:prepend> :open-on-hover="!isMobile"
<v-icon>mdi-translate</v-icon> :open-on-click="isMobile"
:open-delay="!isMobile ? 60 : 0"
:close-delay="!isMobile ? 120 : 0"
:location="isMobile ? 'bottom' : 'end center'"
offset="8"
close-on-content-click
>
<template v-slot:activator="{ props: languageMenuProps }">
<v-list-item
v-bind="languageMenuProps"
class="styled-menu-item chat-settings-group-trigger"
rounded="md"
>
<template v-slot:prepend>
<v-icon>mdi-translate</v-icon>
</template>
<v-list-item-title>{{ t('core.common.language') }}</v-list-item-title>
<template v-slot:append>
<span class="chat-settings-group-current">{{ currentLanguage?.flag }}</span>
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
</template>
</v-list-item>
</template> </template>
<v-list-item-title>{{ t('core.common.language') }}</v-list-item-title>
<template v-slot:append> <v-card class="styled-menu-card" style="min-width: 180px;" elevation="8" rounded="lg">
<LanguageSwitcher variant="chatbox" /> <v-list density="compact" class="styled-menu-list pa-1">
</template> <v-list-item
</v-list-item> v-for="lang in languages"
:key="lang.code"
:value="lang.code"
@click="changeLanguage(lang.code)"
:class="{ 'styled-menu-item-active': currentLocale === lang.code }"
class="styled-menu-item"
rounded="md"
>
<template v-slot:prepend>
<span class="language-flag">{{ lang.flag }}</span>
</template>
<v-list-item-title>{{ lang.name }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<!-- 主题切换 --> <!-- 主题切换 -->
<v-list-item class="styled-menu-item" @click="$emit('toggleTheme')"> <v-list-item class="styled-menu-item" @click="$emit('toggleTheme')">
@@ -117,26 +187,93 @@
<v-list-item-title>{{ isDark ? tm('modes.lightMode') : tm('modes.darkMode') }}</v-list-item-title> <v-list-item-title>{{ isDark ? tm('modes.lightMode') : tm('modes.darkMode') }}</v-list-item-title>
</v-list-item> </v-list-item>
<!-- 通信传输模式 --> <!-- 通信传输模式分组 -->
<v-list-item class="styled-menu-item"> <v-menu
<template v-slot:prepend> :open-on-hover="!isMobile"
<v-icon>mdi-lan-connect</v-icon> :open-on-click="isMobile"
:open-delay="!isMobile ? 60 : 0"
:close-delay="!isMobile ? 120 : 0"
:location="isMobile ? 'bottom' : 'end center'"
offset="8"
close-on-content-click
>
<template v-slot:activator="{ props: transportMenuProps }">
<v-list-item
v-bind="transportMenuProps"
class="styled-menu-item chat-settings-group-trigger"
rounded="md"
>
<template v-slot:prepend>
<v-icon>mdi-lan-connect</v-icon>
</template>
<v-list-item-title>{{ tm('transport.title') }}</v-list-item-title>
<template v-slot:append>
<span class="chat-settings-group-current chat-settings-transport-current">{{ currentTransportLabel }}</span>
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
</template>
</v-list-item>
</template> </template>
<v-list-item-title>{{ tm('transport.title') }}</v-list-item-title>
<template v-slot:append> <v-card class="styled-menu-card" style="min-width: 220px;" elevation="8" rounded="lg">
<v-select <v-list density="compact" class="styled-menu-list pa-1">
:model-value="transportMode" <v-list-item
:items="transportOptions" v-for="opt in transportOptions"
item-title="label" :key="opt.value"
item-value="value" :value="opt.value"
density="compact" @click="handleTransportModeChange(opt.value)"
variant="underlined" :class="{ 'styled-menu-item-active': transportMode === opt.value }"
hide-details class="styled-menu-item"
class="transport-mode-select" rounded="md"
@update:model-value="handleTransportModeChange" >
/> <v-list-item-title>{{ opt.label }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<!-- 发送快捷键分组 -->
<v-menu
:open-on-hover="!isMobile"
:open-on-click="isMobile"
:open-delay="!isMobile ? 60 : 0"
:close-delay="!isMobile ? 120 : 0"
:location="isMobile ? 'bottom' : 'end center'"
offset="8"
close-on-content-click
>
<template v-slot:activator="{ props: sendShortcutMenuProps }">
<v-list-item
v-bind="sendShortcutMenuProps"
class="styled-menu-item chat-settings-group-trigger"
rounded="md"
>
<template v-slot:prepend>
<v-icon>mdi-keyboard-outline</v-icon>
</template>
<v-list-item-title>{{ tm('shortcuts.sendKey.title') }}</v-list-item-title>
<template v-slot:append>
<span class="chat-settings-group-current chat-settings-transport-current">{{ currentSendShortcutLabel }}</span>
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
</template>
</v-list-item>
</template> </template>
</v-list-item>
<v-card class="styled-menu-card" style="min-width: 220px;" elevation="8" rounded="lg">
<v-list density="compact" class="styled-menu-list pa-1">
<v-list-item
v-for="opt in sendShortcutOptions"
:key="opt.value"
:value="opt.value"
@click="handleSendShortcutChange(opt.value)"
:class="{ 'styled-menu-item-active': props.sendShortcut === opt.value }"
class="styled-menu-item"
rounded="md"
>
<v-list-item-title>{{ opt.label }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<!-- 全屏/退出全屏 --> <!-- 全屏/退出全屏 -->
<v-list-item class="styled-menu-item" @click="$emit('toggleFullscreen')"> <v-list-item class="styled-menu-item" @click="$emit('toggleFullscreen')">
@@ -162,15 +299,16 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref, computed } from 'vue';
import { useI18n, useModuleI18n } from '@/i18n/composables'; import { useI18n, useModuleI18n } from '@/i18n/composables';
import type { Session } from '@/composables/useSessions'; import type { Session } from '@/composables/useSessions';
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog'; import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog';
import LanguageSwitcher from '@/components/shared/LanguageSwitcher.vue';
import StyledMenu from '@/components/shared/StyledMenu.vue'; import StyledMenu from '@/components/shared/StyledMenu.vue';
import ProviderConfigDialog from '@/components/chat/ProviderConfigDialog.vue'; import ProviderConfigDialog from '@/components/chat/ProviderConfigDialog.vue';
import ProjectList from '@/components/chat/ProjectList.vue'; import ProjectList from '@/components/chat/ProjectList.vue';
import type { Project } from '@/components/chat/ProjectList.vue'; import type { Project } from '@/components/chat/ProjectList.vue';
import { useLanguageSwitcher } from '@/i18n/composables';
import type { Locale } from '@/i18n/types';
interface Props { interface Props {
sessions: Session[]; sessions: Session[];
@@ -183,6 +321,7 @@ interface Props {
isMobile: boolean; isMobile: boolean;
mobileMenuOpen: boolean; mobileMenuOpen: boolean;
projects?: Project[]; projects?: Project[];
sendShortcut: 'enter' | 'shift_enter';
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -194,6 +333,7 @@ const emit = defineEmits<{
selectConversation: [sessionIds: string[]]; selectConversation: [sessionIds: string[]];
editTitle: [sessionId: string, title: string]; editTitle: [sessionId: string, title: string];
deleteConversation: [sessionId: string]; deleteConversation: [sessionId: string];
batchDeleteConversations: [sessionIds: string[]];
closeMobileSidebar: []; closeMobileSidebar: [];
toggleTheme: []; toggleTheme: [];
toggleFullscreen: []; toggleFullscreen: [];
@@ -202,6 +342,7 @@ const emit = defineEmits<{
editProject: [project: Project]; editProject: [project: Project];
deleteProject: [projectId: string]; deleteProject: [projectId: string];
updateTransportMode: [mode: 'sse' | 'websocket']; updateTransportMode: [mode: 'sse' | 'websocket'];
updateSendShortcut: [mode: 'enter' | 'shift_enter'];
}>(); }>();
const { t } = useI18n(); const { t } = useI18n();
@@ -211,10 +352,84 @@ const confirmDialog = useConfirmDialog();
const sidebarCollapsed = ref(true); const sidebarCollapsed = ref(true);
const showProviderConfigDialog = ref(false); const showProviderConfigDialog = ref(false);
// Batch mode state
const batchMode = ref(false);
const batchSelected = ref<string[]>([]);
const isAllSelected = computed(() =>
props.sessions.length > 0 && batchSelected.value.length === props.sessions.length
);
function toggleBatchMode() {
batchMode.value = !batchMode.value;
batchSelected.value = [];
}
function toggleBatchItem(sessionId: string) {
const idx = batchSelected.value.indexOf(sessionId);
if (idx >= 0) {
batchSelected.value.splice(idx, 1);
} else {
batchSelected.value.push(sessionId);
}
}
function toggleSelectAll() {
if (isAllSelected.value) {
batchSelected.value = [];
} else {
batchSelected.value = props.sessions.map(s => s.session_id);
}
}
async function handleBatchDelete() {
const count = batchSelected.value.length;
if (count === 0) return;
const message = tm('batch.confirmDelete', { count });
if (await askForConfirmation(message, confirmDialog)) {
emit('batchDeleteConversations', [...batchSelected.value]);
batchSelected.value = [];
batchMode.value = false;
}
}
function handleListSelect(sessionIds: string[]) {
if (!batchMode.value) {
emit('selectConversation', sessionIds);
}
}
const transportOptions = [ const transportOptions = [
{ label: tm('transport.sse'), value: 'sse' as const }, { label: tm('transport.sse'), value: 'sse' as const },
{ label: tm('transport.websocket'), value: 'websocket' as const } { label: tm('transport.websocket'), value: 'websocket' as const }
]; ];
const sendShortcutOptions = [
{ label: tm('shortcuts.sendKey.enterToSend'), value: 'enter' as const },
{ label: tm('shortcuts.sendKey.shiftEnterToSend'), value: 'shift_enter' as const }
];
// Language switcher
const { languageOptions, currentLanguage, switchLanguage, locale } = useLanguageSwitcher();
const languages = computed(() =>
languageOptions.value.map(lang => ({
code: lang.value,
name: lang.label,
flag: lang.flag
}))
);
const currentLocale = computed(() => locale.value);
const changeLanguage = async (langCode: string) => {
await switchLanguage(langCode as Locale);
};
const currentTransportLabel = computed(() => {
const found = transportOptions.find(opt => opt.value === props.transportMode);
return found?.label ?? '';
});
const currentSendShortcutLabel = computed(() => {
const found = sendShortcutOptions.find(opt => opt.value === props.sendShortcut);
return found?.label ?? '';
});
// localStorage // localStorage
const savedCollapsedState = localStorage.getItem('sidebarCollapsed'); const savedCollapsedState = localStorage.getItem('sidebarCollapsed');
@@ -242,6 +457,12 @@ function handleTransportModeChange(mode: string | null) {
emit('updateTransportMode', mode); emit('updateTransportMode', mode);
} }
} }
function handleSendShortcutChange(mode: string | null) {
if (mode === 'enter' || mode === 'shift_enter') {
emit('updateSendShortcut', mode);
}
}
</script> </script>
<style scoped> <style scoped>
@@ -310,7 +531,7 @@ function handleTransportModeChange(mode: string | null) {
} }
.conversation-item:hover { .conversation-item:hover {
background-color: rgba(103, 58, 183, 0.05); background-color: rgba(var(--v-theme-primary), 0.05);
} }
.conversation-item:hover .conversation-actions { .conversation-item:hover .conversation-actions {
@@ -402,7 +623,74 @@ function handleTransportModeChange(mode: string | null) {
justify-content: center; justify-content: center;
} }
.transport-mode-select { .chat-settings-group-trigger :deep(.v-list-item__append) {
min-width: 120px; display: flex;
align-items: center;
gap: 6px;
}
.chat-settings-group-current {
font-size: 14px;
line-height: 1;
opacity: 0.8;
}
.chat-settings-transport-current {
font-size: 12px;
}
.chat-settings-group-arrow {
opacity: 0.7;
}
.language-flag {
font-size: 16px;
margin-right: 8px;
}
.new-chat-row {
display: flex;
align-items: center;
gap: 4px;
}
.new-chat-row .new-chat-btn {
flex: 1;
min-width: 0;
}
.batch-action-bar {
display: flex;
align-items: center;
padding: 4px 12px;
gap: 4px;
flex-shrink: 0;
}
.batch-selected-count {
font-size: 12px;
opacity: 0.7;
white-space: nowrap;
}
.batch-checkbox {
flex: none;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.batch-checkbox-slot {
width: 0;
opacity: 0;
overflow: hidden;
pointer-events: none;
transform: translateX(-8px);
transition: width 0.2s ease, opacity 0.2s ease, transform 0.2s ease;
}
.batch-checkbox-slot--active {
width: 28px;
opacity: 1;
pointer-events: auto;
transform: translateX(0);
} }
</style> </style>
@@ -180,7 +180,7 @@
<script> <script>
import { useI18n, useModuleI18n } from '@/i18n/composables'; import { useI18n, useModuleI18n } from '@/i18n/composables';
import { enableKatex, enableMermaid, setCustomComponents } from 'markstream-vue' import { enableKatex, enableMermaid, MarkdownCodeBlockNode, setCustomComponents } from 'markstream-vue'
import 'markstream-vue/index.css' import 'markstream-vue/index.css'
import 'katex/dist/katex.min.css' import 'katex/dist/katex.min.css'
import 'highlight.js/styles/github.css'; import 'highlight.js/styles/github.css';
@@ -194,8 +194,11 @@ import ActionRef from './message_list_comps/ActionRef.vue';
enableKatex(); enableKatex();
enableMermaid(); enableMermaid();
// ref // message-list + Shiki
setCustomComponents('message-list', { ref: RefNode }); setCustomComponents('message-list', {
ref: RefNode,
code_block: MarkdownCodeBlockNode
});
export default { export default {
name: 'MessageList', name: 'MessageList',
@@ -22,7 +22,7 @@
v-model:prompt="prompt" v-model:prompt="prompt"
:stagedImagesUrl="stagedImagesUrl" :stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl" :stagedAudioUrl="stagedAudioUrl"
:disabled="isStreaming" :disabled="false"
:is-running="isStreaming || isConvRunning" :is-running="isStreaming || isConvRunning"
:enableStreaming="enableStreaming" :enableStreaming="enableStreaming"
:isRecording="isRecording" :isRecording="isRecording"
@@ -63,8 +63,9 @@
<!-- Text (Markdown) --> <!-- Text (Markdown) -->
<MarkdownRender <MarkdownRender
v-else-if="renderPart.part.type === 'plain' && renderPart.part.text && renderPart.part.text.trim()" v-else-if="renderPart.part.type === 'plain' && renderPart.part.text && renderPart.part.text.trim()"
:key="`${renderPart.key}-${isDark ? 'dark' : 'light'}`"
custom-id="message-list" :custom-html-tags="['ref']" :content="renderPart.part.text" :typewriter="false" custom-id="message-list" :custom-html-tags="['ref']" :content="renderPart.part.text" :typewriter="false"
class="markdown-content" :is-dark="isDark" :monacoOptions="{ theme: isDark ? 'vs-dark' : 'vs-light' }" /> class="markdown-content" :is-dark="isDark" />
<!-- Image --> <!-- Image -->
<div v-else-if="renderPart.part.type === 'image' && renderPart.part.embedded_url" class="embedded-images"> <div v-else-if="renderPart.part.type === 'image' && renderPart.part.embedded_url" class="embedded-images">
@@ -9,7 +9,7 @@
</span> </span>
</div> </div>
<div v-if="isExpanded" class="reasoning-content animate-fade-in"> <div v-if="isExpanded" class="reasoning-content animate-fade-in">
<MarkdownRender :content="reasoning" class="reasoning-text markdown-content" <MarkdownRender :key="`reasoning-${isDark ? 'dark' : 'light'}`" :content="reasoning" class="reasoning-text markdown-content"
:typewriter="false" :is-dark="isDark" :style="isDark ? { opacity: '0.85' } : {}" /> :typewriter="false" :is-dark="isDark" :style="isDark ? { opacity: '0.85' } : {}" />
</div> </div>
</div> </div>
@@ -1,12 +1,12 @@
<template> <template>
<v-chip v-if="domain" class="ref-chip" size="x-small" variant="flat" <v-chip v-if="domain" class="ref-chip" size="x-small" variant="flat"
:style="{ backgroundColor: isDark ? '#303030' : '#f4f4f4', color: isDark ? '#999' : '#666' }" :href="url" :style="chipStyle" :href="url"
target="_blank" clickable> target="_blank" clickable>
<v-icon start size="x-small" color>mdi-link-variant</v-icon> <v-icon start size="x-small" color>mdi-link-variant</v-icon>
<span>{{ domain }}</span> <span>{{ domain }}</span>
</v-chip> </v-chip>
<span v-else class="ref-fallback" :style="{ color: isDark ? '#999' : '#666' }">{{ 'site' }}</span> <span v-else class="ref-fallback" :style="fallbackStyle">{{ 'site' }}</span>
</template> </template>
<script setup> <script setup>
@@ -46,6 +46,15 @@ const domain = computed(() => {
return '' return ''
} }
}) })
const chipStyle = computed(() => ({
backgroundColor: isDark ? 'rgba(var(--v-theme-on-surface), 0.08)' : 'rgba(var(--v-theme-on-surface), 0.04)',
color: isDark ? 'rgba(var(--v-theme-on-surface), 0.62)' : 'rgba(var(--v-theme-on-surface), 0.72)'
}))
const fallbackStyle = computed(() => ({
color: isDark ? 'rgba(var(--v-theme-on-surface), 0.62)' : 'rgba(var(--v-theme-on-surface), 0.72)'
}))
</script> </script>
<style scoped> <style scoped>
@@ -12,7 +12,7 @@
> >
<v-icon <v-icon
size="18" size="18"
:color="props.variant === 'default' ? (useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa') : undefined" :color="props.variant === 'default' ? 'rgb(var(--v-theme-primary))' : undefined"
> >
mdi-translate mdi-translate
</v-icon> </v-icon>
@@ -42,7 +42,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n, useLanguageSwitcher } from '@/i18n/composables' import { useI18n, useLanguageSwitcher } from '@/i18n/composables'
import { useCustomizerStore } from '@/stores/customizer'
import type { Locale } from '@/i18n/types' import type { Locale } from '@/i18n/types'
import StyledMenu from '@/components/shared/StyledMenu.vue' import StyledMenu from '@/components/shared/StyledMenu.vue'
@@ -90,7 +89,7 @@ const changeLanguage = async (langCode: string) => {
.language-switcher--default:hover { .language-switcher--default:hover {
transform: scale(1.05); transform: scale(1.05);
background: rgba(94, 53, 177, 0.08) !important; background: rgba(var(--v-theme-primary), 0.08) !important;
} }
/* Header变体样式 - 完全继承Vuetify和action-btn的默认样式 */ /* Header变体样式 - 完全继承Vuetify和action-btn的默认样式 */
@@ -103,8 +102,4 @@ const changeLanguage = async (langCode: string) => {
/* 继承action-btn样式,与工具栏主题按钮保持一致 */ /* 继承action-btn样式,与工具栏主题按钮保持一致 */
} }
/* 深色模式下的悬停效果(仅对default变体) */
:deep(.v-theme--PurpleThemeDark) .language-switcher--default:hover {
background: rgba(114, 46, 209, 0.12) !important;
}
</style> </style>
+2 -3
View File
@@ -6,11 +6,11 @@
</div> </div>
<div class="logo-text"> <div class="logo-text">
<h2 <h2
:style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa'}" :style="{ color: 'rgb(var(--v-theme-primary))' }"
v-html="formatTitle(title || t('core.header.logoTitle'))" v-html="formatTitle(title || t('core.header.logoTitle'))"
></h2> ></h2>
<!-- 父子组件传递css变量可能会出错暂时使用十六进制颜色值 --> <!-- 父子组件传递css变量可能会出错暂时使用十六进制颜色值 -->
<h4 :style="{color: useCustomizerStore().uiTheme === 'PurpleTheme' ? '#000000aa' : '#ffffffcc'}" <h4 :style="{ color: 'rgba(var(--v-theme-on-surface), 0.72)' }"
class="hint-text">{{ subtitle || t('core.header.accountDialog.title') }}</h4> class="hint-text">{{ subtitle || t('core.header.accountDialog.title') }}</h4>
</div> </div>
</div> </div>
@@ -18,7 +18,6 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useCustomizerStore } from "@/stores/customizer";
import { useI18n } from '@/i18n/composables'; import { useI18n } from '@/i18n/composables';
const { t } = useI18n(); const { t } = useI18n();
+15 -17
View File
@@ -24,12 +24,12 @@ withDefaults(defineProps<{
}) })
</script> </script>
<style scoped> <style>
.styled-menu-card { .styled-menu-card {
min-width: 100px; min-width: 100px;
width: fit-content; width: fit-content;
border: 1px solid rgba(94, 53, 177, 0.15) !important; border: 1px solid rgba(var(--v-theme-primary), 0.15) !important;
background: #f8f6fc !important; background: rgba(var(--v-theme-surface), 0.98) !important;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
} }
@@ -37,43 +37,41 @@ withDefaults(defineProps<{
background: transparent !important; background: transparent !important;
} }
:deep(.styled-menu-item) { .styled-menu-item {
margin: 2px 0; margin: 2px 0;
transition: all 0.2s ease; transition: all 0.2s ease;
border-radius: 6px; border-radius: 6px;
} }
:deep(.styled-menu-item:hover) { .styled-menu-item:hover {
background: rgba(94, 53, 177, 0.08) !important; background: rgba(var(--v-theme-primary), 0.08) !important;
} }
:deep(.styled-menu-item-active) { .styled-menu-item-active {
background: rgba(94, 53, 177, 0.15) !important; background: rgba(var(--v-theme-primary), 0.15) !important;
font-weight: 500; font-weight: 500;
} }
:deep(.styled-menu-item-active:hover) { .styled-menu-item-active:hover {
background: rgba(94, 53, 177, 0.2) !important; background: rgba(var(--v-theme-primary), 0.2) !important;
} }
</style>
<style>
/* 深色模式下的下拉框样式 - 需要全局样式才能检测主题 */ /* 深色模式下的下拉框样式 - 需要全局样式才能检测主题 */
.v-theme--PurpleThemeDark .styled-menu-card { .v-theme--PurpleThemeDark .styled-menu-card {
background: #2a2733 !important; background: rgba(var(--v-theme-surface), 0.98) !important;
border: 1px solid rgba(110, 60, 180, 0.692) !important; border: 1px solid rgba(var(--v-theme-primary), 0.2) !important;
} }
/* 深色模式下的列表项悬停效果 */ /* 深色模式下的列表项悬停效果 */
.v-theme--PurpleThemeDark .styled-menu-item:hover { .v-theme--PurpleThemeDark .styled-menu-item:hover {
background: rgba(114, 46, 209, 0.12) !important; background: rgba(var(--v-theme-primary), 0.12) !important;
} }
.v-theme--PurpleThemeDark .styled-menu-item-active { .v-theme--PurpleThemeDark .styled-menu-item-active {
background: rgba(114, 46, 209, 0.2) !important; background: rgba(var(--v-theme-primary), 0.2) !important;
} }
.v-theme--PurpleThemeDark .styled-menu-item-active:hover { .v-theme--PurpleThemeDark .styled-menu-item-active:hover {
background: rgba(114, 46, 209, 0.25) !important; background: rgba(var(--v-theme-primary), 0.25) !important;
} }
</style> </style>
@@ -590,9 +590,11 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
async function testProvider(provider: any) { async function testProvider(provider: any) {
testingProviders.value.push(provider.id) testingProviders.value.push(provider.id)
try { try {
const startTime = performance.now()
const response = await axios.get('/api/config/provider/check_one', { params: { id: provider.id } }) const response = await axios.get('/api/config/provider/check_one', { params: { id: provider.id } })
if (response.data.status === 'ok' && response.data.data.error === null) { if (response.data.status === 'ok' && response.data.data.error === null) {
showMessage(tm('models.testSuccess', { id: provider.id })) const latency = Math.max(0, Math.round(performance.now() - startTime))
showMessage(tm('models.testSuccessWithLatency', { id: provider.id, latency }))
} else { } else {
throw new Error(response.data.data.error || tm('models.testError')) throw new Error(response.data.data.error || tm('models.testError'))
} }
+68
View File
@@ -109,6 +109,73 @@ export function useSessions(chatboxMode: boolean = false) {
} }
} }
interface BatchDeleteFailedItem {
session_id: string;
reason: string;
}
interface BatchDeleteResult {
deleted_count: number;
failed_count: number;
failed_items: BatchDeleteFailedItem[];
currentSessionDeleted: boolean;
}
function isBatchDeleteResponseData(data: unknown): data is {
deleted_count: number;
failed_count: number;
failed_items: BatchDeleteFailedItem[];
} {
if (!data || typeof data !== 'object') {
return false;
}
const payload = data as Record<string, unknown>;
return (
typeof payload.deleted_count === 'number' &&
typeof payload.failed_count === 'number' &&
Array.isArray(payload.failed_items)
);
}
async function batchDeleteSessions(sessionIds: string[]): Promise<BatchDeleteResult> {
try {
const currentSessionId = currSessionId.value;
const response = await axios.post('/api/chat/batch_delete_sessions', { session_ids: sessionIds });
if (response.data?.status !== 'ok') {
throw new Error(response.data?.message || 'Failed to batch delete sessions');
}
const data = response.data?.data;
if (!isBatchDeleteResponseData(data)) {
throw new Error('Invalid batch delete response payload');
}
const failedItems = data.failed_items;
const failedSessionIds = new Set(failedItems.map(item => item.session_id));
const currentSessionDeleted = Boolean(
currentSessionId &&
sessionIds.includes(currentSessionId) &&
!failedSessionIds.has(currentSessionId)
);
if (currentSessionDeleted) {
currSessionId.value = '';
selectedSessions.value = [];
}
await getSessions();
return {
deleted_count: data.deleted_count,
failed_count: data.failed_count,
failed_items: failedItems,
currentSessionDeleted,
};
} catch (err) {
console.error(err);
throw err;
}
}
function showEditTitleDialog(sessionId: string, title: string) { function showEditTitleDialog(sessionId: string, title: string) {
editingSessionId.value = sessionId; editingSessionId.value = sessionId;
editingTitle.value = title || ''; editingTitle.value = title || '';
@@ -167,6 +234,7 @@ export function useSessions(chatboxMode: boolean = false) {
getSessions, getSessions,
newSession, newSession,
deleteSession, deleteSession,
batchDeleteSessions,
showEditTitleDialog, showEditTitleDialog,
saveTitle, saveTitle,
updateSessionTitle, updateSessionTitle,
@@ -96,6 +96,7 @@
"save": "Save", "save": "Save",
"livePreview": "Live Preview (may differ)", "livePreview": "Live Preview (may differ)",
"refreshPreview": "Refresh Preview", "refreshPreview": "Refresh Preview",
"previewText": "This is a sample text used to preview the template output.\n\nIt can contain multiple lines and various formatting.",
"syntaxHint": "Supports jinja2 syntax. Available variables: text | safe (text to render), version (AstrBot version)", "syntaxHint": "Supports jinja2 syntax. Available variables: text | safe (text to render), version (AstrBot version)",
"saveAndApply": "Save and Apply Current Template", "saveAndApply": "Save and Apply Current Template",
"confirmReset": "Confirm Reset", "confirmReset": "Confirm Reset",
@@ -71,10 +71,16 @@
"modes": { "modes": {
"darkMode": "Switch to Dark Mode", "darkMode": "Switch to Dark Mode",
"lightMode": "Switch to Light Mode" "lightMode": "Switch to Light Mode"
}, "shortcuts": { },
"shortcuts": {
"help": "Get Help", "help": "Get Help",
"voiceRecord": "Record Voice", "voiceRecord": "Record Voice",
"pasteImage": "Paste Image" "pasteImage": "Paste Image",
"sendKey": {
"title": "Send Shortcut",
"enterToSend": "Enter to send",
"shiftEnterToSend": "Shift+Enter to send"
}
}, },
"streaming": { "streaming": {
"enabled": "Streaming enabled", "enabled": "Streaming enabled",
@@ -141,5 +147,15 @@
"errors": { "errors": {
"sendMessageFailed": "Failed to send message, please try again", "sendMessageFailed": "Failed to send message, please try again",
"createSessionFailed": "Failed to create session, please refresh the page" "createSessionFailed": "Failed to create session, please refresh the page"
},
"batch": {
"selected": "{count} selected",
"confirmDelete": "Are you sure you want to delete {count} conversation(s)? This action cannot be undone.",
"selectAll": "Select All",
"deselectAll": "Deselect All",
"delete": "Delete",
"exit": "Exit",
"partialFailure": "{failed} of {total} conversations failed to delete",
"requestFailed": "Failed to delete conversations. Please try again."
} }
} }
@@ -619,11 +619,6 @@
"type": "string", "type": "string",
"hint": "Required. The Bot Token obtained from the KOOK Developer Platform." "hint": "Required. The Bot Token obtained from the KOOK Developer Platform."
}, },
"kook_bot_nickname": {
"description": "Bot Nickname",
"type": "string",
"hint": "Optional. If the sender nickname matches this value, the message will be ignored to prevent broadcast storms."
},
"kook_reconnect_delay": { "kook_reconnect_delay": {
"description": "Reconnect Delay", "description": "Reconnect Delay",
"type": "int", "type": "int",
@@ -851,7 +846,7 @@
}, },
"interval_method": { "interval_method": {
"description": "Interval Method", "description": "Interval Method",
"hint": "random 为随机时间,log 为根据消息长度计算,$y=log_<log_base>(x)$x为字数,y的单位为秒。" "hint": "random uses a random delay. log calculates delay by message length: $y=log_{log\\_base}(x)$, where x is word count and y is in seconds."
}, },
"interval": { "interval": {
"description": "Random Interval Time", "description": "Random Interval Time",
@@ -132,6 +132,7 @@
"deleteSuccess": "Model deleted successfully", "deleteSuccess": "Model deleted successfully",
"deleteError": "Failed to delete model", "deleteError": "Failed to delete model",
"testSuccess": "Model {id} test passed", "testSuccess": "Model {id} test passed",
"testSuccessWithLatency": "Model {id} test passed, latency {latency} ms",
"testError": "Model test failed", "testError": "Model test failed",
"searchPlaceholder": "Search models or ID", "searchPlaceholder": "Search models or ID",
"manualAddButton": "Custom Model", "manualAddButton": "Custom Model",
@@ -93,24 +93,6 @@
"batchDeleteConfirm": { "batchDeleteConfirm": {
"title": "Confirm Batch Delete", "title": "Confirm Batch Delete",
"message": "Are you sure you want to delete {count} selected rules? Global settings will be used after deletion." "message": "Are you sure you want to delete {count} selected rules? Global settings will be used after deletion."
},
"batchOperations": {
"title": "Batch Operations",
"hint": "Quick batch modify session settings",
"scope": "Apply to",
"scopeSelected": "Selected sessions",
"scopeAll": "All sessions",
"scopeGroup": "All groups",
"scopePrivate": "All private chats",
"llmStatus": "LLM Status",
"ttsStatus": "TTS Status",
"chatProvider": "Chat Model",
"ttsProvider": "TTS Model",
"apply": "Apply Changes"
},
"status": {
"enabled": "Enabled",
"disabled": "Disabled"
}, },
"batchOperations": { "batchOperations": {
"title": "Batch Operations", "title": "Batch Operations",
@@ -126,6 +108,25 @@
"ttsProvider": "TTS Model", "ttsProvider": "TTS Model",
"apply": "Apply Changes" "apply": "Apply Changes"
}, },
"groups": {
"title": "Group Management",
"count": "{count} groups",
"addToGroup": "Add to Group",
"create": "Create Group",
"edit": "Edit Group",
"name": "Group Name",
"sessionsCount": "{count} sessions",
"empty": "No groups yet. Click 'Create Group' to create one.",
"availableSessions": "Available Sessions ({count})",
"selectedSessions": "Selected Sessions ({count})",
"searchPlaceholder": "Search...",
"noMatch": "No matches",
"noMembers": "No members",
"customGroupDivider": "── Custom Groups ──",
"customGroupOption": "📁 {name} ({count})",
"groupOption": "{name} ({count} sessions)",
"deleteConfirm": "Are you sure you want to delete group \"{name}\"?"
},
"status": { "status": {
"enabled": "Enabled", "enabled": "Enabled",
"disabled": "Disabled" "disabled": "Disabled"
@@ -142,7 +143,16 @@
"noChanges": "No changes to save", "noChanges": "No changes to save",
"batchDeleteSuccess": "Batch delete successful", "batchDeleteSuccess": "Batch delete successful",
"batchDeleteError": "Batch delete failed", "batchDeleteError": "Batch delete failed",
"selectSessionsFirst": "Please select sessions first",
"selectAtLeastOneConfig": "Please select at least one setting to modify",
"batchUpdateSuccess": "Batch update successful",
"partialUpdateFailed": "Some updates failed",
"batchUpdateError": "Batch update failed", "batchUpdateError": "Batch update failed",
"batchUpdateSuccess": "Batch update success" "groupNameRequired": "Group name cannot be empty",
"saveGroupError": "Failed to save group",
"deleteGroupError": "Failed to delete group",
"selectSessionsToAddFirst": "Please select sessions to add first",
"addToGroupSuccess": "Added {count} sessions to the group",
"addToGroupError": "Failed to add to group"
} }
} }
@@ -97,6 +97,7 @@
"save": "Сохранить", "save": "Сохранить",
"livePreview": "Предпросмотр (может отличаться)", "livePreview": "Предпросмотр (может отличаться)",
"refreshPreview": "Обновить", "refreshPreview": "Обновить",
"previewText": "Это пример текста для предпросмотра результата шаблона.\n\nОн может содержать несколько строк и различные форматы.",
"syntaxHint": "Поддерживается синтаксис jinja2. Переменные: text | safe (текст для рендеринга), version (версия AstrBot)", "syntaxHint": "Поддерживается синтаксис jinja2. Переменные: text | safe (текст для рендеринга), version (версия AstrBot)",
"saveAndApply": "Сохранить и применить текущий шаблон", "saveAndApply": "Сохранить и применить текущий шаблон",
"confirmReset": "Подтверждение сброса", "confirmReset": "Подтверждение сброса",
@@ -75,7 +75,12 @@
"shortcuts": { "shortcuts": {
"help": "Справка", "help": "Справка",
"voiceRecord": "Запись голоса", "voiceRecord": "Запись голоса",
"pasteImage": "Вставить изображение" "pasteImage": "Вставить изображение",
"sendKey": {
"title": "Клавиша отправки",
"enterToSend": "Enter для отправки",
"shiftEnterToSend": "Shift+Enter для отправки"
}
}, },
"streaming": { "streaming": {
"enabled": "Потоковый ответ включен", "enabled": "Потоковый ответ включен",
@@ -143,4 +148,4 @@
"sendMessageFailed": "Ошибка отправки сообщения, попробуйте еще раз", "sendMessageFailed": "Ошибка отправки сообщения, попробуйте еще раз",
"createSessionFailed": "Ошибка создания сессии, обновите страницу" "createSessionFailed": "Ошибка создания сессии, обновите страницу"
} }
} }
@@ -108,6 +108,25 @@
"ttsProvider": "TTS-модель", "ttsProvider": "TTS-модель",
"apply": "Применить" "apply": "Применить"
}, },
"groups": {
"title": "Управление группами",
"count": "групп: {count}",
"addToGroup": "Добавить в группу",
"create": "Создать группу",
"edit": "Изменить группу",
"name": "Имя группы",
"sessionsCount": "сессий: {count}",
"empty": "Пока нет групп. Нажмите «Создать группу», чтобы добавить.",
"availableSessions": "Доступные сессии ({count})",
"selectedSessions": "Выбранные сессии ({count})",
"searchPlaceholder": "Поиск...",
"noMatch": "Нет совпадений",
"noMembers": "Нет участников",
"customGroupDivider": "── Пользовательские группы ──",
"customGroupOption": "📁 {name} ({count})",
"groupOption": "{name} (сессий: {count})",
"deleteConfirm": "Вы уверены, что хотите удалить группу \"{name}\"?"
},
"status": { "status": {
"enabled": "Включено", "enabled": "Включено",
"disabled": "Выключено" "disabled": "Выключено"
@@ -124,7 +143,16 @@
"noChanges": "Изменений не обнаружено", "noChanges": "Изменений не обнаружено",
"batchDeleteSuccess": "Массовое удаление выполнено", "batchDeleteSuccess": "Массовое удаление выполнено",
"batchDeleteError": "Ошибка массового удаления", "batchDeleteError": "Ошибка массового удаления",
"selectSessionsFirst": "Пожалуйста, сначала выберите сессии",
"selectAtLeastOneConfig": "Пожалуйста, выберите хотя бы одну настройку для изменения",
"batchUpdateSuccess": "Пакетное обновление успешно выполнено",
"partialUpdateFailed": "Некоторые обновления не выполнены",
"batchUpdateError": "Ошибка пакетного обновления", "batchUpdateError": "Ошибка пакетного обновления",
"batchUpdateSuccess": "Пакетное обновление успешно выполнено" "groupNameRequired": "Имя группы не может быть пустым",
"saveGroupError": "Ошибка сохранения группы",
"deleteGroupError": "Ошибка удаления группы",
"selectSessionsToAddFirst": "Пожалуйста, сначала выберите сессии для добавления",
"addToGroupSuccess": "Добавлено сессий в группу: {count}",
"addToGroupError": "Ошибка добавления в группу"
} }
} }
@@ -96,6 +96,7 @@
"save": "保存", "save": "保存",
"livePreview": "实时预览(可能有差异)", "livePreview": "实时预览(可能有差异)",
"refreshPreview": "刷新预览", "refreshPreview": "刷新预览",
"previewText": "这是一个示例文本,用于预览模板效果。\n\n这里可以包含多行文本,支持换行和各种格式。",
"syntaxHint": "支持 jinja2 语法。可用变量:text | safe(要渲染的文本), versionAstrBot 版本)", "syntaxHint": "支持 jinja2 语法。可用变量:text | safe(要渲染的文本), versionAstrBot 版本)",
"saveAndApply": "保存应用当前编辑模板", "saveAndApply": "保存应用当前编辑模板",
"confirmReset": "确认重置", "confirmReset": "确认重置",
@@ -71,10 +71,16 @@
"modes": { "modes": {
"darkMode": "切换到夜间模式", "darkMode": "切换到夜间模式",
"lightMode": "切换到日间模式" "lightMode": "切换到日间模式"
}, "shortcuts": { },
"shortcuts": {
"help": "获取帮助", "help": "获取帮助",
"voiceRecord": "录制语音", "voiceRecord": "录制语音",
"pasteImage": "粘贴图片" "pasteImage": "粘贴图片",
"sendKey": {
"title": "发送快捷键",
"enterToSend": "Enter 发送",
"shiftEnterToSend": "Shift+Enter 发送"
}
}, },
"streaming": { "streaming": {
"enabled": "流式响应已开启", "enabled": "流式响应已开启",
@@ -141,5 +147,15 @@
"errors": { "errors": {
"sendMessageFailed": "发送消息失败,请重试", "sendMessageFailed": "发送消息失败,请重试",
"createSessionFailed": "创建会话失败,请刷新页面重试" "createSessionFailed": "创建会话失败,请刷新页面重试"
},
"batch": {
"selected": "已选择 {count} 个",
"confirmDelete": "确定要删除 {count} 个对话吗?此操作无法撤销。",
"selectAll": "全选",
"deselectAll": "取消全选",
"delete": "删除",
"exit": "退出",
"partialFailure": "{total} 个对话中有 {failed} 个删除失败",
"requestFailed": "删除对话失败,请重试。"
} }
} }
@@ -621,11 +621,6 @@
"type": "string", "type": "string",
"hint": "必填项。从 KOOK 开发者平台获取的机器人 Token" "hint": "必填项。从 KOOK 开发者平台获取的机器人 Token"
}, },
"kook_bot_nickname": {
"description": "Bot Nickname",
"type": "string",
"hint": "可选项。若发送者昵称与此值一致,将忽略该消息。"
},
"kook_reconnect_delay": { "kook_reconnect_delay": {
"description": "重连延迟", "description": "重连延迟",
"type": "int", "type": "int",
@@ -133,6 +133,7 @@
"deleteSuccess": "模型删除成功", "deleteSuccess": "模型删除成功",
"deleteError": "模型删除失败", "deleteError": "模型删除失败",
"testSuccess": "模型 {id} 测试通过", "testSuccess": "模型 {id} 测试通过",
"testSuccessWithLatency": "模型 {id} 测试通过,延迟 {latency} ms",
"testError": "模型测试失败", "testError": "模型测试失败",
"searchPlaceholder": "搜索模型或 ID", "searchPlaceholder": "搜索模型或 ID",
"manualAddButton": "自定义模型", "manualAddButton": "自定义模型",
@@ -108,6 +108,25 @@
"ttsProvider": "TTS 模型", "ttsProvider": "TTS 模型",
"apply": "应用更改" "apply": "应用更改"
}, },
"groups": {
"title": "分组管理",
"count": "{count} 个分组",
"addToGroup": "添加到分组",
"create": "新建分组",
"edit": "编辑分组",
"name": "分组名称",
"sessionsCount": "{count} 个会话",
"empty": "暂无分组,点击「新建分组」创建",
"availableSessions": "可选会话 ({count})",
"selectedSessions": "已选会话 ({count})",
"searchPlaceholder": "搜索...",
"noMatch": "无匹配项",
"noMembers": "暂无成员",
"customGroupDivider": "── 自定义分组 ──",
"customGroupOption": "📁 {name} ({count})",
"groupOption": "{name} ({count} 个会话)",
"deleteConfirm": "确定要删除分组 \"{name}\" 吗?"
},
"status": { "status": {
"enabled": "启用", "enabled": "启用",
"disabled": "禁用" "disabled": "禁用"
@@ -123,6 +142,17 @@
"deleteError": "删除失败", "deleteError": "删除失败",
"noChanges": "没有需要保存的更改", "noChanges": "没有需要保存的更改",
"batchDeleteSuccess": "批量删除成功", "batchDeleteSuccess": "批量删除成功",
"batchDeleteError": "批量删除失败" "batchDeleteError": "批量删除失败",
"selectSessionsFirst": "请先选择要操作的会话",
"selectAtLeastOneConfig": "请至少选择一项要修改的配置",
"batchUpdateSuccess": "批量更新成功",
"partialUpdateFailed": "部分更新失败",
"batchUpdateError": "批量更新失败",
"groupNameRequired": "分组名称不能为空",
"saveGroupError": "保存分组失败",
"deleteGroupError": "删除分组失败",
"selectSessionsToAddFirst": "请先选择要添加的会话",
"addToGroupSuccess": "已添加 {count} 个会话到分组",
"addToGroupError": "添加失败"
} }
} }
@@ -465,23 +465,14 @@ onMounted(async () => {
<v-app-bar elevation="0" height="50" class="top-header"> <v-app-bar elevation="0" height="50" class="top-header">
<!-- 桌面端 menu 按钮 - 仅在 bot 模式下显示 --> <!-- 桌面端 menu 按钮 - 仅在 bot 模式下显示 -->
<v-btn v-if="customizer.viewMode === 'bot' && useCustomizerStore().uiTheme === 'PurpleTheme'" style="margin-left: 16px;" <v-btn v-if="customizer.viewMode === 'bot'"
class="hidden-md-and-down" icon rounded="sm" variant="flat" style="margin-left: 16px;"
@click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)">
<v-icon>mdi-menu</v-icon>
</v-btn>
<v-btn v-else-if="customizer.viewMode === 'bot'"
style="margin-left: 22px;"
class="hidden-md-and-down" icon rounded="sm" variant="flat" class="hidden-md-and-down" icon rounded="sm" variant="flat"
@click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)"> @click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)">
<v-icon>mdi-menu</v-icon> <v-icon>mdi-menu</v-icon>
</v-btn> </v-btn>
<!-- 移动端 menu 按钮 - 仅在 bot 模式下显示 --> <!-- 移动端 menu 按钮 - 仅在 bot 模式下显示 -->
<v-btn v-if="customizer.viewMode === 'bot' && useCustomizerStore().uiTheme === 'PurpleTheme'" class="hidden-lg-and-up ms-3" <v-btn v-if="customizer.viewMode === 'bot'" class="hidden-lg-and-up ms-3" icon rounded="sm" variant="flat"
icon rounded="sm" variant="flat" @click.stop="customizer.SET_SIDEBAR_DRAWER">
<v-icon>mdi-menu</v-icon>
</v-btn>
<v-btn v-else-if="customizer.viewMode === 'bot'" class="hidden-lg-and-up ms-3" icon rounded="sm" variant="flat"
@click.stop="customizer.SET_SIDEBAR_DRAWER"> @click.stop="customizer.SET_SIDEBAR_DRAWER">
<v-icon>mdi-menu</v-icon> <v-icon>mdi-menu</v-icon>
</v-btn> </v-btn>
@@ -572,21 +563,51 @@ onMounted(async () => {
<v-divider class="my-1" /> <v-divider class="my-1" />
</template> </template>
<!-- 语言切换 --> <!-- 语言切换分组 -->
<v-list-item <v-menu
v-for="lang in languages" :open-on-hover="!$vuetify.display.xs"
:key="lang.code" :open-on-click="$vuetify.display.xs"
:value="lang.code" :open-delay="!$vuetify.display.xs ? 60 : 0"
@click="changeLanguage(lang.code)" :close-delay="!$vuetify.display.xs ? 120 : 0"
:class="{ 'styled-menu-item-active': currentLocale === lang.code }" :location="$vuetify.display.xs ? 'bottom' : 'start center'"
class="styled-menu-item" offset="8"
rounded="md"
> >
<template v-slot:prepend> <template v-slot:activator="{ props: languageMenuProps }">
<span class="language-flag">{{ lang.flag }}</span> <v-list-item
v-bind="languageMenuProps"
class="styled-menu-item language-group-trigger"
rounded="md"
>
<template v-slot:prepend>
<v-icon>mdi-translate</v-icon>
</template>
<v-list-item-title>{{ t('core.common.language') }}</v-list-item-title>
<template v-slot:append>
<span class="language-group-current">{{ currentLanguage?.flag }}</span>
<v-icon size="18" class="language-group-arrow">mdi-chevron-right</v-icon>
</template>
</v-list-item>
</template> </template>
<v-list-item-title>{{ lang.name }}</v-list-item-title>
</v-list-item> <v-card class="styled-menu-card" style="min-width: 180px;" elevation="8" rounded="lg">
<v-list density="compact" class="styled-menu-list pa-1">
<v-list-item
v-for="lang in languages"
:key="lang.code"
:value="lang.code"
@click="changeLanguage(lang.code)"
:class="{ 'styled-menu-item-active': currentLocale === lang.code }"
class="styled-menu-item"
rounded="md"
>
<template v-slot:prepend>
<span class="language-flag">{{ lang.flag }}</span>
</template>
<v-list-item-title>{{ lang.name }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<!-- 主题切换 --> <!-- 主题切换 -->
<v-list-item <v-list-item
@@ -978,6 +999,25 @@ onMounted(async () => {
margin-right: 8px; margin-right: 8px;
} }
.language-group-trigger :deep(.v-list-item__append) {
display: flex;
align-items: center;
gap: 6px;
}
.language-group-current {
font-size: 16px;
line-height: 1;
}
.language-group-arrow {
opacity: 0.7;
}
.language-submenu-card {
min-width: 180px;
}
.mobile-mode-toggle-wrapper { .mobile-mode-toggle-wrapper {
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -288,7 +288,7 @@ function openChangelogDialog() {
:rail="customizer.mini_sidebar" :rail="customizer.mini_sidebar"
> >
<div class="sidebar-container"> <div class="sidebar-container">
<v-list class="pa-4 listitem flex-grow-1" v-model:opened="openedItems" :open-strategy="'multiple'"> <v-list :class="['pa-4', 'listitem', 'flex-grow-1', { 'hidden-scrollbar': customizer.mini_sidebar }]" v-model:opened="openedItems" :open-strategy="'multiple'">
<template v-for="(item, i) in sidebarMenu" :key="item.title || item.to || `sidebar-item-${i}`"> <template v-for="(item, i) in sidebarMenu" :key="item.title || item.to || `sidebar-item-${i}`">
<NavItem :item="item" class="leftPadding" /> <NavItem :item="item" class="leftPadding" />
</template> </template>
+6 -3
View File
@@ -11,19 +11,21 @@ import VueApexCharts from 'vue3-apexcharts';
import print from 'vue3-print-nb'; import print from 'vue3-print-nb';
import { loader } from '@guolao/vue-monaco-editor' import { loader } from '@guolao/vue-monaco-editor'
import axios from 'axios'; import axios from 'axios';
import { waitForRouterReadyInBackground } from './utils/routerReadiness.mjs';
// 初始化新的i18n系统,等待完成后再挂载应用 // 初始化新的i18n系统,等待完成后再挂载应用
setupI18n().then(() => { setupI18n().then(async () => {
console.log('🌍 新i18n系统初始化完成'); console.log('🌍 新i18n系统初始化完成');
const app = createApp(App); const app = createApp(App);
app.use(router);
const pinia = createPinia(); const pinia = createPinia();
app.use(pinia); app.use(pinia);
app.use(router);
app.use(print); app.use(print);
app.use(VueApexCharts); app.use(VueApexCharts);
app.use(vuetify); app.use(vuetify);
app.use(confirmPlugin); app.use(confirmPlugin);
await router.isReady();
app.mount('#app'); app.mount('#app');
// 挂载后同步 Vuetify 主题 // 挂载后同步 Vuetify 主题
@@ -49,14 +51,15 @@ setupI18n().then(() => {
// 即使i18n初始化失败,也要挂载应用(使用回退机制) // 即使i18n初始化失败,也要挂载应用(使用回退机制)
const app = createApp(App); const app = createApp(App);
app.use(router);
const pinia = createPinia(); const pinia = createPinia();
app.use(pinia); app.use(pinia);
app.use(router);
app.use(print); app.use(print);
app.use(VueApexCharts); app.use(VueApexCharts);
app.use(vuetify); app.use(vuetify);
app.use(confirmPlugin); app.use(confirmPlugin);
app.mount('#app'); app.mount('#app');
waitForRouterReadyInBackground(router);
// 挂载后同步 Vuetify 主题 // 挂载后同步 Vuetify 主题
import('./stores/customizer').then(({ useCustomizerStore }) => { import('./stores/customizer').then(({ useCustomizerStore }) => {
+3 -1
View File
@@ -1,3 +1,5 @@
import { EXTENSION_ROUTE_NAME } from './routeConstants.mjs';
const MainRoutes = { const MainRoutes = {
path: '/main', path: '/main',
meta: { meta: {
@@ -17,7 +19,7 @@ const MainRoutes = {
component: () => import('@/views/WelcomePage.vue') component: () => import('@/views/WelcomePage.vue')
}, },
{ {
name: 'Extensions', name: EXTENSION_ROUTE_NAME,
path: '/extension', path: '/extension',
component: () => import('@/views/ExtensionPage.vue') component: () => import('@/views/ExtensionPage.vue')
}, },
+1
View File
@@ -0,0 +1 @@
export const EXTENSION_ROUTE_NAME = 'Extensions';
+22 -59
View File
@@ -1,4 +1,13 @@
/* 自定义滚动条样式 - 紫色主题 */ /* 自定义滚动条样式 - 跟随主题 */
:root {
--astrbot-scrollbar-track: rgba(var(--v-theme-primary), 0.08);
--astrbot-scrollbar-thumb: rgba(var(--v-theme-primary), 0.72);
--astrbot-scrollbar-thumb-hover: rgba(var(--v-theme-primary), 0.84);
--astrbot-scrollbar-thumb-active: rgba(var(--v-theme-primary), 0.94);
--astrbot-scrollbar-thumb-border: rgba(var(--v-theme-surface), 0.5);
--astrbot-scrollbar-thumb-shadow: rgba(var(--v-theme-primary), 0.32);
}
/* 全局滚动条样式 */ /* 全局滚动条样式 */
::-webkit-scrollbar { ::-webkit-scrollbar {
@@ -7,52 +16,31 @@
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.05); background: var(--astrbot-scrollbar-track);
border-radius: 5px; border-radius: 5px;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: rgba(160, 60, 254, 0.75); background: var(--astrbot-scrollbar-thumb);
border-radius: 5px; border-radius: 5px;
transition: all 0.3s ease; transition: all 0.3s ease;
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid var(--astrbot-scrollbar-thumb-border);
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: rgba(147, 51, 234, 0.85); background: var(--astrbot-scrollbar-thumb-hover);
transform: scale(1.05); transform: scale(1.05);
box-shadow: 0 2px 8px rgba(147, 51, 234, 0.3); box-shadow: 0 2px 8px var(--astrbot-scrollbar-thumb-shadow);
} }
::-webkit-scrollbar-thumb:active { ::-webkit-scrollbar-thumb:active {
background: rgba(147, 51, 234, 0.95); background: var(--astrbot-scrollbar-thumb-active);
} }
::-webkit-scrollbar-corner { ::-webkit-scrollbar-corner {
background: transparent; background: transparent;
} }
/* 深色主题滚动条样式 */
.v-theme--PurpleThemeDark {
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
}
::-webkit-scrollbar-thumb {
background: rgba(192, 132, 252, 0.75);
border: 1px solid rgba(0, 0, 0, 0.2);
}
::-webkit-scrollbar-thumb:hover {
background: rgba(192, 132, 252, 0.85);
box-shadow: 0 2px 8px rgba(192, 132, 252, 0.4);
}
::-webkit-scrollbar-thumb:active {
background: rgba(192, 132, 252, 0.95);
}
}
/* 细滚动条变体 */ /* 细滚动条变体 */
.thin-scrollbar { .thin-scrollbar {
::-webkit-scrollbar { ::-webkit-scrollbar {
@@ -61,17 +49,11 @@
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: rgba(147, 51, 234, 0.75); background: var(--astrbot-scrollbar-thumb);
border: none; border: none;
} }
} }
.v-theme--PurpleThemeDark .thin-scrollbar {
::-webkit-scrollbar-thumb {
background: rgba(192, 132, 252, 0.75);
}
}
/* 聊天区域滚动条 */ /* 聊天区域滚动条 */
.chat-scrollbar { .chat-scrollbar {
::-webkit-scrollbar { ::-webkit-scrollbar {
@@ -79,33 +61,18 @@
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: rgba(147, 51, 234, 0.08); background: var(--astrbot-scrollbar-track);
border-radius: 4px; border-radius: 4px;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: rgba(147, 51, 234, 0.75); background: var(--astrbot-scrollbar-thumb);
border-radius: 4px; border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid var(--astrbot-scrollbar-thumb-border);
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: rgba(147, 51, 234, 0.85); background: var(--astrbot-scrollbar-thumb-hover);
}
}
.v-theme--PurpleThemeDark .chat-scrollbar {
::-webkit-scrollbar-track {
background: rgba(192, 132, 252, 0.08);
}
::-webkit-scrollbar-thumb {
background: rgba(192, 132, 252, 0.75);
border: 1px solid rgba(0, 0, 0, 0.1);
}
::-webkit-scrollbar-thumb:hover {
background: rgba(192, 132, 252, 0.85);
} }
} }
@@ -123,11 +90,7 @@
/* Firefox 兼容性 */ /* Firefox 兼容性 */
* { * {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: rgba(147, 51, 234, 0.75) rgba(0, 0, 0, 0.05); scrollbar-color: var(--astrbot-scrollbar-thumb) var(--astrbot-scrollbar-track);
}
.v-theme--PurpleThemeDark * {
scrollbar-color: rgba(192, 132, 252, 0.75) rgba(255, 255, 255, 0.05);
} }
/* 平滑滚动 */ /* 平滑滚动 */
+6 -9
View File
@@ -28,27 +28,27 @@
.v-list-group__items .v-list-item, .v-list-group__items .v-list-item,
.v-list-item { .v-list-item {
&:hover { &:hover {
color: #b794f6 !important; color: rgb(var(--v-theme-primary)) !important;
.v-list-item-title { .v-list-item-title {
color: #b794f6 !important; color: rgb(var(--v-theme-primary)) !important;
} }
.v-icon { .v-icon {
color: #b794f6 !important; color: rgb(var(--v-theme-primary)) !important;
} }
} }
// 选中状态的样式 // 选中状态的样式
&.v-list-item--active { &.v-list-item--active {
color: #b794f6 !important; color: rgb(var(--v-theme-primary)) !important;
.v-list-item-title { .v-list-item-title {
color: #b794f6 !important; color: rgb(var(--v-theme-primary)) !important;
} }
.v-icon { .v-icon {
color: #b794f6 !important; color: rgb(var(--v-theme-primary)) !important;
} }
} }
} }
@@ -56,9 +56,6 @@
.v-list-item--density-default.v-list-item--one-line { .v-list-item--density-default.v-list-item--one-line {
min-height: 40px; min-height: 40px;
} }
.leftPadding {
margin-left: 4px;
}
} }
.v-navigation-drawer--rail { .v-navigation-drawer--rail {
.scrollnavbar .v-list .v-list-group__items, .scrollnavbar .v-list .v-list-group__items,
+9 -9
View File
@@ -4,26 +4,26 @@ const PurpleThemeDark: ThemeTypes = {
name: 'PurpleThemeDark', name: 'PurpleThemeDark',
dark: true, dark: true,
variables: { variables: {
'border-color': '#1677ff', 'border-color': '#3c96ca',
'carousel-control-size': 10 'carousel-control-size': 10
}, },
colors: { colors: {
primary: '#1677ff', primary: '#3c96ca',
secondary: '#722ed1', secondary: '#4ea4d8',
info: '#03c9d7', info: '#03c9d7',
success: '#52c41a', success: '#52c41a',
accent: '#FFAB91', accent: '#FFAB91',
warning: '#faad14', warning: '#faad14',
error: '#ff4d4f', error: '#ff4d4f',
lightprimary: '#eef2f6', lightprimary: '#e8f3fa',
lightsecondary: '#ede7f6', lightsecondary: '#e8f3fa',
lightsuccess: '#b9f6ca', lightsuccess: '#b9f6ca',
lighterror: '#f9d8d8', lighterror: '#f9d8d8',
lightwarning: '#fff8e1', lightwarning: '#fff8e1',
primaryText: '#ffffff', primaryText: '#ffffff',
secondaryText: '#ffffffcc', secondaryText: '#ffffffcc',
darkprimary: '#1565c0', darkprimary: '#2f86bd',
darksecondary: '#4527a0', darksecondary: '#2f86bd',
borderLight: '#d0d0d0', borderLight: '#d0d0d0',
border: '#333333ee', border: '#333333ee',
inputBorder: '#787878', inputBorder: '#787878',
@@ -34,8 +34,8 @@ const PurpleThemeDark: ThemeTypes = {
twitter: '#1da1f2', twitter: '#1da1f2',
linkedin: '#0e76a8', linkedin: '#0e76a8',
gray100: '#cccccccc', gray100: '#cccccccc',
primary200: '#90caf9', primary200: '#84c9ea',
secondary200: '#b39ddb', secondary200: '#8cc4e1',
background: '#1d1d1d', background: '#1d1d1d',
overlay: '#111111aa', overlay: '#111111aa',
codeBg: '#282833', codeBg: '#282833',
+4 -4
View File
@@ -9,21 +9,21 @@ const PurpleTheme: ThemeTypes = {
}, },
colors: { colors: {
primary: '#3c96ca', primary: '#3c96ca',
secondary: '#2288b7', secondary: '#2f86bd',
info: '#03c9d7', info: '#03c9d7',
success: '#00c853', success: '#00c853',
accent: '#FFAB91', accent: '#FFAB91',
warning: '#ffc107', warning: '#ffc107',
error: '#f44336', error: '#f44336',
lightprimary: '#eef2f6', lightprimary: '#eef2f6',
lightsecondary: '#ede7f6', lightsecondary: '#e8f3fa',
lightsuccess: '#b9f6ca', lightsuccess: '#b9f6ca',
lighterror: '#f9d8d8', lighterror: '#f9d8d8',
lightwarning: '#fff8e1', lightwarning: '#fff8e1',
primaryText: '#1b1c1d', primaryText: '#1b1c1d',
secondaryText: '#000000aa', secondaryText: '#000000aa',
darkprimary: '#1565c0', darkprimary: '#1565c0',
darksecondary: '#4527a0', darksecondary: '#236b99',
borderLight: '#d0d0d0', borderLight: '#d0d0d0',
border: '#d0d0d0', border: '#d0d0d0',
inputBorder: '#787878', inputBorder: '#787878',
@@ -35,7 +35,7 @@ const PurpleTheme: ThemeTypes = {
linkedin: '#0e76a8', linkedin: '#0e76a8',
gray100: '#fafafacc', gray100: '#fafafacc',
primary200: '#90caf9', primary200: '#90caf9',
secondary200: '#b39ddb', secondary200: '#8cc4e1',
background: '#ffffff', background: '#ffffff',
overlay: '#ffffffaa', overlay: '#ffffffaa',
codeBg: '#ececec', codeBg: '#ececec',
+46
View File
@@ -0,0 +1,46 @@
import { EXTENSION_ROUTE_NAME } from '../router/routeConstants.mjs';
export function getValidHashTab(routeHash, validTabs) {
const hash = String(routeHash || '');
const tab = hash.includes('#') ? hash.slice(hash.lastIndexOf('#') + 1) : hash;
return validTabs.includes(tab) ? tab : null;
}
export function createTabRouteLocation(route, tab, fallbackRouteName = EXTENSION_ROUTE_NAME) {
const query = route?.query ? { ...route.query } : {};
const params = route?.params ? { ...route.params } : undefined;
if (route?.name) {
return {
name: route.name,
...(params ? { params } : {}),
query,
hash: `#${tab}`,
};
}
if (route?.path) {
return {
path: route.path,
query,
hash: `#${tab}`,
};
}
return {
name: fallbackRouteName,
...(params ? { params } : {}),
query,
hash: `#${tab}`,
};
}
export async function replaceTabRoute(router, route, tab, logger = console) {
try {
await router.replace(createTabRouteLocation(route, tab));
return true;
} catch (error) {
logger.warn?.('Failed to update extension tab route:', error);
return false;
}
}
+26 -26
View File
@@ -9,33 +9,33 @@
*/ */
export function getProviderIcon(type) { export function getProviderIcon(type) {
const icons = { const icons = {
'openai': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/openai.svg', 'openai': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/openai.svg',
'azure': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/azure.svg', 'azure': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/azure.svg',
'xai': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/xai.svg', 'xai': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/xai.svg',
'anthropic': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/anthropic.svg', 'anthropic': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/anthropic.svg',
'ollama': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/ollama.svg', 'ollama': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/ollama.svg',
'google': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/gemini-color.svg', 'google': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/gemini-color.svg',
'deepseek': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/deepseek.svg', 'deepseek': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/deepseek.svg',
'modelscope': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/modelscope.svg', 'modelscope': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/modelscope.svg',
'zhipu': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/zhipu.svg', 'zhipu': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/zhipu.svg',
'nvidia': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/nvidia-color.svg', 'nvidia': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/nvidia-color.svg',
'siliconflow': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/siliconcloud.svg', 'siliconflow': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/siliconcloud.svg',
'moonshot': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/kimi.svg', 'moonshot': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/kimi.svg',
'ppio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/ppio.svg', 'ppio': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/ppio.svg',
'dify': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/dify-color.svg', 'dify': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/dify-color.svg',
"coze": "https://registry.npmmirror.com/@lobehub/icons-static-svg/1.66.0/files/icons/coze.svg", "coze": "https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.66.0/icons/coze.svg",
'dashscope': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/alibabacloud-color.svg', 'dashscope': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/alibabacloud-color.svg',
'deerflow': 'https://cdn.jsdelivr.net/gh/bytedance/deer-flow@main/frontend/public/images/deer.svg', 'deerflow': 'https://cdn.jsdelivr.net/gh/bytedance/deer-flow@main/frontend/public/images/deer.svg',
'fastgpt': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/fastgpt-color.svg', 'fastgpt': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/fastgpt-color.svg',
'lm_studio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/lmstudio.svg', 'lm_studio': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/lmstudio.svg',
'fishaudio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/fishaudio.svg', 'fishaudio': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/fishaudio.svg',
'minimax': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/minimax.svg', 'minimax': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/minimax.svg',
'302ai': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/1.53.0/files/icons/ai302-color.svg', '302ai': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.53.0/icons/ai302-color.svg',
'microsoft': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/microsoft.svg', 'microsoft': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/microsoft.svg',
'vllm': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/vllm.svg', 'vllm': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/vllm.svg',
'groq': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/groq.svg', 'groq': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/groq.svg',
'aihubmix': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/aihubmix-color.svg', 'aihubmix': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/aihubmix-color.svg',
'openrouter': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/openrouter.svg', 'openrouter': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/openrouter.svg',
"tokenpony": "https://tokenpony.cn/tokenpony-web/logo.png", "tokenpony": "https://tokenpony.cn/tokenpony-web/logo.png",
"compshare": "https://compshare.cn/favicon.ico" "compshare": "https://compshare.cn/favicon.ico"
}; };
+5
View File
@@ -0,0 +1,5 @@
export function waitForRouterReadyInBackground(router, logger = console) {
router.isReady().catch((error) => {
logger.warn?.('Router did not become ready after fallback mount:', error);
});
}
+3
View File
@@ -602,12 +602,15 @@ async function testSingleProvider(provider) {
return return
} }
const startTime = performance.now()
const res = await axios.get(`/api/config/provider/check_one?id=${provider.id}`) const res = await axios.get(`/api/config/provider/check_one?id=${provider.id}`)
if (res.data && res.data.status === 'ok') { if (res.data && res.data.status === 'ok') {
const index = providerStatuses.value.findIndex(s => s.id === provider.id) const index = providerStatuses.value.findIndex(s => s.id === provider.id)
if (index !== -1) { if (index !== -1) {
providerStatuses.value.splice(index, 1, res.data.data) providerStatuses.value.splice(index, 1, res.data.data)
} }
const latency = Math.max(0, Math.round(performance.now() - startTime))
showMessage(tm('models.testSuccessWithLatency', { id: provider.id, latency }))
} else { } else {
throw new Error(res.data?.message || `Failed to check status for ${provider.id}`) throw new Error(res.data?.message || `Failed to check status for ${provider.id}`)
} }
+35 -32
View File
@@ -156,24 +156,24 @@
<!-- 分组管理面板 --> <!-- 分组管理面板 -->
<v-card flat class="mt-4"> <v-card flat class="mt-4">
<v-card-title class="d-flex align-center py-3 px-4"> <v-card-title class="d-flex align-center py-3 px-4">
<span class="text-h6">分组管理</span> <span class="text-h6">{{ tm('groups.title') }}</span>
<v-chip size="small" class="ml-2" color="secondary" variant="outlined"> <v-chip size="small" class="ml-2" color="secondary" variant="outlined">
{{ groups.length }} 个分组 {{ tm('groups.count', { count: groups.length }) }}
</v-chip> </v-chip>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn v-if="selectedItems.length > 0 && groups.length > 0" color="info" variant="tonal" size="small" class="mr-2"> <v-btn v-if="selectedItems.length > 0 && groups.length > 0" color="info" variant="tonal" size="small" class="mr-2">
<v-icon start>mdi-folder-plus</v-icon> <v-icon start>mdi-folder-plus</v-icon>
添加到分组 {{ tm('groups.addToGroup') }}
<v-menu activator="parent"> <v-menu activator="parent">
<v-list density="compact"> <v-list density="compact">
<v-list-item v-for="g in groups" :key="g.id" @click="addSelectedToGroup(g.id)"> <v-list-item v-for="g in groups" :key="g.id" @click="addSelectedToGroup(g.id)">
<v-list-item-title>{{ g.name }} ({{ g.umo_count }})</v-list-item-title> <v-list-item-title>{{ tm('groups.customGroupOption', { name: g.name, count: g.umo_count }) }}</v-list-item-title>
</v-list-item> </v-list-item>
</v-list> </v-list>
</v-menu> </v-menu>
</v-btn> </v-btn>
<v-btn color="success" variant="tonal" size="small" @click="openCreateGroupDialog" prepend-icon="mdi-folder-plus"> <v-btn color="success" variant="tonal" size="small" @click="openCreateGroupDialog" prepend-icon="mdi-folder-plus">
新建分组 {{ tm('groups.create') }}
</v-btn> </v-btn>
</v-card-title> </v-card-title>
<v-card-text v-if="groups.length > 0"> <v-card-text v-if="groups.length > 0">
@@ -183,7 +183,7 @@
<div class="d-flex align-center justify-space-between"> <div class="d-flex align-center justify-space-between">
<div> <div>
<div class="font-weight-bold">{{ group.name }}</div> <div class="font-weight-bold">{{ group.name }}</div>
<div class="text-caption text-grey">{{ group.umo_count }} 个会话</div> <div class="text-caption text-grey">{{ tm('groups.sessionsCount', { count: group.umo_count }) }}</div>
</div> </div>
<div> <div>
<v-btn icon size="small" variant="text" @click="openEditGroupDialog(group)"> <v-btn icon size="small" variant="text" @click="openEditGroupDialog(group)">
@@ -199,7 +199,7 @@
</v-row> </v-row>
</v-card-text> </v-card-text>
<v-card-text v-else class="text-center text-grey py-6"> <v-card-text v-else class="text-center text-grey py-6">
暂无分组点击新建分组创建 {{ tm('groups.empty') }}
</v-card-text> </v-card-text>
</v-card> </v-card>
@@ -207,15 +207,15 @@
<v-dialog v-model="groupDialog" max-width="800" @after-enter="loadAvailableUmos"> <v-dialog v-model="groupDialog" max-width="800" @after-enter="loadAvailableUmos">
<v-card> <v-card>
<v-card-title class="py-3 px-4"> <v-card-title class="py-3 px-4">
{{ groupDialogMode === 'create' ? '新建分组' : '编辑分组' }} {{ groupDialogMode === 'create' ? tm('groups.create') : tm('groups.edit') }}
</v-card-title> </v-card-title>
<v-card-text> <v-card-text>
<v-text-field v-model="editingGroup.name" label="分组名称" variant="outlined" hide-details class="mb-4"></v-text-field> <v-text-field v-model="editingGroup.name" :label="tm('groups.name')" variant="outlined" hide-details class="mb-4"></v-text-field>
<v-row dense> <v-row dense>
<!-- 左侧可选会话 --> <!-- 左侧可选会话 -->
<v-col cols="5"> <v-col cols="5">
<div class="text-subtitle-2 mb-2">可选会话 ({{ unselectedUmos.length }})</div> <div class="text-subtitle-2 mb-2">{{ tm('groups.availableSessions', { count: unselectedUmos.length }) }}</div>
<v-text-field v-model="groupMemberSearch" placeholder="搜索..." variant="outlined" density="compact" hide-details class="mb-2" clearable prepend-inner-icon="mdi-magnify"></v-text-field> <v-text-field v-model="groupMemberSearch" :placeholder="tm('groups.searchPlaceholder')" variant="outlined" density="compact" hide-details class="mb-2" clearable prepend-inner-icon="mdi-magnify"></v-text-field>
<v-list density="compact" class="transfer-list" lines="one"> <v-list density="compact" class="transfer-list" lines="one">
<v-list-item v-for="umo in filteredUnselectedUmos" :key="umo" @click="addToGroup(umo)" class="transfer-item"> <v-list-item v-for="umo in filteredUnselectedUmos" :key="umo" @click="addToGroup(umo)" class="transfer-item">
<template v-slot:prepend> <template v-slot:prepend>
@@ -224,7 +224,7 @@
<v-list-item-title class="text-caption">{{ formatUmoShort(umo) }}</v-list-item-title> <v-list-item-title class="text-caption">{{ formatUmoShort(umo) }}</v-list-item-title>
</v-list-item> </v-list-item>
<v-list-item v-if="filteredUnselectedUmos.length === 0 && !loadingUmos"> <v-list-item v-if="filteredUnselectedUmos.length === 0 && !loadingUmos">
<v-list-item-title class="text-caption text-grey text-center">无匹配项</v-list-item-title> <v-list-item-title class="text-caption text-grey text-center">{{ tm('groups.noMatch') }}</v-list-item-title>
</v-list-item> </v-list-item>
<v-list-item v-if="loadingUmos"> <v-list-item v-if="loadingUmos">
<v-list-item-title class="text-center"><v-progress-circular indeterminate size="20"></v-progress-circular></v-list-item-title> <v-list-item-title class="text-center"><v-progress-circular indeterminate size="20"></v-progress-circular></v-list-item-title>
@@ -242,8 +242,8 @@
</v-col> </v-col>
<!-- 右侧已选会话 --> <!-- 右侧已选会话 -->
<v-col cols="5"> <v-col cols="5">
<div class="text-subtitle-2 mb-2">已选会话 ({{ editingGroup.umos.length }})</div> <div class="text-subtitle-2 mb-2">{{ tm('groups.selectedSessions', { count: editingGroup.umos.length }) }}</div>
<v-text-field v-model="groupSelectedSearch" placeholder="搜索..." variant="outlined" density="compact" hide-details class="mb-2" clearable prepend-inner-icon="mdi-magnify"></v-text-field> <v-text-field v-model="groupSelectedSearch" :placeholder="tm('groups.searchPlaceholder')" variant="outlined" density="compact" hide-details class="mb-2" clearable prepend-inner-icon="mdi-magnify"></v-text-field>
<v-list density="compact" class="transfer-list" lines="one"> <v-list density="compact" class="transfer-list" lines="one">
<v-list-item v-for="umo in filteredSelectedUmos" :key="umo" @click="removeFromGroup(umo)" class="transfer-item"> <v-list-item v-for="umo in filteredSelectedUmos" :key="umo" @click="removeFromGroup(umo)" class="transfer-item">
<template v-slot:prepend> <template v-slot:prepend>
@@ -252,7 +252,7 @@
<v-list-item-title class="text-caption">{{ formatUmoShort(umo) }}</v-list-item-title> <v-list-item-title class="text-caption">{{ formatUmoShort(umo) }}</v-list-item-title>
</v-list-item> </v-list-item>
<v-list-item v-if="editingGroup.umos.length === 0"> <v-list-item v-if="editingGroup.umos.length === 0">
<v-list-item-title class="text-caption text-grey text-center">暂无成员</v-list-item-title> <v-list-item-title class="text-caption text-grey text-center">{{ tm('groups.noMembers') }}</v-list-item-title>
</v-list-item> </v-list-item>
</v-list> </v-list>
</v-col> </v-col>
@@ -260,8 +260,8 @@
</v-card-text> </v-card-text>
<v-card-actions class="px-4 pb-4"> <v-card-actions class="px-4 pb-4">
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn variant="text" @click="groupDialog = false">取消</v-btn> <v-btn variant="text" @click="groupDialog = false">{{ tm('buttons.cancel') }}</v-btn>
<v-btn color="primary" variant="tonal" @click="saveGroup">保存</v-btn> <v-btn color="primary" variant="tonal" @click="saveGroup">{{ tm('buttons.save') }}</v-btn>
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-dialog> </v-dialog>
@@ -721,9 +721,12 @@ export default {
] ]
// //
if (this.groups.length > 0) { if (this.groups.length > 0) {
options.push({ label: '── 自定义分组 ──', value: '_divider', disabled: true }) options.push({ label: this.tm('groups.customGroupDivider'), value: '_divider', disabled: true })
this.groups.forEach(g => { this.groups.forEach(g => {
options.push({ label: `📁 ${g.name} (${g.umo_count})`, value: `custom_group:${g.id}` }) options.push({
label: this.tm('groups.customGroupOption', { name: g.name, count: g.umo_count }),
value: `custom_group:${g.id}`
})
}) })
} }
return options return options
@@ -731,7 +734,7 @@ export default {
groupOptions() { groupOptions() {
return this.groups.map(g => ({ return this.groups.map(g => ({
label: `${g.name} (${g.umo_count} 个会话)`, label: this.tm('groups.groupOption', { name: g.name, count: g.umo_count }),
value: g.id value: g.id
})) }))
}, },
@@ -1331,7 +1334,7 @@ export default {
if (scope === 'selected') { if (scope === 'selected') {
umos = this.selectedItems.map(item => item.umo) umos = this.selectedItems.map(item => item.umo)
if (umos.length === 0) { if (umos.length === 0) {
this.showError('请先选择要操作的会话') this.showError(this.tm('messages.selectSessionsFirst'))
this.batchUpdating = false this.batchUpdating = false
return return
} }
@@ -1371,7 +1374,7 @@ export default {
} }
if (tasks.length === 0) { if (tasks.length === 0) {
this.showError('请至少选择一项要修改的配置') this.showError(this.tm('messages.selectAtLeastOneConfig'))
this.batchUpdating = false this.batchUpdating = false
return return
} }
@@ -1380,17 +1383,17 @@ export default {
const allOk = results.every(r => r.data.status === 'ok') const allOk = results.every(r => r.data.status === 'ok')
if (allOk) { if (allOk) {
this.showSuccess('批量更新成功') this.showSuccess(this.tm('messages.batchUpdateSuccess'))
this.batchLlmStatus = null this.batchLlmStatus = null
this.batchTtsStatus = null this.batchTtsStatus = null
this.batchChatProvider = null this.batchChatProvider = null
this.batchTtsProvider = null this.batchTtsProvider = null
await this.loadData() await this.loadData()
} else { } else {
this.showError('部分更新失败') this.showError(this.tm('messages.partialUpdateFailed'))
} }
} catch (error) { } catch (error) {
this.showError(error.response?.data?.message || '批量更新失败') this.showError(error.response?.data?.message || this.tm('messages.batchUpdateError'))
} }
this.batchUpdating = false this.batchUpdating = false
}, },
@@ -1477,7 +1480,7 @@ export default {
async saveGroup() { async saveGroup() {
if (!this.editingGroup.name.trim()) { if (!this.editingGroup.name.trim()) {
this.showError('分组名称不能为空') this.showError(this.tm('messages.groupNameRequired'))
return return
} }
@@ -1504,12 +1507,12 @@ export default {
this.showError(response.data.message) this.showError(response.data.message)
} }
} catch (error) { } catch (error) {
this.showError(error.response?.data?.message || '保存分组失败') this.showError(error.response?.data?.message || this.tm('messages.saveGroupError'))
} }
}, },
async deleteGroup(group) { async deleteGroup(group) {
const message = `确定要删除分组 "${group.name}" 吗?` const message = this.tm('groups.deleteConfirm', { name: group.name })
if (!(await askForConfirmationDialog(message, this.confirmDialog))) return if (!(await askForConfirmationDialog(message, this.confirmDialog))) return
try { try {
@@ -1521,7 +1524,7 @@ export default {
this.showError(response.data.message) this.showError(response.data.message)
} }
} catch (error) { } catch (error) {
this.showError(error.response?.data?.message || '删除分组失败') this.showError(error.response?.data?.message || this.tm('messages.deleteGroupError'))
} }
}, },
@@ -1532,7 +1535,7 @@ export default {
async addSelectedToGroup(groupId) { async addSelectedToGroup(groupId) {
if (this.selectedItems.length === 0) { if (this.selectedItems.length === 0) {
this.showError('请先选择要添加的会话') this.showError(this.tm('messages.selectSessionsToAddFirst'))
return return
} }
@@ -1542,13 +1545,13 @@ export default {
add_umos: this.selectedItems.map(item => item.umo) add_umos: this.selectedItems.map(item => item.umo)
}) })
if (response.data.status === 'ok') { if (response.data.status === 'ok') {
this.showSuccess(`已添加 ${this.selectedItems.length} 个会话到分组`) this.showSuccess(this.tm('messages.addToGroupSuccess', { count: this.selectedItems.length }))
await this.loadGroups() await this.loadGroups()
} else { } else {
this.showError(response.data.message) this.showError(response.data.message)
} }
} catch (error) { } catch (error) {
this.showError(error.response?.data?.message || '添加失败') this.showError(error.response?.data?.message || this.tm('messages.addToGroupError'))
} }
}, },
}, },
@@ -45,9 +45,9 @@ onMounted(() => {
<div class="d-flex align-center gap-1"> <div class="d-flex align-center gap-1">
<LanguageSwitcher /> <LanguageSwitcher />
<v-divider vertical class="mx-1" <v-divider vertical class="mx-1"
style="height: 24px !important; opacity: 0.9 !important; align-self: center !important; border-color: rgba(180, 148, 246, 0.8) !important;"></v-divider> style="height: 24px !important; opacity: 0.9 !important; align-self: center !important; border-color: rgba(var(--v-theme-primary), 0.45) !important;"></v-divider>
<v-btn @click="toggleTheme" class="theme-toggle-btn" icon variant="text" size="small"> <v-btn @click="toggleTheme" class="theme-toggle-btn" icon variant="text" size="small">
<v-icon size="18" :color="useCustomizerStore().uiTheme === 'PurpleTheme' ? '#5e35b1' : '#d7c5fa'"> <v-icon size="18" :color="'rgb(var(--v-theme-primary))'">
mdi-white-balance-sunny mdi-white-balance-sunny
</v-icon> </v-icon>
<v-tooltip activator="parent" location="top"> <v-tooltip activator="parent" location="top">
@@ -10,6 +10,10 @@ import {
toInitials, toInitials,
toPinyinText, toPinyinText,
} from "@/utils/pluginSearch"; } from "@/utils/pluginSearch";
import {
getValidHashTab,
replaceTabRoute,
} from "@/utils/hashRouteTabs.mjs";
import { ref, computed, onMounted, onUnmounted, reactive, watch } from "vue"; import { ref, computed, onMounted, onUnmounted, reactive, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { useDisplay } from "vuetify"; import { useDisplay } from "vuetify";
@@ -103,16 +107,11 @@ export const useExtensionPage = () => {
const activeTab = ref("installed"); const activeTab = ref("installed");
const validTabs = ["installed", "market", "mcp", "skills", "components"]; const validTabs = ["installed", "market", "mcp", "skills", "components"];
const isValidTab = (tab) => validTabs.includes(tab); const isValidTab = (tab) => validTabs.includes(tab);
const getLocationHash = () => const getLocationHash = () => route.hash || "";
typeof window !== "undefined" ? window.location.hash : ""; const extractTabFromHash = (hash) => getValidHashTab(hash, validTabs);
const extractTabFromHash = (hash) => {
const lastHashIndex = (hash || "").lastIndexOf("#");
if (lastHashIndex === -1) return "";
return hash.slice(lastHashIndex + 1);
};
const syncTabFromHash = (hash) => { const syncTabFromHash = (hash) => {
const tab = extractTabFromHash(hash); const tab = extractTabFromHash(hash);
if (isValidTab(tab)) { if (tab) {
activeTab.value = tab; activeTab.value = tab;
return true; return true;
} }
@@ -1436,9 +1435,7 @@ export const useExtensionPage = () => {
// 生命周期 // 生命周期
onMounted(async () => { onMounted(async () => {
if (!syncTabFromHash(getLocationHash())) { if (!syncTabFromHash(getLocationHash())) {
if (typeof window !== "undefined") { await replaceTabRoute(router, route, activeTab.value);
window.location.hash = `#${activeTab.value}`;
}
} }
await getExtensions(); await getExtensions();
@@ -1446,17 +1443,9 @@ export const useExtensionPage = () => {
loadCustomSources(); loadCustomSources();
// 检查是否有 open_config 参数 // 检查是否有 open_config 参数
let urlParams; const plugin_name = Array.isArray(route.query.open_config)
if (window.location.hash) { ? route.query.open_config[0]
// For hash mode (#/path?param=value) : route.query.open_config;
const hashQuery = window.location.hash.split("?")[1] || "";
urlParams = new URLSearchParams(hashQuery);
} else {
// For history mode (/path?param=value)
urlParams = new URLSearchParams(window.location.search);
}
console.log("URL Parameters:", urlParams.toString());
const plugin_name = urlParams.get("open_config");
if (plugin_name) { if (plugin_name) {
console.log(`Opening config for plugin: ${plugin_name}`); console.log(`Opening config for plugin: ${plugin_name}`);
openExtensionConfig(plugin_name); openExtensionConfig(plugin_name);
@@ -1528,10 +1517,10 @@ export const useExtensionPage = () => {
); );
watch( watch(
() => route.fullPath, () => route.hash,
() => { (newHash) => {
const tab = extractTabFromHash(getLocationHash()); const tab = extractTabFromHash(newHash);
if (isValidTab(tab) && tab !== activeTab.value) { if (tab && tab !== activeTab.value) {
activeTab.value = tab; activeTab.value = tab;
} }
}, },
@@ -1539,15 +1528,8 @@ export const useExtensionPage = () => {
watch(activeTab, (newTab) => { watch(activeTab, (newTab) => {
if (!isValidTab(newTab)) return; if (!isValidTab(newTab)) return;
const currentTab = extractTabFromHash(getLocationHash()); if (route.hash === `#${newTab}`) return;
if (currentTab === newTab) return; void replaceTabRoute(router, route, newTab);
const hash = getLocationHash();
const lastHashIndex = hash.lastIndexOf("#");
const nextHash =
lastHashIndex > 0 ? `${hash.slice(0, lastHashIndex)}#${newTab}` : `#${newTab}`;
if (typeof window !== "undefined") {
window.location.hash = nextHash;
}
}); });
return { return {
+123
View File
@@ -0,0 +1,123 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as hashRouteTabs from '../src/utils/hashRouteTabs.mjs';
import { EXTENSION_ROUTE_NAME } from '../src/router/routeConstants.mjs';
const { createTabRouteLocation, getValidHashTab } = hashRouteTabs;
test('getValidHashTab returns the tab name for a valid route hash', () => {
const validTabs = ['installed', 'market', 'mcp'];
assert.equal(getValidHashTab('#market', validTabs), 'market');
});
test('getValidHashTab rejects empty and unknown hashes', () => {
const validTabs = ['installed', 'market', 'mcp'];
assert.equal(getValidHashTab('', validTabs), null);
assert.equal(getValidHashTab('#unknown', validTabs), null);
});
test('getValidHashTab uses the last hash segment when multiple hashes are present', () => {
const validTabs = ['installed', 'market', 'mcp'];
assert.equal(getValidHashTab('#/extension#foo#installed', validTabs), 'installed');
});
test('createTabRouteLocation preserves the current path and query', () => {
const query = { open_config: 'sample-plugin', page: '2' };
const location = createTabRouteLocation(
{
path: '/extension',
query,
},
'market',
);
assert.deepEqual(location, {
path: '/extension',
query: { open_config: 'sample-plugin', page: '2' },
hash: '#market',
});
assert.notEqual(location.query, query);
});
test('createTabRouteLocation falls back to the extension route name', () => {
const location = createTabRouteLocation(undefined, 'installed');
assert.deepEqual(location, {
name: EXTENSION_ROUTE_NAME,
query: {},
hash: '#installed',
});
});
test('createTabRouteLocation prefers route name and preserves params', () => {
const params = { pluginId: 'demo-plugin' };
const location = createTabRouteLocation(
{
name: 'ExtensionDetails',
path: '/extension/demo-plugin',
params,
query: { tab: 'details' },
},
'market',
);
assert.deepEqual(location, {
name: 'ExtensionDetails',
params: { pluginId: 'demo-plugin' },
query: { tab: 'details' },
hash: '#market',
});
assert.notEqual(location.params, params);
});
test('createTabRouteLocation omits params for path-based routes', () => {
const params = { pluginId: 'demo-plugin' };
const location = createTabRouteLocation(
{
path: '/extension/demo-plugin',
params,
},
'installed',
);
assert.deepEqual(location, {
path: '/extension/demo-plugin',
query: {},
hash: '#installed',
});
assert.equal(location.params, undefined);
});
test('replaceTabRoute catches rejected router updates', async () => {
assert.equal(typeof hashRouteTabs.replaceTabRoute, 'function');
const error = new Error('blocked');
let logged;
const router = {
replace: async () => {
throw error;
},
};
const logger = {
warn: (message, cause) => {
logged = { message, cause };
},
};
const result = await hashRouteTabs.replaceTabRoute(
router,
{ name: EXTENSION_ROUTE_NAME, query: { page: '1' } },
'installed',
logger,
);
assert.equal(result, false);
assert.deepEqual(logged, {
message: 'Failed to update extension tab route:',
cause: error,
});
});
+29
View File
@@ -0,0 +1,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
test('waitForRouterReadyInBackground returns immediately and logs failures', async () => {
const module = await import('../src/utils/routerReadiness.mjs').catch(() => null);
assert.ok(module?.waitForRouterReadyInBackground);
const error = new Error('router blocked');
let warned;
const readyPromise = Promise.reject(error);
const logger = {
warn: (message, cause) => {
warned = { message, cause };
},
};
const result = module.waitForRouterReadyInBackground(
{ isReady: () => readyPromise },
logger,
);
assert.equal(result, undefined);
await Promise.resolve();
assert.deepEqual(warned, {
message: 'Router did not become ready after fallback mount:',
cause: error,
});
});
+1 -1
View File
@@ -6,7 +6,7 @@ This documentation may not cover all features comprehensively. If you have any q
### Discord ### Discord
<https://discord.gg/PxgzhmxJ> <https://discord.gg/hAVk6tgV36>
### GitHub ### GitHub
+1 -1
View File
@@ -21,7 +21,7 @@
### Discord ### Discord
https://discord.gg/PxgzhmxJ https://discord.gg/hAVk6tgV36
### Astrbook ### Astrbook
+1 -1
View File
@@ -13,5 +13,5 @@
```bash ```bash
uv tool install astrbot uv tool install astrbot
astrbot init # 只需要在第一次部署时执行,后续启动不需要执行 astrbot init # 只需要在第一次部署时执行,后续启动不需要执行
astrbot astrbot run
``` ```
+1 -1
View File
@@ -41,4 +41,4 @@ AstrBot 已经上架至雨云的预装软件列表,支持**一键安装** Astr
![创建NAT端口映射规则](https://files.astrbot.app/docs/source/images/rainyun/image-2.png) ![创建NAT端口映射规则](https://files.astrbot.app/docs/source/images/rainyun/image-2.png)
然后,内网端口填写 `6185`,点击 `创建映射规则`,这样就可以通过 `http://IP:上面设置好的外网端口` 访问 AstrBot 的管理面板了。 然后,内网端口填写 `6185`,点击 `创建映射规则`,这样就可以通过 `http://IP:上面设置好的外网端口` 访问 AstrBot 的管理面板了。如果无法打开,请点击`备用地址`,通过备用地址访问管理面板。
+1 -1
View File
@@ -7,7 +7,7 @@
进入 AstrBot WebUI 的插件市场,搜索 `astrbot_plugin_matrix_adapter`,点击安装。 进入 AstrBot WebUI 的插件市场,搜索 `astrbot_plugin_matrix_adapter`,点击安装。
安装完成后,前往 消息平台 → 新增适配器 → 选择 Matrix(若选项缺失,尝试重启 AstrBot 或检查插件安装状态)。 安装完成后,前往 机器人(旧版本为 `消息平台` → 新增适配器 → 选择 Matrix(若选项缺失,尝试重启 AstrBot 或检查插件安装状态)。
在弹出的配置对话框中点击 `启用` 在弹出的配置对话框中点击 `启用`
+1 -1
View File
@@ -30,7 +30,7 @@
## 配置 AstrBot ## 配置 AstrBot
1. 进入 AstrBot 的管理面板,点击左侧栏 `消息平台`,然后在右侧的界面中,点击 `+ 新增适配器`,选择 `企业微信智能机器人`,进入配置页面。 1. 进入 AstrBot 的管理面板,点击左侧栏 `机器人`(旧版本为 `消息平台`,然后在右侧的界面中,点击 `+ 新增适配器`,选择 `企业微信智能机器人`,进入配置页面。
![新增适配器](https://files.astrbot.app/docs/source/images/wecom_ai_bot/image-2.png) ![新增适配器](https://files.astrbot.app/docs/source/images/wecom_ai_bot/image-2.png)
+1 -1
View File
@@ -23,7 +23,7 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
- 部署 AstrBot:阅读部署指南,快速在本地机器或云服务器上部署 AstrBot。 - 部署 AstrBot:阅读部署指南,快速在本地机器或云服务器上部署 AstrBot。
- 连接 IM 平台:按照说明将 AstrBot 连接到您喜欢的 IM 平台,如 Discord、Telegram、Slack 等。 - 连接 IM 平台:按照说明将 AstrBot 连接到您喜欢的 IM 平台,如 Discord、Telegram、Slack 等。
- 配置 AI 模型:AstrBot 支持各种 AI 模型。请参阅 [连接模型服务](/config/providers/start) - 配置 AI 模型:AstrBot 支持各种 AI 模型。请参阅 [连接模型服务](/providers/start)
## 它是如何实现的? ## 它是如何实现的?

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