Compare commits

...

65 Commits

Author SHA1 Message Date
Soulter 2f15fd019c chore: bump version to v4.10.0-alpha.1 2025-12-20 16:35:54 +08:00
Soulter 82330b8d10 feat: add changelog functionality and dialog component (#4135)
* feat: add changelog functionality and dialog component

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

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

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

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

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

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

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

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

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

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

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

* feat: add provider configuration dialog to chat sidebar

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

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

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

* feat: xmas easter egg

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

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

Fixes #3886

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

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

* fix: streamline conversation selection handling in Chat.vue

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

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

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

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

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

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

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

* refactor: enhance message structure and UI for chat components

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

* chore: ruff format

* feat: implement agent statistics tracking and display in chat

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

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

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

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

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

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

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

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

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

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

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

* refactor: enhance message structure and UI for chat components

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

* chore: ruff format

* feat: implement agent statistics tracking and display in chat

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

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

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

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

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

* Fix: Correct inverted logic in is_seg_reply_required for only_llm_result option

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* style: UI 细节

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

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

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

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

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

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

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

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

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

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

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

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

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

* style(extension): 文案修改

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

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

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

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

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

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

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

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

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

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

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

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

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

* refactor: move mcp and command page to extension page

* refactor: remove unused imports in component panel

* fix: update terminology for handler management in extension localization

---------

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

* chore: ruff format

---------

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


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

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

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

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

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

* Add conversation export functionality (backend and frontend)

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

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

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

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

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

* fix: update conversation export filename format for consistency

---------

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

* Update plugin.py

* Update plugin.py

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

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

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

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

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

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

* refactor: 重构文档上传和导入任务的状态管理,添加任务初始化、结果设置和进度更新方法
2025-12-12 23:15:11 +08:00
146 changed files with 9646 additions and 3274 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
zip -r dist.zip dist zip -r dist.zip dist
- name: Archive production artifacts - name: Archive production artifacts
uses: actions/upload-artifact@v5 uses: actions/upload-artifact@v6
with: with:
name: dist-without-markdown name: dist-without-markdown
path: | path: |
+26 -1
View File
@@ -33,6 +33,20 @@
- 请使用英文描述您的 PR。 - 请使用英文描述您的 PR。
- 标题请使用 `fix: `, `feat: `, `docs: `, `style: `, `refactor: `, `test: `, `chore: ` 等语义化前缀,并简要描述更改内容。如:`fix: correct login page typo` - 标题请使用 `fix: `, `feat: `, `docs: `, `style: `, `refactor: `, `test: `, `chore: ` 等语义化前缀,并简要描述更改内容。如:`fix: correct login page typo`
#### 代码规范
##### Core
我们使用 Ruff 作为代码格式化和静态分析工具。在提交代码之前,请运行以下命令以确保代码符合规范:
```bash
ruff format .
ruff check .
```
如果您使用 VSCode,可以安装 `Ruff` 插件。
## Contributing Guide ## Contributing Guide
First off, thanks for taking the time to contribute! ❤️ First off, thanks for taking the time to contribute! ❤️
@@ -62,4 +76,15 @@ We use the `fix/` prefix for bug fixes and the `feat/` prefix for new features.
#### PR Description #### PR Description
- Please use English to describe your PR. - Please use English to describe your PR.
- Use semantic prefixes like `fix: `, `feat: `, `docs: `, `style: `, `refactor: `, `test: `, `chore: ` in the title, followed by a brief description of the changes, e.g., `fix: correct login page typo`. - Use semantic prefixes like `fix: `, `feat: `, `docs: `, `style: `, `refactor: `, `test: `, `chore: ` in the title, followed by a brief description of the changes, e.g., `fix: correct login page typo`.
#### Code Style
##### Core
We use Ruff as our code formatter and static analysis tool. Before submitting your code, please run the following commands to ensure your code adheres to the style guidelines:
```bash
ruff format .
ruff check .
```
+6
View File
@@ -243,4 +243,10 @@ pre-commit install
</details> </details>
<div align="center">
_私は、高性能ですから!_ _私は、高性能ですから!_
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
</div
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.8.0" __version__ = "4.10.0-alpha.1"
+6 -4
View File
@@ -3,7 +3,7 @@
from typing import Any, ClassVar, Literal, cast from typing import Any, ClassVar, Literal, cast
from pydantic import BaseModel, GetCoreSchemaHandler, model_validator from pydantic import BaseModel, GetCoreSchemaHandler, model_serializer, model_validator
from pydantic_core import core_schema from pydantic_core import core_schema
@@ -122,10 +122,12 @@ class ToolCall(BaseModel):
extra_content: dict[str, Any] | None = None extra_content: dict[str, Any] | None = None
"""Extra metadata for the tool call.""" """Extra metadata for the tool call."""
def model_dump(self, **kwargs: Any) -> dict[str, Any]: @model_serializer(mode="wrap")
def serialize(self, handler):
data = handler(self)
if self.extra_content is None: if self.extra_content is None:
kwargs.setdefault("exclude", set()).add("extra_content") data.pop("extra_content", None)
return super().model_dump(**kwargs) return data
class ToolCallPart(BaseModel): class ToolCallPart(BaseModel):
+22 -1
View File
@@ -1,7 +1,8 @@
import typing as T import typing as T
from dataclasses import dataclass from dataclasses import dataclass, field
from astrbot.core.message.message_event_result import MessageChain from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import TokenUsage
class AgentResponseData(T.TypedDict): class AgentResponseData(T.TypedDict):
@@ -12,3 +13,23 @@ class AgentResponseData(T.TypedDict):
class AgentResponse: class AgentResponse:
type: str type: str
data: AgentResponseData data: AgentResponseData
@dataclass
class AgentStats:
token_usage: TokenUsage = field(default_factory=TokenUsage)
start_time: float = 0.0
end_time: float = 0.0
time_to_first_token: float = 0.0
@property
def duration(self) -> float:
return self.end_time - self.start_time
def to_dict(self) -> dict:
return {
"token_usage": self.token_usage.__dict__,
"start_time": self.start_time,
"end_time": self.end_time,
"time_to_first_token": self.time_to_first_token,
}
+1 -1
View File
@@ -9,7 +9,7 @@ from .message import Message
TContext = TypeVar("TContext", default=Any) TContext = TypeVar("TContext", default=Any)
@dataclass(config={"arbitrary_types_allowed": True}) @dataclass
class ContextWrapper(Generic[TContext]): class ContextWrapper(Generic[TContext]):
"""A context for running an agent, which can be used to pass additional data or state.""" """A context for running an agent, which can be used to pass additional data or state."""
@@ -1,4 +1,5 @@
import sys import sys
import time
import traceback import traceback
import typing as T import typing as T
@@ -12,6 +13,7 @@ from mcp.types import (
) )
from astrbot import logger from astrbot import logger
from astrbot.core.message.components import Json
from astrbot.core.message.message_event_result import ( from astrbot.core.message.message_event_result import (
MessageChain, MessageChain,
) )
@@ -24,7 +26,7 @@ from astrbot.core.provider.provider import Provider
from ..hooks import BaseAgentRunHooks from ..hooks import BaseAgentRunHooks
from ..message import AssistantMessageSegment, Message, ToolCallMessageSegment from ..message import AssistantMessageSegment, Message, ToolCallMessageSegment
from ..response import AgentResponseData from ..response import AgentResponseData, AgentStats
from ..run_context import ContextWrapper, TContext from ..run_context import ContextWrapper, TContext
from ..tool_executor import BaseFunctionToolExecutor from ..tool_executor import BaseFunctionToolExecutor
from .base import AgentResponse, AgentState, BaseAgentRunner from .base import AgentResponse, AgentState, BaseAgentRunner
@@ -69,6 +71,9 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
) )
self.run_context.messages = messages self.run_context.messages = messages
self.stats = AgentStats()
self.stats.start_time = time.time()
async def _iter_llm_responses(self) -> T.AsyncGenerator[LLMResponse, None]: async def _iter_llm_responses(self) -> T.AsyncGenerator[LLMResponse, None]:
"""Yields chunks *and* a final LLMResponse.""" """Yields chunks *and* a final LLMResponse."""
if self.streaming: if self.streaming:
@@ -98,6 +103,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
async for llm_response in self._iter_llm_responses(): async for llm_response in self._iter_llm_responses():
if llm_response.is_chunk: if llm_response.is_chunk:
# update ttft
if self.stats.time_to_first_token == 0:
self.stats.time_to_first_token = time.time() - self.stats.start_time
if llm_response.result_chain: if llm_response.result_chain:
yield AgentResponse( yield AgentResponse(
type="streaming_delta", type="streaming_delta",
@@ -121,6 +130,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
) )
continue continue
llm_resp_result = llm_response llm_resp_result = llm_response
if not llm_response.is_chunk and llm_response.usage:
# only count the token usage of the final response for computation purpose
self.stats.token_usage += llm_response.usage
break # got final response break # got final response
if not llm_resp_result: if not llm_resp_result:
@@ -132,6 +145,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
if llm_resp.role == "err": if llm_resp.role == "err":
# 如果 LLM 响应错误,转换到错误状态 # 如果 LLM 响应错误,转换到错误状态
self.final_llm_resp = llm_resp self.final_llm_resp = llm_resp
self.stats.end_time = time.time()
self._transition_state(AgentState.ERROR) self._transition_state(AgentState.ERROR)
yield AgentResponse( yield AgentResponse(
type="err", type="err",
@@ -146,6 +160,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
# 如果没有工具调用,转换到完成状态 # 如果没有工具调用,转换到完成状态
self.final_llm_resp = llm_resp self.final_llm_resp = llm_resp
self._transition_state(AgentState.DONE) self._transition_state(AgentState.DONE)
self.stats.end_time = time.time()
# record the final assistant message # record the final assistant message
self.run_context.messages.append( self.run_context.messages.append(
Message( Message(
@@ -175,22 +190,19 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
# 如果有工具调用,还需处理工具调用 # 如果有工具调用,还需处理工具调用
if llm_resp.tools_call_name: if llm_resp.tools_call_name:
tool_call_result_blocks = [] tool_call_result_blocks = []
for tool_call_name in llm_resp.tools_call_name:
yield AgentResponse(
type="tool_call",
data=AgentResponseData(
chain=MessageChain(type="tool_call").message(
f"🔨 调用工具: {tool_call_name}"
),
),
)
async for result in self._handle_function_tools(self.req, llm_resp): async for result in self._handle_function_tools(self.req, llm_resp):
if isinstance(result, list): if isinstance(result, list):
tool_call_result_blocks = result tool_call_result_blocks = result
elif isinstance(result, MessageChain): elif isinstance(result, MessageChain):
result.type = "tool_call_result" if result.type is None:
# should not happen
continue
if result.type == "tool_direct_result":
ar_type = "tool_call_result"
else:
ar_type = result.type
yield AgentResponse( yield AgentResponse(
type="tool_call_result", type=ar_type,
data=AgentResponseData(chain=result), data=AgentResponseData(chain=result),
) )
# 将结果添加到上下文中 # 将结果添加到上下文中
@@ -233,6 +245,19 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
llm_response.tools_call_args, llm_response.tools_call_args,
llm_response.tools_call_ids, llm_response.tools_call_ids,
): ):
yield MessageChain(
type="tool_call",
chain=[
Json(
data={
"id": func_tool_id,
"name": func_tool_name,
"args": func_tool_args,
"ts": time.time(),
}
)
],
)
try: try:
if not req.func_tool: if not req.func_tool:
return return
@@ -306,7 +331,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
content=res.content[0].text, content=res.content[0].text,
), ),
) )
yield MessageChain().message(res.content[0].text)
elif isinstance(res.content[0], ImageContent): elif isinstance(res.content[0], ImageContent):
tool_call_result_blocks.append( tool_call_result_blocks.append(
ToolCallMessageSegment( ToolCallMessageSegment(
@@ -328,7 +352,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
content=resource.text, content=resource.text,
), ),
) )
yield MessageChain().message(resource.text)
elif ( elif (
isinstance(resource, BlobResourceContents) isinstance(resource, BlobResourceContents)
and resource.mimeType and resource.mimeType
@@ -352,7 +375,22 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
content="返回的数据类型不受支持", content="返回的数据类型不受支持",
), ),
) )
yield MessageChain().message("返回的数据类型不受支持。")
# yield the last tool call result
if tool_call_result_blocks:
last_tcr_content = str(tool_call_result_blocks[-1].content)
yield MessageChain(
type="tool_call_result",
chain=[
Json(
data={
"id": func_tool_id,
"ts": time.time(),
"result": last_tcr_content,
}
)
],
)
elif resp is None: elif resp is None:
# Tool 直接请求发送消息给用户 # Tool 直接请求发送消息给用户
@@ -362,6 +400,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
f"{func_tool_name} 没有没有返回值或者将结果直接发送给用户,此工具调用不会被记录到历史中。" f"{func_tool_name} 没有没有返回值或者将结果直接发送给用户,此工具调用不会被记录到历史中。"
) )
self._transition_state(AgentState.DONE) self._transition_state(AgentState.DONE)
self.stats.end_time = time.time()
else: else:
# 不应该出现其他类型 # 不应该出现其他类型
logger.warning( logger.warning(
+3 -1
View File
@@ -6,8 +6,10 @@ from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.star.context import Context from astrbot.core.star.context import Context
@dataclass(config={"arbitrary_types_allowed": True}) @dataclass
class AstrAgentContext: class AstrAgentContext:
__pydantic_config__ = {"arbitrary_types_allowed": True}
context: Context context: Context
"""The star context instance""" """The star context instance"""
event: AstrMessageEvent event: AstrMessageEvent
+23 -2
View File
@@ -4,6 +4,7 @@ from collections.abc import AsyncGenerator
from astrbot.core import logger from astrbot.core import logger
from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner
from astrbot.core.astr_agent_context import AstrAgentContext from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.message.components import Json
from astrbot.core.message.message_event_result import ( from astrbot.core.message.message_event_result import (
MessageChain, MessageChain,
MessageEventResult, MessageEventResult,
@@ -33,16 +34,27 @@ async def run_agent(
msg_chain = resp.data["chain"] msg_chain = resp.data["chain"]
if msg_chain.type == "tool_direct_result": if msg_chain.type == "tool_direct_result":
# tool_direct_result 用于标记 llm tool 需要直接发送给用户的内容 # tool_direct_result 用于标记 llm tool 需要直接发送给用户的内容
await astr_event.send(resp.data["chain"]) await astr_event.send(msg_chain)
continue continue
if astr_event.get_platform_id() == "webchat":
await astr_event.send(msg_chain)
# 对于其他情况,暂时先不处理 # 对于其他情况,暂时先不处理
continue continue
elif resp.type == "tool_call": elif resp.type == "tool_call":
if agent_runner.streaming: if agent_runner.streaming:
# 用来标记流式响应需要分节 # 用来标记流式响应需要分节
yield MessageChain(chain=[], type="break") yield MessageChain(chain=[], type="break")
if show_tool_use:
if astr_event.get_platform_name() == "webchat":
await astr_event.send(resp.data["chain"]) await astr_event.send(resp.data["chain"])
elif show_tool_use:
json_comp = resp.data["chain"].chain[0]
if isinstance(json_comp, Json):
m = f"🔨 调用工具: {json_comp.data.get('name')}"
else:
m = "🔨 调用工具..."
chain = MessageChain(type="tool_call").message(m)
await astr_event.send(chain)
continue continue
if stream_to_general and resp.type == "streaming_delta": if stream_to_general and resp.type == "streaming_delta":
@@ -69,6 +81,15 @@ async def run_agent(
continue continue
yield resp.data["chain"] # MessageChain yield resp.data["chain"] # MessageChain
if agent_runner.done(): if agent_runner.done():
# send agent stats to webchat
if astr_event.get_platform_name() == "webchat":
await astr_event.send(
MessageChain(
type="agent_stats",
chain=[Json(data=agent_runner.stats.to_dict())],
)
)
break break
except Exception as e: except Exception as e:
+168 -212
View File
@@ -1,10 +1,11 @@
"""如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。""" """如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。"""
import os import os
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.8.0" VERSION = "4.10.0-alpha.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 = [
@@ -61,7 +62,8 @@ DEFAULT_CONFIG = {
"ignore_bot_self_message": False, "ignore_bot_self_message": False,
"ignore_at_all": False, "ignore_at_all": False,
}, },
"provider": [], "provider_sources": [], # provider sources
"provider": [], # models from provider_sources
"provider_settings": { "provider_settings": {
"enable": True, "enable": True,
"default_provider_id": "", "default_provider_id": "",
@@ -108,6 +110,7 @@ DEFAULT_CONFIG = {
"provider_id": "", "provider_id": "",
"dual_output": False, "dual_output": False,
"use_file_service": False, "use_file_service": False,
"trigger_probability": 1.0,
}, },
"provider_ltm_settings": { "provider_ltm_settings": {
"group_icl_enable": False, "group_icl_enable": False,
@@ -170,6 +173,22 @@ DEFAULT_CONFIG = {
} }
class ChatProviderTemplate(TypedDict):
id: str
provider_source_id: str
model: str
modalities: list
custom_extra_body: dict[str, Any]
CHAT_PROVIDER_TEMPLATE = {
"id": "",
"provide_source_id": "",
"model": "",
"modalities": [],
"custom_extra_body": {},
}
""" """
AstrBot v3 时代的配置元数据,目前仅承担以下功能: AstrBot v3 时代的配置元数据,目前仅承担以下功能:
@@ -208,7 +227,7 @@ CONFIG_METADATA_2 = {
"callback_server_host": "0.0.0.0", "callback_server_host": "0.0.0.0",
"port": 6196, "port": 6196,
}, },
"QQ 个人号(OneBot v11)": { "OneBot v11": {
"id": "default", "id": "default",
"type": "aiocqhttp", "type": "aiocqhttp",
"enable": False, "enable": False,
@@ -843,6 +862,7 @@ CONFIG_METADATA_2 = {
"metadata": { "metadata": {
"provider": { "provider": {
"type": "list", "type": "list",
# provider sources templates
"config_template": { "config_template": {
"OpenAI": { "OpenAI": {
"id": "openai", "id": "openai",
@@ -853,107 +873,10 @@ CONFIG_METADATA_2 = {
"key": [], "key": [],
"api_base": "https://api.openai.com/v1", "api_base": "https://api.openai.com/v1",
"timeout": 120, "timeout": 120,
"model_config": {"model": "gpt-4o-mini", "temperature": 0.4},
"custom_headers": {}, "custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
"hint": "也兼容所有与 OpenAI API 兼容的服务。",
}, },
"Azure OpenAI": { "Google Gemini": {
"id": "azure", "id": "google_gemini",
"provider": "azure",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"api_version": "2024-05-01-preview",
"key": [],
"api_base": "",
"timeout": 120,
"model_config": {"model": "gpt-4o-mini", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"xAI": {
"id": "xai",
"provider": "xai",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.x.ai/v1",
"timeout": 120,
"model_config": {"model": "grok-2-latest", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"xai_native_search": False,
"modalities": ["text", "image", "tool_use"],
},
"Anthropic": {
"hint": "注意Claude系列模型的温度调节范围为0到1.0,超出可能导致报错",
"id": "claude",
"provider": "anthropic",
"type": "anthropic_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.anthropic.com/v1",
"timeout": 120,
"model_config": {
"model": "claude-3-5-sonnet-latest",
"max_tokens": 4096,
"temperature": 0.2,
},
"modalities": ["text", "image", "tool_use"],
},
"Ollama": {
"hint": "启用前请确保已正确安装并运行 Ollama 服务端,Ollama默认不带鉴权,无需修改key",
"id": "ollama_default",
"provider": "ollama",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": ["ollama"], # ollama 的 key 默认是 ollama
"api_base": "http://localhost:11434/v1",
"model_config": {"model": "llama3.1-8b", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"LM Studio": {
"id": "lm_studio",
"provider": "lm_studio",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": ["lmstudio"],
"api_base": "http://localhost:1234/v1",
"model_config": {
"model": "llama-3.1-8b",
},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"Gemini(OpenAI兼容)": {
"id": "gemini_default",
"provider": "google",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://generativelanguage.googleapis.com/v1beta/openai/",
"timeout": 120,
"model_config": {
"model": "gemini-1.5-flash",
"temperature": 0.4,
},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"Gemini": {
"id": "gemini_default",
"provider": "google", "provider": "google",
"type": "googlegenai_chat_completion", "type": "googlegenai_chat_completion",
"provider_type": "chat_completion", "provider_type": "chat_completion",
@@ -961,10 +884,6 @@ CONFIG_METADATA_2 = {
"key": [], "key": [],
"api_base": "https://generativelanguage.googleapis.com/", "api_base": "https://generativelanguage.googleapis.com/",
"timeout": 120, "timeout": 120,
"model_config": {
"model": "gemini-2.0-flash-exp",
"temperature": 0.4,
},
"gm_resp_image_modal": False, "gm_resp_image_modal": False,
"gm_native_search": False, "gm_native_search": False,
"gm_native_coderunner": False, "gm_native_coderunner": False,
@@ -975,13 +894,43 @@ CONFIG_METADATA_2 = {
"sexually_explicit": "BLOCK_MEDIUM_AND_ABOVE", "sexually_explicit": "BLOCK_MEDIUM_AND_ABOVE",
"dangerous_content": "BLOCK_MEDIUM_AND_ABOVE", "dangerous_content": "BLOCK_MEDIUM_AND_ABOVE",
}, },
"gm_thinking_config": { "gm_thinking_config": {"budget": 0, "level": "HIGH"},
"budget": 0, },
}, "Anthropic": {
"modalities": ["text", "image", "tool_use"], "id": "anthropic",
"provider": "anthropic",
"type": "anthropic_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.anthropic.com/v1",
"timeout": 120,
},
"Moonshot": {
"id": "moonshot",
"provider": "moonshot",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"timeout": 120,
"api_base": "https://api.moonshot.cn/v1",
"custom_headers": {},
},
"xAI": {
"id": "xai",
"provider": "xai",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.x.ai/v1",
"timeout": 120,
"custom_headers": {},
"xai_native_search": False,
}, },
"DeepSeek": { "DeepSeek": {
"id": "deepseek_default", "id": "deepseek",
"provider": "deepseek", "provider": "deepseek",
"type": "openai_chat_completion", "type": "openai_chat_completion",
"provider_type": "chat_completion", "provider_type": "chat_completion",
@@ -989,13 +938,75 @@ CONFIG_METADATA_2 = {
"key": [], "key": [],
"api_base": "https://api.deepseek.com/v1", "api_base": "https://api.deepseek.com/v1",
"timeout": 120, "timeout": 120,
"model_config": {"model": "deepseek-chat", "temperature": 0.4},
"custom_headers": {}, "custom_headers": {},
"custom_extra_body": {}, },
"modalities": ["text", "tool_use"], "Zhipu": {
"id": "zhipu",
"provider": "zhipu",
"type": "zhipu_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"timeout": 120,
"api_base": "https://open.bigmodel.cn/api/paas/v4/",
"custom_headers": {},
},
"Azure OpenAI": {
"id": "azure_openai",
"provider": "azure",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"api_version": "2024-05-01-preview",
"key": [],
"api_base": "",
"timeout": 120,
"custom_headers": {},
},
"Ollama": {
"id": "ollama",
"provider": "ollama",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": ["ollama"], # ollama 的 key 默认是 ollama
"api_base": "http://127.0.0.1:11434/v1",
"custom_headers": {},
},
"LM Studio": {
"id": "lm_studio",
"provider": "lm_studio",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": ["lmstudio"],
"api_base": "http://127.0.0.1:1234/v1",
"custom_headers": {},
},
"ModelStack": {
"id": "modelstack",
"provider": "modelstack",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://modelstack.app/v1",
"timeout": 120,
"custom_headers": {},
},
"Gemini_OpenAI_API": {
"id": "google_gemini_openai",
"provider": "google",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://generativelanguage.googleapis.com/v1beta/openai/",
"timeout": 120,
"custom_headers": {},
}, },
"Groq": { "Groq": {
"id": "groq_default", "id": "groq",
"provider": "groq", "provider": "groq",
"type": "groq_chat_completion", "type": "groq_chat_completion",
"provider_type": "chat_completion", "provider_type": "chat_completion",
@@ -1003,13 +1014,7 @@ CONFIG_METADATA_2 = {
"key": [], "key": [],
"api_base": "https://api.groq.com/openai/v1", "api_base": "https://api.groq.com/openai/v1",
"timeout": 120, "timeout": 120,
"model_config": {
"model": "openai/gpt-oss-20b",
"temperature": 0.4,
},
"custom_headers": {}, "custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "tool_use"],
}, },
"302.AI": { "302.AI": {
"id": "302ai", "id": "302ai",
@@ -1020,12 +1025,9 @@ CONFIG_METADATA_2 = {
"key": [], "key": [],
"api_base": "https://api.302.ai/v1", "api_base": "https://api.302.ai/v1",
"timeout": 120, "timeout": 120,
"model_config": {"model": "gpt-4.1-mini", "temperature": 0.4},
"custom_headers": {}, "custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
}, },
"硅基流动": { "SiliconFlow": {
"id": "siliconflow", "id": "siliconflow",
"provider": "siliconflow", "provider": "siliconflow",
"type": "openai_chat_completion", "type": "openai_chat_completion",
@@ -1034,15 +1036,9 @@ CONFIG_METADATA_2 = {
"key": [], "key": [],
"timeout": 120, "timeout": 120,
"api_base": "https://api.siliconflow.cn/v1", "api_base": "https://api.siliconflow.cn/v1",
"model_config": {
"model": "deepseek-ai/DeepSeek-V3",
"temperature": 0.4,
},
"custom_headers": {}, "custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
}, },
"PPIO派欧云": { "PPIO": {
"id": "ppio", "id": "ppio",
"provider": "ppio", "provider": "ppio",
"type": "openai_chat_completion", "type": "openai_chat_completion",
@@ -1051,14 +1047,9 @@ CONFIG_METADATA_2 = {
"key": [], "key": [],
"api_base": "https://api.ppinfra.com/v3/openai", "api_base": "https://api.ppinfra.com/v3/openai",
"timeout": 120, "timeout": 120,
"model_config": {
"model": "deepseek/deepseek-r1",
"temperature": 0.4,
},
"custom_headers": {}, "custom_headers": {},
"custom_extra_body": {},
}, },
"小马算力": { "TokenPony": {
"id": "tokenpony", "id": "tokenpony",
"provider": "tokenpony", "provider": "tokenpony",
"type": "openai_chat_completion", "type": "openai_chat_completion",
@@ -1067,14 +1058,9 @@ CONFIG_METADATA_2 = {
"key": [], "key": [],
"api_base": "https://api.tokenpony.cn/v1", "api_base": "https://api.tokenpony.cn/v1",
"timeout": 120, "timeout": 120,
"model_config": {
"model": "kimi-k2-instruct-0905",
"temperature": 0.7,
},
"custom_headers": {}, "custom_headers": {},
"custom_extra_body": {},
}, },
"优云智算": { "Compshare": {
"id": "compshare", "id": "compshare",
"provider": "compshare", "provider": "compshare",
"type": "openai_chat_completion", "type": "openai_chat_completion",
@@ -1083,42 +1069,18 @@ CONFIG_METADATA_2 = {
"key": [], "key": [],
"api_base": "https://api.modelverse.cn/v1", "api_base": "https://api.modelverse.cn/v1",
"timeout": 120, "timeout": 120,
"model_config": {
"model": "moonshotai/Kimi-K2-Instruct",
},
"custom_headers": {}, "custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
}, },
"Kimi": { "ModelScope": {
"id": "moonshot", "id": "modelscope",
"provider": "moonshot", "provider": "modelscope",
"type": "openai_chat_completion", "type": "openai_chat_completion",
"provider_type": "chat_completion", "provider_type": "chat_completion",
"enable": True, "enable": True,
"key": [], "key": [],
"timeout": 120, "timeout": 120,
"api_base": "https://api.moonshot.cn/v1", "api_base": "https://api-inference.modelscope.cn/v1",
"model_config": {"model": "moonshot-v1-8k", "temperature": 0.4},
"custom_headers": {}, "custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"智谱 AI": {
"id": "zhipu_default",
"provider": "zhipu",
"type": "zhipu_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"timeout": 120,
"api_base": "https://open.bigmodel.cn/api/paas/v4/",
"model_config": {
"model": "glm-4-flash",
},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
}, },
"Dify": { "Dify": {
"id": "dify_app_default", "id": "dify_app_default",
@@ -1133,7 +1095,6 @@ CONFIG_METADATA_2 = {
"dify_query_input_key": "astrbot_text_query", "dify_query_input_key": "astrbot_text_query",
"variables": {}, "variables": {},
"timeout": 60, "timeout": 60,
"hint": "请确保你在 AstrBot 里设置的 APP 类型和 Dify 里面创建的应用的类型一致!",
}, },
"Coze": { "Coze": {
"id": "coze", "id": "coze",
@@ -1164,20 +1125,6 @@ CONFIG_METADATA_2 = {
"variables": {}, "variables": {},
"timeout": 60, "timeout": 60,
}, },
"ModelScope": {
"id": "modelscope",
"provider": "modelscope",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"timeout": 120,
"api_base": "https://api-inference.modelscope.cn/v1",
"model_config": {"model": "Qwen/Qwen3-32B", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"FastGPT": { "FastGPT": {
"id": "fastgpt", "id": "fastgpt",
"provider": "fastgpt", "provider": "fastgpt",
@@ -1201,7 +1148,6 @@ CONFIG_METADATA_2 = {
"model": "whisper-1", "model": "whisper-1",
}, },
"Whisper(Local)": { "Whisper(Local)": {
"hint": "启用前请 pip 安装 openai-whisper 库(N卡用户大约下载 2GB,主要是 torch 和 cudaCPU 用户大约下载 1 GB),并且安装 ffmpeg。否则将无法正常转文字。",
"provider": "openai", "provider": "openai",
"type": "openai_whisper_selfhost", "type": "openai_whisper_selfhost",
"provider_type": "speech_to_text", "provider_type": "speech_to_text",
@@ -1210,7 +1156,6 @@ CONFIG_METADATA_2 = {
"model": "tiny", "model": "tiny",
}, },
"SenseVoice(Local)": { "SenseVoice(Local)": {
"hint": "启用前请 pip 安装 funasr、funasr_onnx、torchaudio、torch、modelscope、jieba 库(默认使用CPU,大约下载 1 GB),并且安装 ffmpeg。否则将无法正常转文字。",
"type": "sensevoice_stt_selfhost", "type": "sensevoice_stt_selfhost",
"provider": "sensevoice", "provider": "sensevoice",
"provider_type": "speech_to_text", "provider_type": "speech_to_text",
@@ -1232,7 +1177,6 @@ CONFIG_METADATA_2 = {
"timeout": "20", "timeout": "20",
}, },
"Edge TTS": { "Edge TTS": {
"hint": "提示:使用这个服务前需要安装有 ffmpeg,并且可以直接在终端调用 ffmpeg 指令。",
"id": "edge_tts", "id": "edge_tts",
"provider": "microsoft", "provider": "microsoft",
"type": "edge_tts", "type": "edge_tts",
@@ -1448,6 +1392,10 @@ CONFIG_METADATA_2 = {
}, },
}, },
"items": { "items": {
"provider_source_id": {
"invisible": True,
"type": "string",
},
"xai_native_search": { "xai_native_search": {
"description": "启用原生搜索功能", "description": "启用原生搜索功能",
"type": "bool", "type": "bool",
@@ -1818,13 +1766,24 @@ CONFIG_METADATA_2 = {
}, },
}, },
"gm_thinking_config": { "gm_thinking_config": {
"description": "Gemini思考设置", "description": "Thinking Config",
"type": "object", "type": "object",
"items": { "items": {
"budget": { "budget": {
"description": "思考预算", "description": "Thinking Budget",
"type": "int", "type": "int",
"hint": "模型应该生成的思考Token的数量,设为0关闭思考。除gemini-2.5-flash外的模型会静默忽略此参数。", "hint": "Guides the model on the specific number of thinking tokens to use for reasoning. See: https://ai.google.dev/gemini-api/docs/thinking#set-budget",
},
"level": {
"description": "Thinking Level",
"type": "string",
"hint": "Recommended for Gemini 3 models and onwards, lets you control reasoning behavior.See: https://ai.google.dev/gemini-api/docs/thinking#thinking-levels",
"options": [
"MINIMAL",
"LOW",
"MEDIUM",
"HIGH",
],
}, },
}, },
}, },
@@ -2005,7 +1964,6 @@ CONFIG_METADATA_2 = {
"id": { "id": {
"description": "ID", "description": "ID",
"type": "string", "type": "string",
"hint": "模型提供商名字。",
}, },
"type": { "type": {
"description": "模型提供商种类", "description": "模型提供商种类",
@@ -2025,29 +1983,15 @@ CONFIG_METADATA_2 = {
"description": "API Key", "description": "API Key",
"type": "list", "type": "list",
"items": {"type": "string"}, "items": {"type": "string"},
"hint": "提供商 API Key。",
}, },
"api_base": { "api_base": {
"description": "API Base URL", "description": "API Base URL",
"type": "string", "type": "string",
"hint": "API Base URL 请在模型提供商处获得。如出现 404 报错,尝试在地址末尾加上 /v1",
}, },
"model_config": { "model": {
"description": "模型配置", "description": "模型 ID",
"type": "object", "type": "string",
"items": { "hint": "模型名称,如 gpt-4o-mini, deepseek-chat。",
"model": {
"description": "模型名称",
"type": "string",
"hint": "模型名称,如 gpt-4o-mini, deepseek-chat。",
},
"max_tokens": {
"description": "模型最大输出长度(tokens",
"type": "int",
},
"temperature": {"description": "温度", "type": "float"},
"top_p": {"description": "Top P值", "type": "float"},
},
}, },
"dify_api_key": { "dify_api_key": {
"description": "API Key", "description": "API Key",
@@ -2209,6 +2153,9 @@ CONFIG_METADATA_2 = {
"use_file_service": { "use_file_service": {
"type": "bool", "type": "bool",
}, },
"trigger_probability": {
"type": "float",
},
}, },
}, },
"provider_ltm_settings": { "provider_ltm_settings": {
@@ -2419,6 +2366,14 @@ CONFIG_METADATA_3 = {
"provider_tts_settings.enable": True, "provider_tts_settings.enable": True,
}, },
}, },
"provider_tts_settings.trigger_probability": {
"description": "TTS 触发概率",
"type": "float",
"slider": {"min": 0, "max": 1, "step": 0.05},
"condition": {
"provider_tts_settings.enable": True,
},
},
"provider_settings.image_caption_prompt": { "provider_settings.image_caption_prompt": {
"description": "图片转述提示词", "description": "图片转述提示词",
"type": "text", "type": "text",
@@ -2986,6 +2941,7 @@ CONFIG_METADATA_3 = {
"description": "回复概率", "description": "回复概率",
"type": "float", "type": "float",
"hint": "0.0-1.0 之间的数值", "hint": "0.0-1.0 之间的数值",
"slider": {"min": 0, "max": 1, "step": 0.05},
"condition": { "condition": {
"provider_ltm_settings.active_reply.enable": True, "provider_ltm_settings.active_reply.enable": True,
}, },
+1
View File
@@ -79,6 +79,7 @@ class ConfigMetadataI18n:
"_special", "_special",
"invisible", "invisible",
"options", "options",
"slider",
]: ]:
if attr in field_data: if attr in field_data:
field_result[attr] = field_data[attr] field_result[attr] = field_data[attr]
+3
View File
@@ -33,6 +33,7 @@ from astrbot.core.star.context import Context
from astrbot.core.star.star_handler import EventType, star_handlers_registry, star_map from astrbot.core.star.star_handler import EventType, star_handlers_registry, star_map
from astrbot.core.umop_config_router import UmopConfigRouter from astrbot.core.umop_config_router import UmopConfigRouter
from astrbot.core.updator import AstrBotUpdator from astrbot.core.updator import AstrBotUpdator
from astrbot.core.utils.llm_metadata import update_llm_metadata
from astrbot.core.utils.migra_helper import migra from astrbot.core.utils.migra_helper import migra
from . import astrbot_config, html_renderer from . import astrbot_config, html_renderer
@@ -185,6 +186,8 @@ class AstrBotCoreLifecycle:
# 初始化关闭控制面板的事件 # 初始化关闭控制面板的事件
self.dashboard_shutdown_event = asyncio.Event() self.dashboard_shutdown_event = asyncio.Event()
asyncio.create_task(update_llm_metadata())
def _load(self) -> None: def _load(self) -> None:
"""加载事件总线和任务并初始化.""" """加载事件总线和任务并初始化."""
# 创建一个异步任务来执行事件总线的 dispatch() 方法 # 创建一个异步任务来执行事件总线的 dispatch() 方法
+72
View File
@@ -9,6 +9,8 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
from astrbot.core.db.po import ( from astrbot.core.db.po import (
Attachment, Attachment,
CommandConfig,
CommandConflict,
ConversationV2, ConversationV2,
Persona, Persona,
PlatformMessageHistory, PlatformMessageHistory,
@@ -314,6 +316,76 @@ class BaseDatabase(abc.ABC):
"""Clear all preferences for a specific scope ID.""" """Clear all preferences for a specific scope ID."""
... ...
@abc.abstractmethod
async def get_command_configs(self) -> list[CommandConfig]:
"""Get all stored command configurations."""
...
@abc.abstractmethod
async def get_command_config(self, handler_full_name: str) -> CommandConfig | None:
"""Fetch a single command configuration by handler."""
...
@abc.abstractmethod
async def upsert_command_config(
self,
handler_full_name: str,
plugin_name: str,
module_path: str,
original_command: str,
*,
resolved_command: str | None = None,
enabled: bool | None = None,
keep_original_alias: bool | None = None,
conflict_key: str | None = None,
resolution_strategy: str | None = None,
note: str | None = None,
extra_data: dict | None = None,
auto_managed: bool | None = None,
) -> CommandConfig:
"""Create or update a command configuration."""
...
@abc.abstractmethod
async def delete_command_config(self, handler_full_name: str) -> None:
"""Delete a single command configuration."""
...
@abc.abstractmethod
async def delete_command_configs(self, handler_full_names: list[str]) -> None:
"""Bulk delete command configurations."""
...
@abc.abstractmethod
async def list_command_conflicts(
self,
status: str | None = None,
) -> list[CommandConflict]:
"""List recorded command conflict entries."""
...
@abc.abstractmethod
async def upsert_command_conflict(
self,
conflict_key: str,
handler_full_name: str,
plugin_name: str,
*,
status: str | None = None,
resolution: str | None = None,
resolved_command: str | None = None,
note: str | None = None,
extra_data: dict | None = None,
auto_generated: bool | None = None,
) -> CommandConflict:
"""Create or update a conflict record."""
...
@abc.abstractmethod
async def delete_command_conflicts(self, ids: list[int]) -> None:
"""Delete conflict records."""
...
# @abc.abstractmethod # @abc.abstractmethod
# async def insert_llm_message( # async def insert_llm_message(
# self, # self,
+59
View File
@@ -234,6 +234,65 @@ class Attachment(SQLModel, table=True):
) )
class CommandConfig(SQLModel, table=True):
"""Per-command configuration overrides for dashboard management."""
__tablename__ = "command_configs" # type: ignore
handler_full_name: str = Field(
primary_key=True,
max_length=512,
)
plugin_name: str = Field(nullable=False, max_length=255)
module_path: str = Field(nullable=False, max_length=255)
original_command: str = Field(nullable=False, max_length=255)
resolved_command: str | None = Field(default=None, max_length=255)
enabled: bool = Field(default=True, nullable=False)
keep_original_alias: bool = Field(default=False, nullable=False)
conflict_key: str | None = Field(default=None, max_length=255)
resolution_strategy: str | None = Field(default=None, max_length=64)
note: str | None = Field(default=None, sa_type=Text)
extra_data: dict | None = Field(default=None, sa_type=JSON)
auto_managed: bool = Field(default=False, nullable=False)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
class CommandConflict(SQLModel, table=True):
"""Conflict tracking for duplicated command names."""
__tablename__ = "command_conflicts" # type: ignore
id: int | None = Field(
default=None, primary_key=True, sa_column_kwargs={"autoincrement": True}
)
conflict_key: str = Field(nullable=False, max_length=255)
handler_full_name: str = Field(nullable=False, max_length=512)
plugin_name: str = Field(nullable=False, max_length=255)
status: str = Field(default="pending", max_length=32)
resolution: str | None = Field(default=None, max_length=64)
resolved_command: str | None = Field(default=None, max_length=255)
note: str | None = Field(default=None, sa_type=Text)
extra_data: dict | None = Field(default=None, sa_type=JSON)
auto_generated: bool = Field(default=False, nullable=False)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc),
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
)
__table_args__ = (
UniqueConstraint(
"conflict_key",
"handler_full_name",
name="uix_conflict_handler",
),
)
@dataclass @dataclass
class Conversation: class Conversation:
"""LLM 对话类 """LLM 对话类
+240
View File
@@ -1,6 +1,7 @@
import asyncio import asyncio
import threading import threading
import typing as T import typing as T
from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from sqlalchemy import CursorResult from sqlalchemy import CursorResult
@@ -10,6 +11,8 @@ from sqlmodel import col, delete, desc, func, or_, select, text, update
from astrbot.core.db import BaseDatabase from astrbot.core.db import BaseDatabase
from astrbot.core.db.po import ( from astrbot.core.db.po import (
Attachment, Attachment,
CommandConfig,
CommandConflict,
ConversationV2, ConversationV2,
Persona, Persona,
PlatformMessageHistory, PlatformMessageHistory,
@@ -26,6 +29,7 @@ from astrbot.core.db.po import (
) )
NOT_GIVEN = T.TypeVar("NOT_GIVEN") NOT_GIVEN = T.TypeVar("NOT_GIVEN")
TxResult = T.TypeVar("TxResult")
class SQLiteDatabase(BaseDatabase): class SQLiteDatabase(BaseDatabase):
@@ -670,6 +674,242 @@ class SQLiteDatabase(BaseDatabase):
) )
await session.commit() await session.commit()
# ====
# Command Configuration & Conflict Tracking
# ====
async def _run_in_tx(
self,
fn: Callable[[AsyncSession], Awaitable[TxResult]],
) -> TxResult:
async with self.get_db() as session:
session: AsyncSession
async with session.begin():
return await fn(session)
@staticmethod
def _apply_updates(model, **updates) -> None:
for field, value in updates.items():
if value is not None:
setattr(model, field, value)
@staticmethod
def _new_command_config(
handler_full_name: str,
plugin_name: str,
module_path: str,
original_command: str,
*,
resolved_command: str | None = None,
enabled: bool | None = None,
keep_original_alias: bool | None = None,
conflict_key: str | None = None,
resolution_strategy: str | None = None,
note: str | None = None,
extra_data: dict | None = None,
auto_managed: bool | None = None,
) -> CommandConfig:
return CommandConfig(
handler_full_name=handler_full_name,
plugin_name=plugin_name,
module_path=module_path,
original_command=original_command,
resolved_command=resolved_command,
enabled=True if enabled is None else enabled,
keep_original_alias=False
if keep_original_alias is None
else keep_original_alias,
conflict_key=conflict_key or original_command,
resolution_strategy=resolution_strategy,
note=note,
extra_data=extra_data,
auto_managed=bool(auto_managed),
)
@staticmethod
def _new_command_conflict(
conflict_key: str,
handler_full_name: str,
plugin_name: str,
*,
status: str | None = None,
resolution: str | None = None,
resolved_command: str | None = None,
note: str | None = None,
extra_data: dict | None = None,
auto_generated: bool | None = None,
) -> CommandConflict:
return CommandConflict(
conflict_key=conflict_key,
handler_full_name=handler_full_name,
plugin_name=plugin_name,
status=status or "pending",
resolution=resolution,
resolved_command=resolved_command,
note=note,
extra_data=extra_data,
auto_generated=bool(auto_generated),
)
async def get_command_configs(self) -> list[CommandConfig]:
async with self.get_db() as session:
session: AsyncSession
result = await session.execute(select(CommandConfig))
return list(result.scalars().all())
async def get_command_config(
self,
handler_full_name: str,
) -> CommandConfig | None:
async with self.get_db() as session:
session: AsyncSession
return await session.get(CommandConfig, handler_full_name)
async def upsert_command_config(
self,
handler_full_name: str,
plugin_name: str,
module_path: str,
original_command: str,
*,
resolved_command: str | None = None,
enabled: bool | None = None,
keep_original_alias: bool | None = None,
conflict_key: str | None = None,
resolution_strategy: str | None = None,
note: str | None = None,
extra_data: dict | None = None,
auto_managed: bool | None = None,
) -> CommandConfig:
async def _op(session: AsyncSession) -> CommandConfig:
config = await session.get(CommandConfig, handler_full_name)
if not config:
config = self._new_command_config(
handler_full_name,
plugin_name,
module_path,
original_command,
resolved_command=resolved_command,
enabled=enabled,
keep_original_alias=keep_original_alias,
conflict_key=conflict_key,
resolution_strategy=resolution_strategy,
note=note,
extra_data=extra_data,
auto_managed=auto_managed,
)
session.add(config)
else:
self._apply_updates(
config,
plugin_name=plugin_name,
module_path=module_path,
original_command=original_command,
resolved_command=resolved_command,
enabled=enabled,
keep_original_alias=keep_original_alias,
conflict_key=conflict_key,
resolution_strategy=resolution_strategy,
note=note,
extra_data=extra_data,
auto_managed=auto_managed,
)
await session.flush()
await session.refresh(config)
return config
return await self._run_in_tx(_op)
async def delete_command_config(self, handler_full_name: str) -> None:
await self.delete_command_configs([handler_full_name])
async def delete_command_configs(self, handler_full_names: list[str]) -> None:
if not handler_full_names:
return
async def _op(session: AsyncSession) -> None:
await session.execute(
delete(CommandConfig).where(
col(CommandConfig.handler_full_name).in_(handler_full_names),
),
)
await self._run_in_tx(_op)
async def list_command_conflicts(
self,
status: str | None = None,
) -> list[CommandConflict]:
async with self.get_db() as session:
session: AsyncSession
query = select(CommandConflict)
if status:
query = query.where(CommandConflict.status == status)
result = await session.execute(query)
return list(result.scalars().all())
async def upsert_command_conflict(
self,
conflict_key: str,
handler_full_name: str,
plugin_name: str,
*,
status: str | None = None,
resolution: str | None = None,
resolved_command: str | None = None,
note: str | None = None,
extra_data: dict | None = None,
auto_generated: bool | None = None,
) -> CommandConflict:
async def _op(session: AsyncSession) -> CommandConflict:
result = await session.execute(
select(CommandConflict).where(
CommandConflict.conflict_key == conflict_key,
CommandConflict.handler_full_name == handler_full_name,
),
)
record = result.scalar_one_or_none()
if not record:
record = self._new_command_conflict(
conflict_key,
handler_full_name,
plugin_name,
status=status,
resolution=resolution,
resolved_command=resolved_command,
note=note,
extra_data=extra_data,
auto_generated=auto_generated,
)
session.add(record)
else:
self._apply_updates(
record,
plugin_name=plugin_name,
status=status,
resolution=resolution,
resolved_command=resolved_command,
note=note,
extra_data=extra_data,
auto_generated=auto_generated,
)
await session.flush()
await session.refresh(record)
return record
return await self._run_in_tx(_op)
async def delete_command_conflicts(self, ids: list[int]) -> None:
if not ids:
return
async def _op(session: AsyncSession) -> None:
await session.execute(
delete(CommandConflict).where(col(CommandConflict.id).in_(ids)),
)
await self._run_in_tx(_op)
# ==== # ====
# Deprecated Methods # Deprecated Methods
# ==== # ====
+2 -1
View File
@@ -24,6 +24,7 @@ import asyncio
import logging import logging
import os import os
import sys import sys
import time
from asyncio import Queue from asyncio import Queue
from collections import deque from collections import deque
@@ -148,7 +149,7 @@ class LogQueueHandler(logging.Handler):
self.log_broker.publish( self.log_broker.publish(
{ {
"level": record.levelname, "level": record.levelname,
"time": record.asctime, "time": time.time(),
"data": log_entry, "data": log_entry,
}, },
) )
+4 -5
View File
@@ -629,12 +629,11 @@ class Nodes(BaseMessageComponent):
class Json(BaseMessageComponent): class Json(BaseMessageComponent):
type = ComponentType.Json type = ComponentType.Json
data: str | dict data: dict
resid: int | None = 0
def __init__(self, data, **_): def __init__(self, data: str | dict, **_):
if isinstance(data, dict): if isinstance(data, str):
data = json.dumps(data) data = json.loads(data)
super().__init__(data=data, **_) super().__init__(data=data, **_)
+5 -1
View File
@@ -119,7 +119,7 @@ class RespondStage(Stage):
if (result := event.get_result()) is None: if (result := event.get_result()) is None:
return False return False
if self.only_llm_result and result.is_llm_result(): if self.only_llm_result and not result.is_llm_result():
return False return False
if event.get_platform_name() in [ if event.get_platform_name() in [
@@ -158,7 +158,11 @@ class RespondStage(Stage):
result = event.get_result() result = event.get_result()
if result is None: if result is None:
return return
if event.get_extra("_streaming_finished", False):
# prevent some plugin make result content type to LLM_RESULT after streaming finished, lead to send again
return
if result.result_content_type == ResultContentType.STREAMING_FINISH: if result.result_content_type == ResultContentType.STREAMING_FINISH:
event.set_extra("_streaming_finished", True)
return return
logger.info( logger.info(
+21 -1
View File
@@ -1,3 +1,4 @@
import random
import re import re
import time import time
import traceback import traceback
@@ -42,6 +43,18 @@ class ResultDecorateStage(Stage):
"forward_threshold" "forward_threshold"
] ]
trigger_probability = ctx.astrbot_config["provider_tts_settings"].get(
"trigger_probability",
1,
)
try:
self.tts_trigger_probability = max(
0.0,
min(float(trigger_probability), 1.0),
)
except (TypeError, ValueError):
self.tts_trigger_probability = 1.0
# 分段回复 # 分段回复
self.words_count_threshold = int( self.words_count_threshold = int(
ctx.astrbot_config["platform_settings"]["segmented_reply"][ ctx.astrbot_config["platform_settings"]["segmented_reply"][
@@ -246,7 +259,14 @@ class ResultDecorateStage(Stage):
and result.is_llm_result() and result.is_llm_result()
and SessionServiceManager.should_process_tts_request(event) and SessionServiceManager.should_process_tts_request(event)
): ):
if not tts_provider: should_tts = self.tts_trigger_probability >= 1.0 or (
self.tts_trigger_probability > 0.0
and random.random() <= self.tts_trigger_probability
)
if not should_tts:
logger.debug("跳过 TTS:触发概率未命中。")
elif not tts_provider:
logger.warning( logger.warning(
f"会话 {event.unified_msg_origin} 未配置文本转语音模型。", f"会话 {event.unified_msg_origin} 未配置文本转语音模型。",
) )
@@ -81,7 +81,12 @@ class LarkPlatformAdapter(Platform):
) )
self.lark_api = ( self.lark_api = (
lark.Client.builder().app_id(self.appid).app_secret(self.appsecret).build() lark.Client.builder()
.app_id(self.appid)
.app_secret(self.appsecret)
.log_level(lark.LogLevel.ERROR)
.domain(self.domain)
.build()
) )
self.webhook_server = None self.webhook_server = None
@@ -200,6 +200,15 @@ class TelegramPlatformEvent(AstrMessageEvent):
if isinstance(chain, MessageChain): if isinstance(chain, MessageChain):
if chain.type == "break": if chain.type == "break":
# 分割符 # 分割符
if message_id:
try:
await self.client.edit_message_text(
text=delta,
chat_id=payload["chat_id"],
message_id=message_id,
)
except Exception as e:
logger.warning(f"编辑消息失败(streaming-break): {e!s}")
message_id = None # 重置消息 ID message_id = None # 重置消息 ID
delta = "" # 重置 delta delta = "" # 重置 delta
continue continue
@@ -1,11 +1,12 @@
import base64 import base64
import json
import os import os
import shutil import shutil
import uuid import uuid
from astrbot.api import logger from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import File, Image, Plain, Record from astrbot.api.message_components import File, Image, Json, Plain, Record
from astrbot.core.utils.astrbot_path import get_astrbot_data_path from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from .webchat_queue_mgr import webchat_queue_mgr from .webchat_queue_mgr import webchat_queue_mgr
@@ -41,12 +42,20 @@ class WebChatMessageEvent(AstrMessageEvent):
await web_chat_back_queue.put( await web_chat_back_queue.put(
{ {
"type": "plain", "type": "plain",
"cid": cid,
"data": data, "data": data,
"streaming": streaming, "streaming": streaming,
"chain_type": message.type, "chain_type": message.type,
}, },
) )
elif isinstance(comp, Json):
await web_chat_back_queue.put(
{
"type": "plain",
"data": json.dumps(comp.data, ensure_ascii=False),
"streaming": streaming,
"chain_type": message.type,
},
)
elif isinstance(comp, Image): elif isinstance(comp, Image):
# save image to local # save image to local
filename = f"{str(uuid.uuid4())}.jpg" filename = f"{str(uuid.uuid4())}.jpg"
@@ -58,7 +67,6 @@ class WebChatMessageEvent(AstrMessageEvent):
await web_chat_back_queue.put( await web_chat_back_queue.put(
{ {
"type": "image", "type": "image",
"cid": cid,
"data": data, "data": data,
"streaming": streaming, "streaming": streaming,
}, },
@@ -74,7 +82,6 @@ class WebChatMessageEvent(AstrMessageEvent):
await web_chat_back_queue.put( await web_chat_back_queue.put(
{ {
"type": "record", "type": "record",
"cid": cid,
"data": data, "data": data,
"streaming": streaming, "streaming": streaming,
}, },
@@ -91,7 +98,6 @@ class WebChatMessageEvent(AstrMessageEvent):
await web_chat_back_queue.put( await web_chat_back_queue.put(
{ {
"type": "file", "type": "file",
"cid": cid,
"data": data, "data": data,
"streaming": streaming, "streaming": streaming,
}, },
@@ -111,18 +117,17 @@ class WebChatMessageEvent(AstrMessageEvent):
cid = self.session_id.split("!")[-1] cid = self.session_id.split("!")[-1]
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid) web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
async for chain in generator: async for chain in generator:
if chain.type == "break" and final_data: # if chain.type == "break" and final_data:
# 分割符 # # 分割符
await web_chat_back_queue.put( # await web_chat_back_queue.put(
{ # {
"type": "break", # break means a segment end # "type": "break", # break means a segment end
"data": final_data, # "data": final_data,
"streaming": True, # "streaming": True,
"cid": cid, # },
}, # )
) # final_data = ""
final_data = "" # continue
continue
r = await WebChatMessageEvent._send( r = await WebChatMessageEvent._send(
chain, chain,
@@ -142,7 +147,6 @@ class WebChatMessageEvent(AstrMessageEvent):
"data": final_data, "data": final_data,
"reasoning": reasoning_content, "reasoning": reasoning_content,
"streaming": True, "streaming": True,
"cid": cid,
}, },
) )
await super().send_streaming(generator, use_fallback) await super().send_streaming(generator, use_fallback)
+41
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import base64 import base64
import enum import enum
import json import json
@@ -199,6 +201,38 @@ class ProviderRequest:
return "" return ""
@dataclass
class TokenUsage:
input_other: int = 0
"""The number of input tokens, excluding cached tokens."""
input_cached: int = 0
"""The number of input cached tokens."""
output: int = 0
"""The number of output tokens."""
@property
def total(self) -> int:
return self.input_other + self.input_cached + self.output
@property
def input(self) -> int:
return self.input_other + self.input_cached
def __add__(self, other: TokenUsage) -> TokenUsage:
return TokenUsage(
input_other=self.input_other + other.input_other,
input_cached=self.input_cached + other.input_cached,
output=self.output + other.output,
)
def __sub__(self, other: TokenUsage) -> TokenUsage:
return TokenUsage(
input_other=self.input_other - other.input_other,
input_cached=self.input_cached - other.input_cached,
output=self.output - other.output,
)
@dataclass @dataclass
class LLMResponse: class LLMResponse:
role: str role: str
@@ -227,6 +261,11 @@ class LLMResponse:
is_chunk: bool = False is_chunk: bool = False
"""Indicates if the response is a chunked response.""" """Indicates if the response is a chunked response."""
id: str | None = None
"""The ID of the response. For chunked responses, it's the ID of the chunk; for non-chunked responses, it's the ID of the response."""
usage: TokenUsage | None = None
"""The usage of the response. For chunked responses, it's the usage of the chunk; for non-chunked responses, it's the usage of the response."""
def __init__( def __init__(
self, self,
role: str, role: str,
@@ -241,6 +280,8 @@ class LLMResponse:
| AnthropicMessage | AnthropicMessage
| None = None, | None = None,
is_chunk: bool = False, is_chunk: bool = False,
id: str | None = None,
usage: TokenUsage | None = None,
): ):
"""初始化 LLMResponse """初始化 LLMResponse
+203 -93
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
import copy
import traceback import traceback
from typing import Protocol, runtime_checkable from typing import Protocol, runtime_checkable
@@ -32,10 +33,12 @@ class ProviderManager:
persona_mgr: PersonaManager, persona_mgr: PersonaManager,
): ):
self.reload_lock = asyncio.Lock() self.reload_lock = asyncio.Lock()
self.resource_lock = asyncio.Lock()
self.persona_mgr = persona_mgr self.persona_mgr = persona_mgr
self.acm = acm self.acm = acm
config = acm.confs["default"] config = acm.confs["default"]
self.providers_config: list = config["provider"] self.providers_config: list = config["provider"]
self.provider_sources_config: list = config.get("provider_sources", [])
self.provider_settings: dict = config["provider_settings"] self.provider_settings: dict = config["provider_settings"]
self.provider_stt_settings: dict = config.get("provider_stt_settings", {}) self.provider_stt_settings: dict = config.get("provider_stt_settings", {})
self.provider_tts_settings: dict = config.get("provider_tts_settings", {}) self.provider_tts_settings: dict = config.get("provider_tts_settings", {})
@@ -148,6 +151,7 @@ class ProviderManager:
""" """
provider = None provider = None
provider_id = None
if umo: if umo:
provider_id = sp.get( provider_id = sp.get(
f"provider_perf_{provider_type.value}", f"provider_perf_{provider_type.value}",
@@ -185,6 +189,12 @@ class ProviderManager:
) )
else: else:
raise ValueError(f"Unknown provider type: {provider_type}") raise ValueError(f"Unknown provider type: {provider_type}")
if not provider and provider_id:
logger.warning(
f"没有找到 ID 为 {provider_id} 的提供商,这可能是由于您修改了提供商(模型)ID 导致的。"
)
return provider return provider
async def initialize(self): async def initialize(self):
@@ -251,7 +261,136 @@ class ProviderManager:
# 初始化 MCP Client 连接 # 初始化 MCP Client 连接
asyncio.create_task(self.llm_tools.init_mcp_clients(), name="init_mcp_clients") asyncio.create_task(self.llm_tools.init_mcp_clients(), name="init_mcp_clients")
def dynamic_import_provider(self, type: str):
"""动态导入提供商适配器模块
Args:
type (str): 提供商请求类型
Raises:
ImportError: 如果提供商类型未知或无法导入对应模块则抛出异常
"""
match type:
case "openai_chat_completion":
from .sources.openai_source import (
ProviderOpenAIOfficial as ProviderOpenAIOfficial,
)
case "zhipu_chat_completion":
from .sources.zhipu_source import ProviderZhipu as ProviderZhipu
case "groq_chat_completion":
from .sources.groq_source import ProviderGroq as ProviderGroq
case "anthropic_chat_completion":
from .sources.anthropic_source import (
ProviderAnthropic as ProviderAnthropic,
)
case "googlegenai_chat_completion":
from .sources.gemini_source import (
ProviderGoogleGenAI as ProviderGoogleGenAI,
)
case "sensevoice_stt_selfhost":
from .sources.sensevoice_selfhosted_source import (
ProviderSenseVoiceSTTSelfHost as ProviderSenseVoiceSTTSelfHost,
)
case "openai_whisper_api":
from .sources.whisper_api_source import (
ProviderOpenAIWhisperAPI as ProviderOpenAIWhisperAPI,
)
case "openai_whisper_selfhost":
from .sources.whisper_selfhosted_source import (
ProviderOpenAIWhisperSelfHost as ProviderOpenAIWhisperSelfHost,
)
case "xinference_stt":
from .sources.xinference_stt_provider import (
ProviderXinferenceSTT as ProviderXinferenceSTT,
)
case "openai_tts_api":
from .sources.openai_tts_api_source import (
ProviderOpenAITTSAPI as ProviderOpenAITTSAPI,
)
case "edge_tts":
from .sources.edge_tts_source import (
ProviderEdgeTTS as ProviderEdgeTTS,
)
case "gsv_tts_selfhost":
from .sources.gsv_selfhosted_source import (
ProviderGSVTTS as ProviderGSVTTS,
)
case "gsvi_tts_api":
from .sources.gsvi_tts_source import (
ProviderGSVITTS as ProviderGSVITTS,
)
case "fishaudio_tts_api":
from .sources.fishaudio_tts_api_source import (
ProviderFishAudioTTSAPI as ProviderFishAudioTTSAPI,
)
case "dashscope_tts":
from .sources.dashscope_tts import (
ProviderDashscopeTTSAPI as ProviderDashscopeTTSAPI,
)
case "azure_tts":
from .sources.azure_tts_source import (
AzureTTSProvider as AzureTTSProvider,
)
case "minimax_tts_api":
from .sources.minimax_tts_api_source import (
ProviderMiniMaxTTSAPI as ProviderMiniMaxTTSAPI,
)
case "volcengine_tts":
from .sources.volcengine_tts import (
ProviderVolcengineTTS as ProviderVolcengineTTS,
)
case "gemini_tts":
from .sources.gemini_tts_source import (
ProviderGeminiTTSAPI as ProviderGeminiTTSAPI,
)
case "openai_embedding":
from .sources.openai_embedding_source import (
OpenAIEmbeddingProvider as OpenAIEmbeddingProvider,
)
case "gemini_embedding":
from .sources.gemini_embedding_source import (
GeminiEmbeddingProvider as GeminiEmbeddingProvider,
)
case "vllm_rerank":
from .sources.vllm_rerank_source import (
VLLMRerankProvider as VLLMRerankProvider,
)
case "xinference_rerank":
from .sources.xinference_rerank_source import (
XinferenceRerankProvider as XinferenceRerankProvider,
)
case "bailian_rerank":
from .sources.bailian_rerank_source import (
BailianRerankProvider as BailianRerankProvider,
)
def get_merged_provider_config(self, provider_config: dict) -> dict:
"""获取 provider 配置和 provider_source 配置合并后的结果
Returns:
dict: 合并后的 provider 配置key provider idvalue 为合并后的配置字典
"""
pc = copy.deepcopy(provider_config)
provider_source_id = pc.get("provider_source_id", "")
if provider_source_id:
provider_source = None
for ps in self.provider_sources_config:
if ps.get("id") == provider_source_id:
provider_source = ps
break
if provider_source:
# 合并配置,provider 的配置优先级更高
merged_config = {**provider_source, **pc}
# 保持 id 为 provider 的 id,而不是 source 的 id
merged_config["id"] = pc["id"]
pc = merged_config
return pc
async def load_provider(self, provider_config: dict): async def load_provider(self, provider_config: dict):
# 如果 provider_source_id 存在且不为空,则从 provider_sources 中找到对应的配置并合并
provider_config = self.get_merged_provider_config(provider_config)
if not provider_config["enable"]: if not provider_config["enable"]:
logger.info(f"Provider {provider_config['id']} is disabled, skipping") logger.info(f"Provider {provider_config['id']} is disabled, skipping")
return return
@@ -264,99 +403,7 @@ class ProviderManager:
# 动态导入 # 动态导入
try: try:
match provider_config["type"]: self.dynamic_import_provider(provider_config["type"])
case "openai_chat_completion":
from .sources.openai_source import (
ProviderOpenAIOfficial as ProviderOpenAIOfficial,
)
case "zhipu_chat_completion":
from .sources.zhipu_source import ProviderZhipu as ProviderZhipu
case "groq_chat_completion":
from .sources.groq_source import ProviderGroq as ProviderGroq
case "anthropic_chat_completion":
from .sources.anthropic_source import (
ProviderAnthropic as ProviderAnthropic,
)
case "googlegenai_chat_completion":
from .sources.gemini_source import (
ProviderGoogleGenAI as ProviderGoogleGenAI,
)
case "sensevoice_stt_selfhost":
from .sources.sensevoice_selfhosted_source import (
ProviderSenseVoiceSTTSelfHost as ProviderSenseVoiceSTTSelfHost,
)
case "openai_whisper_api":
from .sources.whisper_api_source import (
ProviderOpenAIWhisperAPI as ProviderOpenAIWhisperAPI,
)
case "openai_whisper_selfhost":
from .sources.whisper_selfhosted_source import (
ProviderOpenAIWhisperSelfHost as ProviderOpenAIWhisperSelfHost,
)
case "xinference_stt":
from .sources.xinference_stt_provider import (
ProviderXinferenceSTT as ProviderXinferenceSTT,
)
case "openai_tts_api":
from .sources.openai_tts_api_source import (
ProviderOpenAITTSAPI as ProviderOpenAITTSAPI,
)
case "edge_tts":
from .sources.edge_tts_source import (
ProviderEdgeTTS as ProviderEdgeTTS,
)
case "gsv_tts_selfhost":
from .sources.gsv_selfhosted_source import (
ProviderGSVTTS as ProviderGSVTTS,
)
case "gsvi_tts_api":
from .sources.gsvi_tts_source import (
ProviderGSVITTS as ProviderGSVITTS,
)
case "fishaudio_tts_api":
from .sources.fishaudio_tts_api_source import (
ProviderFishAudioTTSAPI as ProviderFishAudioTTSAPI,
)
case "dashscope_tts":
from .sources.dashscope_tts import (
ProviderDashscopeTTSAPI as ProviderDashscopeTTSAPI,
)
case "azure_tts":
from .sources.azure_tts_source import (
AzureTTSProvider as AzureTTSProvider,
)
case "minimax_tts_api":
from .sources.minimax_tts_api_source import (
ProviderMiniMaxTTSAPI as ProviderMiniMaxTTSAPI,
)
case "volcengine_tts":
from .sources.volcengine_tts import (
ProviderVolcengineTTS as ProviderVolcengineTTS,
)
case "gemini_tts":
from .sources.gemini_tts_source import (
ProviderGeminiTTSAPI as ProviderGeminiTTSAPI,
)
case "openai_embedding":
from .sources.openai_embedding_source import (
OpenAIEmbeddingProvider as OpenAIEmbeddingProvider,
)
case "gemini_embedding":
from .sources.gemini_embedding_source import (
GeminiEmbeddingProvider as GeminiEmbeddingProvider,
)
case "vllm_rerank":
from .sources.vllm_rerank_source import (
VLLMRerankProvider as VLLMRerankProvider,
)
case "xinference_rerank":
from .sources.xinference_rerank_source import (
XinferenceRerankProvider as XinferenceRerankProvider,
)
case "bailian_rerank":
from .sources.bailian_rerank_source import (
BailianRerankProvider as BailianRerankProvider,
)
except (ImportError, ModuleNotFoundError) as e: except (ImportError, ModuleNotFoundError) as e:
logger.critical( logger.critical(
f"加载 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}。可能是因为有未安装的依赖。", f"加载 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}。可能是因为有未安装的依赖。",
@@ -499,6 +546,7 @@ class ProviderManager:
# 和配置文件保持同步 # 和配置文件保持同步
self.providers_config = astrbot_config["provider"] self.providers_config = astrbot_config["provider"]
self.provider_sources_config = astrbot_config.get("provider_sources", [])
config_ids = [provider["id"] for provider in self.providers_config] config_ids = [provider["id"] for provider in self.providers_config]
logger.info(f"providers in user's config: {config_ids}") logger.info(f"providers in user's config: {config_ids}")
for key in list(self.inst_map.keys()): for key in list(self.inst_map.keys()):
@@ -570,6 +618,68 @@ class ProviderManager:
) )
del self.inst_map[provider_id] del self.inst_map[provider_id]
async def delete_provider(
self, provider_id: str | None = None, provider_source_id: str | None = None
):
"""Delete provider and/or provider source from config and terminate the instances. Config will be saved after deletion."""
async with self.resource_lock:
# delete from config
target_prov_ids = []
if provider_id:
target_prov_ids.append(provider_id)
else:
for prov in self.providers_config:
if prov.get("provider_source_id") == provider_source_id:
target_prov_ids.append(prov.get("id"))
config = self.acm.default_conf
for tpid in target_prov_ids:
await self.terminate_provider(tpid)
config["provider"] = [
prov for prov in config["provider"] if prov.get("id") != tpid
]
config.save_config()
logger.info(f"Provider {target_prov_ids} 已从配置中删除。")
async def update_provider(self, origin_provider_id: str, new_config: dict):
"""Update provider config and reload the instance. Config will be saved after update."""
async with self.resource_lock:
npid = new_config.get("id", None)
if not npid:
raise ValueError("New provider config must have an 'id' field")
config = self.acm.default_conf
for provider in config["provider"]:
if (
provider.get("id", None) == npid
and provider.get("id", None) != origin_provider_id
):
raise ValueError(f"Provider ID {npid} already exists")
# update config
for idx, provider in enumerate(config["provider"]):
if provider.get("id", None) == origin_provider_id:
config["provider"][idx] = new_config
break
else:
raise ValueError(f"Provider ID {origin_provider_id} not found")
config.save_config()
# reload instance
await self.reload(new_config)
async def create_provider(self, new_config: dict):
"""Add new provider config and load the instance. Config will be saved after addition."""
async with self.resource_lock:
npid = new_config.get("id", None)
if not npid:
raise ValueError("New provider config must have an 'id' field")
config = self.acm.default_conf
for provider in config["provider"]:
if provider.get("id", None) == npid:
raise ValueError(f"Provider ID {npid} already exists")
# add to config
config["provider"].append(new_config)
config.save_config()
# load instance
await self.load_provider(new_config)
async def terminate(self): async def terminate(self):
for provider_inst in self.provider_insts: for provider_inst in self.provider_insts:
if hasattr(provider_inst, "terminate"): if hasattr(provider_inst, "terminate"):
@@ -6,10 +6,12 @@ from mimetypes import guess_type
import anthropic import anthropic
from anthropic import AsyncAnthropic from anthropic import AsyncAnthropic
from anthropic.types import Message from anthropic.types import Message
from anthropic.types.message_delta_usage import MessageDeltaUsage
from anthropic.types.usage import Usage
from astrbot import logger from astrbot import logger
from astrbot.api.provider import Provider from astrbot.api.provider import Provider
from astrbot.core.provider.entities import LLMResponse from astrbot.core.provider.entities import LLMResponse, TokenUsage
from astrbot.core.provider.func_tool_manager import ToolSet from astrbot.core.provider.func_tool_manager import ToolSet
from astrbot.core.utils.io import download_image_by_url from astrbot.core.utils.io import download_image_by_url
@@ -45,7 +47,7 @@ class ProviderAnthropic(Provider):
base_url=self.base_url, base_url=self.base_url,
) )
self.set_model(provider_config["model_config"]["model"]) self.set_model(provider_config.get("model", "unknown"))
def _prepare_payload(self, messages: list[dict]): def _prepare_payload(self, messages: list[dict]):
"""准备 Anthropic API 的请求 payload """准备 Anthropic API 的请求 payload
@@ -107,12 +109,32 @@ class ProviderAnthropic(Provider):
return system_prompt, new_messages return system_prompt, new_messages
def _extract_usage(self, usage: Usage) -> TokenUsage:
# https://docs.claude.com/en/docs/build-with-claude/prompt-caching#tracking-cache-performance
return TokenUsage(
input_other=usage.input_tokens or 0,
input_cached=usage.cache_read_input_tokens or 0,
output=usage.output_tokens,
)
def _update_usage(self, token_usage: TokenUsage, usage: MessageDeltaUsage) -> None:
if usage.input_tokens is not None:
token_usage.input_other = usage.input_tokens
if usage.cache_read_input_tokens is not None:
token_usage.input_cached = usage.cache_read_input_tokens
if usage.output_tokens is not None:
token_usage.output = usage.output_tokens
async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse:
if tools: if tools:
if tool_list := tools.get_func_desc_anthropic_style(): if tool_list := tools.get_func_desc_anthropic_style():
payloads["tools"] = tool_list payloads["tools"] = tool_list
completion = await self.client.messages.create(**payloads, stream=False) extra_body = self.provider_config.get("custom_extra_body", {})
completion = await self.client.messages.create(
**payloads, stream=False, extra_body=extra_body
)
assert isinstance(completion, Message) assert isinstance(completion, Message)
logger.debug(f"completion: {completion}") logger.debug(f"completion: {completion}")
@@ -131,6 +153,10 @@ class ProviderAnthropic(Provider):
llm_response.tools_call_args.append(content_block.input) llm_response.tools_call_args.append(content_block.input)
llm_response.tools_call_name.append(content_block.name) llm_response.tools_call_name.append(content_block.name)
llm_response.tools_call_ids.append(content_block.id) llm_response.tools_call_ids.append(content_block.id)
llm_response.id = completion.id
llm_response.usage = self._extract_usage(completion.usage)
# TODO(Soulter): 处理 end_turn 情况 # TODO(Soulter): 处理 end_turn 情况
if not llm_response.completion_text and not llm_response.tools_call_args: if not llm_response.completion_text and not llm_response.tools_call_args:
raise Exception(f"Anthropic API 返回的 completion 无法解析:{completion}") raise Exception(f"Anthropic API 返回的 completion 无法解析:{completion}")
@@ -151,10 +177,19 @@ class ProviderAnthropic(Provider):
# 用于累积最终结果 # 用于累积最终结果
final_text = "" final_text = ""
final_tool_calls = [] final_tool_calls = []
id = None
usage = TokenUsage()
extra_body = self.provider_config.get("custom_extra_body", {})
async with self.client.messages.stream(**payloads) as stream: async with self.client.messages.stream(
**payloads, extra_body=extra_body
) as stream:
assert isinstance(stream, anthropic.AsyncMessageStream) assert isinstance(stream, anthropic.AsyncMessageStream)
async for event in stream: async for event in stream:
if event.type == "message_start":
# the usage contains input token usage
id = event.message.id
usage = self._extract_usage(event.message.usage)
if event.type == "content_block_start": if event.type == "content_block_start":
if event.content_block.type == "text": if event.content_block.type == "text":
# 文本块开始 # 文本块开始
@@ -162,6 +197,8 @@ class ProviderAnthropic(Provider):
role="assistant", role="assistant",
completion_text="", completion_text="",
is_chunk=True, is_chunk=True,
usage=usage,
id=id,
) )
elif event.content_block.type == "tool_use": elif event.content_block.type == "tool_use":
# 工具使用块开始,初始化缓冲区 # 工具使用块开始,初始化缓冲区
@@ -179,6 +216,8 @@ class ProviderAnthropic(Provider):
role="assistant", role="assistant",
completion_text=event.delta.text, completion_text=event.delta.text,
is_chunk=True, is_chunk=True,
usage=usage,
id=id,
) )
elif event.delta.type == "input_json_delta": elif event.delta.type == "input_json_delta":
# 工具调用参数增量 # 工具调用参数增量
@@ -215,6 +254,8 @@ class ProviderAnthropic(Provider):
tools_call_name=[tool_info["name"]], tools_call_name=[tool_info["name"]],
tools_call_ids=[tool_info["id"]], tools_call_ids=[tool_info["id"]],
is_chunk=True, is_chunk=True,
usage=usage,
id=id,
) )
except json.JSONDecodeError: except json.JSONDecodeError:
# JSON 解析失败,跳过这个工具调用 # JSON 解析失败,跳过这个工具调用
@@ -223,11 +264,17 @@ class ProviderAnthropic(Provider):
# 清理缓冲区 # 清理缓冲区
del tool_use_buffer[event.index] del tool_use_buffer[event.index]
elif event.type == "message_delta":
if event.usage:
self._update_usage(usage, event.usage)
# 返回最终的完整结果 # 返回最终的完整结果
final_response = LLMResponse( final_response = LLMResponse(
role="assistant", role="assistant",
completion_text=final_text, completion_text=final_text,
is_chunk=False, is_chunk=False,
usage=usage,
id=id,
) )
if final_tool_calls: if final_tool_calls:
@@ -277,10 +324,9 @@ class ProviderAnthropic(Provider):
system_prompt, new_messages = self._prepare_payload(context_query) system_prompt, new_messages = self._prepare_payload(context_query)
model_config = self.provider_config.get("model_config", {}) model = model or self.get_model()
model_config["model"] = model or self.get_model()
payloads = {"messages": new_messages, **model_config} payloads = {"messages": new_messages, "model": model}
# Anthropic has a different way of handling system prompts # Anthropic has a different way of handling system prompts
if system_prompt: if system_prompt:
@@ -290,7 +336,6 @@ class ProviderAnthropic(Provider):
try: try:
llm_response = await self._query(payloads, func_tool) llm_response = await self._query(payloads, func_tool)
except Exception as e: except Exception as e:
# logger.error(f"发生了错误。Provider 配置如下: {model_config}")
raise e raise e
return llm_response return llm_response
@@ -332,10 +377,9 @@ class ProviderAnthropic(Provider):
system_prompt, new_messages = self._prepare_payload(context_query) system_prompt, new_messages = self._prepare_payload(context_query)
model_config = self.provider_config.get("model_config", {}) model = model or self.get_model()
model_config["model"] = model or self.get_model()
payloads = {"messages": new_messages, **model_config} payloads = {"messages": new_messages, "model": model}
# Anthropic has a different way of handling system prompts # Anthropic has a different way of handling system prompts
if system_prompt: if system_prompt:
+68 -33
View File
@@ -14,7 +14,7 @@ import astrbot.core.message.components as Comp
from astrbot import logger from astrbot import logger
from astrbot.api.provider import Provider from astrbot.api.provider import Provider
from astrbot.core.message.message_event_result import MessageChain from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import LLMResponse from astrbot.core.provider.entities import LLMResponse, TokenUsage
from astrbot.core.provider.func_tool_manager import ToolSet from astrbot.core.provider.func_tool_manager import ToolSet
from astrbot.core.utils.io import download_image_by_url from astrbot.core.utils.io import download_image_by_url
@@ -68,7 +68,7 @@ class ProviderGoogleGenAI(Provider):
self.api_base = self.api_base[:-1] self.api_base = self.api_base[:-1]
self._init_client() self._init_client()
self.set_model(provider_config["model_config"]["model"]) self.set_model(provider_config.get("model", "unknown"))
self._init_safety_settings() self._init_safety_settings()
def _init_client(self) -> None: def _init_client(self) -> None:
@@ -138,7 +138,7 @@ class ProviderGoogleGenAI(Provider):
modalities = ["TEXT"] modalities = ["TEXT"]
tool_list: list[types.Tool] | None = [] tool_list: list[types.Tool] | None = []
model_name = self.get_model() model_name = payloads.get("model", self.get_model())
native_coderunner = self.provider_config.get("gm_native_coderunner", False) native_coderunner = self.provider_config.get("gm_native_coderunner", False)
native_search = self.provider_config.get("gm_native_search", False) native_search = self.provider_config.get("gm_native_search", False)
url_context = self.provider_config.get("gm_url_context", False) url_context = self.provider_config.get("gm_url_context", False)
@@ -197,6 +197,37 @@ class ProviderGoogleGenAI(Provider):
types.Tool(function_declarations=func_desc["function_declarations"]), types.Tool(function_declarations=func_desc["function_declarations"]),
] ]
# oper thinking config
thinking_config = None
if model_name.startswith("gemini-2.5"):
# The thinkingBudget parameter, introduced with the Gemini 2.5 series
thinking_budget = self.provider_config.get("gm_thinking_config", {}).get(
"budget", 0
)
if thinking_budget is not None:
thinking_config = types.ThinkingConfig(
thinking_budget=thinking_budget,
)
elif model_name.startswith("gemini-3"):
# The thinkingLevel parameter, recommended for Gemini 3 models and onwards
# Gemini 2.5 series models don't support thinkingLevel; use thinkingBudget instead.
thinking_level = self.provider_config.get("gm_thinking_config", {}).get(
"level", "HIGH"
)
if thinking_level and isinstance(thinking_level, str):
thinking_level = thinking_level.upper()
if thinking_level not in ["MINIMAL", "LOW", "MEDIUM", "HIGH"]:
logger.warning(
f"Invalid thinking level: {thinking_level}, using HIGH"
)
thinking_level = "HIGH"
level = types.ThinkingLevel(thinking_level)
thinking_config = types.ThinkingConfig()
if not hasattr(types.ThinkingConfig, "thinking_level"):
setattr(types.ThinkingConfig, "thinking_level", level)
else:
thinking_config.thinking_level = level
return types.GenerateContentConfig( return types.GenerateContentConfig(
system_instruction=system_instruction, system_instruction=system_instruction,
temperature=temperature, temperature=temperature,
@@ -216,22 +247,7 @@ class ProviderGoogleGenAI(Provider):
response_modalities=modalities, response_modalities=modalities,
tools=cast(types.ToolListUnion | None, tool_list), tools=cast(types.ToolListUnion | None, tool_list),
safety_settings=self.safety_settings if self.safety_settings else None, safety_settings=self.safety_settings if self.safety_settings else None,
thinking_config=( thinking_config=thinking_config,
types.ThinkingConfig(
thinking_budget=min(
int(
self.provider_config.get("gm_thinking_config", {}).get(
"budget",
0,
),
),
24576,
),
)
if "gemini-2.5-flash" in self.get_model()
and hasattr(types.ThinkingConfig, "thinking_budget")
else None
),
automatic_function_calling=types.AutomaticFunctionCallingConfig( automatic_function_calling=types.AutomaticFunctionCallingConfig(
disable=True, disable=True,
), ),
@@ -347,6 +363,16 @@ class ProviderGoogleGenAI(Provider):
] ]
return "".join(thought_buf).strip() return "".join(thought_buf).strip()
def _extract_usage(
self, usage_metadata: types.GenerateContentResponseUsageMetadata
) -> TokenUsage:
"""Extract usage from candidate"""
return TokenUsage(
input_other=usage_metadata.prompt_token_count or 0,
input_cached=usage_metadata.cached_content_token_count or 0,
output=usage_metadata.candidates_token_count or 0,
)
def _process_content_parts( def _process_content_parts(
self, self,
candidate: types.Candidate, candidate: types.Candidate,
@@ -431,6 +457,8 @@ class ProviderGoogleGenAI(Provider):
None, None,
) )
model = payloads.get("model", self.get_model())
modalities = ["TEXT"] modalities = ["TEXT"]
if self.provider_config.get("gm_resp_image_modal", False): if self.provider_config.get("gm_resp_image_modal", False):
modalities.append("IMAGE") modalities.append("IMAGE")
@@ -449,7 +477,7 @@ class ProviderGoogleGenAI(Provider):
temperature, temperature,
) )
result = await self.client.models.generate_content( result = await self.client.models.generate_content(
model=self.get_model(), model=model,
contents=cast(types.ContentListUnion, conversation), contents=cast(types.ContentListUnion, conversation),
config=config, config=config,
) )
@@ -475,11 +503,11 @@ class ProviderGoogleGenAI(Provider):
e.message = "" e.message = ""
if "Developer instruction is not enabled" in e.message: if "Developer instruction is not enabled" in e.message:
logger.warning( logger.warning(
f"{self.get_model()} 不支持 system prompt,已自动去除(影响人格设置)", f"{model} 不支持 system prompt,已自动去除(影响人格设置)",
) )
system_instruction = None system_instruction = None
elif "Function calling is not enabled" in e.message: elif "Function calling is not enabled" in e.message:
logger.warning(f"{self.get_model()} 不支持函数调用,已自动去除") logger.warning(f"{model} 不支持函数调用,已自动去除")
tools = None tools = None
elif ( elif (
"Multi-modal output is not supported" in e.message "Multi-modal output is not supported" in e.message
@@ -488,7 +516,7 @@ class ProviderGoogleGenAI(Provider):
or "only supports text output" in e.message or "only supports text output" in e.message
): ):
logger.warning( logger.warning(
f"{self.get_model()} 不支持多模态输出,降级为文本模态", f"{model} 不支持多模态输出,降级为文本模态",
) )
modalities = ["TEXT"] modalities = ["TEXT"]
else: else:
@@ -501,6 +529,9 @@ class ProviderGoogleGenAI(Provider):
result.candidates[0], result.candidates[0],
llm_response, llm_response,
) )
llm_response.id = result.response_id
if result.usage_metadata:
llm_response.usage = self._extract_usage(result.usage_metadata)
return llm_response return llm_response
async def _query_stream( async def _query_stream(
@@ -513,7 +544,7 @@ class ProviderGoogleGenAI(Provider):
(msg["content"] for msg in payloads["messages"] if msg["role"] == "system"), (msg["content"] for msg in payloads["messages"] if msg["role"] == "system"),
None, None,
) )
model = payloads.get("model", self.get_model())
conversation = self._prepare_conversation(payloads) conversation = self._prepare_conversation(payloads)
result = None result = None
@@ -525,7 +556,7 @@ class ProviderGoogleGenAI(Provider):
system_instruction, system_instruction,
) )
result = await self.client.models.generate_content_stream( result = await self.client.models.generate_content_stream(
model=self.get_model(), model=model,
contents=cast(types.ContentListUnion, conversation), contents=cast(types.ContentListUnion, conversation),
config=config, config=config,
) )
@@ -535,11 +566,11 @@ class ProviderGoogleGenAI(Provider):
e.message = "" e.message = ""
if "Developer instruction is not enabled" in e.message: if "Developer instruction is not enabled" in e.message:
logger.warning( logger.warning(
f"{self.get_model()} 不支持 system prompt,已自动去除(影响人格设置)", f"{model} 不支持 system prompt,已自动去除(影响人格设置)",
) )
system_instruction = None system_instruction = None
elif "Function calling is not enabled" in e.message: elif "Function calling is not enabled" in e.message:
logger.warning(f"{self.get_model()} 不支持函数调用,已自动去除") logger.warning(f"{model} 不支持函数调用,已自动去除")
tools = None tools = None
else: else:
raise raise
@@ -569,6 +600,9 @@ class ProviderGoogleGenAI(Provider):
chunk.candidates[0], chunk.candidates[0],
llm_response, llm_response,
) )
llm_response.id = chunk.response_id
if chunk.usage_metadata:
llm_response.usage = self._extract_usage(chunk.usage_metadata)
yield llm_response yield llm_response
return return
@@ -596,6 +630,9 @@ class ProviderGoogleGenAI(Provider):
chunk.candidates[0], chunk.candidates[0],
final_response, final_response,
) )
final_response.id = chunk.response_id
if chunk.usage_metadata:
final_response.usage = self._extract_usage(chunk.usage_metadata)
break break
# Yield final complete response with accumulated text # Yield final complete response with accumulated text
@@ -652,10 +689,9 @@ class ProviderGoogleGenAI(Provider):
for tcr in tool_calls_result: for tcr in tool_calls_result:
context_query.extend(tcr.to_openai_messages()) context_query.extend(tcr.to_openai_messages())
model_config = self.provider_config.get("model_config", {}) model = model or self.get_model()
model_config["model"] = model or self.get_model()
payloads = {"messages": context_query, **model_config} payloads = {"messages": context_query, "model": model}
retry = 10 retry = 10
keys = self.api_keys.copy() keys = self.api_keys.copy()
@@ -705,10 +741,9 @@ class ProviderGoogleGenAI(Provider):
for tcr in tool_calls_result: for tcr in tool_calls_result:
context_query.extend(tcr.to_openai_messages()) context_query.extend(tcr.to_openai_messages())
model_config = self.provider_config.get("model_config", {}) model = model or self.get_model()
model_config["model"] = model or self.get_model()
payloads = {"messages": context_query, **model_config} payloads = {"messages": context_query, "model": model}
retry = 10 retry = 10
keys = self.api_keys.copy() keys = self.api_keys.copy()
+21 -6
View File
@@ -12,6 +12,7 @@ from openai._exceptions import NotFoundError
from openai.lib.streaming.chat._completions import ChatCompletionStreamState from openai.lib.streaming.chat._completions import ChatCompletionStreamState
from openai.types.chat.chat_completion import ChatCompletion from openai.types.chat.chat_completion import ChatCompletion
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
from openai.types.completion_usage import CompletionUsage
import astrbot.core.message.components as Comp import astrbot.core.message.components as Comp
from astrbot import logger from astrbot import logger
@@ -19,7 +20,7 @@ from astrbot.api.provider import Provider
from astrbot.core.agent.message import Message from astrbot.core.agent.message import Message
from astrbot.core.agent.tool import ToolSet from astrbot.core.agent.tool import ToolSet
from astrbot.core.message.message_event_result import MessageChain from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import LLMResponse, ToolCallsResult from astrbot.core.provider.entities import LLMResponse, TokenUsage, ToolCallsResult
from astrbot.core.utils.io import download_image_by_url from astrbot.core.utils.io import download_image_by_url
from ..register import register_provider_adapter from ..register import register_provider_adapter
@@ -68,8 +69,7 @@ class ProviderOpenAIOfficial(Provider):
self.client.chat.completions.create, self.client.chat.completions.create,
).parameters.keys() ).parameters.keys()
model_config = provider_config.get("model_config", {}) model = provider_config.get("model", "unknown")
model = model_config.get("model", "unknown")
self.set_model(model) self.set_model(model)
self.reasoning_key = "reasoning_content" self.reasoning_key = "reasoning_content"
@@ -208,6 +208,7 @@ class ProviderOpenAIOfficial(Provider):
# handle the content delta # handle the content delta
reasoning = self._extract_reasoning_content(chunk) reasoning = self._extract_reasoning_content(chunk)
_y = False _y = False
llm_response.id = chunk.id
if reasoning: if reasoning:
llm_response.reasoning_content = reasoning llm_response.reasoning_content = reasoning
_y = True _y = True
@@ -217,6 +218,8 @@ class ProviderOpenAIOfficial(Provider):
chain=[Comp.Plain(completion_text)], chain=[Comp.Plain(completion_text)],
) )
_y = True _y = True
if chunk.usage:
llm_response.usage = self._extract_usage(chunk.usage)
if _y: if _y:
yield llm_response yield llm_response
@@ -245,6 +248,15 @@ class ProviderOpenAIOfficial(Provider):
reasoning_text = str(reasoning_attr) reasoning_text = str(reasoning_attr)
return reasoning_text return reasoning_text
def _extract_usage(self, usage: CompletionUsage) -> TokenUsage:
ptd = usage.prompt_tokens_details
cached = ptd.cached_tokens if ptd and ptd.cached_tokens else 0
return TokenUsage(
input_other=usage.prompt_tokens - cached,
input_cached=ptd.cached_tokens if ptd and ptd.cached_tokens else 0,
output=usage.completion_tokens,
)
async def _parse_openai_completion( async def _parse_openai_completion(
self, completion: ChatCompletion, tools: ToolSet | None self, completion: ChatCompletion, tools: ToolSet | None
) -> LLMResponse: ) -> LLMResponse:
@@ -321,6 +333,10 @@ class ProviderOpenAIOfficial(Provider):
raise Exception(f"API 返回的 completion 无法解析:{completion}") raise Exception(f"API 返回的 completion 无法解析:{completion}")
llm_response.raw_completion = completion llm_response.raw_completion = completion
llm_response.id = completion.id
if completion.usage:
llm_response.usage = self._extract_usage(completion.usage)
return llm_response return llm_response
@@ -358,10 +374,9 @@ class ProviderOpenAIOfficial(Provider):
for tcr in tool_calls_result: for tcr in tool_calls_result:
context_query.extend(tcr.to_openai_messages()) context_query.extend(tcr.to_openai_messages())
model_config = self.provider_config.get("model_config", {}) model = model or self.get_model()
model_config["model"] = model or self.get_model()
payloads = {"messages": context_query, **model_config} payloads = {"messages": context_query, "model": model}
# xAI origin search tool inject # xAI origin search tool inject
self._maybe_inject_xai_search(payloads, **kwargs) self._maybe_inject_xai_search(payloads, **kwargs)
+5 -1
View File
@@ -2,15 +2,19 @@ from astrbot.core import html_renderer
from astrbot.core.provider import Provider from astrbot.core.provider import Provider
from astrbot.core.star.star_tools import StarTools from astrbot.core.star.star_tools import StarTools
from astrbot.core.utils.command_parser import CommandParserMixin from astrbot.core.utils.command_parser import CommandParserMixin
from astrbot.core.utils.plugin_kv_store import PluginKVStoreMixin
from .context import Context from .context import Context
from .star import StarMetadata, star_map, star_registry from .star import StarMetadata, star_map, star_registry
from .star_manager import PluginManager from .star_manager import PluginManager
class Star(CommandParserMixin): class Star(CommandParserMixin, PluginKVStoreMixin):
"""所有插件(Star)的父类,所有插件都应该继承于这个类""" """所有插件(Star)的父类,所有插件都应该继承于这个类"""
author: str
name: str
def __init__(self, context: Context, config: dict | None = None): def __init__(self, context: Context, config: dict | None = None):
StarTools.initialize(context) StarTools.initialize(context)
self.context = context self.context = context
+449
View File
@@ -0,0 +1,449 @@
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Any
from astrbot.core import db_helper
from astrbot.core.db.po import CommandConfig
from astrbot.core.star.filter.command import CommandFilter
from astrbot.core.star.filter.command_group import CommandGroupFilter
from astrbot.core.star.filter.permission import PermissionType, PermissionTypeFilter
from astrbot.core.star.star import star_map
from astrbot.core.star.star_handler import StarHandlerMetadata, star_handlers_registry
@dataclass
class CommandDescriptor:
handler: StarHandlerMetadata = field(repr=False)
filter_ref: CommandFilter | CommandGroupFilter | None = field(
default=None,
repr=False,
)
handler_full_name: str = ""
handler_name: str = ""
plugin_name: str = ""
plugin_display_name: str | None = None
module_path: str = ""
description: str = ""
command_type: str = "command" # "command" | "group" | "sub_command"
raw_command_name: str | None = None
current_fragment: str | None = None
parent_signature: str = ""
parent_group_handler: str = ""
original_command: str | None = None
effective_command: str | None = None
aliases: list[str] = field(default_factory=list)
permission: str = "everyone"
enabled: bool = True
is_group: bool = False
is_sub_command: bool = False
reserved: bool = False
config: CommandConfig | None = None
has_conflict: bool = False
sub_commands: list[CommandDescriptor] = field(default_factory=list)
async def sync_command_configs() -> None:
"""同步指令配置,清理过期配置。"""
descriptors = _collect_descriptors(include_sub_commands=False)
config_records = await db_helper.get_command_configs()
config_map = _bind_configs_to_descriptors(descriptors, config_records)
live_handlers = {desc.handler_full_name for desc in descriptors}
stale_configs = [key for key in config_map if key not in live_handlers]
if stale_configs:
await db_helper.delete_command_configs(stale_configs)
async def toggle_command(handler_full_name: str, enabled: bool) -> CommandDescriptor:
descriptor = _build_descriptor_by_full_name(handler_full_name)
if not descriptor:
raise ValueError("指定的处理函数不存在或不是指令。")
existing_cfg = await db_helper.get_command_config(handler_full_name)
config = await db_helper.upsert_command_config(
handler_full_name=handler_full_name,
plugin_name=descriptor.plugin_name or "",
module_path=descriptor.module_path,
original_command=descriptor.original_command or descriptor.handler_name,
resolved_command=(
existing_cfg.resolved_command
if existing_cfg
else descriptor.current_fragment
),
enabled=enabled,
keep_original_alias=False,
conflict_key=existing_cfg.conflict_key
if existing_cfg and existing_cfg.conflict_key
else descriptor.original_command,
resolution_strategy=existing_cfg.resolution_strategy if existing_cfg else None,
note=existing_cfg.note if existing_cfg else None,
extra_data=existing_cfg.extra_data if existing_cfg else None,
auto_managed=False,
)
_bind_descriptor_with_config(descriptor, config)
await sync_command_configs()
return descriptor
async def rename_command(
handler_full_name: str,
new_fragment: str,
) -> CommandDescriptor:
descriptor = _build_descriptor_by_full_name(handler_full_name)
if not descriptor:
raise ValueError("指定的处理函数不存在或不是指令。")
new_fragment = new_fragment.strip()
if not new_fragment:
raise ValueError("指令名不能为空。")
candidate_full = _compose_command(descriptor.parent_signature, new_fragment)
if _is_command_in_use(handler_full_name, candidate_full):
raise ValueError("新的指令名已被其他指令占用,请换一个名称。")
config = await db_helper.upsert_command_config(
handler_full_name=handler_full_name,
plugin_name=descriptor.plugin_name or "",
module_path=descriptor.module_path,
original_command=descriptor.original_command or descriptor.handler_name,
resolved_command=new_fragment,
enabled=True if descriptor.enabled else False,
keep_original_alias=False,
conflict_key=descriptor.original_command,
resolution_strategy="manual_rename",
note=None,
extra_data=None,
auto_managed=False,
)
_bind_descriptor_with_config(descriptor, config)
await sync_command_configs()
return descriptor
async def list_commands() -> list[dict[str, Any]]:
descriptors = _collect_descriptors(include_sub_commands=True)
config_records = await db_helper.get_command_configs()
_bind_configs_to_descriptors(descriptors, config_records)
conflict_groups = _group_conflicts(descriptors)
conflict_handler_names: set[str] = {
d.handler_full_name for group in conflict_groups.values() for d in group
}
# 分类,设置冲突标志,将子指令挂载到父指令组
group_map: dict[str, CommandDescriptor] = {}
sub_commands: list[CommandDescriptor] = []
root_commands: list[CommandDescriptor] = []
for desc in descriptors:
desc.has_conflict = desc.handler_full_name in conflict_handler_names
if desc.is_group:
group_map[desc.handler_full_name] = desc
elif desc.is_sub_command:
sub_commands.append(desc)
else:
root_commands.append(desc)
for sub in sub_commands:
if sub.parent_group_handler and sub.parent_group_handler in group_map:
group_map[sub.parent_group_handler].sub_commands.append(sub)
else:
root_commands.append(sub)
# 指令组 + 普通指令,按 effective_command 字母排序
all_commands = list(group_map.values()) + root_commands
all_commands.sort(key=lambda d: (d.effective_command or "").lower())
result = [_descriptor_to_dict(desc) for desc in all_commands]
return result
async def list_command_conflicts() -> list[dict[str, Any]]:
"""列出所有冲突的指令组。"""
descriptors = _collect_descriptors(include_sub_commands=False)
config_records = await db_helper.get_command_configs()
_bind_configs_to_descriptors(descriptors, config_records)
conflict_groups = _group_conflicts(descriptors)
details = [
{
"conflict_key": key,
"handlers": [
{
"handler_full_name": item.handler_full_name,
"plugin": item.plugin_name,
"current_name": item.effective_command,
}
for item in group
],
}
for key, group in conflict_groups.items()
]
return details
# Internal helpers ----------------------------------------------------------
def _collect_descriptors(include_sub_commands: bool) -> list[CommandDescriptor]:
"""收集指令,按需包含子指令。"""
descriptors: list[CommandDescriptor] = []
for handler in star_handlers_registry:
desc = _build_descriptor(handler)
if not desc:
continue
if not include_sub_commands and desc.is_sub_command:
continue
descriptors.append(desc)
return descriptors
def _build_descriptor(handler: StarHandlerMetadata) -> CommandDescriptor | None:
filter_ref = _locate_primary_filter(handler)
if filter_ref is None:
return None
plugin_meta = star_map.get(handler.handler_module_path)
plugin_name = (
plugin_meta.name if plugin_meta else None
) or handler.handler_module_path
plugin_display = plugin_meta.display_name if plugin_meta else None
is_sub_command = bool(handler.extras_configs.get("sub_command"))
parent_group_handler = ""
if isinstance(filter_ref, CommandFilter):
raw_fragment = getattr(
filter_ref, "_original_command_name", filter_ref.command_name
)
current_fragment = filter_ref.command_name
parent_signature = (filter_ref.parent_command_names or [""])[0].strip()
# 如果是子指令,尝试找到父指令组的 handler_full_name
if is_sub_command and parent_signature:
parent_group_handler = _find_parent_group_handler(
handler.handler_module_path, parent_signature
)
else:
raw_fragment = getattr(
filter_ref, "_original_group_name", filter_ref.group_name
)
current_fragment = filter_ref.group_name
parent_signature = _resolve_group_parent_signature(filter_ref)
original_command = _compose_command(parent_signature, raw_fragment)
effective_command = _compose_command(parent_signature, current_fragment)
# 确定 command_type
if isinstance(filter_ref, CommandGroupFilter):
command_type = "group"
elif is_sub_command:
command_type = "sub_command"
else:
command_type = "command"
descriptor = CommandDescriptor(
handler=handler,
filter_ref=filter_ref,
handler_full_name=handler.handler_full_name,
handler_name=handler.handler_name,
plugin_name=plugin_name,
plugin_display_name=plugin_display,
module_path=handler.handler_module_path,
description=handler.desc or "",
command_type=command_type,
raw_command_name=raw_fragment,
current_fragment=current_fragment,
parent_signature=parent_signature,
parent_group_handler=parent_group_handler,
original_command=original_command,
effective_command=effective_command,
aliases=sorted(getattr(filter_ref, "alias", set())),
permission=_determine_permission(handler),
enabled=handler.enabled,
is_group=isinstance(filter_ref, CommandGroupFilter),
is_sub_command=is_sub_command,
reserved=plugin_meta.reserved if plugin_meta else False,
)
return descriptor
def _build_descriptor_by_full_name(full_name: str) -> CommandDescriptor | None:
handler = star_handlers_registry.get_handler_by_full_name(full_name)
if not handler:
return None
return _build_descriptor(handler)
def _locate_primary_filter(
handler: StarHandlerMetadata,
) -> CommandFilter | CommandGroupFilter | None:
for filter_ref in handler.event_filters:
if isinstance(filter_ref, (CommandFilter, CommandGroupFilter)):
return filter_ref
return None
def _determine_permission(handler: StarHandlerMetadata) -> str:
for filter_ref in handler.event_filters:
if isinstance(filter_ref, PermissionTypeFilter):
return (
"admin"
if filter_ref.permission_type == PermissionType.ADMIN
else "member"
)
return "everyone"
def _resolve_group_parent_signature(group_filter: CommandGroupFilter) -> str:
signatures: list[str] = []
parent = group_filter.parent_group
while parent:
signatures.append(getattr(parent, "_original_group_name", parent.group_name))
parent = parent.parent_group
return " ".join(reversed(signatures)).strip()
def _find_parent_group_handler(module_path: str, parent_signature: str) -> str:
"""根据模块路径和父级签名,找到对应的指令组 handler_full_name。"""
parent_sig_normalized = parent_signature.strip()
for handler in star_handlers_registry:
if handler.handler_module_path != module_path:
continue
filter_ref = _locate_primary_filter(handler)
if not isinstance(filter_ref, CommandGroupFilter):
continue
# 检查该指令组的完整指令名是否匹配 parent_signature
group_names = filter_ref.get_complete_command_names()
if parent_sig_normalized in group_names:
return handler.handler_full_name
return ""
def _compose_command(parent_signature: str, fragment: str | None) -> str:
fragment = (fragment or "").strip()
parent_signature = parent_signature.strip()
if not parent_signature:
return fragment
if not fragment:
return parent_signature
return f"{parent_signature} {fragment}"
def _bind_descriptor_with_config(
descriptor: CommandDescriptor,
config: CommandConfig,
) -> None:
_apply_config_to_descriptor(descriptor, config)
_apply_config_to_runtime(descriptor, config)
def _apply_config_to_descriptor(
descriptor: CommandDescriptor,
config: CommandConfig,
) -> None:
descriptor.config = config
descriptor.enabled = config.enabled
if config.original_command:
descriptor.original_command = config.original_command
new_fragment = config.resolved_command or descriptor.current_fragment
descriptor.current_fragment = new_fragment
descriptor.effective_command = _compose_command(
descriptor.parent_signature,
new_fragment,
)
def _apply_config_to_runtime(
descriptor: CommandDescriptor,
config: CommandConfig,
) -> None:
descriptor.handler.enabled = config.enabled
if descriptor.filter_ref and descriptor.current_fragment:
_set_filter_fragment(descriptor.filter_ref, descriptor.current_fragment)
def _bind_configs_to_descriptors(
descriptors: list[CommandDescriptor],
config_records: list[CommandConfig],
) -> dict[str, CommandConfig]:
config_map = {cfg.handler_full_name: cfg for cfg in config_records}
for desc in descriptors:
if cfg := config_map.get(desc.handler_full_name):
_bind_descriptor_with_config(desc, cfg)
return config_map
def _group_conflicts(
descriptors: list[CommandDescriptor],
) -> dict[str, list[CommandDescriptor]]:
conflicts: dict[str, list[CommandDescriptor]] = defaultdict(list)
for desc in descriptors:
if desc.effective_command and desc.enabled:
conflicts[desc.effective_command].append(desc)
return {k: v for k, v in conflicts.items() if len(v) > 1}
def _set_filter_fragment(
filter_ref: CommandFilter | CommandGroupFilter,
fragment: str,
) -> None:
attr = (
"group_name" if isinstance(filter_ref, CommandGroupFilter) else "command_name"
)
current_value = getattr(filter_ref, attr)
if fragment == current_value:
return
setattr(filter_ref, attr, fragment)
if hasattr(filter_ref, "_cmpl_cmd_names"):
filter_ref._cmpl_cmd_names = None
def _is_command_in_use(
target_handler_full_name: str,
candidate_full_command: str,
) -> bool:
candidate = candidate_full_command.strip()
for handler in star_handlers_registry:
if handler.handler_full_name == target_handler_full_name:
continue
filter_ref = _locate_primary_filter(handler)
if not filter_ref:
continue
names = {name.strip() for name in filter_ref.get_complete_command_names()}
if candidate in names:
return True
return False
def _descriptor_to_dict(desc: CommandDescriptor) -> dict[str, Any]:
result = {
"handler_full_name": desc.handler_full_name,
"handler_name": desc.handler_name,
"plugin": desc.plugin_name,
"plugin_display_name": desc.plugin_display_name,
"module_path": desc.module_path,
"description": desc.description,
"type": desc.command_type,
"parent_signature": desc.parent_signature,
"parent_group_handler": desc.parent_group_handler,
"original_command": desc.original_command,
"current_fragment": desc.current_fragment,
"effective_command": desc.effective_command,
"aliases": desc.aliases,
"permission": desc.permission,
"enabled": desc.enabled,
"is_group": desc.is_group,
"has_conflict": desc.has_conflict,
"reserved": desc.reserved,
}
# 如果是指令组,包含子指令列表
if desc.is_group and desc.sub_commands:
result["sub_commands"] = [_descriptor_to_dict(sub) for sub in desc.sub_commands]
else:
result["sub_commands"] = []
return result
+4
View File
@@ -267,6 +267,10 @@ class Context:
): ):
"""通过 ID 获取对应的 LLM Provider。""" """通过 ID 获取对应的 LLM Provider。"""
prov = self.provider_manager.inst_map.get(provider_id) prov = self.provider_manager.inst_map.get(provider_id)
if provider_id and not prov:
logger.warning(
f"没有找到 ID 为 {provider_id} 的提供商,这可能是由于您修改了提供商(模型)ID 导致的。"
)
return prov return prov
def get_all_providers(self) -> list[Provider]: def get_all_providers(self) -> list[Provider]:
+1
View File
@@ -40,6 +40,7 @@ class CommandFilter(HandlerFilter):
): ):
self.command_name = command_name self.command_name = command_name
self.alias = alias if alias else set() self.alias = alias if alias else set()
self._original_command_name = command_name
self.parent_command_names = ( self.parent_command_names = (
parent_command_names if parent_command_names is not None else [""] parent_command_names if parent_command_names is not None else [""]
) )
@@ -18,6 +18,7 @@ class CommandGroupFilter(HandlerFilter):
): ):
self.group_name = group_name self.group_name = group_name
self.alias = alias if alias else set() self.alias = alias if alias else set()
self._original_group_name = group_name
self.sub_command_filters: list[CommandFilter | CommandGroupFilter] = [] self.sub_command_filters: list[CommandFilter | CommandGroupFilter] = []
self.custom_filter_list: list[CustomFilter] = [] self.custom_filter_list: list[CustomFilter] = []
self.parent_group = parent_group self.parent_group = parent_group
+4
View File
@@ -118,6 +118,8 @@ class StarHandlerRegistry(Generic[T]):
# 过滤事件类型 # 过滤事件类型
if handler.event_type != event_type: if handler.event_type != event_type:
continue continue
if not handler.enabled:
continue
# 过滤启用状态 # 过滤启用状态
if only_activated: if only_activated:
plugin = star_map.get(handler.handler_module_path) plugin = star_map.get(handler.handler_module_path)
@@ -220,6 +222,8 @@ class StarHandlerMetadata(Generic[H]):
extras_configs: dict = field(default_factory=dict) extras_configs: dict = field(default_factory=dict)
"""插件注册的一些其他的信息, 如 priority 等""" """插件注册的一些其他的信息, 如 priority 等"""
enabled: bool = True
def __lt__(self, other: StarHandlerMetadata): def __lt__(self, other: StarHandlerMetadata):
"""定义小于运算符以支持优先队列""" """定义小于运算符以支持优先队列"""
return self.extras_configs.get("priority", 0) < other.extras_configs.get( return self.extras_configs.get("priority", 0) < other.extras_configs.get(
+14
View File
@@ -23,6 +23,7 @@ from astrbot.core.utils.astrbot_path import (
from astrbot.core.utils.io import remove_dir from astrbot.core.utils.io import remove_dir
from . import StarMetadata from . import StarMetadata
from .command_management import sync_command_configs
from .context import Context from .context import Context
from .filter.permission import PermissionType, PermissionTypeFilter from .filter.permission import PermissionType, PermissionTypeFilter
from .star import star_map, star_registry from .star import star_map, star_registry
@@ -467,6 +468,18 @@ class PluginManager:
metadata.star_cls = metadata.star_cls_type( metadata.star_cls = metadata.star_cls_type(
context=self.context, context=self.context,
) )
p_name = (metadata.name or "unknown").lower().replace("/", "_")
p_author = (
(metadata.author or "unknown").lower().replace("/", "_")
)
setattr(metadata.star_cls, "name", p_name)
setattr(metadata.star_cls, "author", p_author)
setattr(
metadata.star_cls,
"plugin_id",
f"{p_author}/{p_name}",
)
else: else:
logger.info(f"插件 {metadata.name} 已被禁用。") logger.info(f"插件 {metadata.name} 已被禁用。")
@@ -618,6 +631,7 @@ class PluginManager:
# 清除 pip.main 导致的多余的 logging handlers # 清除 pip.main 导致的多余的 logging handlers
for handler in logging.root.handlers[:]: for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler) logging.root.removeHandler(handler)
await sync_command_configs()
if not fail_rec: if not fail_rec:
return True, None return True, None
+63
View File
@@ -0,0 +1,63 @@
from typing import Literal, TypedDict
import aiohttp
from astrbot.core import logger
class LLMModalities(TypedDict):
input: list[Literal["text", "image", "audio", "video"]]
output: list[Literal["text", "image", "audio", "video"]]
class LLMLimit(TypedDict):
context: int
output: int
class LLMMetadata(TypedDict):
id: str
reasoning: bool
tool_call: bool
knowledge: str
release_date: str
modalities: LLMModalities
open_weights: bool
limit: LLMLimit
LLM_METADATAS: dict[str, LLMMetadata] = {}
async def update_llm_metadata():
url = "https://models.dev/api.json"
try:
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
data = await response.json()
global LLM_METADATAS
models = {}
for info in data.values():
for model in info.get("models", {}).values():
model_id = model.get("id")
if not model_id:
continue
models[model_id] = LLMMetadata(
id=model_id,
reasoning=model.get("reasoning", False),
tool_call=model.get("tool_call", False),
knowledge=model.get("knowledge", "none"),
release_date=model.get("release_date", ""),
modalities=model.get(
"modalities", {"input": [], "output": []}
),
open_weights=model.get("open_weights", False),
limit=model.get("limit", {"context": 0, "output": 0}),
)
# Replace the global cache in-place so references remain valid
LLM_METADATAS.clear()
LLM_METADATAS.update(models)
logger.info(f"Successfully fetched metadata for {len(models)} LLMs.")
except Exception as e:
logger.error(f"Failed to fetch LLM metadata: {e}")
return
+93
View File
@@ -32,6 +32,92 @@ def _migra_agent_runner_configs(conf: AstrBotConfig, ids_map: dict) -> None:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
def _migra_provider_to_source_structure(conf: AstrBotConfig) -> None:
"""
Migrate old provider structure to new provider-source separation.
Provider only keeps: id, provider_source_id, model, modalities, custom_extra_body
All other fields move to provider_sources.
"""
providers = conf.get("provider", [])
provider_sources = conf.get("provider_sources", [])
# Track if any migration happened
migrated = False
# Provider-only fields that should stay in provider
provider_only_fields = {
"id",
"provider_source_id",
"model",
"modalities",
"custom_extra_body",
"enable",
}
# Fields that should not go to source
source_exclude_fields = provider_only_fields | {"model_config"}
for provider in providers:
# Skip if already has provider_source_id
if provider.get("provider_source_id"):
continue
# Skip non-chat-completion types (they don't need source separation)
provider_type = provider.get("provider_type", "")
if provider_type != "chat_completion":
# For old types without provider_type, check type field
old_type = provider.get("type", "")
if "chat_completion" not in old_type:
continue
migrated = True
logger.info(f"Migrating provider {provider.get('id')} to new structure")
# Extract source fields from provider
source_fields = {}
for key, value in list(provider.items()):
if key not in source_exclude_fields:
source_fields[key] = value
# Create new provider_source
source_id = provider.get("id", "") + "_source"
new_source = {"id": source_id, **source_fields}
# Update provider to only keep necessary fields
provider["provider_source_id"] = source_id
# Extract model from model_config if exists
if "model_config" in provider and isinstance(provider["model_config"], dict):
model_config = provider["model_config"]
provider["model"] = model_config.get("model", "")
# Put other model_config fields into custom_extra_body
extra_body_fields = {k: v for k, v in model_config.items() if k != "model"}
if extra_body_fields:
if "custom_extra_body" not in provider:
provider["custom_extra_body"] = {}
provider["custom_extra_body"].update(extra_body_fields)
# Initialize new fields if not present
if "modalities" not in provider:
provider["modalities"] = []
if "custom_extra_body" not in provider:
provider["custom_extra_body"] = {}
# Remove fields that should be in source
keys_to_remove = [k for k in provider.keys() if k not in provider_only_fields]
for key in keys_to_remove:
del provider[key]
# Add source to provider_sources
provider_sources.append(new_source)
if migrated:
conf["provider_sources"] = provider_sources
conf.save_config()
logger.info("Provider-source structure migration completed")
async def migra( async def migra(
db, astrbot_config_mgr, umop_config_router, acm: AstrBotConfigManager db, astrbot_config_mgr, umop_config_router, acm: AstrBotConfigManager
) -> None: ) -> None:
@@ -71,3 +157,10 @@ async def migra(
for conf in acm.confs.values(): for conf in acm.confs.values():
_migra_agent_runner_configs(conf, ids_map) _migra_agent_runner_configs(conf, ids_map)
# Migrate providers to new structure: extract source fields to provider_sources
try:
_migra_provider_to_source_structure(astrbot_config)
except Exception as e:
logger.error(f"Migration for provider-source structure failed: {e!s}")
logger.error(traceback.format_exc())
+28
View File
@@ -0,0 +1,28 @@
from typing import TypeVar
from astrbot.core import sp
SUPPORTED_VALUE_TYPES = int | float | str | bytes | bool | dict | list | None
_VT = TypeVar("_VT")
class PluginKVStoreMixin:
"""为插件提供键值存储功能的 Mixin 类"""
plugin_id: str
async def put_kv_data(
self,
key: str,
value: SUPPORTED_VALUE_TYPES,
) -> None:
"""为指定插件存储一个键值对"""
await sp.put_async("plugin", self.plugin_id, key, value)
async def get_kv_data(self, key: str, default: _VT) -> _VT | None:
"""获取指定插件存储的键值对"""
return await sp.get_async("plugin", self.plugin_id, key, default)
async def delete_kv_data(self, key: str) -> None:
"""删除指定插件存储的键值对"""
await sp.remove_async("plugin", self.plugin_id, key)
+2
View File
@@ -1,5 +1,6 @@
from .auth import AuthRoute from .auth import AuthRoute
from .chat import ChatRoute from .chat import ChatRoute
from .command import CommandRoute
from .config import ConfigRoute from .config import ConfigRoute
from .conversation import ConversationRoute from .conversation import ConversationRoute
from .file import FileRoute from .file import FileRoute
@@ -17,6 +18,7 @@ from .update import UpdateRoute
__all__ = [ __all__ = [
"AuthRoute", "AuthRoute",
"ChatRoute", "ChatRoute",
"CommandRoute",
"ConfigRoute", "ConfigRoute",
"ConversationRoute", "ConversationRoute",
"FileRoute", "FileRoute",
+56 -13
View File
@@ -227,16 +227,19 @@ class ChatRoute(Route):
text: str, text: str,
media_parts: list, media_parts: list,
reasoning: str, reasoning: str,
agent_stats: dict,
): ):
"""保存 bot 消息到历史记录,返回保存的记录""" """保存 bot 消息到历史记录,返回保存的记录"""
bot_message_parts = [] bot_message_parts = []
bot_message_parts.extend(media_parts)
if text: if text:
bot_message_parts.append({"type": "plain", "text": text}) bot_message_parts.append({"type": "plain", "text": text})
bot_message_parts.extend(media_parts)
new_his = {"type": "bot", "message": bot_message_parts} new_his = {"type": "bot", "message": bot_message_parts}
if reasoning: if reasoning:
new_his["reasoning"] = reasoning new_his["reasoning"] = reasoning
if agent_stats:
new_his["agent_stats"] = agent_stats
record = await self.platform_history_mgr.insert( record = await self.platform_history_mgr.insert(
platform_id="webchat", platform_id="webchat",
@@ -294,7 +297,8 @@ class ChatRoute(Route):
accumulated_parts = [] accumulated_parts = []
accumulated_text = "" accumulated_text = ""
accumulated_reasoning = "" accumulated_reasoning = ""
tool_calls = {}
agent_stats = {}
try: try:
async with track_conversation(self.running_convs, webchat_conv_id): async with track_conversation(self.running_convs, webchat_conv_id):
while True: while True:
@@ -314,6 +318,16 @@ class ChatRoute(Route):
result_text = result["data"] result_text = result["data"]
msg_type = result.get("type") msg_type = result.get("type")
streaming = result.get("streaming", False) streaming = result.get("streaming", False)
chain_type = result.get("chain_type")
if chain_type == "agent_stats":
stats_info = {
"type": "agent_stats",
"data": json.loads(result_text),
}
yield f"data: {json.dumps(stats_info, ensure_ascii=False)}\n\n"
agent_stats = stats_info["data"]
continue
# 发送 SSE 数据 # 发送 SSE 数据
try: try:
@@ -335,11 +349,35 @@ class ChatRoute(Route):
# 累积消息部分 # 累积消息部分
if msg_type == "plain": if msg_type == "plain":
chain_type = result.get("chain_type", "normal") chain_type = result.get("chain_type")
if chain_type == "reasoning": if chain_type == "tool_call":
tool_call = json.loads(result_text)
tool_calls[tool_call.get("id")] = tool_call
if accumulated_text:
# 如果累积了文本,则先保存文本
accumulated_parts.append(
{"type": "plain", "text": accumulated_text}
)
accumulated_text = ""
elif chain_type == "tool_call_result":
tcr = json.loads(result_text)
tc_id = tcr.get("id")
if tc_id in tool_calls:
tool_calls[tc_id]["result"] = tcr.get("result")
tool_calls[tc_id]["finished_ts"] = tcr.get("ts")
accumulated_parts.append(
{
"type": "tool_call",
"tool_calls": [tool_calls[tc_id]],
}
)
tool_calls.pop(tc_id, None)
elif chain_type == "reasoning":
accumulated_reasoning += result_text accumulated_reasoning += result_text
else: elif streaming:
accumulated_text += result_text accumulated_text += result_text
else:
accumulated_text = result_text
elif msg_type == "image": elif msg_type == "image":
filename = result_text.replace("[IMAGE]", "") filename = result_text.replace("[IMAGE]", "")
part = await self._create_attachment_from_file( part = await self._create_attachment_from_file(
@@ -367,15 +405,20 @@ class ChatRoute(Route):
if msg_type == "end": if msg_type == "end":
break break
elif ( elif (
(streaming and msg_type == "complete") (streaming and msg_type == "complete") or not streaming
or not streaming # or msg_type == "break"
or msg_type == "break"
): ):
if (
chain_type == "tool_call"
or chain_type == "tool_call_result"
):
continue
saved_record = await self._save_bot_message( saved_record = await self._save_bot_message(
webchat_conv_id, webchat_conv_id,
accumulated_text, accumulated_text,
accumulated_parts, accumulated_parts,
accumulated_reasoning, accumulated_reasoning,
agent_stats,
) )
# 发送保存的消息信息给前端 # 发送保存的消息信息给前端
if saved_record and not client_disconnected: if saved_record and not client_disconnected:
@@ -390,11 +433,11 @@ class ChatRoute(Route):
yield f"data: {json.dumps(saved_info, ensure_ascii=False)}\n\n" yield f"data: {json.dumps(saved_info, ensure_ascii=False)}\n\n"
except Exception: except Exception:
pass pass
# 重置累积变量 (对于 break 后的下一段消息) accumulated_parts = []
if msg_type == "break": accumulated_text = ""
accumulated_parts = [] accumulated_reasoning = ""
accumulated_text = "" tool_calls = {}
accumulated_reasoning = "" agent_stats = {}
except BaseException as e: except BaseException as e:
logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True) logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True)
+82
View File
@@ -0,0 +1,82 @@
from quart import request
from astrbot.core.star.command_management import (
list_command_conflicts,
list_commands,
)
from astrbot.core.star.command_management import (
rename_command as rename_command_service,
)
from astrbot.core.star.command_management import (
toggle_command as toggle_command_service,
)
from .route import Response, Route, RouteContext
class CommandRoute(Route):
def __init__(self, context: RouteContext) -> None:
super().__init__(context)
self.routes = {
"/commands": ("GET", self.get_commands),
"/commands/conflicts": ("GET", self.get_conflicts),
"/commands/toggle": ("POST", self.toggle_command),
"/commands/rename": ("POST", self.rename_command),
}
self.register_routes()
async def get_commands(self):
commands = await list_commands()
summary = {
"total": len(commands),
"disabled": len([cmd for cmd in commands if not cmd["enabled"]]),
"conflicts": len([cmd for cmd in commands if cmd.get("has_conflict")]),
}
return Response().ok({"items": commands, "summary": summary}).__dict__
async def get_conflicts(self):
conflicts = await list_command_conflicts()
return Response().ok(conflicts).__dict__
async def toggle_command(self):
data = await request.get_json()
handler_full_name = data.get("handler_full_name")
enabled = data.get("enabled")
if handler_full_name is None or enabled is None:
return Response().error("handler_full_name 与 enabled 均为必填。").__dict__
if isinstance(enabled, str):
enabled = enabled.lower() in ("1", "true", "yes", "on")
try:
await toggle_command_service(handler_full_name, bool(enabled))
except ValueError as exc:
return Response().error(str(exc)).__dict__
payload = await _get_command_payload(handler_full_name)
return Response().ok(payload).__dict__
async def rename_command(self):
data = await request.get_json()
handler_full_name = data.get("handler_full_name")
new_name = data.get("new_name")
if not handler_full_name or not new_name:
return Response().error("handler_full_name 与 new_name 均为必填。").__dict__
try:
await rename_command_service(handler_full_name, new_name)
except ValueError as exc:
return Response().error(str(exc)).__dict__
payload = await _get_command_payload(handler_full_name)
return Response().ok(payload).__dict__
async def _get_command_payload(handler_full_name: str):
commands = await list_commands()
for cmd in commands:
if cmd["handler_full_name"] == handler_full_name:
return cmd
return {}
+291 -33
View File
@@ -6,7 +6,7 @@ from typing import Any
from quart import request from quart import request
from astrbot.core import file_token_service, logger from astrbot.core import astrbot_config, file_token_service, logger
from astrbot.core.config.astrbot_config import AstrBotConfig from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.config.default import ( from astrbot.core.config.default import (
CONFIG_METADATA_2, CONFIG_METADATA_2,
@@ -21,6 +21,7 @@ from astrbot.core.platform.register import platform_cls_map, platform_registry
from astrbot.core.provider import Provider from astrbot.core.provider import Provider
from astrbot.core.provider.register import provider_registry from astrbot.core.provider.register import provider_registry
from astrbot.core.star.star import star_registry from astrbot.core.star.star import star_registry
from astrbot.core.utils.llm_metadata import LLM_METADATAS
from astrbot.core.utils.webhook_utils import ensure_platform_webhook_config from astrbot.core.utils.webhook_utils import ensure_platform_webhook_config
from .route import Response, Route, RouteContext from .route import Response, Route, RouteContext
@@ -179,13 +180,149 @@ class ConfigRoute(Route):
"/config/provider/new": ("POST", self.post_new_provider), "/config/provider/new": ("POST", self.post_new_provider),
"/config/provider/update": ("POST", self.post_update_provider), "/config/provider/update": ("POST", self.post_update_provider),
"/config/provider/delete": ("POST", self.post_delete_provider), "/config/provider/delete": ("POST", self.post_delete_provider),
"/config/provider/template": ("GET", self.get_provider_template),
"/config/provider/check_one": ("GET", self.check_one_provider_status), "/config/provider/check_one": ("GET", self.check_one_provider_status),
"/config/provider/list": ("GET", self.get_provider_config_list), "/config/provider/list": ("GET", self.get_provider_config_list),
"/config/provider/model_list": ("GET", self.get_provider_model_list), "/config/provider/model_list": ("GET", self.get_provider_model_list),
"/config/provider/get_embedding_dim": ("POST", self.get_embedding_dim), "/config/provider/get_embedding_dim": ("POST", self.get_embedding_dim),
"/config/provider_sources/<provider_source_id>/models": (
"GET",
self.get_provider_source_models,
),
"/config/provider_sources/<provider_source_id>/update": (
"POST",
self.update_provider_source,
),
"/config/provider_sources/<provider_source_id>/delete": (
"POST",
self.delete_provider_source,
),
} }
self.register_routes() self.register_routes()
async def delete_provider_source(self, provider_source_id: str):
"""删除 provider_source,并更新关联的 providers"""
provider_sources = self.config.get("provider_sources", [])
target_idx = next(
(
i
for i, ps in enumerate(provider_sources)
if ps.get("id") == provider_source_id
),
-1,
)
if target_idx == -1:
return Response().error("未找到对应的 provider source").__dict__
# 删除 provider_source
del provider_sources[target_idx]
# 写回配置
self.config["provider_sources"] = provider_sources
# 删除引用了该 provider_source 的 providers
await self.core_lifecycle.provider_manager.delete_provider(
provider_source_id=provider_source_id
)
try:
save_config(self.config, self.config, is_core=True)
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
return Response().ok(message="删除 provider source 成功").__dict__
async def update_provider_source(self, provider_source_id: str):
"""更新或新增 provider_source,并重载关联的 providers"""
post_data = await request.json
if not post_data:
return Response().error("缺少配置数据").__dict__
new_source_config = post_data.get("config") or post_data
original_id = provider_source_id
if not isinstance(new_source_config, dict):
return Response().error("缺少或错误的配置数据").__dict__
# 确保配置中有 id 字段
if not new_source_config.get("id"):
new_source_config["id"] = original_id
provider_sources = self.config.get("provider_sources", [])
for ps in provider_sources:
if ps.get("id") == new_source_config["id"] and ps.get("id") != original_id:
return (
Response()
.error(
f"Provider source ID '{new_source_config['id']}' exists already, please try another ID.",
)
.__dict__
)
# 查找旧的 provider_source,若不存在则追加为新配置
target_idx = next(
(i for i, ps in enumerate(provider_sources) if ps.get("id") == original_id),
-1,
)
old_id = original_id
if target_idx == -1:
provider_sources.append(new_source_config)
else:
old_id = provider_sources[target_idx].get("id")
provider_sources[target_idx] = new_source_config
# 更新引用了该 provider_source 的 providers
affected_providers = []
for provider in self.config.get("provider", []):
if provider.get("provider_source_id") == old_id:
provider["provider_source_id"] = new_source_config["id"]
affected_providers.append(provider)
# 写回配置
self.config["provider_sources"] = provider_sources
try:
save_config(self.config, self.config, is_core=True)
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
# 重载受影响的 providers,使新的 source 配置生效
reload_errors = []
prov_mgr = self.core_lifecycle.provider_manager
for provider in affected_providers:
try:
await prov_mgr.reload(provider)
except Exception as e:
logger.error(traceback.format_exc())
reload_errors.append(f"{provider.get('id')}: {e}")
if reload_errors:
return (
Response()
.error("更新成功,但部分提供商重载失败: " + ", ".join(reload_errors))
.__dict__
)
return Response().ok(message="更新 provider source 成功").__dict__
async def get_provider_template(self):
config_schema = {
"provider": CONFIG_METADATA_2["provider_group"]["metadata"]["provider"]
}
data = {
"config_schema": config_schema,
"providers": astrbot_config["provider"],
"provider_sources": astrbot_config["provider_sources"],
}
return Response().ok(data=data).__dict__
async def get_uc_table(self): async def get_uc_table(self):
"""获取 UMOP 配置路由表""" """获取 UMOP 配置路由表"""
return Response().ok({"routing": self.ucr.umop_to_conf_id}).__dict__ return Response().ok({"routing": self.ucr.umop_to_conf_id}).__dict__
@@ -433,9 +570,25 @@ class ConfigRoute(Route):
return Response().error("缺少参数 provider_type").__dict__ return Response().error("缺少参数 provider_type").__dict__
provider_type_ls = provider_type.split(",") provider_type_ls = provider_type.split(",")
provider_list = [] provider_list = []
astrbot_config = self.core_lifecycle.astrbot_config ps = self.core_lifecycle.provider_manager.providers_config
for provider in astrbot_config["provider"]: p_source_pt = {
if provider.get("provider_type", None) in provider_type_ls: psrc["id"]: psrc["provider_type"]
for psrc in self.core_lifecycle.provider_manager.provider_sources_config
}
for provider in ps:
ps_id = provider.get("provider_source_id", None)
if (
ps_id
and ps_id in p_source_pt
and p_source_pt[ps_id] in provider_type_ls
):
# chat
prov = self.core_lifecycle.provider_manager.get_merged_provider_config(
provider
)
provider_list.append(prov)
elif not ps_id and provider.get("provider_type", None) in provider_type_ls:
# agent runner, embedding, etc
provider_list.append(provider) provider_list.append(provider)
return Response().ok(provider_list).__dict__ return Response().ok(provider_list).__dict__
@@ -458,9 +611,18 @@ class ConfigRoute(Route):
try: try:
models = await provider.get_models() models = await provider.get_models()
models = models or []
metadata_map = {}
for model_id in models:
meta = LLM_METADATAS.get(model_id)
if meta:
metadata_map[model_id] = meta
ret = { ret = {
"models": models, "models": models,
"provider_id": provider_id, "provider_id": provider_id,
"model_metadata": metadata_map,
} }
return Response().ok(ret).__dict__ return Response().ok(ret).__dict__
except Exception as e: except Exception as e:
@@ -522,6 +684,100 @@ class ConfigRoute(Route):
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return Response().error(f"获取嵌入维度失败: {e!s}").__dict__ return Response().error(f"获取嵌入维度失败: {e!s}").__dict__
async def get_provider_source_models(self, provider_source_id: str):
"""获取指定 provider_source 支持的模型列表
本质上会临时初始化一个 Provider 实例调用 get_models() 获取模型列表然后销毁实例
"""
try:
from astrbot.core.provider.register import provider_cls_map
# 从配置中查找对应的 provider_source
provider_sources = self.config.get("provider_sources", [])
provider_source = None
for ps in provider_sources:
if ps.get("id") == provider_source_id:
provider_source = ps
break
if not provider_source:
return (
Response()
.error(f"未找到 ID 为 {provider_source_id} 的 provider_source")
.__dict__
)
# 获取 provider 类型
provider_type = provider_source.get("type", None)
if not provider_type:
return Response().error("provider_source 缺少 type 字段").__dict__
try:
self.core_lifecycle.provider_manager.dynamic_import_provider(
provider_type
)
except ImportError as e:
logger.error(traceback.format_exc())
return Response().error(f"动态导入提供商适配器失败: {e!s}").__dict__
# 获取对应的 provider 类
if provider_type not in provider_cls_map:
return (
Response()
.error(f"未找到适用于 {provider_type} 的提供商适配器")
.__dict__
)
provider_metadata = provider_cls_map[provider_type]
cls_type = provider_metadata.cls_type
if not cls_type:
return Response().error(f"无法找到 {provider_type} 的类").__dict__
# 检查是否是 Provider 类型
if not issubclass(cls_type, Provider):
return (
Response()
.error(f"提供商 {provider_type} 不支持获取模型列表")
.__dict__
)
# 临时实例化 provider
inst = cls_type(provider_source, {})
# 如果有 initialize 方法,调用它
init_fn = getattr(inst, "initialize", None)
if inspect.iscoroutinefunction(init_fn):
await init_fn()
# 获取模型列表
models = await inst.get_models()
models = models or []
metadata_map = {}
for model_id in models:
meta = LLM_METADATAS.get(model_id)
if meta:
metadata_map[model_id] = meta
# 销毁实例(如果有 terminate 方法)
terminate_fn = getattr(inst, "terminate", None)
if inspect.iscoroutinefunction(terminate_fn):
await terminate_fn()
logger.info(
f"获取到 provider_source {provider_source_id} 的模型列表: {models}",
)
return (
Response()
.ok({"models": models, "model_metadata": metadata_map})
.__dict__
)
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"获取模型列表失败: {e!s}").__dict__
async def get_platform_list(self): async def get_platform_list(self):
"""获取所有平台的列表""" """获取所有平台的列表"""
platform_list = [] platform_list = []
@@ -533,7 +789,15 @@ class ConfigRoute(Route):
data = await request.json data = await request.json
config = data.get("config", None) config = data.get("config", None)
conf_id = data.get("conf_id", None) conf_id = data.get("conf_id", None)
try: try:
# 不更新 provider_sources, provider, platform
# 这些配置有单独的接口进行更新
if conf_id == "default":
no_update_keys = ["provider_sources", "provider", "platform"]
for key in no_update_keys:
config[key] = self.acm.default_conf[key]
await self._save_astrbot_configs(config, conf_id) await self._save_astrbot_configs(config, conf_id)
await self.core_lifecycle.reload_pipeline_scheduler(conf_id) await self.core_lifecycle.reload_pipeline_scheduler(conf_id)
return Response().ok(None, "保存成功~").__dict__ return Response().ok(None, "保存成功~").__dict__
@@ -573,28 +837,30 @@ class ConfigRoute(Route):
async def post_new_provider(self): async def post_new_provider(self):
new_provider_config = await request.json new_provider_config = await request.json
self.config["provider"].append(new_provider_config)
try: try:
save_config(self.config, self.config, is_core=True) await self.core_lifecycle.provider_manager.create_provider(
await self.core_lifecycle.provider_manager.load_provider( new_provider_config
new_provider_config,
) )
except Exception as e: except Exception as e:
return Response().error(str(e)).__dict__ return Response().error(str(e)).__dict__
return Response().ok(None, "新增服务提供商配置成功~").__dict__ return Response().ok(None, "新增服务提供商配置成功").__dict__
async def post_update_platform(self): async def post_update_platform(self):
update_platform_config = await request.json update_platform_config = await request.json
platform_id = update_platform_config.get("id", None) origin_platform_id = update_platform_config.get("id", None)
new_config = update_platform_config.get("config", None) new_config = update_platform_config.get("config", None)
if not platform_id or not new_config: if not origin_platform_id or not new_config:
return Response().error("参数错误").__dict__ return Response().error("参数错误").__dict__
if origin_platform_id != new_config.get("id", None):
return Response().error("机器人名称不允许修改").__dict__
# 如果是支持统一 webhook 模式的平台,且启用了统一 webhook 模式,确保有 webhook_uuid # 如果是支持统一 webhook 模式的平台,且启用了统一 webhook 模式,确保有 webhook_uuid
ensure_platform_webhook_config(new_config) ensure_platform_webhook_config(new_config)
for i, platform in enumerate(self.config["platform"]): for i, platform in enumerate(self.config["platform"]):
if platform["id"] == platform_id: if platform["id"] == origin_platform_id:
self.config["platform"][i] = new_config self.config["platform"][i] = new_config
break break
else: else:
@@ -609,21 +875,15 @@ class ConfigRoute(Route):
async def post_update_provider(self): async def post_update_provider(self):
update_provider_config = await request.json update_provider_config = await request.json
provider_id = update_provider_config.get("id", None) origin_provider_id = update_provider_config.get("id", None)
new_config = update_provider_config.get("config", None) new_config = update_provider_config.get("config", None)
if not provider_id or not new_config: if not origin_provider_id or not new_config:
return Response().error("参数错误").__dict__ return Response().error("参数错误").__dict__
for i, provider in enumerate(self.config["provider"]):
if provider["id"] == provider_id:
self.config["provider"][i] = new_config
break
else:
return Response().error("未找到对应服务提供商").__dict__
try: try:
save_config(self.config, self.config, is_core=True) await self.core_lifecycle.provider_manager.update_provider(
await self.core_lifecycle.provider_manager.reload(new_config) origin_provider_id, new_config
)
except Exception as e: except Exception as e:
return Response().error(str(e)).__dict__ return Response().error(str(e)).__dict__
return Response().ok(None, "更新成功,已经实时生效~").__dict__ return Response().ok(None, "更新成功,已经实时生效~").__dict__
@@ -646,19 +906,17 @@ class ConfigRoute(Route):
async def post_delete_provider(self): async def post_delete_provider(self):
provider_id = await request.json provider_id = await request.json
provider_id = provider_id.get("id") provider_id = provider_id.get("id", "")
for i, provider in enumerate(self.config["provider"]): if not provider_id:
if provider["id"] == provider_id: return Response().error("缺少参数 id").__dict__
del self.config["provider"][i]
break
else:
return Response().error("未找到对应服务提供商").__dict__
try: try:
save_config(self.config, self.config, is_core=True) await self.core_lifecycle.provider_manager.delete_provider(
await self.core_lifecycle.provider_manager.terminate_provider(provider_id) provider_id=provider_id
)
except Exception as e: except Exception as e:
return Response().error(str(e)).__dict__ return Response().error(str(e)).__dict__
return Response().ok(None, "删除成功,已经实时生效~").__dict__ return Response().ok(None, "删除成功,已经实时生效").__dict__
async def get_llm_tools(self): async def get_llm_tools(self):
"""获取函数调用工具。包含了本地加载的以及 MCP 服务的工具""" """获取函数调用工具。包含了本地加载的以及 MCP 服务的工具"""
+91 -1
View File
@@ -1,7 +1,9 @@
import json import json
import traceback import traceback
from datetime import datetime
from io import BytesIO
from quart import request from quart import request, send_file
from astrbot.core import logger from astrbot.core import logger
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
@@ -30,6 +32,7 @@ class ConversationRoute(Route):
"POST", "POST",
self.update_history, self.update_history,
), ),
"/conversation/export": ("POST", self.export_conversations),
} }
self.db_helper = db_helper self.db_helper = db_helper
self.conv_mgr = core_lifecycle.conversation_manager self.conv_mgr = core_lifecycle.conversation_manager
@@ -283,3 +286,90 @@ class ConversationRoute(Route):
except Exception as e: except Exception as e:
logger.error(f"更新对话历史失败: {e!s}\n{traceback.format_exc()}") logger.error(f"更新对话历史失败: {e!s}\n{traceback.format_exc()}")
return Response().error(f"更新对话历史失败: {e!s}").__dict__ return Response().error(f"更新对话历史失败: {e!s}").__dict__
async def export_conversations(self):
"""批量导出对话为 JSONL 格式"""
try:
data = await request.get_json()
conversations_to_export = data.get("conversations", [])
if not conversations_to_export:
return Response().error("导出列表不能为空").__dict__
# 收集所有对话的内容
jsonl_lines = []
exported_count = 0
failed_items = []
for conv_info in conversations_to_export:
user_id = conv_info.get("user_id")
cid = conv_info.get("cid")
if not user_id or not cid:
failed_items.append(
f"user_id:{user_id}, cid:{cid} - 缺少必要参数",
)
continue
try:
conversation = await self.conv_mgr.get_conversation(
unified_msg_origin=user_id,
conversation_id=cid,
)
if not conversation:
failed_items.append(
f"user_id:{user_id}, cid:{cid} - 对话不存在"
)
continue
# 解析对话内容 (history is always a JSON string from _convert_conv_from_v2_to_v1)
content = json.loads(conversation.history)
# 创建导出记录
export_record = {
"cid": cid,
"user_id": user_id,
"platform_id": conversation.platform_id,
"title": conversation.title,
"persona_id": conversation.persona_id,
"created_at": conversation.created_at,
"updated_at": conversation.updated_at,
"content": content,
}
# 将记录转换为 JSON 字符串并添加到 JSONL
jsonl_lines.append(json.dumps(export_record, ensure_ascii=False))
exported_count += 1
except Exception as e:
failed_items.append(f"user_id:{user_id}, cid:{cid} - {e!s}")
logger.error(
f"导出对话失败: user_id={user_id}, cid={cid}, error={e!s}"
)
if exported_count == 0:
return Response().error("没有成功导出任何对话").__dict__
# 创建 JSONL 内容
jsonl_content = "\n".join(jsonl_lines)
# 创建一个内存文件对象
file_obj = BytesIO(jsonl_content.encode("utf-8"))
file_obj.seek(0)
# 生成文件名
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"astrbot_conversations_export_{timestamp}.jsonl"
# 返回文件流
return await send_file(
file_obj,
mimetype="application/jsonl",
as_attachment=True,
attachment_filename=filename,
)
except Exception as e:
logger.error(f"批量导出对话失败: {e!s}\n{traceback.format_exc()}")
return Response().error(f"批量导出对话失败: {e!s}").__dict__
+253 -78
View File
@@ -48,6 +48,7 @@ class KnowledgeBaseRoute(Route):
# 文档管理 # 文档管理
"/kb/document/list": ("GET", self.list_documents), "/kb/document/list": ("GET", self.list_documents),
"/kb/document/upload": ("POST", self.upload_document), "/kb/document/upload": ("POST", self.upload_document),
"/kb/document/import": ("POST", self.import_documents),
"/kb/document/upload/url": ("POST", self.upload_document_from_url), "/kb/document/upload/url": ("POST", self.upload_document_from_url),
"/kb/document/upload/progress": ("GET", self.get_upload_progress), "/kb/document/upload/progress": ("GET", self.get_upload_progress),
"/kb/document/get": ("GET", self.get_document), "/kb/document/get": ("GET", self.get_document),
@@ -66,6 +67,65 @@ class KnowledgeBaseRoute(Route):
def _get_kb_manager(self): def _get_kb_manager(self):
return self.core_lifecycle.kb_manager return self.core_lifecycle.kb_manager
def _init_task(self, task_id: str, status: str = "pending") -> None:
self.upload_tasks[task_id] = {
"status": status,
"result": None,
"error": None,
}
def _set_task_result(
self, task_id: str, status: str, result: any = None, error: str | None = None
) -> None:
self.upload_tasks[task_id] = {
"status": status,
"result": result,
"error": error,
}
if task_id in self.upload_progress:
self.upload_progress[task_id]["status"] = status
def _update_progress(
self,
task_id: str,
*,
status: str | None = None,
file_index: int | None = None,
file_name: str | None = None,
stage: str | None = None,
current: int | None = None,
total: int | None = None,
) -> None:
if task_id not in self.upload_progress:
return
p = self.upload_progress[task_id]
if status is not None:
p["status"] = status
if file_index is not None:
p["file_index"] = file_index
if file_name is not None:
p["file_name"] = file_name
if stage is not None:
p["stage"] = stage
if current is not None:
p["current"] = current
if total is not None:
p["total"] = total
def _make_progress_callback(self, task_id: str, file_idx: int, file_name: str):
async def _callback(stage: str, current: int, total: int):
self._update_progress(
task_id,
status="processing",
file_index=file_idx,
file_name=file_name,
stage=stage,
current=current,
total=total,
)
return _callback
async def _background_upload_task( async def _background_upload_task(
self, self,
task_id: str, task_id: str,
@@ -80,11 +140,7 @@ class KnowledgeBaseRoute(Route):
"""后台上传任务""" """后台上传任务"""
try: try:
# 初始化任务状态 # 初始化任务状态
self.upload_tasks[task_id] = { self._init_task(task_id, status="processing")
"status": "processing",
"result": None,
"error": None,
}
self.upload_progress[task_id] = { self.upload_progress[task_id] = {
"status": "processing", "status": "processing",
"file_index": 0, "file_index": 0,
@@ -100,30 +156,20 @@ class KnowledgeBaseRoute(Route):
for file_idx, file_info in enumerate(files_to_upload): for file_idx, file_info in enumerate(files_to_upload):
try: try:
# 更新整体进度 # 更新整体进度
self.upload_progress[task_id].update( self._update_progress(
{ task_id,
"status": "processing", status="processing",
"file_index": file_idx, file_index=file_idx,
"file_name": file_info["file_name"], file_name=file_info["file_name"],
"stage": "parsing", stage="parsing",
"current": 0, current=0,
"total": 100, total=100,
},
) )
# 创建进度回调函数 # 创建进度回调函数
async def progress_callback(stage, current, total): progress_callback = self._make_progress_callback(
if task_id in self.upload_progress: task_id, file_idx, file_info["file_name"]
self.upload_progress[task_id].update( )
{
"status": "processing",
"file_index": file_idx,
"file_name": file_info["file_name"],
"stage": stage,
"current": current,
"total": total,
},
)
doc = await kb_helper.upload_document( doc = await kb_helper.upload_document(
file_name=file_info["file_name"], file_name=file_info["file_name"],
@@ -154,23 +200,99 @@ class KnowledgeBaseRoute(Route):
"failed_count": len(failed_docs), "failed_count": len(failed_docs),
} }
self.upload_tasks[task_id] = { self._set_task_result(task_id, "completed", result=result)
"status": "completed",
"result": result,
"error": None,
}
self.upload_progress[task_id]["status"] = "completed"
except Exception as e: except Exception as e:
logger.error(f"后台上传任务 {task_id} 失败: {e}") logger.error(f"后台上传任务 {task_id} 失败: {e}")
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
self.upload_tasks[task_id] = { self._set_task_result(task_id, "failed", error=str(e))
"status": "failed",
"result": None, async def _background_import_task(
"error": str(e), self,
task_id: str,
kb_helper,
documents: list,
batch_size: int,
tasks_limit: int,
max_retries: int,
):
"""后台导入预切片文档任务"""
try:
# 初始化任务状态
self._init_task(task_id, status="processing")
self.upload_progress[task_id] = {
"status": "processing",
"file_index": 0,
"file_total": len(documents),
"stage": "waiting",
"current": 0,
"total": 100,
} }
if task_id in self.upload_progress:
self.upload_progress[task_id]["status"] = "failed" uploaded_docs = []
failed_docs = []
for file_idx, doc_info in enumerate(documents):
file_name = doc_info.get("file_name", f"imported_doc_{file_idx}")
chunks = doc_info.get("chunks", [])
try:
# 更新整体进度
self._update_progress(
task_id,
status="processing",
file_index=file_idx,
file_name=file_name,
stage="importing",
current=0,
total=100,
)
# 创建进度回调函数
progress_callback = self._make_progress_callback(
task_id, file_idx, file_name
)
# 调用 upload_document,传入 pre_chunked_text
doc = await kb_helper.upload_document(
file_name=file_name,
file_content=None, # 预切片模式下不需要原始内容
file_type=doc_info.get("file_type")
or (
file_name.rsplit(".", 1)[-1].lower()
if "." in file_name
else "txt"
),
batch_size=batch_size,
tasks_limit=tasks_limit,
max_retries=max_retries,
progress_callback=progress_callback,
pre_chunked_text=chunks,
)
uploaded_docs.append(doc.model_dump())
except Exception as e:
logger.error(f"导入文档 {file_name} 失败: {e}")
failed_docs.append(
{"file_name": file_name, "error": str(e)},
)
# 更新任务完成状态
result = {
"task_id": task_id,
"uploaded": uploaded_docs,
"failed": failed_docs,
"total": len(documents),
"success_count": len(uploaded_docs),
"failed_count": len(failed_docs),
}
self._set_task_result(task_id, "completed", result=result)
except Exception as e:
logger.error(f"后台导入任务 {task_id} 失败: {e}")
logger.error(traceback.format_exc())
self._set_task_result(task_id, "failed", error=str(e))
async def list_kbs(self): async def list_kbs(self):
"""获取知识库列表 """获取知识库列表
@@ -614,11 +736,7 @@ class KnowledgeBaseRoute(Route):
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
# 初始化任务状态 # 初始化任务状态
self.upload_tasks[task_id] = { self._init_task(task_id, status="pending")
"status": "pending",
"result": None,
"error": None,
}
# 启动后台任务 # 启动后台任务
asyncio.create_task( asyncio.create_task(
@@ -653,6 +771,93 @@ class KnowledgeBaseRoute(Route):
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return Response().error(f"上传文档失败: {e!s}").__dict__ return Response().error(f"上传文档失败: {e!s}").__dict__
def _validate_import_request(self, data: dict):
kb_id = data.get("kb_id")
if not kb_id:
raise ValueError("缺少参数 kb_id")
documents = data.get("documents")
if not documents or not isinstance(documents, list):
raise ValueError("缺少参数 documents 或格式错误")
for doc in documents:
if "file_name" not in doc or "chunks" not in doc:
raise ValueError("文档格式错误,必须包含 file_name 和 chunks")
if not isinstance(doc["chunks"], list):
raise ValueError("chunks 必须是列表")
if not all(
isinstance(chunk, str) and chunk.strip() for chunk in doc["chunks"]
):
raise ValueError("chunks 必须是非空字符串列表")
batch_size = data.get("batch_size", 32)
tasks_limit = data.get("tasks_limit", 3)
max_retries = data.get("max_retries", 3)
return kb_id, documents, batch_size, tasks_limit, max_retries
async def import_documents(self):
"""导入预切片文档
Body:
- kb_id: 知识库 ID (必填)
- documents: 文档列表 (必填)
- file_name: 文件名 (必填)
- chunks: 切片列表 (必填, list[str])
- file_type: 文件类型 (可选, 默认从文件名推断或为 txt)
- batch_size: 批处理大小 (可选, 默认32)
- tasks_limit: 并发任务限制 (可选, 默认3)
- max_retries: 最大重试次数 (可选, 默认3)
"""
try:
kb_manager = self._get_kb_manager()
data = await request.json
kb_id, documents, batch_size, tasks_limit, max_retries = (
self._validate_import_request(data)
)
# 获取知识库
kb_helper = await kb_manager.get_kb(kb_id)
if not kb_helper:
return Response().error("知识库不存在").__dict__
# 生成任务ID
task_id = str(uuid.uuid4())
# 初始化任务状态
self._init_task(task_id, status="pending")
# 启动后台任务
asyncio.create_task(
self._background_import_task(
task_id=task_id,
kb_helper=kb_helper,
documents=documents,
batch_size=batch_size,
tasks_limit=tasks_limit,
max_retries=max_retries,
),
)
return (
Response()
.ok(
{
"task_id": task_id,
"doc_count": len(documents),
"message": "import task created, processing in background",
},
)
.__dict__
)
except ValueError as e:
return Response().error(str(e)).__dict__
except Exception as e:
logger.error(f"导入文档失败: {e}")
logger.error(traceback.format_exc())
return Response().error(f"导入文档失败: {e!s}").__dict__
async def get_upload_progress(self): async def get_upload_progress(self):
"""获取上传进度和结果 """获取上传进度和结果
@@ -960,11 +1165,7 @@ class KnowledgeBaseRoute(Route):
task_id = str(uuid.uuid4()) task_id = str(uuid.uuid4())
# 初始化任务状态 # 初始化任务状态
self.upload_tasks[task_id] = { self._init_task(task_id, status="pending")
"status": "pending",
"result": None,
"error": None,
}
# 启动后台任务 # 启动后台任务
asyncio.create_task( asyncio.create_task(
@@ -1017,11 +1218,7 @@ class KnowledgeBaseRoute(Route):
"""后台上传URL任务""" """后台上传URL任务"""
try: try:
# 初始化任务状态 # 初始化任务状态
self.upload_tasks[task_id] = { self._init_task(task_id, status="processing")
"status": "processing",
"result": None,
"error": None,
}
self.upload_progress[task_id] = { self.upload_progress[task_id] = {
"status": "processing", "status": "processing",
"file_index": 0, "file_index": 0,
@@ -1033,18 +1230,7 @@ class KnowledgeBaseRoute(Route):
} }
# 创建进度回调函数 # 创建进度回调函数
async def progress_callback(stage, current, total): progress_callback = self._make_progress_callback(task_id, 0, f"URL: {url}")
if task_id in self.upload_progress:
self.upload_progress[task_id].update(
{
"status": "processing",
"file_index": 0,
"file_name": f"URL: {url}",
"stage": stage,
"current": current,
"total": total,
},
)
# 上传文档 # 上传文档
doc = await kb_helper.upload_from_url( doc = await kb_helper.upload_from_url(
@@ -1069,20 +1255,9 @@ class KnowledgeBaseRoute(Route):
"failed_count": 0, "failed_count": 0,
} }
self.upload_tasks[task_id] = { self._set_task_result(task_id, "completed", result=result)
"status": "completed",
"result": result,
"error": None,
}
self.upload_progress[task_id]["status"] = "completed"
except Exception as e: except Exception as e:
logger.error(f"后台上传URL任务 {task_id} 失败: {e}") logger.error(f"后台上传URL任务 {task_id} 失败: {e}")
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
self.upload_tasks[task_id] = { self._set_task_result(task_id, "failed", error=str(e))
"status": "failed",
"result": None,
"error": str(e),
}
if task_id in self.upload_progress:
self.upload_progress[task_id]["status"] = "failed"
+5 -1
View File
@@ -124,7 +124,11 @@ class PluginRoute(Route):
session.get(url) as response, session.get(url) as response,
): ):
if response.status == 200: if response.status == 200:
remote_data = await response.json() try:
remote_data = await response.json()
except aiohttp.ContentTypeError:
remote_text = await response.text()
remote_data = json.loads(remote_text)
# 检查远程数据是否为空 # 检查远程数据是否为空
if not remote_data or ( if not remote_data or (
+96
View File
@@ -1,6 +1,9 @@
import os
import re
import threading import threading
import time import time
import traceback import traceback
from functools import cmp_to_key
import aiohttp import aiohttp
import psutil import psutil
@@ -11,7 +14,9 @@ from astrbot.core.config import VERSION
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db import BaseDatabase from astrbot.core.db import BaseDatabase
from astrbot.core.db.migration.helper import check_migration_needed_v4 from astrbot.core.db.migration.helper import check_migration_needed_v4
from astrbot.core.utils.astrbot_path import get_astrbot_path
from astrbot.core.utils.io import get_dashboard_version from astrbot.core.utils.io import get_dashboard_version
from astrbot.core.utils.version_comparator import VersionComparator
from .route import Response, Route, RouteContext from .route import Response, Route, RouteContext
@@ -30,6 +35,8 @@ class StatRoute(Route):
"/stat/start-time": ("GET", self.get_start_time), "/stat/start-time": ("GET", self.get_start_time),
"/stat/restart-core": ("POST", self.restart_core), "/stat/restart-core": ("POST", self.restart_core),
"/stat/test-ghproxy-connection": ("POST", self.test_ghproxy_connection), "/stat/test-ghproxy-connection": ("POST", self.test_ghproxy_connection),
"/stat/changelog": ("GET", self.get_changelog),
"/stat/changelog/list": ("GET", self.list_changelog_versions),
} }
self.db_helper = db_helper self.db_helper = db_helper
self.register_routes() self.register_routes()
@@ -183,3 +190,92 @@ class StatRoute(Route):
except Exception as e: except Exception as e:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return Response().error(f"Error: {e!s}").__dict__ return Response().error(f"Error: {e!s}").__dict__
async def get_changelog(self):
"""获取指定版本的更新日志"""
try:
version = request.args.get("version")
if not version:
return Response().error("version parameter is required").__dict__
version = version.lstrip("v")
# 防止路径遍历攻击
if not re.match(r"^[a-zA-Z0-9._-]+$", version):
return Response().error("Invalid version format").__dict__
if ".." in version or "/" in version or "\\" in version:
return Response().error("Invalid version format").__dict__
filename = f"v{version}.md"
project_path = get_astrbot_path()
changelogs_dir = os.path.join(project_path, "changelogs")
changelog_path = os.path.join(changelogs_dir, filename)
# 规范化路径,防止符号链接攻击
changelog_path = os.path.realpath(changelog_path)
changelogs_dir = os.path.realpath(changelogs_dir)
# 验证最终路径在预期的 changelogs 目录内(防止路径遍历)
# 确保规范化后的路径以 changelogs_dir 开头,且是目录内的文件
changelog_path_normalized = os.path.normpath(changelog_path)
changelogs_dir_normalized = os.path.normpath(changelogs_dir)
# 检查路径是否在预期目录内(必须是目录的子文件,不能是目录本身)
expected_prefix = changelogs_dir_normalized + os.sep
if not changelog_path_normalized.startswith(expected_prefix):
logger.warning(
f"Path traversal attempt detected: {version} -> {changelog_path}",
)
return Response().error("Invalid version format").__dict__
if not os.path.exists(changelog_path):
return (
Response()
.error(f"Changelog for version {version} not found")
.__dict__
)
if not os.path.isfile(changelog_path):
return (
Response()
.error(f"Changelog for version {version} not found")
.__dict__
)
with open(changelog_path, encoding="utf-8") as f:
content = f.read()
return Response().ok({"content": content, "version": version}).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"Error: {e!s}").__dict__
async def list_changelog_versions(self):
"""获取所有可用的更新日志版本列表"""
try:
project_path = get_astrbot_path()
changelogs_dir = os.path.join(project_path, "changelogs")
if not os.path.exists(changelogs_dir):
return Response().ok({"versions": []}).__dict__
versions = []
for filename in os.listdir(changelogs_dir):
if filename.endswith(".md") and filename.startswith("v"):
# 提取版本号(去除 v 前缀和 .md 后缀)
version = filename[1:-3] # 去掉 "v" 和 ".md"
# 验证版本号格式
if re.match(r"^[a-zA-Z0-9._-]+$", version):
versions.append(version)
# 按版本号排序(降序,最新的在前)
# 使用项目中的 VersionComparator 进行语义化版本号排序
versions.sort(
key=cmp_to_key(
lambda v1, v2: VersionComparator.compare_version(v2, v1),
),
)
return Response().ok({"versions": versions}).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"Error: {e!s}").__dict__
+20 -4
View File
@@ -3,6 +3,7 @@ import traceback
from quart import request from quart import request
from astrbot.core import logger from astrbot.core import logger
from astrbot.core.agent.mcp_client import MCPTool
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.star import star_map from astrbot.core.star import star_map
@@ -296,15 +297,30 @@ class ToolsRoute(Route):
"""获取所有注册的工具列表""" """获取所有注册的工具列表"""
try: try:
tools = self.tool_mgr.func_list tools = self.tool_mgr.func_list
tools_dict = [ tools_dict = []
{ for tool in tools:
if isinstance(tool, MCPTool):
origin = "mcp"
origin_name = tool.mcp_server_name
elif tool.handler_module_path and star_map.get(
tool.handler_module_path
):
star = star_map[tool.handler_module_path]
origin = "plugin"
origin_name = star.name
else:
origin = "unknown"
origin_name = "unknown"
tool_info = {
"name": tool.name, "name": tool.name,
"description": tool.description, "description": tool.description,
"parameters": tool.parameters, "parameters": tool.parameters,
"active": tool.active, "active": tool.active,
"origin": origin,
"origin_name": origin_name,
} }
for tool in tools tools_dict.append(tool_info)
]
return Response().ok(data=tools_dict).__dict__ return Response().ok(data=tools_dict).__dict__
except Exception as e: except Exception as e:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
+1
View File
@@ -67,6 +67,7 @@ class AstrBotDashboard:
core_lifecycle, core_lifecycle,
core_lifecycle.plugin_manager, core_lifecycle.plugin_manager,
) )
self.command_route = CommandRoute(self.context)
self.cr = ConfigRoute(self.context, core_lifecycle) self.cr = ConfigRoute(self.context, core_lifecycle)
self.lr = LogRoute(self.context, core_lifecycle.log_broker) self.lr = LogRoute(self.context, core_lifecycle.log_broker)
self.sfr = StaticFileRoute(self.context) self.sfr = StaticFileRoute(self.context)
+34
View File
@@ -0,0 +1,34 @@
## What's Changed
> 📢 在升级前,请**完整阅读**本次更新日志。
>
> **特别提醒:**
> 1. 该版本为 alpha.1 预览版本。
> 2. 本次升级**如果再降级**,会由于提供商配置的变更,导致提供商配置错乱,需要手动删除后重新添加。
> 3. 此版本 WebUI 包体相较上一个版本增加约 **193%**,共约 **9.8 MB**,升级可能会需要一些时间。
### 重构与优化
- 重构 Provider 页面和提供商的配置结构,将 Chat Provider 配置拆分为 Provider Source(提供商源)和 Provider(代表提供商源的各个模型),引入了提供商模型自动发现、模型元数据自动发现的功能,**提供更加便捷的模型添加体验**。
- ⚠️ 将 “MCP” 页面移动到了 “插件” 页面中
- ⚠️ 将 “MCP” 页面中的工具管理移动到了 “插件” -> “管理行为” 中。
- ⚠️ 将 “QQ 个人号(OneBot v11)” 机器人适配器类型更名为 “OneBot v11”,并将其 Logo 更改为 OneBot 的 Logo。
- ⚠️ AstrBot WebChat 升级为 **AstrBot ChatUI**,入口从边栏修改为顶部(右上角)切换按钮。
- 优化引用消息的逻辑,减少对模型输入缓存的破坏。
### 修复
- ‼️ 修复部分情况下,分段回复无法正常分段的问题。
- 修复处理工具返回结果的过程中,导致一些直接发送图片的工具(如生图工具)无法正确发送到用户的问题。
- 修复 WebChat 部分情况下,上一条消息文字内容增量到下一条消息的问题。
### 新增
- 支持**指令管理**,设置指令别名、解决指令冲突、查看指令详情等。入口:“插件” -> “管理行为”。
- 支持 Google Gemini 3 系列引入的 [Thinking Level](https://ai.google.dev/gemini-api/docs/thinking#thinking-levels) 配置。
- 支持记录每条 LLM 消息的耗时、Token 使用量、TTFT 数据,以及每次 Agent Loop 的各种统计数据。
- AstrBot ChatUI 支持查看每条消息的 TTFT、Token 使用量数据。
- AstrBot ChatUI 支持显示每次工具调用的耗时、参数和响应。
- AstrBot ChatUI 支持渲染 Mermaid、LateX 内容,优化了 Code Block 的显示效果(使用 Monaco Editor),并减少 DOM 更新于内存占用。(Powered by [Simon-He95/markstream-vue](https://github.com/Simon-He95/markstream-vue)
- 支持查看 Changelog 历史版本更新日志。
- 🎄
+19
View File
@@ -0,0 +1,19 @@
## What's Changed
### 新增
- 支持自定义插件源。
- 支持飞书(Lark)的 Webhook 模式(将事件推送至开发者服务器)。
- 支持 “禁用自带指令” 快捷配置项,启用后将禁用所有 AstrBot 自带指令。入口: WebUI -> 配置文件 -> 平台配置。
### 优化
- 从 WebUI 移除了开发版本渠道。
- 当试图测试"Agent Runner"时,提示前往配置文件页测试。
- WebUI 列表项支持批量粘贴、回车创建项目。
### 修复
- Gemini API 部分调用失败的问题。
- WebUI 插件安装加载 Dialog 关闭按钮在手机端下显示异常的问题。
- 部分情况下,WebUI 日志显示不全的问题。
+3
View File
@@ -0,0 +1,3 @@
## What's Changed
-
+17
View File
@@ -0,0 +1,17 @@
## What's Changed
### 修复
- 企业自部署飞书(自定义 domain)可以接收消息但无法发送消息的问题。
- 安装插件 Dialog 的深色样式问题。
### 优化
- 避免某些插件在流式响应结束后重d复发送消息的问题。
### 新增
- 支持在对话管理批量导出对话轨迹数据为 `jsonl` 格式文件。入口:WebUI -> 对话管理 -> 批量选中 -> 导出。
- 支持对 TTS(文本转语音)设置概率触发。
- (插件开发)支持在 schema 中对 float 和 int 类型设置 `slider` 滑块控件。例如 `slider: {min: 0, max: 1, step: 0.1}`
- (插件开发)支持 key-value 存储功能。例如使用 `await self.put_kv_data("key", value)`, `await self.get_kv_data("key", default_value)``await self.delete_kv_data("key")`
+1 -1
View File
@@ -8,7 +8,7 @@
<meta name="description" content="AstrBot Dashboard" /> <meta name="description" content="AstrBot Dashboard" />
<link <link
rel="stylesheet" rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Poppins:wght@400;500;600;700&family=Roboto:wght@400;500;700&display=swap" href="https://fonts.googleapis.com/css2?family=Outfit&family=Poppins:wght@400;500;600;700&family=Roboto:wght@400;500;700&display=swap"
/> />
<title>AstrBot - 仪表盘</title> <title>AstrBot - 仪表盘</title>
</head> </head>
+8 -5
View File
@@ -14,22 +14,26 @@
}, },
"dependencies": { "dependencies": {
"@guolao/vue-monaco-editor": "^1.5.4", "@guolao/vue-monaco-editor": "^1.5.4",
"@mdit/plugin-katex": "^0.24.1",
"@tiptap/starter-kit": "2.1.7", "@tiptap/starter-kit": "2.1.7",
"@tiptap/vue-3": "2.1.7", "@tiptap/vue-3": "2.1.7",
"apexcharts": "3.42.0", "apexcharts": "3.42.0",
"axios": ">=1.6.2 <1.10.0 || >1.10.0 <2.0.0", "axios": ">=1.6.2 <1.10.0 || >1.10.0 <2.0.0",
"axios-mock-adapter": "^1.22.0", "axios-mock-adapter": "^1.22.0",
"chance": "1.1.11", "chance": "1.1.11",
"d3": "^7.9.0",
"date-fns": "2.30.0", "date-fns": "2.30.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"js-md5": "^0.8.3", "js-md5": "^0.8.3",
"katex": "^0.16.27",
"lodash": "4.17.21", "lodash": "4.17.21",
"marked": "^15.0.7", "markstream-vue": "0.0.3-beta.7",
"markdown-it": "^14.1.0", "mermaid": "^11.12.2",
"pinyin-pro": "^3.26.0",
"pinia": "2.1.6", "pinia": "2.1.6",
"pinyin-pro": "^3.26.0",
"remixicon": "3.5.0", "remixicon": "3.5.0",
"shiki": "^3.20.0",
"stream-markdown": "^0.0.11",
"stream-monaco": "^0.0.8",
"vee-validate": "4.11.3", "vee-validate": "4.11.3",
"vite-plugin-vuetify": "1.0.2", "vite-plugin-vuetify": "1.0.2",
"vue": "3.3.4", "vue": "3.3.4",
@@ -44,7 +48,6 @@
"@mdi/font": "7.2.96", "@mdi/font": "7.2.96",
"@rushstack/eslint-patch": "1.3.3", "@rushstack/eslint-patch": "1.3.3",
"@types/chance": "1.1.3", "@types/chance": "1.1.3",
"@types/markdown-it": "^14.1.2",
"@types/node": "^20.5.7", "@types/node": "^20.5.7",
"@vitejs/plugin-vue": "4.3.3", "@vitejs/plugin-vue": "4.3.3",
"@vue/eslint-config-prettier": "8.0.0", "@vue/eslint-config-prettier": "8.0.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+77 -51
View File
@@ -18,63 +18,39 @@
@editTitle="showEditTitleDialog" @editTitle="showEditTitleDialog"
@deleteConversation="handleDeleteConversation" @deleteConversation="handleDeleteConversation"
@closeMobileSidebar="closeMobileSidebar" @closeMobileSidebar="closeMobileSidebar"
@toggleTheme="toggleTheme"
@toggleFullscreen="toggleFullscreen"
/> />
<!-- 右侧聊天内容区域 --> <!-- 右侧聊天内容区域 -->
<div class="chat-content-panel"> <div class="chat-content-panel">
<div class="conversation-header fade-in"> <div class="conversation-header fade-in" v-if="isMobile">
<!-- 手机端菜单按钮 --> <!-- 手机端菜单按钮 -->
<v-btn icon class="mobile-menu-btn" @click="toggleMobileSidebar" v-if="isMobile" variant="text"> <v-btn icon class="mobile-menu-btn" @click="toggleMobileSidebar" variant="text">
<v-icon>mdi-menu</v-icon> <v-icon>mdi-menu</v-icon>
</v-btn> </v-btn>
<!-- <div v-if="currCid && getCurrentConversation">
<h3
style="max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
{{ getCurrentConversation.title || tm('conversation.newConversation') }}</h3>
<span style="font-size: 12px;">{{ formatDate(getCurrentConversation.updated_at) }}</span>
</div> -->
<div class="conversation-header-actions">
<!-- router 推送到 /chatbox -->
<v-tooltip :text="tm('actions.fullscreen')" v-if="!chatboxMode">
<template v-slot:activator="{ props }">
<v-icon v-bind="props"
@click="router.push(currSessionId ? `/chatbox/${currSessionId}` : '/chatbox')"
class="fullscreen-icon">mdi-fullscreen</v-icon>
</template>
</v-tooltip>
<!-- 语言切换按钮 -->
<v-tooltip :text="t('core.common.language')" v-if="chatboxMode">
<template v-slot:activator="{ props }">
<LanguageSwitcher variant="chatbox" />
</template>
</v-tooltip>
<!-- 主题切换按钮 -->
<v-tooltip :text="isDark ? tm('modes.lightMode') : tm('modes.darkMode')" v-if="chatboxMode">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon @click="toggleTheme" class="theme-toggle-icon"
size="small" rounded="sm" style="margin-right: 8px;" variant="text">
<v-icon>{{ isDark ? 'mdi-weather-night' : 'mdi-white-balance-sunny' }}</v-icon>
</v-btn>
</template>
</v-tooltip>
<!-- router 推送到 /chat -->
<v-tooltip :text="tm('actions.exitFullscreen')" v-if="chatboxMode">
<template v-slot:activator="{ props }">
<v-icon v-bind="props" @click="router.push(currSessionId ? `/chat/${currSessionId}` : '/chat')"
class="fullscreen-icon">mdi-fullscreen-exit</v-icon>
</template>
</v-tooltip>
</div>
</div> </div>
<MessageList v-if="messages && messages.length > 0" :messages="messages" :isDark="isDark" <div class="message-list-wrapper" v-if="messages && messages.length > 0">
:isStreaming="isStreaming || isConvRunning" @openImagePreview="openImagePreview" <MessageList :messages="messages" :isDark="isDark"
@replyMessage="handleReplyMessage" :isStreaming="isStreaming || isConvRunning"
ref="messageList" /> :isLoadingMessages="isLoadingMessages"
@openImagePreview="openImagePreview"
@replyMessage="handleReplyMessage"
ref="messageList" />
<div class="message-list-fade" :class="{ 'fade-dark': isDark }"></div>
</div>
<div class="welcome-container fade-in" v-else> <div class="welcome-container fade-in" v-else>
<div class="welcome-title"> <div v-if="isLoadingMessages" class="loading-overlay-welcome">
<v-progress-circular
indeterminate
size="48"
width="4"
color="primary"
></v-progress-circular>
</div>
<div v-else class="welcome-title">
<span>Hello, I'm</span> <span>Hello, I'm</span>
<span class="bot-name">AstrBot </span> <span class="bot-name">AstrBot </span>
</div> </div>
@@ -173,6 +149,7 @@ const isMobile = ref(false);
const mobileMenuOpen = ref(false); const mobileMenuOpen = ref(false);
const imagePreviewDialog = ref(false); const imagePreviewDialog = ref(false);
const previewImageUrl = ref(''); const previewImageUrl = ref('');
const isLoadingMessages = ref(false);
// 使 composables // 使 composables
const { const {
@@ -260,6 +237,14 @@ function toggleTheme() {
theme.global.name.value = newTheme; theme.global.name.value = newTheme;
} }
function toggleFullscreen() {
if (props.chatboxMode) {
router.push(currSessionId.value ? `/chat/${currSessionId.value}` : '/chat');
} else {
router.push(currSessionId.value ? `/chatbox/${currSessionId.value}` : '/chatbox');
}
}
function openImagePreview(imageUrl: string) { function openImagePreview(imageUrl: string) {
previewImageUrl.value = imageUrl; previewImageUrl.value = imageUrl;
imagePreviewDialog.value = true; imagePreviewDialog.value = true;
@@ -303,11 +288,14 @@ function clearReply() {
async function handleSelectConversation(sessionIds: string[]) { async function handleSelectConversation(sessionIds: string[]) {
if (!sessionIds[0]) return; if (!sessionIds[0]) return;
//
currSessionId.value = sessionIds[0];
selectedSessions.value = [sessionIds[0]];
// URL // URL
const basePath = props.chatboxMode ? '/chatbox' : '/chat'; const basePath = props.chatboxMode ? '/chatbox' : '/chat';
if (route.path !== `${basePath}/${sessionIds[0]}`) { if (route.path !== `${basePath}/${sessionIds[0]}`) {
router.push(`${basePath}/${sessionIds[0]}`); router.push(`${basePath}/${sessionIds[0]}`);
return;
} }
// //
@@ -317,11 +305,15 @@ async function handleSelectConversation(sessionIds: string[]) {
// //
clearReply(); clearReply();
currSessionId.value = sessionIds[0];
selectedSessions.value = [sessionIds[0]];
await getSessionMsg(sessionIds[0], router); //
isLoadingMessages.value = true;
try {
await getSessionMsg(sessionIds[0], router);
} finally {
isLoadingMessages.value = false;
}
nextTick(() => { nextTick(() => {
messageList.value?.scrollToBottom(); messageList.value?.scrollToBottom();
@@ -510,6 +502,29 @@ onBeforeUnmount(() => {
overflow: hidden; overflow: hidden;
} }
.message-list-wrapper {
flex: 1;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
.message-list-fade {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 40px;
background: linear-gradient(to top, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%);
pointer-events: none;
z-index: 1;
}
.message-list-fade.fade-dark {
background: linear-gradient(to top, rgba(30, 30, 30, 1) 0%, rgba(30, 30, 30, 0) 100%);
}
.conversation-header { .conversation-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -543,6 +558,7 @@ onBeforeUnmount(() => {
justify-content: center; justify-content: center;
align-items: center; align-items: center;
flex-direction: column; flex-direction: column;
position: relative;
} }
.welcome-title { .welcome-title {
@@ -550,6 +566,12 @@ onBeforeUnmount(() => {
margin-bottom: 16px; margin-bottom: 16px;
} }
.loading-overlay-welcome {
display: flex;
justify-content: center;
align-items: center;
}
.bot-name { .bot-name {
font-weight: 700; font-weight: 700;
margin-left: 8px; margin-left: 8px;
@@ -575,5 +597,9 @@ onBeforeUnmount(() => {
.chat-page-container { .chat-page-container {
padding: 0 !important; padding: 0 !important;
} }
.conversation-header {
padding: 2px;
}
} }
</style> </style>
+9 -7
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="input-area fade-in"> <div class="input-area fade-in">
<div class="input-container" <div class="input-container"
style="width: 85%; max-width: 900px; margin: 0 auto; border: 1px solid #e0e0e0; border-radius: 24px;"> style="width: 85%; max-width: 900px; margin: 0 auto; border: 1px solid #e0e0e0; border-radius: 24px; box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.1);">
<!-- 引用预览区 --> <!-- 引用预览区 -->
<div class="reply-preview" v-if="props.replyTo"> <div class="reply-preview" v-if="props.replyTo">
<div class="reply-content"> <div class="reply-content">
@@ -16,8 +16,8 @@
@keydown="handleKeyDown" @keydown="handleKeyDown"
:disabled="disabled" :disabled="disabled"
placeholder="Ask AstrBot..." placeholder="Ask AstrBot..."
style="width: 100%; resize: none; outline: none; border: 1px solid var(--v-theme-border); border-radius: 12px; padding: 8px 16px; min-height: 40px; font-family: inherit; font-size: 16px; background-color: var(--v-theme-surface);"></textarea> style="width: 100%; resize: none; outline: none; border: 1px solid var(--v-theme-border); border-radius: 12px; padding: 12px 16px; min-height: 40px; font-family: inherit; font-size: 16px; background-color: var(--v-theme-surface);"></textarea>
<div style="display: flex; justify-content: space-between; align-items: center; padding: 0px 12px;"> <div style="display: flex; justify-content: space-between; align-items: center; padding: 6px 14px;">
<div style="display: flex; justify-content: flex-start; margin-top: 4px; align-items: center; gap: 8px;"> <div style="display: flex; justify-content: flex-start; margin-top: 4px; align-items: center; gap: 8px;">
<ConfigSelector <ConfigSelector
:session-id="sessionId || null" :session-id="sessionId || null"
@@ -26,7 +26,9 @@
:initial-config-id="props.configId" :initial-config-id="props.configId"
@config-changed="handleConfigChange" @config-changed="handleConfigChange"
/> />
<ProviderModelSelector v-if="showProviderSelector" ref="providerModelSelectorRef" />
<!-- Provider/Model Selector Menu -->
<ProviderModelMenu v-if="showProviderSelector" ref="providerModelMenuRef" />
<v-tooltip :text="enableStreaming ? tm('streaming.enabled') : tm('streaming.disabled')" location="top"> <v-tooltip :text="enableStreaming ? tm('streaming.enabled') : tm('streaming.disabled')" location="top">
<template v-slot:activator="{ props }"> <template v-slot:activator="{ props }">
@@ -84,8 +86,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'; import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useModuleI18n } from '@/i18n/composables'; import { useModuleI18n } from '@/i18n/composables';
import ProviderModelSelector from './ProviderModelSelector.vue';
import ConfigSelector from './ConfigSelector.vue'; import ConfigSelector from './ConfigSelector.vue';
import ProviderModelMenu from './ProviderModelMenu.vue';
import type { Session } from '@/composables/useSessions'; import type { Session } from '@/composables/useSessions';
interface StagedFileInfo { interface StagedFileInfo {
@@ -141,7 +143,7 @@ const { tm } = useModuleI18n('features/chat');
const inputField = ref<HTMLTextAreaElement | null>(null); const inputField = ref<HTMLTextAreaElement | null>(null);
const imageInputRef = ref<HTMLInputElement | null>(null); const imageInputRef = ref<HTMLInputElement | null>(null);
const providerModelSelectorRef = ref<InstanceType<typeof ProviderModelSelector> | null>(null); const providerModelMenuRef = ref<InstanceType<typeof ProviderModelMenu> | null>(null);
const showProviderSelector = ref(true); const showProviderSelector = ref(true);
const localPrompt = computed({ const localPrompt = computed({
@@ -234,7 +236,7 @@ function getCurrentSelection() {
if (!showProviderSelector.value) { if (!showProviderSelector.value) {
return null; return null;
} }
return providerModelSelectorRef.value?.getCurrentSelection(); return providerModelMenuRef.value?.getCurrentSelection();
} }
onMounted(() => { onMounted(() => {
@@ -17,7 +17,7 @@
</template> </template>
</v-tooltip> </v-tooltip>
<v-dialog v-model="dialog" max-width="480" persistent> <v-dialog v-model="dialog" max-width="480">
<v-card> <v-card>
<v-card-title class="d-flex align-center justify-space-between"> <v-card-title class="d-flex align-center justify-space-between">
<span>选择配置文件</span> <span>选择配置文件</span>
@@ -5,21 +5,11 @@
'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="{ 'background-color': isDark ? sidebarCollapsed ? '#1e1e1e' : '#2d2d2d' : sidebarCollapsed ? '#ffffff' : '#f1f4f9' }">
@mouseenter="handleSidebarMouseEnter"
@mouseleave="handleSidebarMouseLeave">
<div style="display: flex; align-items: center; justify-content: center; padding: 16px; padding-bottom: 0px;"
v-if="chatboxMode">
<img width="50" src="@/assets/images/icon-no-shadow.svg" alt="AstrBot Logo">
<span v-if="!sidebarCollapsed"
style="font-weight: 1000; font-size: 26px; margin-left: 8px;">AstrBot</span>
</div>
<div class="sidebar-collapse-btn-container" v-if="!isMobile"> <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">
<v-icon>{{ (sidebarCollapsed || (!sidebarCollapsed && sidebarHoverExpanded)) ? <v-icon>{{ sidebarCollapsed ? 'mdi-chevron-right' : 'mdi-chevron-left' }}</v-icon>
'mdi-chevron-right' : 'mdi-chevron-left' }}</v-icon>
</v-btn> </v-btn>
</div> </div>
@@ -30,19 +20,14 @@
</v-btn> </v-btn>
</div> </div>
<div style="padding: 16px; padding-top: 8px;"> <div style="padding: 8px; opacity: 0.6;">
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId" <v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId"
v-if="!sidebarCollapsed || isMobile" prepend-icon="mdi-plus" v-if="!sidebarCollapsed || isMobile" prepend-icon="mdi-square-edit-outline">{{ tm('actions.newChat') }}</v-btn>
style="background-color: transparent !important; border-radius: 4px;">{{ tm('actions.newChat') }}</v-btn> <v-btn icon="mdi-square-edit-outline" rounded="xl" @click="$emit('newChat')" :disabled="!currSessionId"
<v-btn icon="mdi-plus" rounded="lg" @click="$emit('newChat')" :disabled="!currSessionId"
v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn> v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn>
</div> </div>
<div v-if="!sidebarCollapsed || isMobile">
<v-divider class="mx-4"></v-divider>
</div>
<div style="overflow-y: auto; flex-grow: 1;" :class="{ 'fade-in': sidebarHoverExpanded }" <div style="overflow-y: auto; flex-grow: 1;"
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"
@@ -53,15 +38,15 @@
<v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title"> <v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title">
{{ 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="!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"
@click.stop="$emit('editTitle', item.session_id, item.display_name)" /> @click.stop="$emit('editTitle', item.session_id, item.display_name ?? '')" />
<v-btn icon="mdi-delete" size="x-small" variant="text" <v-btn icon="mdi-delete" size="x-small" variant="text"
class="delete-conversation-btn" color="error" class="delete-conversation-btn" color="error"
@click.stop="handleDeleteConversation(item)" /> @click.stop="handleDeleteConversation(item)" />
@@ -74,19 +59,83 @@
<v-fade-transition> <v-fade-transition>
<div class="no-conversations" v-if="sessions.length === 0"> <div class="no-conversations" v-if="sessions.length === 0">
<v-icon icon="mdi-message-text-outline" size="large" color="grey-lighten-1"></v-icon> <v-icon icon="mdi-message-text-outline" size="large" color="grey-lighten-1"></v-icon>
<div class="no-conversations-text" v-if="!sidebarCollapsed || sidebarHoverExpanded || isMobile"> <div class="no-conversations-text" v-if="!sidebarCollapsed || isMobile">
{{ tm('conversation.noHistory') }} {{ tm('conversation.noHistory') }}
</div> </div>
</div> </div>
</v-fade-transition> </v-fade-transition>
</div> </div>
<!-- 收起时的占位元素 -->
<div class="sidebar-spacer" v-if="sidebarCollapsed && !isMobile"></div>
<!-- 底部设置按钮 -->
<div class="sidebar-footer">
<StyledMenu location="top" :close-on-content-click="false">
<template v-slot:activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
:icon="sidebarCollapsed && !isMobile"
:block="!sidebarCollapsed || isMobile"
variant="text"
class="settings-btn"
:class="{ 'settings-btn-collapsed': sidebarCollapsed && !isMobile }"
:prepend-icon="(!sidebarCollapsed || isMobile) ? 'mdi-cog-outline' : undefined"
>
<v-icon v-if="sidebarCollapsed && !isMobile">mdi-cog-outline</v-icon>
<template v-if="!sidebarCollapsed || isMobile">{{ t('core.common.settings') }}</template>
</v-btn>
</template>
<!-- 语言切换 -->
<v-list-item class="styled-menu-item">
<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>
<LanguageSwitcher variant="chatbox" />
</template>
</v-list-item>
<!-- 主题切换 -->
<v-list-item class="styled-menu-item" @click="$emit('toggleTheme')">
<template v-slot:prepend>
<v-icon>{{ isDark ? 'mdi-weather-night' : 'mdi-white-balance-sunny' }}</v-icon>
</template>
<v-list-item-title>{{ isDark ? tm('modes.lightMode') : tm('modes.darkMode') }}</v-list-item-title>
</v-list-item>
<!-- 全屏/退出全屏 -->
<v-list-item class="styled-menu-item" @click="$emit('toggleFullscreen')">
<template v-slot:prepend>
<v-icon>{{ chatboxMode ? 'mdi-fullscreen-exit' : 'mdi-fullscreen' }}</v-icon>
</template>
<v-list-item-title>{{ chatboxMode ? tm('actions.exitFullscreen') : tm('actions.fullscreen') }}</v-list-item-title>
</v-list-item>
<!-- 提供商配置 -->
<v-list-item class="styled-menu-item" @click="showProviderConfigDialog = true">
<template v-slot:prepend>
<v-icon>mdi-creation</v-icon>
</template>
<v-list-item-title>{{ tm('actions.providerConfig') }}</v-list-item-title>
</v-list-item>
</StyledMenu>
</div>
<!-- 提供商配置对话框 -->
<ProviderConfigDialog v-model="showProviderConfigDialog" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { useModuleI18n } from '@/i18n/composables'; import { useI18n, useModuleI18n } from '@/i18n/composables';
import type { Session } from '@/composables/useSessions'; import type { Session } from '@/composables/useSessions';
import LanguageSwitcher from '@/components/shared/LanguageSwitcher.vue';
import StyledMenu from '@/components/shared/StyledMenu.vue';
import ProviderConfigDialog from '@/components/chat/ProviderConfigDialog.vue';
interface Props { interface Props {
sessions: Session[]; sessions: Session[];
@@ -106,15 +155,15 @@ const emit = defineEmits<{
editTitle: [sessionId: string, title: string]; editTitle: [sessionId: string, title: string];
deleteConversation: [sessionId: string]; deleteConversation: [sessionId: string];
closeMobileSidebar: []; closeMobileSidebar: [];
toggleTheme: [];
toggleFullscreen: [];
}>(); }>();
const { t } = useI18n();
const { tm } = useModuleI18n('features/chat'); const { tm } = useModuleI18n('features/chat');
const sidebarCollapsed = ref(true); const sidebarCollapsed = ref(true);
const sidebarHovered = ref(false); const showProviderConfigDialog = ref(false);
const sidebarHoverTimer = ref<number | null>(null);
const sidebarHoverExpanded = ref(false);
const sidebarHoverDelay = 100;
// localStorage // localStorage
const savedCollapsedState = localStorage.getItem('sidebarCollapsed'); const savedCollapsedState = localStorage.getItem('sidebarCollapsed');
@@ -125,40 +174,10 @@ if (savedCollapsedState !== null) {
} }
function toggleSidebar() { function toggleSidebar() {
if (sidebarHoverExpanded.value) {
sidebarHoverExpanded.value = false;
return;
}
sidebarCollapsed.value = !sidebarCollapsed.value; sidebarCollapsed.value = !sidebarCollapsed.value;
localStorage.setItem('sidebarCollapsed', JSON.stringify(sidebarCollapsed.value)); localStorage.setItem('sidebarCollapsed', JSON.stringify(sidebarCollapsed.value));
} }
function handleSidebarMouseEnter() {
if (!sidebarCollapsed.value || props.isMobile) return;
sidebarHovered.value = true;
sidebarHoverTimer.value = window.setTimeout(() => {
if (sidebarHovered.value) {
sidebarHoverExpanded.value = true;
sidebarCollapsed.value = false;
}
}, sidebarHoverDelay);
}
function handleSidebarMouseLeave() {
sidebarHovered.value = false;
if (sidebarHoverTimer.value) {
clearTimeout(sidebarHoverTimer.value);
sidebarHoverTimer.value = null;
}
if (sidebarHoverExpanded.value) {
sidebarCollapsed.value = true;
}
sidebarHoverExpanded.value = false;
}
function handleDeleteConversation(session: Session) { function handleDeleteConversation(session: Session) {
const sessionTitle = session.display_name || tm('conversation.newConversation'); const sessionTitle = session.display_name || tm('conversation.newConversation');
const message = tm('conversation.confirmDelete', { name: sessionTitle }); const message = tm('conversation.confirmDelete', { name: sessionTitle });
@@ -184,8 +203,8 @@ function handleDeleteConversation(session: Session) {
} }
.sidebar-collapsed { .sidebar-collapsed {
max-width: 75px; max-width: 60px;
min-width: 75px; min-width: 60px;
transition: all 0.3s ease; transition: all 0.3s ease;
} }
@@ -206,7 +225,7 @@ function handleDeleteConversation(session: Session) {
} }
.sidebar-collapse-btn-container { .sidebar-collapse-btn-container {
margin: 16px; margin: 8px;
margin-bottom: 0px; margin-bottom: 0px;
z-index: 10; z-index: 10;
} }
@@ -218,13 +237,19 @@ function handleDeleteConversation(session: Session) {
padding: 0; padding: 0;
} }
.conversation-item { .new-chat-btn {
margin-bottom: 4px; justify-content: flex-start;
border-radius: 8px !important; background-color: transparent !important;
transition: all 0.2s ease; border-radius: 20px;
height: auto !important;
min-height: 56px;
padding: 8px 16px !important; padding: 8px 16px !important;
}
.conversation-item {
/* margin-bottom: 4px; */
border-radius: 20px !important;
height: auto !important;
/* min-height: 56px; */
padding: 0px 16px !important;
position: relative; position: relative;
} }
@@ -287,17 +312,31 @@ function handleDeleteConversation(session: Session) {
transition: opacity 0.25s ease; transition: opacity 0.25s ease;
} }
.fade-in { .sidebar-spacer {
animation: fadeInContent 0.3s ease; flex-grow: 1;
} }
@keyframes fadeInContent { .sidebar-footer {
from { padding: 8px 8px;
opacity: 0; padding-bottom: 16px;
} flex-shrink: 0;
to { }
opacity: 1;
} .settings-btn {
opacity: 0.6;
justify-content: flex-start;
padding: 8px 16px !important;
border-radius: 20px !important;
}
.settings-btn:hover {
opacity: 1;
}
.settings-btn-collapsed {
width: 100%;
display: flex;
justify-content: center;
} }
</style> </style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,375 @@
<template>
<v-dialog v-model="dialog" :max-width="isMobile ? undefined : '1400'" :fullscreen="isMobile" scrollable>
<v-card class="provider-config-dialog" :class="{ 'mobile-dialog': isMobile }">
<v-card-title class="d-flex align-center justify-space-between pa-4 pb-0">
<div class="d-flex align-center ga-2">
<span class="text-h2 font-weight-bold">{{ tm('title') }}</span>
</div>
<v-btn icon variant="text" @click="closeDialog">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="pa-4 pt-0" :class="{ 'mobile-content': isMobile }"
:style="isMobile ? {} : { height: 'calc(100vh - 200px); max-height: 800px;' }">
<div :class="isMobile ? 'mobile-layout' : 'd-flex'" :style="isMobile ? {} : { height: '100%' }">
<!-- 左侧Provider Sources 列表 -->
<div class="provider-sources-column" :class="{ 'mobile-sources': isMobile }"
:style="isMobile ? {} : { width: '320px', minWidth: '320px', borderRight: '1px solid rgba(var(--v-border-color), var(--v-border-opacity))', overflowY: 'auto' }">
<ProviderSourcesPanel :displayed-provider-sources="displayedProviderSources"
:selected-provider-source="selectedProviderSource" :available-source-types="availableSourceTypes" :tm="tm"
:resolve-source-icon="resolveSourceIcon" :get-source-display-name="getSourceDisplayName"
@add-provider-source="addProviderSource" @select-provider-source="selectProviderSource"
@delete-provider-source="deleteProviderSource" />
</div>
<!-- 右侧配置和模型 -->
<div class="provider-config-column" :class="{ 'mobile-config': isMobile }"
:style="isMobile ? {} : { flex: 1, overflowY: 'auto', minWidth: 0 }">
<div v-if="selectedProviderSource" class="pa-4">
<!-- Provider Source 配置 -->
<div class="mb-4">
<div class="d-flex align-center justify-space-between mb-3">
<div>
<div class="text-h5 font-weight-bold">{{ selectedProviderSource.id }}</div>
<div class="text-caption text-medium-emphasis">{{ selectedProviderSource.api_base || 'N/A' }}</div>
</div>
<v-btn color="success" prepend-icon="mdi-check" :loading="savingSource" :disabled="!isSourceModified"
@click="saveProviderSource" variant="flat">
{{ tm('providerSources.save') }}
</v-btn>
</div>
<!-- 基础配置 -->
<div class="mb-4">
<AstrBotConfig v-if="basicSourceConfig" :iterable="basicSourceConfig" :metadata="configSchema"
metadataKey="provider" :is-editing="true" />
</div>
<!-- 高级配置 -->
<v-expansion-panels variant="accordion" class="mb-4">
<v-expansion-panel elevation="0" class="border rounded-lg">
<v-expansion-panel-title>
<span class="font-weight-medium">{{ tm('providerSources.advancedConfig') }}</span>
</v-expansion-panel-title>
<v-expansion-panel-text>
<AstrBotConfig v-if="advancedSourceConfig" :iterable="advancedSourceConfig"
:metadata="configSchema" metadataKey="provider" :is-editing="true" />
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
<!-- 模型配置 -->
<ProviderModelsPanel :entries="filteredMergedModelEntries" :available-count="availableModels.length"
v-model:model-search="modelSearch" :loading-models="loadingModels"
:is-source-modified="isSourceModified" :supports-image-input="supportsImageInput"
:supports-tool-call="supportsToolCall" :supports-reasoning="supportsReasoning"
:format-context-limit="formatContextLimit" :testing-providers="testingProviders" :tm="tm"
@fetch-models="fetchAvailableModels" @open-manual-model="openManualModelDialog"
@open-provider-edit="openProviderEdit" @toggle-provider-enable="toggleProviderEnable"
@test-provider="testProvider" @delete-provider="deleteProvider"
@add-model-provider="addModelProvider" />
</div>
</div>
<div v-else class="d-flex align-center justify-center" style="height: 100%;">
<div class="text-center text-medium-emphasis">
<v-icon size="64" color="grey-lighten-1">mdi-cursor-default-click</v-icon>
<p class="mt-4 text-h6">{{ tm('providerSources.selectHint') }}</p>
</div>
</div>
</div>
</div>
</v-card-text>
</v-card>
<!-- 手动添加模型对话框 -->
<v-dialog v-model="showManualModelDialog" max-width="400">
<v-card :title="tm('models.manualDialogTitle')">
<v-card-text class="py-4">
<v-text-field v-model="manualModelId" :label="tm('models.manualDialogModelLabel')" flat variant="solo-filled"
autofocus clearable></v-text-field>
<v-text-field :model-value="manualProviderId" flat variant="solo-filled"
:label="tm('models.manualDialogPreviewLabel')" persistent-hint
:hint="tm('models.manualDialogPreviewHint')"></v-text-field>
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="showManualModelDialog = false">取消</v-btn>
<v-btn color="primary" @click="confirmManualModel">添加</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 已配置模型编辑对话框 -->
<v-dialog v-model="showProviderEditDialog" width="800">
<v-card :title="providerEditData?.id || tm('dialogs.config.editTitle')">
<v-card-text class="py-4">
<small style="color: gray;">不建议修改 ID可能会导致指向该模型的相关配置如默认模型插件相关配置等失效</small>
<AstrBotConfig v-if="providerEditData" :iterable="providerEditData" :metadata="configSchema"
metadataKey="provider" :is-editing="true" />
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="showProviderEditDialog = false"
:disabled="savingProviders.includes(providerEditData?.id)">
{{ tm('dialogs.config.cancel') }}
</v-btn>
<v-btn color="primary" @click="saveEditedProvider" :loading="savingProviders.includes(providerEditData?.id)">
{{ tm('dialogs.config.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-dialog>
</template>
<script setup>
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
import { useModuleI18n } from '@/i18n/composables'
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue'
import ProviderModelsPanel from '@/components/provider/ProviderModelsPanel.vue'
import ProviderSourcesPanel from '@/components/provider/ProviderSourcesPanel.vue'
import { useProviderSources } from '@/composables/useProviderSources'
import { getProviderIcon } from '@/utils/providerUtils'
import axios from 'axios'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue'])
const { tm } = useModuleI18n('features/provider')
//
const isMobile = ref(false)
function checkMobile() {
isMobile.value = window.innerWidth <= 768
}
const dialog = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const snackbar = ref({
show: false,
message: '',
color: 'success'
})
function showMessage(message, color = 'success') {
snackbar.value = { show: true, message, color }
}
const {
selectedProviderSource,
availableModels,
loadingModels,
savingSource,
testingProviders,
isSourceModified,
configSchema,
manualModelId,
modelSearch,
availableSourceTypes,
displayedProviderSources,
filteredMergedModelEntries,
basicSourceConfig,
advancedSourceConfig,
manualProviderId,
resolveSourceIcon,
getSourceDisplayName,
supportsImageInput,
supportsToolCall,
supportsReasoning,
formatContextLimit,
selectProviderSource,
addProviderSource,
deleteProviderSource,
saveProviderSource,
fetchAvailableModels,
addModelProvider,
deleteProvider,
testProvider,
loadConfig,
modelAlreadyConfigured,
} = useProviderSources({
defaultTab: 'chat_completion',
tm,
showMessage
})
const showManualModelDialog = ref(false)
const showProviderEditDialog = ref(false)
const providerEditData = ref(null)
const providerEditOriginalId = ref('')
const savingProviders = ref([])
function closeDialog() {
dialog.value = false
}
function openManualModelDialog() {
if (!selectedProviderSource.value) {
showMessage(tm('providerSources.selectHint'), 'error')
return
}
manualModelId.value = ''
showManualModelDialog.value = true
}
async function confirmManualModel() {
const modelId = manualModelId.value.trim()
if (!selectedProviderSource.value) {
showMessage(tm('providerSources.selectHint'), 'error')
return
}
if (!modelId) {
showMessage(tm('models.manualModelRequired'), 'error')
return
}
if (modelAlreadyConfigured(modelId)) {
showMessage(tm('models.manualModelExists'), 'error')
return
}
await addModelProvider(modelId)
showManualModelDialog.value = false
}
function openProviderEdit(provider) {
providerEditData.value = JSON.parse(JSON.stringify(provider))
providerEditOriginalId.value = provider.id
showProviderEditDialog.value = true
}
async function saveEditedProvider() {
if (!providerEditData.value) return
savingProviders.value.push(providerEditData.value.id)
try {
const res = await axios.post('/api/config/provider/update', {
id: providerEditOriginalId.value || providerEditData.value.id,
config: providerEditData.value
})
if (res.data.status === 'error') {
throw new Error(res.data.message)
}
showMessage(res.data.message || tm('providerSources.saveSuccess'))
showProviderEditDialog.value = false
await loadConfig()
} catch (err) {
showMessage(err.response?.data?.message || err.message || tm('providerSources.saveError'), 'error')
} finally {
savingProviders.value = savingProviders.value.filter(id => id !== providerEditData.value?.id)
}
}
async function toggleProviderEnable(provider, value) {
provider.enable = value
try {
const res = await axios.post('/api/config/provider/update', {
id: provider.id,
config: provider
})
if (res.data.status === 'error') {
throw new Error(res.data.message)
}
showMessage(res.data.message || tm('messages.success.statusUpdate'))
} catch (error) {
showMessage(error.response?.data?.message || error.message || tm('providerSources.saveError'), 'error')
} finally {
await loadConfig()
}
}
// dialog
watch(dialog, (newVal) => {
if (newVal) {
loadConfig()
checkMobile()
}
})
onMounted(() => {
checkMobile()
window.addEventListener('resize', checkMobile)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', checkMobile)
})
</script>
<style scoped>
.provider-config-dialog {
height: calc(100vh - 100px);
display: flex;
flex-direction: column;
}
.provider-config-dialog.mobile-dialog {
height: 100vh;
}
.provider-sources-column {
overflow-y: auto;
background-color: var(--v-theme-surface);
}
.provider-config-column {
background-color: var(--v-theme-background);
}
.border {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
/* 手机端样式 */
.mobile-content {
padding: 8px !important;
padding-top: 0 !important;
height: calc(100vh - 64px) !important;
max-height: none !important;
}
.mobile-layout {
display: flex;
flex-direction: column;
height: 100%;
gap: 16px;
}
.mobile-sources {
width: 100% !important;
min-width: 100% !important;
border-right: none !important;
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
max-height: 40vh;
overflow-y: auto;
}
.mobile-config {
flex: 1;
overflow-y: auto;
min-width: 100% !important;
}
@media (max-width: 768px) {
.provider-config-dialog :deep(.v-card-title) {
padding: 12px 16px !important;
}
.provider-config-dialog :deep(.v-card-title .text-h2) {
font-size: 1.5rem !important;
}
}
</style>
@@ -0,0 +1,205 @@
<template>
<v-menu :close-on-content-click="false" location="top">
<template v-slot:activator="{ props: menuProps }">
<v-chip v-bind="menuProps" class="text-none provider-chip" variant="tonal" size="x-small">
<v-icon start size="14">mdi-creation</v-icon>
<span v-if="selectedProviderId">
{{ selectedProviderId }}
</span>
<span v-else>Model</span>
</v-chip>
</template>
<v-card class="provider-menu-card" min-width="280" max-width="400">
<v-card-text class="pa-2">
<v-text-field
v-model="searchQuery"
placeholder="Search..."
hide-details
variant="plain"
flat
density="compact"
prepend-inner-icon="mdi-magnify"
class="ml-2 mb-2 mr-2"
clearable
/>
<v-list density="compact" nav class="provider-menu-list">
<v-list-item v-for="provider in filteredProviders" :key="provider.id"
:active="selectedProviderId === provider.id" @click="selectProvider(provider)" rounded="lg"
class="provider-menu-item">
<v-list-item-title class="text-body-2">{{ provider.id }}</v-list-item-title>
<v-list-item-subtitle class="provider-subtitle">
<span class="model-name">{{ provider.model }}</span>
<span class="meta-icons">
<v-tooltip text="支持图像输入" location="top" v-if="supportsImageInput(provider)">
<template v-slot:activator="{ props: tipProps }">
<v-icon v-bind="tipProps" size="12" color="grey">mdi-eye-outline</v-icon>
</template>
</v-tooltip>
<v-tooltip text="支持工具调用" location="top" v-if="supportsToolCall(provider)">
<template v-slot:activator="{ props: tipProps }">
<v-icon v-bind="tipProps" size="12" color="grey">mdi-wrench</v-icon>
</template>
</v-tooltip>
<v-tooltip text="支持推理" location="top" v-if="supportsReasoning(provider)">
<template v-slot:activator="{ props: tipProps }">
<v-icon v-bind="tipProps" size="12" color="grey">mdi-brain</v-icon>
</template>
</v-tooltip>
</span>
</v-list-item-subtitle>
</v-list-item>
</v-list>
<div v-if="providerConfigs.length === 0" class="empty-hint">
No available models
</div>
</v-card-text>
</v-card>
</v-menu>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import axios from 'axios';
interface ModelMetadata {
modalities?: { input?: string[] };
tool_call?: boolean;
reasoning?: boolean;
}
interface ProviderConfig {
id: string;
model: string;
api_base?: string;
model_metadata?: ModelMetadata;
}
const providerConfigs = ref<ProviderConfig[]>([]);
const selectedProviderId = ref('');
const searchQuery = ref('');
const filteredProviders = computed(() => {
if (!searchQuery.value) {
return providerConfigs.value;
}
const query = searchQuery.value.toLowerCase();
return providerConfigs.value.filter(p =>
p.id.toLowerCase().includes(query) ||
p.model.toLowerCase().includes(query)
);
});
function loadFromStorage() {
const savedProvider = localStorage.getItem('selectedProvider');
if (savedProvider) {
selectedProviderId.value = savedProvider;
}
}
function saveToStorage() {
if (selectedProviderId.value) {
localStorage.setItem('selectedProvider', selectedProviderId.value);
}
}
function loadProviderConfigs() {
axios.get('/api/config/provider/list', {
params: { provider_type: 'chat_completion' }
}).then(response => {
if (response.data.status === 'ok') {
providerConfigs.value = response.data.data || [];
}
}).catch(error => {
console.error('获取提供商列表失败:', error);
});
}
function selectProvider(provider: ProviderConfig) {
selectedProviderId.value = provider.id;
saveToStorage();
}
function supportsImageInput(provider: ProviderConfig): boolean {
const inputs = provider.model_metadata?.modalities?.input || [];
return inputs.includes('image');
}
function supportsToolCall(provider: ProviderConfig): boolean {
return Boolean(provider.model_metadata?.tool_call);
}
function supportsReasoning(provider: ProviderConfig): boolean {
return Boolean(provider.model_metadata?.reasoning);
}
function getCurrentSelection() {
const provider = providerConfigs.value.find(p => p.id === selectedProviderId.value);
return {
providerId: selectedProviderId.value,
modelName: provider?.model || ''
};
}
onMounted(() => {
loadFromStorage();
loadProviderConfigs();
});
defineExpose({
getCurrentSelection
});
</script>
<style scoped>
.provider-chip {
cursor: pointer;
}
.provider-menu-card {
border-radius: 12px !important;
}
.provider-menu-list {
max-height: 280px;
overflow-y: auto;
}
.provider-menu-item {
margin-bottom: 2px;
border-radius: 8px !important;
min-height: 44px !important;
}
.provider-menu-item:hover {
background-color: rgba(103, 58, 183, 0.05);
}
.provider-menu-item.v-list-item--active {
background-color: rgba(103, 58, 183, 0.1);
}
.provider-subtitle {
display: flex;
align-items: center;
gap: 8px;
}
.model-name {
font-size: 12px;
color: var(--v-theme-secondaryText);
}
.meta-icons {
display: flex;
align-items: center;
gap: 4px;
}
.empty-hint {
font-size: 12px;
color: var(--v-theme-secondaryText);
text-align: center;
padding: 16px;
opacity: 0.6;
}
</style>
@@ -1,359 +0,0 @@
<template>
<div>
<!-- 选择提供商和模型按钮 -->
<v-chip class="text-none" variant="tonal" size="x-small"
v-if="selectedProviderId && selectedModelName" @click="openDialog">
<v-icon start size="14">mdi-creation</v-icon>
{{ selectedProviderId }} / {{ selectedModelName }}
</v-chip>
<v-chip variant="tonal" rounded="xl" size="x-small" v-else @click="openDialog">
选择模型
</v-chip>
<!-- 选择提供商和模型对话框 -->
<v-dialog v-model="showDialog" max-width="800" persistent>
<v-card style="padding: 8px;">
<v-card-title class="dialog-title">
<span>选择提供商和模型</span>
</v-card-title>
<v-card-text class="pa-0">
<div class="provider-model-container">
<!-- 左侧提供商列表 -->
<div class="provider-list-panel">
<div class="panel-header">
<h4>提供商</h4>
</div>
<v-list density="compact" nav class="provider-list">
<v-list-item v-for="provider in providerConfigs" :key="provider.id" :value="provider.id"
@click="selectProvider(provider)" :active="tempSelectedProviderId === provider.id"
rounded="lg" class="provider-item">
<v-list-item-title>{{ provider.id }}</v-list-item-title>
<v-list-item-subtitle v-if="provider.api_base">{{ provider.api_base
}}</v-list-item-subtitle>
</v-list-item>
</v-list>
<div v-if="providerConfigs.length === 0" class="empty-state">
<v-icon icon="mdi-cloud-off-outline" size="large" color="grey-lighten-1"></v-icon>
<div class="empty-text">暂无可用提供商</div>
</div>
</div>
<!-- 右侧模型列表 -->
<div class="model-list-panel">
<div class="panel-header">
<h4>模型</h4>
<v-btn v-if="tempSelectedProviderId" icon="mdi-refresh" size="small" variant="text"
@click="refreshModels" :loading="loadingModels">
</v-btn>
</div>
<v-list density="compact" nav class="model-list" v-if="tempSelectedProviderId">
<v-text-field v-model="tempSelectedModelName" placeholder="自定义模型" hide-details solo variant="outlined" density="compact" class="mb-2 mx-2"></v-text-field>
<v-list-item v-for="model in modelList" :key="model" :value="model"
@click="selectModel(model)" :active="tempSelectedModelName === model" rounded="lg"
class="model-item">
<v-list-item-title>{{ model }}</v-list-item-title>
<v-list-item-subtitle v-if="model.description">{{ model.description
}}</v-list-item-subtitle>
</v-list-item>
</v-list>
<div v-else class="empty-state">
<v-icon icon="mdi-robot-outline" size="large" color="grey-lighten-1"></v-icon>
<div class="empty-text">请先选择提供商</div>
</div>
<div v-if="tempSelectedProviderId && modelList.length === 0 && !loadingModels"
class="empty-state">
<v-icon icon="mdi-robot-off-outline" size="large" color="grey-lighten-1"></v-icon>
<div class="empty-text">该提供商暂无可用模型</div>
</div>
</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn text @click="closeDialog" color="grey-darken-1">取消</v-btn>
<v-btn text @click="confirmSelection" color="primary"
:disabled="!tempSelectedProviderId || !tempSelectedModelName">
确认选择
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
import axios from 'axios';
export default {
name: 'ProviderModelSelector',
props: {
initialProvider: {
type: String,
default: ''
},
initialModel: {
type: String,
default: ''
}
},
emits: ['selection-changed'],
data() {
return {
showDialog: false,
providerConfigs: [],
modelList: [],
selectedProviderId: '',
selectedModelName: '',
//
tempSelectedProviderId: '',
tempSelectedModelName: '',
loadingModels: false
};
},
mounted() {
// localStorage
this.loadFromStorage();
//
this.resetTempSelection();
//
this.loadProviderConfigs();
//
if (this.selectedProviderId) {
this.getProviderModels(this.selectedProviderId);
}
},
methods: {
// localStorage
loadFromStorage() {
const savedProvider = localStorage.getItem('selectedProvider');
const savedModel = localStorage.getItem('selectedModel');
if (savedProvider) {
this.selectedProviderId = savedProvider;
} else if (this.initialProvider) {
this.selectedProviderId = this.initialProvider;
}
if (savedModel) {
this.selectedModelName = savedModel;
} else if (this.initialModel) {
this.selectedModelName = this.initialModel;
}
},
// localStorage
saveToStorage() {
if (this.selectedProviderId) {
localStorage.setItem('selectedProvider', this.selectedProviderId);
}
if (this.selectedModelName) {
localStorage.setItem('selectedModel', this.selectedModelName);
}
},
//
loadProviderConfigs() {
axios.get('/api/config/provider/list', {
params: {
provider_type: 'chat_completion'
}
})
.then(response => {
if (response.data.status === 'ok') {
this.providerConfigs = response.data.data || [];
} else {
console.error('获取聊天完成提供商列表失败:', response.data.message);
}
})
.catch(error => {
console.error('获取聊天完成提供商列表失败:', error);
});
},
//
getProviderModels(providerId) {
this.loadingModels = true;
axios.get('/api/config/provider/model_list', {
params: {
provider_id: providerId
}
})
.then(response => {
if (response.data.status === 'ok') {
this.modelList = response.data.data.models || [];
} else {
console.error('获取模型列表失败:', response.data.message);
this.modelList = [];
}
})
.catch(error => {
console.error('获取模型列表失败:', error);
this.modelList = [];
})
.finally(() => {
this.loadingModels = false;
});
},
//
selectProvider(provider) {
this.tempSelectedProviderId = provider.id;
this.tempSelectedModelName = ''; //
this.modelList = []; //
this.getProviderModels(provider.id); //
},
//
selectModel(model) {
this.tempSelectedModelName = model;
},
//
refreshModels() {
if (this.tempSelectedProviderId) {
this.getProviderModels(this.tempSelectedProviderId);
}
},
//
confirmSelection() {
if (this.tempSelectedProviderId && this.tempSelectedModelName) {
//
this.selectedProviderId = this.tempSelectedProviderId;
this.selectedModelName = this.tempSelectedModelName;
// localStorage
this.saveToStorage();
//
this.$emit('selection-changed', {
providerId: this.selectedProviderId,
modelName: this.selectedModelName
});
this.closeDialog();
}
},
//
closeDialog() {
this.showDialog = false;
//
this.resetTempSelection();
},
//
resetTempSelection() {
this.tempSelectedProviderId = this.selectedProviderId;
this.tempSelectedModelName = this.selectedModelName;
//
if (this.tempSelectedProviderId) {
this.getProviderModels(this.tempSelectedProviderId);
}
},
//
openDialog() {
this.resetTempSelection();
this.showDialog = true;
},
//
getCurrentSelection() {
return {
providerId: this.selectedProviderId,
modelName: this.selectedModelName
};
}
}
};
</script>
<style scoped>
/* 对话框标题样式 */
.dialog-title {
font-size: 18px;
font-weight: 500;
padding-bottom: 8px;
}
/* 提供商和模型选择对话框样式 */
.provider-model-container {
display: flex;
height: 500px;
border: 1px solid var(--v-theme-border);
border-radius: 8px;
overflow: hidden;
}
.provider-list-panel,
.model-list-panel {
flex: 1;
display: flex;
flex-direction: column;
background-color: var(--v-theme-surface);
}
.provider-list-panel {
border-right: 1px solid var(--v-theme-border);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid var(--v-theme-border);
background-color: var(--v-theme-containerBg);
}
.panel-header h4 {
margin: 0;
font-size: 16px;
font-weight: 500;
color: var(--v-theme-primaryText);
}
.provider-list,
.model-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.provider-item,
.model-item {
margin-bottom: 4px;
border-radius: 8px !important;
transition: all 0.2s ease;
cursor: pointer;
}
.provider-item:hover,
.model-item:hover {
background-color: rgba(103, 58, 183, 0.05);
}
.provider-item.v-list-item--active,
.model-item.v-list-item--active {
background-color: rgba(103, 58, 183, 0.1);
color: var(--v-theme-secondary);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
opacity: 0.6;
gap: 12px;
}
.empty-text {
font-size: 14px;
color: var(--v-theme-secondaryText);
}
</style>
@@ -4,42 +4,18 @@
<!-- 页面标题 --> <!-- 页面标题 -->
<v-row class="d-flex justify-space-between align-center px-4 py-3 pb-8"> <v-row class="d-flex justify-space-between align-center px-4 py-3 pb-8">
<div> <div>
<h1 class="text-h1 font-weight-bold mb-2">
<v-icon color="black" class="me-2">mdi-function-variant</v-icon>{{ tm('title') }}
</h1>
<p class="text-subtitle-1 text-medium-emphasis mb-4 d-flex align-center">
{{ tm('subtitle') }}
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-icon v-bind="props" size="small" color="primary" class="ms-1 cursor-pointer"
@click="openurl('https://astrbot.app/use/function-calling.html')">
mdi-information
</v-icon>
</template>
<span>{{ tm('tooltip.info') }}</span>
</v-tooltip>
</p>
</div>
<div>
<v-btn color="primary" prepend-icon="mdi-tools" class="me-2" variant="tonal" @click="showToolsDialog = true"
rounded="xl" size="x-large">
{{ tm('functionTools.buttons.view') }}({{ tools.length }})
</v-btn>
<v-btn color="success" prepend-icon="mdi-plus" class="me-2" variant="tonal" <v-btn color="success" prepend-icon="mdi-plus" class="me-2" variant="tonal"
@click="showMcpServerDialog = true" rounded="xl" size="x-large"> @click="showMcpServerDialog = true" >
{{ tm('mcpServers.buttons.add') }} {{ tm('mcpServers.buttons.add') }}
</v-btn> </v-btn>
<v-btn color="success" prepend-icon="mdi-refresh" variant="tonal" @click="showSyncMcpServerDialog = true" <v-btn color="success" prepend-icon="mdi-refresh" variant="tonal" @click="showSyncMcpServerDialog = true"
rounded="xl" size="x-large"> >
{{ tm('mcpServers.buttons.sync') }} {{ tm('mcpServers.buttons.sync') }}
</v-btn> </v-btn>
</div> </div>
</v-row> </v-row>
<!-- 本地服务器列表 -->
<!-- MCP 服务器部分 --> <!-- MCP 服务器部分 -->
<div v-if="mcpServers.length === 0" class="text-center pa-8"> <div v-if="mcpServers.length === 0" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">mdi-server-off</v-icon> <v-icon size="64" color="grey-lighten-1">mdi-server-off</v-icon>
<p class="text-grey mt-4">{{ tm('mcpServers.empty') }}</p> <p class="text-grey mt-4">{{ tm('mcpServers.empty') }}</p>
@@ -57,7 +33,6 @@
</span> </span>
</div> </div>
<div class="d-flex" style="gap: 8px;"> <div class="d-flex" style="gap: 8px;">
<div> <div>
<div v-if="item.tools && item.tools.length > 0"> <div v-if="item.tools && item.tools.length > 0">
@@ -67,8 +42,7 @@
<template v-slot:activator="{ props: listToolsProps }"> <template v-slot:activator="{ props: listToolsProps }">
<span class="text-caption text-medium-emphasis cursor-pointer" v-bind="listToolsProps" <span class="text-caption text-medium-emphasis cursor-pointer" v-bind="listToolsProps"
style="text-decoration: underline;"> style="text-decoration: underline;">
{{ tm('mcpServers.status.availableTools', { count: item.tools.length }) }} ({{ {{ tm('mcpServers.status.availableTools', { count: item.tools.length }) }} ({{ item.tools.length }})
item.tools.length }})
</span> </span>
</template> </template>
<template v-slot:default="{ isActive }"> <template v-slot:default="{ isActive }">
@@ -78,10 +52,7 @@
</v-card-title> </v-card-title>
<v-card-text> <v-card-text>
<ul> <ul>
<li v-for="(tool, idx) in item.tools" :key="idx" style="margin: 8px 0px;">{{ <li v-for="(tool, idx) in item.tools" :key="idx" style="margin: 8px 0px;">{{ tool }}</li>
tool
}}
</li>
</ul> </ul>
</v-card-text> </v-card-text>
<v-card-actions class="d-flex justify-end"> <v-card-actions class="d-flex justify-end">
@@ -91,8 +62,6 @@
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</template> </template>
</v-dialog> </v-dialog>
</div> </div>
</div> </div>
@@ -105,8 +74,6 @@
<v-progress-circular indeterminate color="primary" size="16"></v-progress-circular> <v-progress-circular indeterminate color="primary" size="16"></v-progress-circular>
</div> </div>
</div> </div>
</template> </template>
</item-card> </item-card>
</v-col> </v-col>
@@ -183,8 +150,7 @@
</v-card> </v-card>
</v-dialog> </v-dialog>
<!-- 同步 MCP 服务器对话框 -->
<!-- 添加/编辑 MCP 服务器对话框 -->
<v-dialog v-model="showSyncMcpServerDialog" max-width="500px" persistent> <v-dialog v-model="showSyncMcpServerDialog" max-width="500px" persistent>
<v-card> <v-card>
<v-card-title class="bg-primary text-white py-3"> <v-card-title class="bg-primary text-white py-3">
@@ -240,115 +206,8 @@
</v-card> </v-card>
</v-dialog> </v-dialog>
<!-- 函数工具对话框 -->
<v-dialog v-model="showToolsDialog" max-width="800px">
<v-card elevation="0" class="mt-4">
<v-card-title class="d-flex align-center py-3 px-4">
{{ tm('functionTools.title') }}
<v-chip color="info" size="small" class="ml-2">{{ tools.length }}</v-chip>
</v-card-title>
<v-expand-transition>
<v-card-text class="pa-0" v-if="showTools">
<div class="pa-4">
<div v-if="tools.length === 0" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">mdi-api-off</v-icon>
<p class="text-grey mt-4">{{ tm('functionTools.empty') }}</p>
</div>
<div v-else>
<v-text-field v-model="toolSearch" prepend-inner-icon="mdi-magnify" :label="tm('functionTools.search')"
variant="outlined" density="compact" class="mb-4" hide-details clearable></v-text-field>
<small>复选框代表该工具是否被启用</small>
<v-expansion-panels v-model="openedPanel" multiple style="max-height: 500px; overflow-y: auto;">
<v-expansion-panel v-for="(tool, index) in filteredTools" :key="index" :value="index"
class="mb-2 tool-panel" rounded="lg">
<v-expansion-panel-title>
<v-row no-gutters align="center">
<v-col cols="1">
<v-checkbox v-model="tool.active" color="primary" hide-details density="compact" @click.stop
@change="toggleToolStatus(tool)"></v-checkbox>
</v-col>
<v-col cols="3">
<div class="d-flex align-center">
<v-icon color="primary" class="me-2" size="small">
{{ tool.name.includes(':') ? 'mdi-server-network' : 'mdi-function-variant' }}
</v-icon>
<span class="text-body-1 text-high-emphasis font-weight-medium text-truncate"
:title="tool.name">
{{ formatToolName(tool.name) }}
</span>
</div>
</v-col>
<v-col cols="8" class="text-grey">
{{ tool.description }}
</v-col>
</v-row>
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-card flat>
<v-card-text>
<p class="text-body-1 font-weight-medium mb-3">
<v-icon color="primary" size="small" class="me-1">mdi-information</v-icon>
{{ tm('functionTools.description') }}
</p>
<p class="text-body-2 ml-6 mb-4">{{ tool.description }}</p>
<template v-if="tool.parameters && tool.parameters.properties">
<p class="text-body-1 font-weight-medium mb-3">
<v-icon color="primary" size="small" class="me-1">mdi-code-json</v-icon>
{{ tm('functionTools.parameters') }}
</p>
<v-table density="compact" class="params-table mt-1">
<thead>
<tr>
<th>{{ tm('functionTools.table.paramName') }}</th>
<th>{{ tm('functionTools.table.type') }}</th>
<th>{{ tm('functionTools.table.description') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(param, paramName) in tool.parameters.properties" :key="paramName">
<td class="font-weight-medium">{{ paramName }}</td>
<td>
<v-chip size="x-small" color="primary" text class="text-caption">
{{ param.type }}
</v-chip>
</td>
<td>{{ param.description }}</td>
</tr>
</tbody>
</v-table>
</template>
<div v-else class="text-center pa-4 text-medium-emphasis">
<v-icon size="large" color="grey-lighten-1">mdi-code-brackets</v-icon>
<p>{{ tm('functionTools.noParameters') }}</p>
</div>
</v-card-text>
</v-card>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</div>
</div>
</v-card-text>
</v-expand-transition>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="showToolsDialog = false">
{{ tm('dialogs.serverDetail.buttons.close') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 消息提示 --> <!-- 消息提示 -->
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack" <v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack" location="top">
location="top">
{{ save_message }} {{ save_message }}
</v-snackbar> </v-snackbar>
</div> </div>
@@ -356,15 +215,13 @@
<script> <script>
import axios from 'axios'; import axios from 'axios';
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'; import { VueMonacoEditor } from '@guolao/vue-monaco-editor';
import ItemCard from '@/components/shared/ItemCard.vue'; import ItemCard from '@/components/shared/ItemCard.vue';
import { useI18n, useModuleI18n } from '@/i18n/composables'; import { useI18n, useModuleI18n } from '@/i18n/composables';
export default { export default {
name: 'ToolUsePage', name: 'McpServersSection',
components: { components: {
AstrBotConfig,
VueMonacoEditor, VueMonacoEditor,
ItemCard ItemCard
}, },
@@ -377,20 +234,15 @@ export default {
return { return {
refreshInterval: null, refreshInterval: null,
mcpServers: [], mcpServers: [],
tools: [],
showMcpServerDialog: false, showMcpServerDialog: false,
selectedMcpServerProvider: 'modelscope',
selectedMcpServerProvider: "modelscope", mcpServerProviderList: ['modelscope'],
mcpServerProviderList: ["modelscope"],
mcpProviderToken: '', mcpProviderToken: '',
showSyncMcpServerDialog: false, showSyncMcpServerDialog: false,
addServerDialogMessage: "", addServerDialogMessage: '',
showToolsDialog: false,
showTools: true,
loading: false, loading: false,
loadingGettingServers: false, loadingGettingServers: false,
mcpServerUpdateLoaders: {}, // record loading state for each server update mcpServerUpdateLoaders: {},
isEditMode: false, isEditMode: false,
serverConfigJson: '', serverConfigJson: '',
jsonError: null, jsonError: null,
@@ -400,87 +252,50 @@ export default {
tools: [] tools: []
}, },
save_message_snack: false, save_message_snack: false,
save_message: "", save_message: '',
save_message_success: "success", save_message_success: 'success'
toolSearch: '', };
openedPanel: [], //
}
}, },
computed: { computed: {
filteredTools() {
if (!this.toolSearch) return this.tools;
const searchTerm = this.toolSearch.toLowerCase();
return this.tools.filter(tool =>
tool.name.toLowerCase().includes(searchTerm) ||
tool.description.toLowerCase().includes(searchTerm)
);
},
isServerFormValid() { isServerFormValid() {
return !!this.currentServer.name && !this.jsonError; return !!this.currentServer.name && !this.jsonError;
}, },
//
getServerConfigSummary() { getServerConfigSummary() {
return (server) => { return (server) => {
if (server.command) { if (server.command) {
return `${server.command} ${(server.args || []).join(' ')}`; return `${server.command} ${(server.args || []).join(' ')}`;
} }
// command
const configKeys = Object.keys(server).filter(key => const configKeys = Object.keys(server).filter(key =>
!['name', 'active', 'tools'].includes(key) !['name', 'active', 'tools'].includes(key)
); );
if (configKeys.length > 0) { if (configKeys.length > 0) {
return this.tm('mcpServers.status.configSummary', { keys: configKeys.join(', ') }); return this.tm('mcpServers.status.configSummary', { keys: configKeys.join(', ') });
} }
return this.tm('mcpServers.status.noConfig'); return this.tm('mcpServers.status.noConfig');
} };
}, }
}, },
mounted() { mounted() {
this.getServers(); this.getServers();
this.getTools();
this.refreshInterval = setInterval(() => { this.refreshInterval = setInterval(() => {
this.getServers(); this.getServers();
this.getTools();
}, 5000); }, 5000);
}, },
unmounted() { unmounted() {
// if it exists
if (this.refreshInterval) { if (this.refreshInterval) {
clearInterval(this.refreshInterval); clearInterval(this.refreshInterval);
} }
}, },
methods: { methods: {
openurl(url) { openurl(url) {
window.open(url, '_blank'); window.open(url, '_blank');
}, },
formatToolName(name) {
if (name.includes(':')) {
// MCP mcp:server:tool
const parts = name.split(':');
return parts[parts.length - 1]; //
}
return name;
},
getServers() { getServers() {
this.loadingGettingServers = true; this.loadingGettingServers = true;
axios.get('/api/tools/mcp/servers') axios.get('/api/tools/mcp/servers')
.then(response => { .then(response => {
this.mcpServers = response.data.data || []; this.mcpServers = response.data.data || [];
this.mcpServers.forEach(server => { this.mcpServers.forEach(server => {
// Ensure each server has a loader state
if (!this.mcpServerUpdateLoaders[server.name]) { if (!this.mcpServerUpdateLoaders[server.name]) {
this.mcpServerUpdateLoaders[server.name] = false; this.mcpServerUpdateLoaders[server.name] = false;
} }
@@ -492,24 +307,12 @@ export default {
this.loadingGettingServers = false; this.loadingGettingServers = false;
}); });
}, },
getTools() {
axios.get('/api/tools/list')
.then(response => {
this.tools = response.data.data || [];
})
.catch(error => {
this.showError(this.tm('messages.getToolsError', { error: error.message }));
});
},
validateJson() { validateJson() {
try { try {
if (!this.serverConfigJson.trim()) { if (!this.serverConfigJson.trim()) {
this.jsonError = this.tm('dialogs.addServer.errors.configEmpty'); this.jsonError = this.tm('dialogs.addServer.errors.configEmpty');
return false; return false;
} }
JSON.parse(this.serverConfigJson); JSON.parse(this.serverConfigJson);
this.jsonError = null; this.jsonError = null;
return true; return true;
@@ -518,61 +321,51 @@ export default {
return false; return false;
} }
}, },
setConfigTemplate(type = 'stdio') { setConfigTemplate(type = 'stdio') {
let template = {}; let template = {};
if (type === 'streamable_http') { if (type === 'streamable_http') {
template = { template = {
transport: "streamable_http", transport: 'streamable_http',
url: "your mcp server url", url: 'your mcp server url',
headers: {}, headers: {},
timeout: 5, timeout: 5,
sse_read_timeout: 300, sse_read_timeout: 300
}; };
} else if (type === 'sse') { } else if (type === 'sse') {
template = { template = {
transport: "sse", transport: 'sse',
url: "your mcp server url", url: 'your mcp server url',
headers: {}, headers: {},
timeout: 5, timeout: 5,
sse_read_timeout: 300, sse_read_timeout: 300
}; };
} else { } else {
template = { template = {
command: "python", command: 'python',
args: ["-m", "your_module"], args: ['-m', 'your_module']
}; };
} }
this.serverConfigJson = JSON.stringify(template, null, 2); this.serverConfigJson = JSON.stringify(template, null, 2);
}, },
saveServer() { saveServer() {
if (!this.validateJson()) { if (!this.validateJson()) {
return; return;
} }
this.loading = true; this.loading = true;
// JSON
try { try {
const configObj = JSON.parse(this.serverConfigJson); const configObj = JSON.parse(this.serverConfigJson);
//
const serverData = { const serverData = {
name: this.currentServer.name, name: this.currentServer.name,
active: this.currentServer.active, active: this.currentServer.active,
...configObj ...configObj
}; };
const endpoint = this.isEditMode ? '/api/tools/mcp/update' : '/api/tools/mcp/add'; const endpoint = this.isEditMode ? '/api/tools/mcp/update' : '/api/tools/mcp/add';
axios.post(endpoint, serverData) axios.post(endpoint, serverData)
.then(response => { .then(response => {
this.loading = false; this.loading = false;
this.showMcpServerDialog = false; this.showMcpServerDialog = false;
this.addServerDialogMessage = ""; this.addServerDialogMessage = '';
this.getServers(); this.getServers();
this.getTools();
this.showSuccess(response.data.message || this.tm('messages.saveSuccess')); this.showSuccess(response.data.message || this.tm('messages.saveSuccess'));
this.resetForm(); this.resetForm();
}) })
@@ -585,14 +378,12 @@ export default {
this.showError(this.tm('dialogs.addServer.errors.jsonParse', { error: e.message })); this.showError(this.tm('dialogs.addServer.errors.jsonParse', { error: e.message }));
} }
}, },
deleteServer(server) { deleteServer(server) {
let serverName = server.name || server; const serverName = server.name || server;
if (confirm(this.tm('dialogs.confirmDelete', { name: serverName }))) { if (confirm(this.tm('dialogs.confirmDelete', { name: serverName }))) {
axios.post('/api/tools/mcp/delete', { name: serverName }) axios.post('/api/tools/mcp/delete', { name: serverName })
.then(response => { .then(response => {
this.getServers(); this.getServers();
this.getTools();
this.showSuccess(response.data.message || this.tm('messages.deleteSuccess')); this.showSuccess(response.data.message || this.tm('messages.deleteSuccess'));
}) })
.catch(error => { .catch(error => {
@@ -600,37 +391,22 @@ export default {
}); });
} }
}, },
editServer(server) { editServer(server) {
//
const configCopy = { ...server }; const configCopy = { ...server };
delete configCopy.name;
// delete configCopy.active;
try { delete configCopy.tools;
delete configCopy.name; delete configCopy.errlogs;
delete configCopy.active;
delete configCopy.tools;
delete configCopy.errlogs;
} catch (e) {
console.error("Error removing basic fields: ", e);
}
//
this.currentServer = { this.currentServer = {
name: server.name, name: server.name,
active: server.active, active: server.active,
tools: server.tools || [] tools: server.tools || []
}; };
// JSON
this.serverConfigJson = JSON.stringify(configCopy, null, 2); this.serverConfigJson = JSON.stringify(configCopy, null, 2);
this.isEditMode = true; this.isEditMode = true;
this.showMcpServerDialog = true; this.showMcpServerDialog = true;
}, },
updateServerStatus(server) { updateServerStatus(server) {
//
this.mcpServerUpdateLoaders[server.name] = true; this.mcpServerUpdateLoaders[server.name] = true;
server.active = !server.active; server.active = !server.active;
axios.post('/api/tools/mcp/update', server) axios.post('/api/tools/mcp/update', server)
@@ -646,20 +422,16 @@ export default {
this.mcpServerUpdateLoaders[server.name] = false; this.mcpServerUpdateLoaders[server.name] = false;
}); });
}, },
closeServerDialog() { closeServerDialog() {
this.showMcpServerDialog = false; this.showMcpServerDialog = false;
this.addServerDialogMessage = ''; this.addServerDialogMessage = '';
this.resetForm(); this.resetForm();
}, },
testServerConnection() { testServerConnection() {
if (!this.validateJson()) { if (!this.validateJson()) {
return; return;
} }
this.loading = true; this.loading = true;
let configObj; let configObj;
try { try {
configObj = JSON.parse(this.serverConfigJson); configObj = JSON.parse(this.serverConfigJson);
@@ -668,9 +440,8 @@ export default {
this.showError(this.tm('dialogs.addServer.errors.jsonParse', { error: e.message })); this.showError(this.tm('dialogs.addServer.errors.jsonParse', { error: e.message }));
return; return;
} }
axios.post('/api/tools/mcp/test', { axios.post('/api/tools/mcp/test', {
"mcp_server_config": configObj, mcp_server_config: configObj
}) })
.then(response => { .then(response => {
this.loading = false; this.loading = false;
@@ -681,7 +452,6 @@ export default {
this.showError(this.tm('messages.testError', { error: error.response?.data?.message || error.message })); this.showError(this.tm('messages.testError', { error: error.response?.data?.message || error.message }));
}); });
}, },
resetForm() { resetForm() {
this.currentServer = { this.currentServer = {
name: '', name: '',
@@ -692,58 +462,26 @@ export default {
this.jsonError = null; this.jsonError = null;
this.isEditMode = false; this.isEditMode = false;
}, },
showSuccess(message) { showSuccess(message) {
this.save_message = message; this.save_message = message;
this.save_message_success = "success"; this.save_message_success = 'success';
this.save_message_snack = true; this.save_message_snack = true;
}, },
showError(message) { showError(message) {
this.save_message = message; this.save_message = message;
this.save_message_success = "error"; this.save_message_success = 'error';
this.save_message_snack = true; this.save_message_snack = true;
}, },
// MCP
//
async toggleToolStatus(tool) {
try {
const response = await axios.post('/api/tools/toggle-tool', {
name: tool.name,
activate: tool.active
});
if (response.data.status === 'ok') {
this.showSuccess(response.data.message || this.tm('messages.toggleToolSuccess'));
} else {
//
tool.active = !tool.active;
this.showError(response.data.message || this.tm('messages.toggleToolError'));
}
} catch (error) {
//
tool.active = !tool.active;
this.showError(this.tm('messages.toggleToolError', { error: error.response?.data?.message || error.message }));
}
},
// MCP
async syncMcpServers() { async syncMcpServers() {
if (!this.selectedMcpServerProvider) { if (!this.selectedMcpServerProvider) {
this.showError(this.tm('syncProvider.status.selectProvider')); this.showError(this.tm('syncProvider.status.selectProvider'));
return; return;
} }
this.loading = true; this.loading = true;
try { try {
const requestData = { const requestData = {
name: this.selectedMcpServerProvider name: this.selectedMcpServerProvider
}; };
//
if (this.selectedMcpServerProvider === 'modelscope') { if (this.selectedMcpServerProvider === 'modelscope') {
if (!this.mcpProviderToken.trim()) { if (!this.mcpProviderToken.trim()) {
this.showError(this.tm('syncProvider.status.enterToken')); this.showError(this.tm('syncProvider.status.enterToken'));
@@ -752,61 +490,33 @@ export default {
} }
requestData.access_token = this.mcpProviderToken.trim(); requestData.access_token = this.mcpProviderToken.trim();
} }
const response = await axios.post('/api/tools/mcp/sync-provider', requestData); const response = await axios.post('/api/tools/mcp/sync-provider', requestData);
if (response.data.status === 'ok') { if (response.data.status === 'ok') {
this.showSuccess(response.data.message || this.tm('syncProvider.messages.syncSuccess')); this.showSuccess(response.data.message || this.tm('syncProvider.messages.syncSuccess'));
this.showSyncMcpServerDialog = false; this.showSyncMcpServerDialog = false;
this.mcpProviderToken = ''; this.mcpProviderToken = '';
//
this.getServers(); this.getServers();
this.getTools();
} else { } else {
this.showError(response.data.message || this.tm('syncProvider.messages.syncError', { error: 'Unknown error' })); this.showError(response.data.message || this.tm('syncProvider.messages.syncError', { error: 'Unknown error' }));
} }
} catch (error) { } catch (error) {
console.error('同步 MCP 服务器失败:', error); this.showError(this.tm('syncProvider.messages.syncError', {
this.showError(this.tm('syncProvider.messages.syncError', { error: error.response?.data?.message || error.message || '网络连接或访问令牌问题'
error: error.response?.data?.message || error.message || '网络连接或访问令牌问题'
})); }));
} finally { } finally {
this.loading = false; this.loading = false;
} }
} }
} }
} };
</script> </script>
<style scoped> <style scoped>
.tools-page { .tools-page {
padding: 20px; padding: 0px;
padding-top: 8px; padding-top: 8px;
} }
.tool-chips {
max-height: 60px;
overflow-y: auto;
}
.tool-panel {
border: 1px solid rgba(0, 0, 0, 0.05);
transition: all 0.2s ease;
}
.tool-panel:hover {
border-color: rgba(0, 0, 0, 0.1);
}
.params-table {
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 8px;
}
.params-table th {
background-color: rgba(0, 0, 0, 0.02);
}
.monaco-container { .monaco-container {
border: 1px solid rgba(0, 0, 0, 0.1); border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 8px; border-radius: 8px;
@@ -814,4 +524,4 @@ export default {
margin-top: 4px; margin-top: 4px;
overflow: hidden; overflow: hidden;
} }
</style> </style>
@@ -0,0 +1,155 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
const { tm } = useModuleI18n('features/command');
// Props
const props = defineProps<{
availablePlugins: string[];
hasSystemPluginConflict: boolean;
effectiveShowSystemPlugins: boolean;
pluginFilter: string;
typeFilter: string;
permissionFilter: string;
statusFilter: string;
showSystemPlugins: boolean;
searchQuery: string;
}>();
// Emits
const emit = defineEmits<{
(e: 'update:pluginFilter', value: string): void;
(e: 'update:typeFilter', value: string): void;
(e: 'update:permissionFilter', value: string): void;
(e: 'update:statusFilter', value: string): void;
(e: 'update:showSystemPlugins', value: boolean): void;
(e: 'update:searchQuery', value: string): void;
}>();
// Computed items for selects
const pluginItems = computed(() => [
{ title: tm('filters.all'), value: 'all' },
...props.availablePlugins.map(p => ({ title: p, value: p }))
]);
const typeItems = [
{ title: tm('filters.all'), value: 'all' },
{ title: tm('type.group'), value: 'group' },
{ title: tm('type.command'), value: 'command' },
{ title: tm('type.subCommand'), value: 'sub_command' }
];
const permissionItems = [
{ title: tm('filters.all'), value: 'all' },
{ title: tm('permission.everyone'), value: 'everyone' },
{ title: tm('permission.admin'), value: 'admin' }
];
const statusItems = [
{ title: tm('filters.all'), value: 'all' },
{ title: tm('filters.enabled'), value: 'enabled' },
{ title: tm('filters.disabled'), value: 'disabled' },
{ title: tm('filters.conflict'), value: 'conflict' }
];
</script>
<template>
<!-- 过滤器行 -->
<v-row class="mb-4" align="center">
<v-col cols="12" sm="6" md="3">
<v-select
:model-value="pluginFilter"
@update:model-value="emit('update:pluginFilter', $event)"
:items="pluginItems"
:label="tm('filters.byPlugin')"
density="compact"
variant="outlined"
hide-details
/>
</v-col>
<v-col cols="12" sm="6" md="2">
<v-select
:model-value="typeFilter"
@update:model-value="emit('update:typeFilter', $event)"
:items="typeItems"
:label="tm('filters.byType')"
density="compact"
variant="outlined"
hide-details
/>
</v-col>
<v-col cols="12" sm="6" md="2">
<v-select
:model-value="permissionFilter"
@update:model-value="emit('update:permissionFilter', $event)"
:items="permissionItems"
:label="tm('filters.byPermission')"
density="compact"
variant="outlined"
hide-details
/>
</v-col>
<v-col cols="12" sm="6" md="2">
<v-select
:model-value="statusFilter"
@update:model-value="emit('update:statusFilter', $event)"
:items="statusItems"
:label="tm('filters.byStatus')"
density="compact"
variant="outlined"
hide-details
/>
</v-col>
</v-row>
<!-- 搜索栏 + 统计信息行 -->
<div class="mb-4 d-flex flex-wrap align-center ga-4">
<div style="min-width: 200px; max-width: 350px; flex: 1; border: 1px solid #B9B9B9; border-radius: 16px;">
<v-text-field
:model-value="searchQuery"
@update:model-value="emit('update:searchQuery', $event)"
density="compact"
:label="tm('search.placeholder')"
prepend-inner-icon="mdi-magnify"
variant="solo-filled"
flat
hide-details
single-line
/>
</div>
<div class="d-flex align-center ga-4">
<slot name="stats"></slot>
<v-divider vertical class="mx-1" style="height: 20px;" />
<v-checkbox
:model-value="effectiveShowSystemPlugins"
@update:model-value="emit('update:showSystemPlugins', !!$event)"
:label="tm('filters.showSystemPlugins')"
density="compact"
hide-details
:disabled="hasSystemPluginConflict"
class="system-plugin-checkbox"
>
<template v-slot:label>
<span class="text-body-2">{{ tm('filters.showSystemPlugins') }}</span>
<v-tooltip v-if="hasSystemPluginConflict" location="top">
<template v-slot:activator="{ props: tooltipProps }">
<v-icon v-bind="tooltipProps" size="16" color="warning" class="ml-1">mdi-alert-circle</v-icon>
</template>
{{ tm('filters.systemPluginConflictHint') }}
</v-tooltip>
</template>
</v-checkbox>
</div>
</div>
</template>
<style scoped>
.system-plugin-checkbox {
flex: none;
}
.system-plugin-checkbox :deep(.v-selection-control) {
min-height: auto;
}
</style>
@@ -0,0 +1,257 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import type { CommandItem, TypeInfo, StatusInfo } from '../types';
const { tm } = useModuleI18n('features/command');
// Props
const props = defineProps<{
items: CommandItem[];
expandedGroups: Set<string>;
loading?: boolean;
}>();
// Emits
const emit = defineEmits<{
(e: 'toggle-expand', cmd: CommandItem): void;
(e: 'toggle-command', cmd: CommandItem): void;
(e: 'rename', cmd: CommandItem): void;
(e: 'view-details', cmd: CommandItem): void;
}>();
//
const commandHeaders = computed(() => [
{ title: tm('table.headers.command'), key: 'effective_command', minWidth: '100px' },
{ title: tm('table.headers.type'), key: 'type', sortable: false, width: '100px' },
{ title: tm('table.headers.plugin'), key: 'plugin', width: '140px' },
{ title: tm('table.headers.description'), key: 'description', sortable: false },
{ title: tm('table.headers.permission'), key: 'permission', sortable: false, width: '100px' },
{ title: tm('table.headers.status'), key: 'enabled', sortable: false, width: '100px' },
{ title: tm('table.headers.actions'), key: 'actions', sortable: false, width: '140px' }
]);
//
const isGroupExpanded = (cmd: CommandItem): boolean => {
return props.expandedGroups.has(cmd.handler_full_name);
};
//
const getTypeInfo = (type: string): TypeInfo => {
switch (type) {
case 'group':
return { text: tm('type.group'), color: 'info', icon: 'mdi-folder-outline' };
case 'sub_command':
return { text: tm('type.subCommand'), color: 'secondary', icon: 'mdi-subdirectory-arrow-right' };
default:
return { text: tm('type.command'), color: 'primary', icon: 'mdi-console-line' };
}
};
//
const getPermissionColor = (permission: string): string => {
switch (permission) {
case 'admin': return 'error';
default: return 'success';
}
};
//
const getPermissionLabel = (permission: string): string => {
switch (permission) {
case 'admin': return tm('permission.admin');
default: return tm('permission.everyone');
}
};
//
const getStatusInfo = (cmd: CommandItem): StatusInfo => {
if (cmd.has_conflict) {
return { text: tm('status.conflict'), color: 'warning', variant: 'flat' };
}
if (cmd.enabled) {
return { text: tm('status.enabled'), color: 'success', variant: 'flat' };
}
return { text: tm('status.disabled'), color: 'error', variant: 'outlined' };
};
//
const getRowProps = ({ item }: { item: CommandItem }) => {
const classes: string[] = [];
if (item.has_conflict) {
classes.push('conflict-row');
}
if (item.type === 'sub_command') {
classes.push('sub-command-row');
}
if (item.is_group) {
classes.push('group-row');
}
return classes.length > 0 ? { class: classes.join(' ') } : {};
};
</script>
<template>
<v-card class="rounded-lg overflow-hidden elevation-1">
<v-data-table
:headers="commandHeaders"
:items="items"
item-key="handler_full_name"
hover
:row-props="getRowProps"
:loading="props.loading"
>
<template v-slot:item.effective_command="{ item }">
<div class="d-flex align-center py-2">
<!-- 展开/折叠按钮针对指令组 -->
<v-btn
v-if="item.is_group && item.sub_commands?.length > 0"
icon
variant="text"
size="x-small"
class="mr-1"
@click.stop="emit('toggle-expand', item)"
>
<v-icon size="18">{{ isGroupExpanded(item) ? 'mdi-chevron-down' : 'mdi-chevron-right' }}</v-icon>
</v-btn>
<!-- 子指令缩进 -->
<div v-else-if="item.type === 'sub_command'" class="ml-6"></div>
<div>
<div class="text-subtitle-1 font-weight-medium">
<code :class="{ 'sub-command-code': item.type === 'sub_command' }">{{ item.effective_command }}</code>
</div>
</div>
</div>
</template>
<template v-slot:item.type="{ item }">
<v-chip
:color="getTypeInfo(item.type).color"
size="small"
variant="tonal"
>
<v-icon start size="14">{{ getTypeInfo(item.type).icon }}</v-icon>
{{ getTypeInfo(item.type).text }}{{ item.is_group && item.sub_commands?.length > 0 ? `(${item.sub_commands.length})` : '' }}
</v-chip>
</template>
<template v-slot:item.plugin="{ item }">
<div class="text-body-2">{{ item.plugin_display_name || item.plugin }}</div>
</template>
<template v-slot:item.description="{ item }">
<div class="text-body-2 text-medium-emphasis" style="max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
{{ item.description || '-' }}
</div>
</template>
<template v-slot:item.permission="{ item }">
<v-chip :color="getPermissionColor(item.permission)" size="small" class="font-weight-medium">
{{ getPermissionLabel(item.permission) }}
</v-chip>
</template>
<template v-slot:item.enabled="{ item }">
<v-chip
:color="getStatusInfo(item).color"
size="small"
class="font-weight-medium"
:variant="getStatusInfo(item).variant"
>
{{ getStatusInfo(item).text }}
</v-chip>
</template>
<template v-slot:item.actions="{ item }">
<div class="d-flex align-center">
<v-btn-group density="default" variant="text" color="primary">
<v-btn
v-if="!item.enabled"
icon
size="small"
color="success"
@click="emit('toggle-command', item)"
>
<v-icon size="22">mdi-play</v-icon>
<v-tooltip activator="parent" location="top">{{ tm('tooltips.enable') }}</v-tooltip>
</v-btn>
<v-btn
v-else
icon
size="small"
color="error"
@click="emit('toggle-command', item)"
>
<v-icon size="22">mdi-pause</v-icon>
<v-tooltip activator="parent" location="top">{{ tm('tooltips.disable') }}</v-tooltip>
</v-btn>
<v-btn icon size="small" color="warning" @click="emit('rename', item)">
<v-icon size="22">mdi-pencil</v-icon>
<v-tooltip activator="parent" location="top">{{ tm('tooltips.rename') }}</v-tooltip>
</v-btn>
<v-btn icon size="small" @click="emit('view-details', item)">
<v-icon size="22">mdi-information</v-icon>
<v-tooltip activator="parent" location="top">{{ tm('tooltips.viewDetails') }}</v-tooltip>
</v-btn>
</v-btn-group>
</div>
</template>
<template v-slot:no-data>
<div class="text-center pa-8">
<v-icon size="64" color="info" class="mb-4">mdi-console-line</v-icon>
<div class="text-h5 mb-2">{{ tm('empty.noCommands') }}</div>
<div class="text-body-1 mb-4">{{ tm('empty.noCommandsDesc') }}</div>
</div>
</template>
</v-data-table>
</v-card>
</template>
<style scoped>
code {
background-color: rgba(var(--v-theme-primary), 0.1);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
white-space: nowrap;
}
code.sub-command-code {
background-color: rgba(var(--v-theme-secondary), 0.1);
color: rgb(var(--v-theme-secondary));
}
</style>
<style>
/* 冲突行高亮 */
.v-data-table .conflict-row {
background: linear-gradient(90deg, rgba(var(--v-theme-warning), 0.15) 0%, rgba(var(--v-theme-warning), 0.05) 100%) !important;
border-left: 3px solid rgb(var(--v-theme-warning)) !important;
}
.v-data-table .conflict-row:hover {
background: linear-gradient(90deg, rgba(var(--v-theme-warning), 0.25) 0%, rgba(var(--v-theme-warning), 0.1) 100%) !important;
}
/* 指令组行样式 */
.v-data-table .group-row {
background-color: rgba(var(--v-theme-info), 0.05);
}
.v-data-table .group-row:hover {
background-color: rgba(var(--v-theme-info), 0.08) !important;
}
/* 子指令行样式 */
.v-data-table .sub-command-row {
background-color: rgba(var(--v-theme-info), 0.05);
}
.v-data-table .sub-command-row:hover {
background-color: rgba(var(--v-theme-info), 0.08) !important;
}
</style>
@@ -0,0 +1,143 @@
<script setup lang="ts">
import { useI18n, useModuleI18n } from '@/i18n/composables';
import type { CommandItem, TypeInfo } from '../types';
const { t } = useI18n();
const { tm } = useModuleI18n('features/command');
// Props
defineProps<{
show: boolean;
command: CommandItem | null;
}>();
// Emits
const emit = defineEmits<{
(e: 'update:show', value: boolean): void;
}>();
//
const getTypeInfo = (type: string): TypeInfo => {
switch (type) {
case 'group':
return { text: tm('type.group'), color: 'info', icon: 'mdi-folder-outline' };
case 'sub_command':
return { text: tm('type.subCommand'), color: 'secondary', icon: 'mdi-subdirectory-arrow-right' };
default:
return { text: tm('type.command'), color: 'primary', icon: 'mdi-console-line' };
}
};
//
const getPermissionColor = (permission: string): string => {
switch (permission) {
case 'admin': return 'error';
default: return 'success';
}
};
//
const getPermissionLabel = (permission: string): string => {
switch (permission) {
case 'admin': return tm('permission.admin');
default: return tm('permission.everyone');
}
};
</script>
<template>
<v-dialog :model-value="show" @update:model-value="emit('update:show', $event)" max-width="500">
<v-card v-if="command">
<v-card-title class="text-h5">{{ tm('dialogs.details.title') }}</v-card-title>
<v-card-text>
<v-list density="compact">
<v-list-item>
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.type') }}</v-list-item-title>
<v-list-item-subtitle>
<v-chip
:color="getTypeInfo(command.type).color"
size="small"
variant="tonal"
>
<v-icon start size="14">{{ getTypeInfo(command.type).icon }}</v-icon>
{{ getTypeInfo(command.type).text }}
</v-chip>
</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.handler') }}</v-list-item-title>
<v-list-item-subtitle><code>{{ command.handler_name }}</code></v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.module') }}</v-list-item-title>
<v-list-item-subtitle><code>{{ command.module_path }}</code></v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.originalCommand') }}</v-list-item-title>
<v-list-item-subtitle><code>{{ command.original_command }}</code></v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.effectiveCommand') }}</v-list-item-title>
<v-list-item-subtitle><code>{{ command.effective_command }}</code></v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="command.parent_signature">
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.parentGroup') }}</v-list-item-title>
<v-list-item-subtitle><code>{{ command.parent_signature }}</code></v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="command.aliases.length > 0">
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.aliases') }}</v-list-item-title>
<v-list-item-subtitle>
<v-chip v-for="alias in command.aliases" :key="alias" size="small" class="mr-1">
{{ alias }}
</v-chip>
</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="command.is_group && command.sub_commands?.length > 0">
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.subCommands') }}</v-list-item-title>
<v-list-item-subtitle>
<div class="d-flex flex-wrap ga-1 mt-1">
<v-chip
v-for="sub in command.sub_commands"
:key="sub.handler_full_name"
size="small"
variant="outlined"
>
{{ sub.current_fragment }}
</v-chip>
</div>
</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.permission') }}</v-list-item-title>
<v-list-item-subtitle>
<v-chip :color="getPermissionColor(command.permission)" size="small">
{{ getPermissionLabel(command.permission) }}
</v-chip>
</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="command.has_conflict">
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.conflictStatus') }}</v-list-item-title>
<v-list-item-subtitle>
<v-chip color="warning" size="small">{{ tm('status.conflict') }}</v-chip>
</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn color="primary" variant="text" @click="emit('update:show', false)">
{{ t('core.actions.close') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
code {
background-color: rgba(var(--v-theme-primary), 0.1);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
</style>
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { useModuleI18n } from '@/i18n/composables';
import type { CommandItem } from '../types';
const { tm } = useModuleI18n('features/command');
// Props
defineProps<{
show: boolean;
command: CommandItem | null;
newName: string;
loading: boolean;
}>();
// Emits
const emit = defineEmits<{
(e: 'update:show', value: boolean): void;
(e: 'update:newName', value: string): void;
(e: 'confirm'): void;
}>();
</script>
<template>
<v-dialog :model-value="show" @update:model-value="emit('update:show', $event)" max-width="500">
<v-card>
<v-card-title class="text-h5">{{ tm('dialogs.rename.title') }}</v-card-title>
<v-card-text>
<v-text-field
:model-value="newName"
@update:model-value="emit('update:newName', $event)"
:label="tm('dialogs.rename.newName')"
variant="outlined"
density="compact"
autofocus
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn color="grey" variant="text" @click="emit('update:show', false)">
{{ tm('dialogs.rename.cancel') }}
</v-btn>
<v-btn
color="primary"
variant="text"
:loading="loading"
@click="emit('confirm')"
>
{{ tm('dialogs.rename.confirm') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
@@ -0,0 +1,144 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import type { ToolItem } from '../types';
const { tm: tmTool } = useModuleI18n('features/tooluse');
const { tm: tmCommand } = useModuleI18n('features/command');
const props = defineProps<{
items: ToolItem[];
loading?: boolean;
}>();
const emit = defineEmits<{
(e: 'toggle-tool', tool: ToolItem): void;
}>();
const toolHeaders = computed(() => [
{ title: tmTool('functionTools.title'), key: 'name', minWidth: '160px' },
{ title: tmTool('functionTools.description'), key: 'description' },
{ title: tmTool('functionTools.table.origin'), key: 'origin', sortable: false, width: '120px' },
{ title: tmTool('functionTools.table.originName'), key: 'origin_name', sortable: false, width: '160px' },
{ title: tmCommand('status.enabled'), key: 'active', sortable: false, width: '120px' },
{ title: tmTool('functionTools.table.actions'), key: 'actions', sortable: false, width: '120px' }
]);
const parameterEntries = (tool: ToolItem) => Object.entries(tool.parameters?.properties || {});
</script>
<template>
<v-card class="rounded-lg overflow-hidden elevation-1">
<v-data-table
:headers="toolHeaders"
:items="items"
item-key="name"
hover
show-expand
class="tool-table"
:loading="props.loading"
>
<template #item.name="{ item }">
<div class="d-flex align-center py-2">
<v-icon color="primary" class="mr-2" size="18">
{{ item.name.includes(':') ? 'mdi-server-network' : 'mdi-function-variant' }}
</v-icon>
<div>
<div class="text-subtitle-1 font-weight-medium">{{ item.name }}</div>
</div>
</div>
</template>
<template #item.description="{ item }">
<div class="text-body-2 text-medium-emphasis" style="max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
{{ item.description || '-' }}
</div>
</template>
<template #item.origin="{ item }">
<v-chip size="small" variant="tonal" color="info" class="text-caption font-weight-medium">
{{ item.origin || '-' }}
</v-chip>
</template>
<template #item.origin_name="{ item }">
<div class="text-body-2 text-medium-emphasis" style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
{{ item.origin_name || '-' }}
</div>
</template>
<template #item.active="{ item }">
<v-chip :color="item.active ? 'success' : 'error'" size="small" class="font-weight-medium" :variant="item.active ? 'flat' : 'outlined'">
{{ item.active ? tmCommand('status.enabled') : tmCommand('status.disabled') }}
</v-chip>
</template>
<template #item.actions="{ item }">
<v-switch
:model-value="item.active"
color="primary"
density="compact"
hide-details
inset
@update:model-value="emit('toggle-tool', item)"
/>
</template>
<template #no-data>
<div class="text-center pa-8">
<v-icon size="64" color="info" class="mb-4">mdi-function-variant</v-icon>
<div class="text-h5 mb-2">{{ tmTool('functionTools.empty') }}</div>
</div>
</template>
<template #expanded-row="{ item }">
<td :colspan="toolHeaders.length + 1" class="pa-4">
<div class="d-flex align-start ga-4">
<v-icon size="20" color="primary">mdi-code-json</v-icon>
<div class="flex-1">
<div class="text-subtitle-2 font-weight-medium mb-2">{{ tmTool('functionTools.parameters') }}</div>
<div v-if="parameterEntries(item).length === 0" class="text-caption text-medium-emphasis">
{{ tmTool('functionTools.noParameters') }}
</div>
<v-table
v-else
density="compact"
class="param-table"
>
<thead>
<tr>
<th class="text-left text-caption text-medium-emphasis">{{ tmTool('functionTools.table.paramName') }}</th>
<th class="text-left text-caption text-medium-emphasis" style="width: 140px;">{{ tmTool('functionTools.table.type') }}</th>
<th class="text-left text-caption text-medium-emphasis">{{ tmTool('functionTools.table.description') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="([paramName, param]) in parameterEntries(item)" :key="paramName">
<td class="font-weight-medium text-body-2">{{ paramName }}</td>
<td class="text-body-2">
<v-chip size="x-small" color="primary" class="text-caption">
{{ param?.type || '-' }}
</v-chip>
</td>
<td class="text-body-2 text-medium-emphasis">{{ param?.description || '-' }}</td>
</tr>
</tbody>
</v-table>
</div>
</div>
</td>
</template>
</v-data-table>
</v-card>
</template>
<style scoped>
.param-table {
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 8px;
}
.tool-table :deep(.v-data-table__td) {
vertical-align: middle;
}
</style>
@@ -0,0 +1,177 @@
/**
* Composable
*/
import { reactive } from 'vue';
import axios from 'axios';
import type { CommandItem, RenameDialogState, DetailsDialogState, TypeInfo, StatusInfo } from '../types';
export function useCommandActions(
toast: (message: string, color?: string) => void,
fetchCommands: () => Promise<void>
) {
// 重命名对话框状态
const renameDialog = reactive<RenameDialogState>({
show: false,
command: null,
newName: '',
loading: false
});
// 详情对话框状态
const detailsDialog = reactive<DetailsDialogState>({
show: false,
command: null
});
/**
* /
*/
const toggleCommand = async (
cmd: CommandItem,
successMessage: string,
errorMessage: string
) => {
try {
const res = await axios.post('/api/commands/toggle', {
handler_full_name: cmd.handler_full_name,
enabled: !cmd.enabled
});
if (res.data.status === 'ok') {
toast(successMessage, 'success');
await fetchCommands();
} else {
toast(res.data.message || errorMessage, 'error');
}
} catch (err: any) {
toast(err?.message || errorMessage, 'error');
}
};
/**
*
*/
const openRenameDialog = (cmd: CommandItem) => {
renameDialog.command = cmd;
renameDialog.newName = cmd.current_fragment || '';
renameDialog.show = true;
};
/**
*
*/
const confirmRename = async (successMessage: string, errorMessage: string) => {
if (!renameDialog.command || !renameDialog.newName.trim()) return;
renameDialog.loading = true;
try {
const res = await axios.post('/api/commands/rename', {
handler_full_name: renameDialog.command.handler_full_name,
new_name: renameDialog.newName.trim()
});
if (res.data.status === 'ok') {
toast(successMessage, 'success');
renameDialog.show = false;
await fetchCommands();
} else {
toast(res.data.message || errorMessage, 'error');
}
} catch (err: any) {
toast(err?.message || errorMessage, 'error');
} finally {
renameDialog.loading = false;
}
};
/**
*
*/
const openDetailsDialog = (cmd: CommandItem) => {
detailsDialog.command = cmd;
detailsDialog.show = true;
};
/**
*
*/
const getTypeInfo = (type: string, translations: { group: string; subCommand: string; command: string }): TypeInfo => {
switch (type) {
case 'group':
return { text: translations.group, color: 'info', icon: 'mdi-folder-outline' };
case 'sub_command':
return { text: translations.subCommand, color: 'secondary', icon: 'mdi-subdirectory-arrow-right' };
default:
return { text: translations.command, color: 'primary', icon: 'mdi-console-line' };
}
};
/**
*
*/
const getPermissionColor = (permission: string): string => {
switch (permission) {
case 'admin': return 'error';
default: return 'success';
}
};
/**
*
*/
const getPermissionLabel = (permission: string, translations: { admin: string; everyone: string }): string => {
switch (permission) {
case 'admin': return translations.admin;
default: return translations.everyone;
}
};
/**
*
*/
const getStatusInfo = (
cmd: CommandItem,
translations: { conflict: string; enabled: string; disabled: string }
): StatusInfo => {
if (cmd.has_conflict) {
return { text: translations.conflict, color: 'warning', variant: 'flat' };
}
if (cmd.enabled) {
return { text: translations.enabled, color: 'success', variant: 'flat' };
}
return { text: translations.disabled, color: 'error', variant: 'outlined' };
};
/**
*
*/
const getRowProps = ({ item }: { item: CommandItem }) => {
const classes: string[] = [];
if (item.has_conflict) {
classes.push('conflict-row');
}
if (item.type === 'sub_command') {
classes.push('sub-command-row');
}
if (item.is_group) {
classes.push('group-row');
}
return classes.length > 0 ? { class: classes.join(' ') } : {};
};
return {
// 状态
renameDialog,
detailsDialog,
// 方法
toggleCommand,
openRenameDialog,
confirmRename,
openDetailsDialog,
getTypeInfo,
getPermissionColor,
getPermissionLabel,
getStatusInfo,
getRowProps
};
}
@@ -0,0 +1,187 @@
/**
* Composable
*/
import { ref, computed, type Ref } from 'vue';
import type { CommandItem, FilterState } from '../types';
export function useCommandFilters(commands: Ref<CommandItem[]>) {
// 过滤状态
const searchQuery = ref('');
const pluginFilter = ref('all');
const permissionFilter = ref('all');
const statusFilter = ref('all');
const typeFilter = ref('all');
const showSystemPlugins = ref(false);
// 展开的指令组
const expandedGroups = ref<Set<string>>(new Set());
/**
*
*/
const hasSystemPluginConflict = computed(() => {
return commands.value.some(cmd => cmd.has_conflict && cmd.reserved);
});
/**
*
*/
const effectiveShowSystemPlugins = computed(() => {
return showSystemPlugins.value || hasSystemPluginConflict.value;
});
/**
*
*/
const availablePlugins = computed(() => {
const plugins = new Set(
commands.value
.filter(cmd => effectiveShowSystemPlugins.value || !cmd.reserved)
.map(cmd => cmd.plugin)
);
return Array.from(plugins).sort();
});
/**
*
*/
const matchesFilters = (cmd: CommandItem, query: string): boolean => {
// 系统插件过滤(除非显示系统插件)
if (!effectiveShowSystemPlugins.value && cmd.reserved) {
return false;
}
// 搜索过滤
if (query) {
const matchesSearch =
cmd.effective_command?.toLowerCase().includes(query) ||
cmd.description?.toLowerCase().includes(query) ||
cmd.plugin?.toLowerCase().includes(query);
if (!matchesSearch) return false;
}
// 插件过滤
if (pluginFilter.value !== 'all' && cmd.plugin !== pluginFilter.value) {
return false;
}
// 权限过滤
if (permissionFilter.value !== 'all') {
if (permissionFilter.value === 'everyone') {
if (cmd.permission !== 'everyone' && cmd.permission !== 'member') return false;
} else if (cmd.permission !== permissionFilter.value) {
return false;
}
}
// 状态过滤
if (statusFilter.value !== 'all') {
if (statusFilter.value === 'enabled' && !cmd.enabled) return false;
if (statusFilter.value === 'disabled' && cmd.enabled) return false;
if (statusFilter.value === 'conflict' && !cmd.has_conflict) return false;
}
// 类型过滤
if (typeFilter.value !== 'all') {
if (typeFilter.value === 'group' && cmd.type !== 'group') return false;
if (typeFilter.value === 'command' && cmd.type !== 'command') return false;
if (typeFilter.value === 'sub_command' && cmd.type !== 'sub_command') return false;
}
return true;
};
/**
*
*/
const filteredCommands = computed(() => {
const query = searchQuery.value.toLowerCase();
const conflictCmds: CommandItem[] = [];
const normalCmds: CommandItem[] = [];
for (const cmd of commands.value) {
// 对于指令组,检查组本身或子指令是否匹配
if (cmd.is_group) {
const groupMatches = matchesFilters(cmd, query);
const matchingSubCmds = (cmd.sub_commands || []).filter(sub => matchesFilters(sub, query));
// 如果组匹配或有匹配的子指令,则包含它
if (groupMatches || matchingSubCmds.length > 0) {
if (cmd.has_conflict) {
conflictCmds.push(cmd);
} else {
normalCmds.push(cmd);
}
// 如果组已展开,添加匹配的子指令
if (expandedGroups.value.has(cmd.handler_full_name)) {
const subsToShow = query ? matchingSubCmds : (cmd.sub_commands || []);
for (const sub of subsToShow) {
if (sub.has_conflict) {
conflictCmds.push(sub);
} else {
normalCmds.push(sub);
}
}
}
}
} else if (cmd.type !== 'sub_command') {
// 普通指令(子指令通过组处理)
if (matchesFilters(cmd, query)) {
if (cmd.has_conflict) {
conflictCmds.push(cmd);
} else {
normalCmds.push(cmd);
}
}
}
}
// 按 effective_command 排序冲突指令,使其分组在一起
conflictCmds.sort((a, b) => (a.effective_command || '').localeCompare(b.effective_command || ''));
return [...conflictCmds, ...normalCmds];
});
/**
* /
*/
const toggleGroupExpand = (cmd: CommandItem) => {
if (!cmd.is_group) return;
if (expandedGroups.value.has(cmd.handler_full_name)) {
expandedGroups.value.delete(cmd.handler_full_name);
} else {
expandedGroups.value.add(cmd.handler_full_name);
}
};
/**
*
*/
const isGroupExpanded = (cmd: CommandItem): boolean => {
return expandedGroups.value.has(cmd.handler_full_name);
};
return {
// 状态
searchQuery,
pluginFilter,
permissionFilter,
statusFilter,
typeFilter,
showSystemPlugins,
expandedGroups,
// 计算属性
hasSystemPluginConflict,
effectiveShowSystemPlugins,
availablePlugins,
filteredCommands,
// 方法
matchesFilters,
toggleGroupExpand,
isGroupExpanded
};
}
@@ -0,0 +1,83 @@
/**
* Composable
*/
import { ref, reactive } from 'vue';
import axios from 'axios';
import type { CommandItem, CommandSummary, SnackbarState, ToolItem } from '../types';
export function useComponentData() {
const loading = ref(false);
const commands = ref<CommandItem[]>([]);
const tools = ref<ToolItem[]>([]);
const toolsLoading = ref(false);
const summary = reactive<CommandSummary>({
disabled: 0,
conflicts: 0
});
const snackbar = reactive<SnackbarState>({
show: false,
message: '',
color: 'success'
});
/**
* Toast
*/
const toast = (message: string, color: string = 'success') => {
snackbar.message = message;
snackbar.color = color;
snackbar.show = true;
};
/**
*
*/
const fetchCommands = async (errorMessage: string) => {
loading.value = true;
try {
const res = await axios.get('/api/commands');
if (res.data.status === 'ok') {
commands.value = res.data.data.items || [];
const s = res.data.data.summary || {};
summary.disabled = s.disabled || 0;
summary.conflicts = s.conflicts || 0;
} else {
toast(res.data.message || errorMessage, 'error');
}
} catch (err: any) {
toast(err?.message || errorMessage, 'error');
} finally {
loading.value = false;
}
};
const fetchTools = async (errorMessage: string) => {
toolsLoading.value = true;
try {
const res = await axios.get('/api/tools/list');
if (res.data.status === 'ok') {
tools.value = res.data.data || [];
} else {
toast(res.data.message || errorMessage, 'error');
}
} catch (err: any) {
toast(err?.message || errorMessage, 'error');
} finally {
toolsLoading.value = false;
}
};
return {
loading,
commands,
tools,
toolsLoading,
summary,
snackbar,
toast,
fetchCommands,
fetchTools
};
}
@@ -0,0 +1,307 @@
<script setup lang="ts">
/**
* 组件管理页面 - 主入口
*
* 模块化结构
* - types.ts: 类型定义
* - composables/useComponentData.ts: 数据获取和状态管理
* - composables/useCommandFilters.ts: 过滤逻辑
* - composables/useCommandActions.ts: 操作方法
* - components/CommandFilters.vue: 过滤器组件
* - components/CommandTable.vue: 表格组件
* - components/RenameDialog.vue: 重命名对话框
* - components/DetailsDialog.vue: 详情对话框
*/
import { computed, onActivated, onMounted, ref, watch} from 'vue';
import axios from 'axios';
import { useModuleI18n } from '@/i18n/composables';
// Composables
import { useComponentData } from './composables/useComponentData';
import { useCommandFilters } from './composables/useCommandFilters';
import { useCommandActions } from './composables/useCommandActions';
// Components
import CommandFilters from './components/CommandFilters.vue';
import CommandTable from './components/CommandTable.vue';
import ToolTable from './components/ToolTable.vue';
import RenameDialog from './components/RenameDialog.vue';
import DetailsDialog from './components/DetailsDialog.vue';
// Types
import type { CommandItem, ToolItem } from './types';
defineOptions({ name: 'ComponentPanel' });
const props = withDefaults(defineProps<{ active?: boolean }>(), {
active: true
});
const { tm } = useModuleI18n('features/command');
const { tm: tmTool } = useModuleI18n('features/tooluse');
const viewMode = ref<'commands' | 'tools'>('commands');
const toolSearch = ref('');
//
const {
loading,
commands,
tools,
toolsLoading,
summary,
snackbar,
toast,
fetchCommands,
fetchTools
} = useComponentData();
//
const {
searchQuery,
pluginFilter,
permissionFilter,
statusFilter,
typeFilter,
showSystemPlugins,
expandedGroups,
hasSystemPluginConflict,
effectiveShowSystemPlugins,
availablePlugins,
filteredCommands,
toggleGroupExpand
} = useCommandFilters(commands);
//
const {
renameDialog,
detailsDialog,
toggleCommand,
openRenameDialog,
confirmRename,
openDetailsDialog
} = useCommandActions(toast, () => fetchCommands(tm('messages.loadFailed')));
const filteredTools = computed(() => {
const query = toolSearch.value.trim().toLowerCase();
if (!query) return tools.value;
return tools.value.filter(tool =>
tool.name?.toLowerCase().includes(query) ||
tool.description?.toLowerCase().includes(query)
);
});
//
const handleToggleCommand = async (cmd: CommandItem) => {
await toggleCommand(cmd, tm('messages.toggleSuccess'), tm('messages.toggleFailed'));
};
const handleToggleTool = async (tool: ToolItem) => {
const previous = tool.active;
tool.active = !tool.active;
try {
const res = await axios.post('/api/tools/toggle-tool', {
name: tool.name,
activate: tool.active
});
if (res.data.status === 'ok') {
toast(res.data.message || tmTool('messages.toggleToolSuccess'));
} else {
tool.active = previous;
toast(res.data.message || tmTool('messages.toggleToolError', { error: '' }), 'error');
}
} catch (error: any) {
tool.active = previous;
toast(error?.response?.data?.message || error?.message || tmTool('messages.toggleToolError', { error: '' }), 'error');
}
};
//
const handleConfirmRename = async () => {
await confirmRename(tm('messages.renameSuccess'), tm('messages.renameFailed'));
};
//
onMounted(async () => {
await Promise.all([
fetchCommands(tm('messages.loadFailed')),
fetchTools(tmTool('messages.getToolsError', { error: '' }))
]);
});
watch(() => props.active, async (isActive) => {
if (!isActive) return;
if (viewMode.value === 'commands') {
await fetchCommands(tm('messages.loadFailed'));
} else {
await fetchTools(tmTool('messages.getToolsError', { error: '' }));
}
});
watch(viewMode, async (mode) => {
if (mode === 'commands') {
await fetchCommands(tm('messages.loadFailed'));
} else {
await fetchTools(tmTool('messages.getToolsError', { error: '' }));
}
});
</script>
<template>
<v-row>
<v-col cols="12">
<v-card variant="flat" style="background-color: transparent">
<v-card-text style="padding: 20px 12px; padding-top: 0px;">
<div class="d-flex justify-space-between align-center mb-6 flex-wrap ga-3">
<v-btn-toggle v-model="viewMode" color="primary" variant="outlined" density="comfortable" mandatory>
<v-btn value="commands">
<v-icon size="18" class="mr-1">mdi-console-line</v-icon>
{{ tm('type.command') }}
</v-btn>
<v-btn value="tools">
<v-icon size="18" class="mr-1">mdi-function-variant</v-icon>
{{ tmTool('functionTools.title') }}
</v-btn>
</v-btn-toggle>
<v-progress-linear
v-if="viewMode === 'commands' && loading"
indeterminate
color="primary"
style="max-width: 220px; flex: 1;"
/>
<v-progress-linear
v-else-if="viewMode === 'tools' && toolsLoading"
indeterminate
color="primary"
style="max-width: 220px; flex: 1;"
/>
</div>
<div v-if="viewMode === 'commands'">
<CommandFilters
:plugin-filter="pluginFilter"
@update:plugin-filter="pluginFilter = $event"
:type-filter="typeFilter"
@update:type-filter="typeFilter = $event"
:permission-filter="permissionFilter"
@update:permission-filter="permissionFilter = $event"
:status-filter="statusFilter"
@update:status-filter="statusFilter = $event"
:show-system-plugins="showSystemPlugins"
@update:show-system-plugins="showSystemPlugins = $event"
:search-query="searchQuery"
@update:search-query="searchQuery = $event"
:available-plugins="availablePlugins"
:has-system-plugin-conflict="hasSystemPluginConflict"
:effective-show-system-plugins="effectiveShowSystemPlugins"
>
<template #stats>
<div class="d-flex align-center">
<v-icon size="18" color="primary" class="mr-1">mdi-console-line</v-icon>
<span class="text-body-2 text-medium-emphasis mr-1">{{ tm('summary.total') }}:</span>
<span class="text-body-1 font-weight-bold text-primary">{{ filteredCommands.length }}</span>
</div>
<v-divider vertical class="mx-1" style="height: 20px;" />
<div class="d-flex align-center">
<v-icon size="18" color="error" class="mr-1">mdi-close-circle-outline</v-icon>
<span class="text-body-2 text-medium-emphasis mr-1">{{ tm('summary.disabled') }}:</span>
<span class="text-body-1 font-weight-bold text-error">{{ summary.disabled }}</span>
</div>
</template>
</CommandFilters>
<v-alert
v-if="summary.conflicts > 0"
type="error"
variant="tonal"
class="mb-4"
prominent
border="start"
>
<template v-slot:prepend>
<v-icon size="28">mdi-alert-circle</v-icon>
</template>
<v-alert-title class="text-subtitle-1 font-weight-bold">
{{ tm('conflictAlert.title') }}
</v-alert-title>
<div class="text-body-2 mt-1">
{{ tm('conflictAlert.description', { count: summary.conflicts }) }}
</div>
<div class="text-body-2 mt-2">
<v-icon size="16" class="mr-1">mdi-lightbulb-outline</v-icon>
{{ tm('conflictAlert.hint') }}
</div>
</v-alert>
<CommandTable
:items="filteredCommands"
:expanded-groups="expandedGroups"
:loading="loading"
@toggle-expand="toggleGroupExpand"
@toggle-command="handleToggleCommand"
@rename="openRenameDialog"
@view-details="openDetailsDialog"
/>
</div>
<div v-else>
<div class="d-flex flex-wrap align-center ga-3 mb-4">
<div style="min-width: 240px; max-width: 380px; flex: 1;">
<v-text-field
v-model="toolSearch"
prepend-inner-icon="mdi-magnify"
:label="tmTool('functionTools.search')"
variant="outlined"
density="compact"
hide-details
clearable
/>
</div>
<div class="d-flex align-center ga-2">
<div class="d-flex align-center">
<v-icon size="18" color="primary" class="mr-1">mdi-function-variant</v-icon>
<span class="text-body-2 text-medium-emphasis mr-1">{{ tm('summary.total') }}:</span>
<span class="text-body-1 font-weight-bold text-primary">{{ filteredTools.length }}</span>
</div>
<v-divider vertical class="mx-1" style="height: 20px;" />
<div class="d-flex align-center">
<v-icon size="18" color="success" class="mr-1">mdi-check-circle-outline</v-icon>
<span class="text-body-2 text-medium-emphasis mr-1">{{ tm('status.enabled') }}:</span>
<span class="text-body-1 font-weight-bold text-success">{{ filteredTools.filter(t => t.active).length }}</span>
</div>
</div>
</div>
<ToolTable
:items="filteredTools"
:loading="toolsLoading"
@toggle-tool="handleToggleTool"
/>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- 重命名对话框 -->
<RenameDialog
:show="renameDialog.show"
@update:show="renameDialog.show = $event"
:new-name="renameDialog.newName"
@update:new-name="renameDialog.newName = $event"
:command="renameDialog.command"
:loading="renameDialog.loading"
@confirm="handleConfirmRename"
/>
<!-- 详情对话框 -->
<DetailsDialog
:show="detailsDialog.show"
@update:show="detailsDialog.show = $event"
:command="detailsDialog.command"
/>
<!-- Snackbar -->
<v-snackbar :timeout="2000" elevation="24" :color="snackbar.color" v-model="snackbar.show">
{{ snackbar.message }}
</v-snackbar>
</template>
@@ -0,0 +1,102 @@
/**
* -
*/
/** 指令项接口 */
export interface CommandItem {
handler_full_name: string;
handler_name: string;
plugin: string;
plugin_display_name: string | null;
module_path: string;
description: string;
type: CommandType;
parent_signature: string;
parent_group_handler: string;
original_command: string;
current_fragment: string;
effective_command: string;
aliases: string[];
permission: PermissionType;
enabled: boolean;
is_group: boolean;
has_conflict: boolean;
reserved: boolean;
sub_commands: CommandItem[];
}
/** 指令类型 */
export type CommandType = 'command' | 'group' | 'sub_command';
/** 权限类型 */
export type PermissionType = 'admin' | 'everyone' | 'member';
/** 指令摘要统计 */
export interface CommandSummary {
disabled: number;
conflicts: number;
}
/** 过滤器状态 */
export interface FilterState {
searchQuery: string;
pluginFilter: string;
permissionFilter: string;
statusFilter: string;
typeFilter: string;
showSystemPlugins: boolean;
}
/** 重命名对话框状态 */
export interface RenameDialogState {
show: boolean;
command: CommandItem | null;
newName: string;
loading: boolean;
}
/** 详情对话框状态 */
export interface DetailsDialogState {
show: boolean;
command: CommandItem | null;
}
/** Toast 消息状态 */
export interface SnackbarState {
show: boolean;
message: string;
color: string;
}
/** 类型信息展示 */
export interface TypeInfo {
text: string;
color: string;
icon: string;
}
/** 状态信息展示 */
export interface StatusInfo {
text: string;
color: string;
variant: 'flat' | 'outlined' | 'text' | 'elevated' | 'tonal' | 'plain';
}
/** MCP/函数工具参数定义 */
export interface ToolParameter {
type?: string;
description?: string;
}
/** MCP/函数工具对象 */
export interface ToolItem {
name: string;
description: string;
active: boolean;
parameters?: {
properties?: Record<string, ToolParameter>;
};
origin?: string;
origin_name?: string;
}
@@ -394,6 +394,9 @@ export default {
// //
showConfigDrawer: false, showConfigDrawer: false,
configDrawerTargetId: null, configDrawerTargetId: null,
// ID ID
originalUpdatingPlatformId: null,
}; };
}, },
setup() { setup() {
@@ -481,6 +484,7 @@ export default {
updatingPlatformConfig: { updatingPlatformConfig: {
handler(newConfig) { handler(newConfig) {
if (this.updatingMode && newConfig && newConfig.id) { if (this.updatingMode && newConfig && newConfig.id) {
this.originalUpdatingPlatformId = newConfig.id;
this.getPlatformConfigs(newConfig.id); this.getPlatformConfigs(newConfig.id);
} }
}, },
@@ -533,6 +537,8 @@ export default {
this.showConfigDrawer = false; this.showConfigDrawer = false;
this.configDrawerTargetId = null; this.configDrawerTargetId = null;
this.originalUpdatingPlatformId = null;
}, },
closeDialog() { closeDialog() {
this.resetForm(); this.resetForm();
@@ -624,7 +630,7 @@ export default {
} }
}, },
async updatePlatform() { async updatePlatform() {
let id = this.updatingPlatformConfig.id; const id = this.originalUpdatingPlatformId || this.updatingPlatformConfig.id;
if (!id) { if (!id) {
this.loading = false; this.loading = false;
this.showError('更新失败,缺少平台 ID。'); this.showError('更新失败,缺少平台 ID。');
@@ -633,11 +639,15 @@ export default {
try { try {
// //
await axios.post('/api/config/platform/update', { let resp = await axios.post('/api/config/platform/update', {
id: id, id: id,
config: this.updatingPlatformConfig config: this.updatingPlatformConfig
}); })
if (resp.data.status === 'error') {
throw new Error(resp.data.message || '平台更新失败');
}
// //
await this.saveRoutesInternal(); await this.saveRoutesInternal();
@@ -885,7 +895,10 @@ export default {
// //
async saveRoutesInternal() { async saveRoutesInternal() {
if (!this.updatingPlatformConfig || !this.updatingPlatformConfig.id) { const originalPlatformId = this.originalUpdatingPlatformId || this.updatingPlatformConfig?.id;
const newPlatformId = this.updatingPlatformConfig?.id || originalPlatformId;
if (!originalPlatformId && !newPlatformId) {
throw new Error('无法获取平台 ID'); throw new Error('无法获取平台 ID');
} }
@@ -895,9 +908,11 @@ export default {
const fullRoutingTable = routesRes.data.data.routing; const fullRoutingTable = routesRes.data.data.routing;
// //
const platformId = this.updatingPlatformConfig.id;
for (const umop in fullRoutingTable) { for (const umop in fullRoutingTable) {
if (this.isUmopMatchPlatform(umop, platformId)) { if (
(originalPlatformId && this.isUmopMatchPlatform(umop, originalPlatformId)) ||
(newPlatformId && this.isUmopMatchPlatform(umop, newPlatformId))
) {
delete fullRoutingTable[umop]; delete fullRoutingTable[umop];
} }
} }
@@ -906,7 +921,8 @@ export default {
for (const route of this.platformRoutes) { for (const route of this.platformRoutes) {
const messageType = route.messageType === '*' ? '*' : route.messageType; const messageType = route.messageType === '*' ? '*' : route.messageType;
const sessionId = route.sessionId === '*' ? '*' : route.sessionId; const sessionId = route.sessionId === '*' ? '*' : route.sessionId;
const newUmop = `${platformId}:${messageType}:${sessionId}`; const platformIdForRoute = newPlatformId || originalPlatformId;
const newUmop = `${platformIdForRoute}:${messageType}:${sessionId}`;
if (route.configId) { if (route.configId) {
fullRoutingTable[newUmop] = route.configId; fullRoutingTable[newUmop] = route.configId;
@@ -3,10 +3,6 @@
<v-card :title="tm('dialogs.addProvider.title')"> <v-card :title="tm('dialogs.addProvider.title')">
<v-card-text style="overflow-y: auto;"> <v-card-text style="overflow-y: auto;">
<v-tabs v-model="activeProviderTab" grow> <v-tabs v-model="activeProviderTab" grow>
<v-tab value="chat_completion" class="font-weight-medium px-3">
<v-icon start>mdi-message-text</v-icon>
{{ tm('dialogs.addProvider.tabs.basic') }}
</v-tab>
<v-tab value="agent_runner" class="font-weight-medium px-3"> <v-tab value="agent_runner" class="font-weight-medium px-3">
<v-icon start>mdi-cogs</v-icon> <v-icon start>mdi-cogs</v-icon>
{{ tm('dialogs.addProvider.tabs.agentRunner') }} {{ tm('dialogs.addProvider.tabs.agentRunner') }}
@@ -116,7 +112,7 @@ export default {
// //
getTemplatesByType(type) { getTemplatesByType(type) {
const templates = this.metadata['provider_group']?.metadata?.provider?.config_template || {}; const templates = this.metadata.provider.config_template || {};
const filtered = {}; const filtered = {};
for (const [name, template] of Object.entries(templates)) { for (const [name, template] of Object.entries(templates)) {
@@ -0,0 +1,211 @@
<template>
<div class="mt-4">
<div class="d-flex align-center ga-2 mb-2">
<h3 class="text-h5 font-weight-bold mb-0">{{ tm('models.configured') }}</h3>
<small style="color: grey;" v-if="availableCount">{{ tm('models.available') }} {{ availableCount }}</small>
<v-text-field
v-model="modelSearchProxy"
density="compact"
prepend-inner-icon="mdi-magnify"
hide-details
variant="solo-filled"
flat
class="ml-1"
style="max-width: 240px;"
:placeholder="tm('models.searchPlaceholder')"
/>
<v-spacer></v-spacer>
<v-btn
color="primary"
prepend-icon="mdi-download"
:loading="loadingModels"
@click="emit('fetch-models')"
variant="tonal"
size="small"
>
{{ isSourceModified ? tm('providerSources.saveAndFetchModels') : tm('providerSources.fetchModels') }}
</v-btn>
<v-btn
color="primary"
prepend-icon="mdi-pencil-plus"
variant="text"
size="small"
class="ml-1"
@click="emit('open-manual-model')"
>
{{ tm('models.manualAddButton') }}
</v-btn>
</div>
<v-list
density="compact"
class="rounded-lg border"
style="max-height: 520px; overflow-y: auto; font-family:system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;"
>
<template v-if="entries.length > 0">
<template v-for="entry in entries" :key="entry.type === 'configured' ? `provider-${entry.provider.id}` : `model-${entry.model}`">
<v-list-item
v-if="entry.type === 'configured'"
class="provider-compact-item"
@click="emit('open-provider-edit', entry.provider)"
>
<v-list-item-title class="font-weight-medium text-truncate">
{{ entry.provider.id }}
</v-list-item-title>
<v-list-item-subtitle class="text-caption text-grey d-flex align-center ga-1" style="font-family: monospace;">
<span>{{ entry.provider.model }}</span>
<v-icon v-if="supportsImageInput(entry.metadata)" size="14" color="grey">
mdi-eye-outline
</v-icon>
<v-icon v-if="supportsToolCall(entry.metadata)" size="14" color="grey">
mdi-wrench
</v-icon>
<v-icon v-if="supportsReasoning(entry.metadata)" size="14" color="grey">
mdi-brain
</v-icon>
<span v-if="formatContextLimit(entry.metadata)">
{{ formatContextLimit(entry.metadata) }}
</span>
</v-list-item-subtitle>
<template #append>
<div class="d-flex align-center ga-1" @click.stop>
<v-switch
v-model="entry.provider.enable"
density="compact"
inset
hide-details
color="primary"
class="mr-1"
@update:modelValue="emit('toggle-provider-enable', entry.provider, $event)"
></v-switch>
<v-tooltip location="top" max-width="300">
{{ tm('availability.test') }}
<template #activator="{ props }">
<v-btn
icon="mdi-wrench"
size="small"
variant="text"
:disabled="!entry.provider.enable"
:loading="isProviderTesting(entry.provider.id)"
v-bind="props"
@click.stop="emit('test-provider', entry.provider)"
></v-btn>
</template>
</v-tooltip>
<v-btn icon="mdi-delete" size="small" variant="text" color="error" @click.stop="emit('delete-provider', entry.provider)"></v-btn>
</div>
</template>
</v-list-item>
<v-list-item v-else class="cursor-pointer" @click="emit('add-model-provider', entry.model)">
<v-list-item-title>{{ entry.model }}</v-list-item-title>
<v-list-item-subtitle class="text-caption text-grey d-flex align-center ga-1">
<span>{{ entry.model }}</span>
<v-icon v-if="supportsImageInput(entry.metadata)" size="14" color="grey">
mdi-eye-outline
</v-icon>
<v-icon v-if="supportsToolCall(entry.metadata)" size="14" color="grey">
mdi-wrench
</v-icon>
<v-icon v-if="supportsReasoning(entry.metadata)" size="14" color="grey">
mdi-brain
</v-icon>
<span v-if="formatContextLimit(entry.metadata)">
{{ formatContextLimit(entry.metadata) }}
</span>
</v-list-item-subtitle>
<template #append>
<v-btn icon="mdi-plus" size="small" variant="text" color="primary"></v-btn>
</template>
</v-list-item>
</template>
</template>
<template v-else>
<div class="text-center pa-4 text-medium-emphasis">
<v-icon size="48" color="grey-lighten-1">mdi-package-variant</v-icon>
<p class="text-grey mt-2">{{ tm('models.empty') }}</p>
</div>
</template>
</v-list>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
entries: {
type: Array,
default: () => []
},
availableCount: {
type: Number,
default: 0
},
modelSearch: {
type: String,
default: ''
},
loadingModels: {
type: Boolean,
default: false
},
isSourceModified: {
type: Boolean,
default: false
},
supportsImageInput: {
type: Function,
required: true
},
supportsToolCall: {
type: Function,
required: true
},
supportsReasoning: {
type: Function,
required: true
},
formatContextLimit: {
type: Function,
required: true
},
testingProviders: {
type: Array,
default: () => []
},
tm: {
type: Function,
required: true
}
})
const emit = defineEmits([
'update:modelSearch',
'fetch-models',
'open-manual-model',
'open-provider-edit',
'toggle-provider-enable',
'test-provider',
'delete-provider',
'add-model-provider'
])
const modelSearchProxy = computed({
get: () => props.modelSearch,
set: (val) => emit('update:modelSearch', val)
})
const isProviderTesting = (providerId) => props.testingProviders.includes(providerId)
</script>
<style scoped>
.border {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.cursor-pointer {
cursor: pointer;
}
</style>
@@ -0,0 +1,150 @@
<template>
<v-card class="provider-sources-panel h-100" elevation="0">
<div class="d-flex align-center justify-space-between px-4 pt-4 pb-2">
<div class="d-flex align-center ga-2">
<h3 class="mb-0">{{ tm('providerSources.title') }}</h3>
</div>
<v-menu>
<template #activator="{ props }">
<v-btn
v-bind="props"
prepend-icon="mdi-plus"
color="primary"
variant="tonal"
rounded="xl"
size="small"
>
新增
</v-btn>
</template>
<v-list density="compact">
<v-list-item
v-for="sourceType in availableSourceTypes"
:key="sourceType.value"
@click="emitAddSource(sourceType.value)"
>
<v-list-item-title>{{ sourceType.label }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
<div v-if="displayedProviderSources.length > 0">
<v-list class="provider-source-list" nav density="compact" lines="two">
<v-list-item
v-for="source in displayedProviderSources"
:key="source.isPlaceholder ? `template-${source.templateKey}` : source.id"
:value="source.id"
:active="isActive(source)"
:class="['provider-source-list-item', { 'provider-source-list-item--active': isActive(source) }]"
rounded="lg"
@click="emitSelectSource(source)"
>
<template #prepend>
<v-avatar size="32" class="bg-grey-lighten-4" rounded="0">
<v-img v-if="source?.provider" :src="resolveSourceIcon(source)" alt="logo" cover></v-img>
<v-icon v-else size="32">mdi-creation</v-icon>
</v-avatar>
</template>
<v-list-item-title class="font-weight-bold">{{ getSourceDisplayName(source) }}</v-list-item-title>
<v-list-item-subtitle class="text-truncate">{{ source.api_base || 'N/A' }}</v-list-item-subtitle>
<template #append>
<div class="d-flex align-center ga-1">
<v-btn
v-if="!source.isPlaceholder"
icon="mdi-delete"
variant="text"
size="x-small"
color="error"
@click.stop="emitDeleteSource(source)"
></v-btn>
</div>
</template>
</v-list-item>
</v-list>
</div>
<div v-else class="text-center py-8 px-4">
<v-icon size="48" color="grey-lighten-1">mdi-api-off</v-icon>
<p class="text-grey mt-2">{{ tm('providerSources.empty') }}</p>
</div>
</v-card>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
displayedProviderSources: {
type: Array,
default: () => []
},
selectedProviderSource: {
type: Object,
default: null
},
availableSourceTypes: {
type: Array,
default: () => []
},
tm: {
type: Function,
required: true
},
resolveSourceIcon: {
type: Function,
required: true
},
getSourceDisplayName: {
type: Function,
required: true
}
})
const emit = defineEmits([
'add-provider-source',
'select-provider-source',
'delete-provider-source'
])
const selectedId = computed(() => props.selectedProviderSource?.id || null)
const isActive = (source) => {
if (source.isPlaceholder) return false
return selectedId.value !== null && selectedId.value === source.id
}
const emitAddSource = (type) => emit('add-provider-source', type)
const emitSelectSource = (source) => emit('select-provider-source', source)
const emitDeleteSource = (source) => emit('delete-provider-source', source)
</script>
<style scoped>
.provider-sources-panel {
min-height: 320px;
}
.provider-source-list {
max-height: calc(100vh - 335px);
overflow-y: auto;
padding: 6px 8px;
}
.provider-source-list-item {
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.provider-source-list-item--active {
background-color: #E8F0FE;
border: 1px solid rgba(var(--v-theme-primary), 0.25);
}
@media (max-width: 960px) {
.provider-source-list {
max-height: none;
}
.provider-sources-panel {
min-height: auto;
}
}
</style>
@@ -162,7 +162,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
<!-- Regular Property --> <!-- Regular Property -->
<template v-else> <template v-else>
<v-row v-if="!metadata[metadataKey].items[key]?.invisible && shouldShowItem(metadata[metadataKey].items[key], key)" class="config-row"> <v-row v-if="!metadata[metadataKey].items[key]?.invisible && shouldShowItem(metadata[metadataKey].items[key], key)" class="config-row">
<v-col cols="12" sm="7" class="property-info"> <v-col cols="12" sm="6" class="property-info">
<v-list-item density="compact"> <v-list-item density="compact">
<v-list-item-title class="property-name"> <v-list-item-title class="property-name">
<span v-if="metadata[metadataKey].items[key]?.description"> <span v-if="metadata[metadataKey].items[key]?.description">
@@ -180,7 +180,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
</v-list-item> </v-list-item>
</v-col> </v-col>
<v-col cols="12" sm="5" class="config-input"> <v-col cols="12" sm="6" class="config-input">
<div v-if="metadata[metadataKey].items[key]" class="w-100"> <div v-if="metadata[metadataKey].items[key]" class="w-100">
<!-- Special handling for specific metadata types --> <!-- Special handling for specific metadata types -->
<div v-if="metadata[metadataKey].items[key]?._special === 'select_provider'"> <div v-if="metadata[metadataKey].items[key]?._special === 'select_provider'">
@@ -304,16 +304,32 @@ function hasVisibleItemsAfter(items, currentIndex) {
hide-details hide-details
></v-text-field> ></v-text-field>
<!-- Numeric input --> <!-- Numeric input with optional slider -->
<v-text-field <div
v-else-if="(metadata[metadataKey].items[key]?.type === 'int' || metadata[metadataKey].items[key]?.type === 'float') && !metadata[metadataKey]?.invisible" v-else-if="(metadata[metadataKey].items[key]?.type === 'int' || metadata[metadataKey].items[key]?.type === 'float') && !metadata[metadataKey]?.invisible"
v-model="iterable[key]" class="d-flex align-center gap-3"
density="compact" >
variant="outlined" <v-slider
class="config-field" v-if="metadata[metadataKey].items[key]?.slider"
type="number" v-model.number="iterable[key]"
hide-details :min="metadata[metadataKey].items[key]?.slider?.min ?? 0"
></v-text-field> :max="metadata[metadataKey].items[key]?.slider?.max ?? 100"
:step="metadata[metadataKey].items[key]?.slider?.step ?? 1"
color="primary"
density="compact"
hide-details
class="flex-grow-1"
></v-slider>
<v-text-field
v-model.number="iterable[key]"
density="compact"
variant="outlined"
class="config-field"
type="number"
hide-details
style="max-width: 140px;"
></v-text-field>
</div>
<!-- Text area --> <!-- Text area -->
<v-textarea <v-textarea
@@ -413,16 +429,32 @@ function hasVisibleItemsAfter(items, currentIndex) {
hide-details hide-details
></v-text-field> ></v-text-field>
<!-- Numeric input --> <!-- Numeric input with optional slider -->
<v-text-field <div
v-else-if="(metadata[metadataKey]?.type === 'int' || metadata[metadataKey]?.type === 'float') && !metadata[metadataKey]?.invisible" v-else-if="(metadata[metadataKey]?.type === 'int' || metadata[metadataKey]?.type === 'float') && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]" class="d-flex align-center gap-3"
density="compact" >
variant="outlined" <v-slider
class="config-field" v-if="metadata[metadataKey]?.slider"
type="number" v-model.number="iterable[metadataKey]"
hide-details :min="metadata[metadataKey]?.slider?.min ?? 0"
></v-text-field> :max="metadata[metadataKey]?.slider?.max ?? 100"
:step="metadata[metadataKey]?.slider?.step ?? 1"
color="primary"
density="compact"
hide-details
class="flex-grow-1"
></v-slider>
<v-text-field
v-model.number="iterable[metadataKey]"
density="compact"
variant="outlined"
class="config-field"
type="number"
hide-details
style="max-width: 140px;"
></v-text-field>
</div>
<!-- Text area --> <!-- Text area -->
<v-textarea <v-textarea
@@ -508,6 +540,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
font-size: 0.85em; font-size: 0.85em;
opacity: 0.7; opacity: 0.7;
font-weight: normal; font-weight: normal;
display: none;
} }
.important-hint { .important-hint {
@@ -541,7 +574,6 @@ function hasVisibleItemsAfter(items, currentIndex) {
align-items: center; align-items: center;
padding: 4px 8px; padding: 4px 8px;
border-radius: 4px; border-radius: 4px;
transition: background-color 0.2s;
} }
.config-row:hover { .config-row:hover {
@@ -245,10 +245,29 @@ function getSpecialSubtype(value) {
<v-text-field v-else-if="itemMeta?.type === 'string'" v-model="createSelectorModel(itemKey).value" <v-text-field v-else-if="itemMeta?.type === 'string'" v-model="createSelectorModel(itemKey).value"
density="compact" variant="outlined" class="config-field" hide-details></v-text-field> density="compact" variant="outlined" class="config-field" hide-details></v-text-field>
<!-- Numeric input for JSON selector --> <!-- Numeric input with optional slider for JSON selector -->
<v-text-field v-else-if="itemMeta?.type === 'int' || itemMeta?.type === 'float'" <div v-else-if="itemMeta?.type === 'int' || itemMeta?.type === 'float'" class="d-flex align-center gap-3">
v-model="createSelectorModel(itemKey).value" density="compact" variant="outlined" class="config-field" <v-slider
type="number" hide-details></v-text-field> v-if="itemMeta?.slider"
v-model.number="createSelectorModel(itemKey).value"
:min="itemMeta?.slider?.min ?? 0"
:max="itemMeta?.slider?.max ?? 100"
:step="itemMeta?.slider?.step ?? 1"
color="primary"
density="compact"
hide-details
style="flex: 3"
></v-slider>
<v-text-field
v-model.number="createSelectorModel(itemKey).value"
density="compact"
variant="outlined"
class="config-field"
style="flex: 2"
type="number"
hide-details
></v-text-field>
</div>
<!-- Text area for JSON selector --> <!-- Text area for JSON selector -->
<v-textarea v-else-if="itemMeta?.type === 'text'" v-model="createSelectorModel(itemKey).value" <v-textarea v-else-if="itemMeta?.type === 'text'" v-model="createSelectorModel(itemKey).value"
@@ -0,0 +1,209 @@
<script setup>
import { ref, watch, computed } from 'vue';
import { useI18n } from '@/i18n/composables';
import axios from 'axios';
import { MarkdownRender, enableKatex, enableMermaid } from 'markstream-vue';
import 'markstream-vue/index.css';
import 'katex/dist/katex.min.css';
import 'highlight.js/styles/github.css';
enableKatex();
enableMermaid();
const { t } = useI18n();
const props = defineProps({
modelValue: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['update:modelValue']);
const dialog = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
const changelogContent = ref('');
const changelogLoading = ref(false);
const changelogError = ref('');
const changelogVersion = ref('');
const selectedVersion = ref('');
const availableVersions = ref([]);
const loadingVersions = ref(false);
//
async function getCurrentVersion() {
try {
const res = await axios.get('/api/stat/version');
const version = res.data.data?.version || '';
changelogVersion.value = version;
selectedVersion.value = version;
return version;
} catch (err) {
console.error('Failed to get version:', err);
return '';
}
}
//
async function loadChangelog(version) {
const targetVersion = version || selectedVersion.value || changelogVersion.value;
if (!targetVersion) {
changelogError.value = t('core.navigation.changelogDialog.selectVersion');
return;
}
changelogLoading.value = true;
changelogError.value = '';
changelogContent.value = '';
try {
const res = await axios.get('/api/stat/changelog', {
params: { version: targetVersion }
});
if (res.data.status === 'ok') {
changelogContent.value = res.data.data.content;
selectedVersion.value = targetVersion;
} else {
changelogError.value = res.data.message || t('core.navigation.changelogDialog.error');
}
} catch (err) {
console.error('Failed to load changelog:', err);
if (err.response?.status === 404 || err.response?.data?.message?.includes('not found')) {
changelogError.value = t('core.navigation.changelogDialog.notFound');
} else {
changelogError.value = t('core.navigation.changelogDialog.error');
}
} finally {
changelogLoading.value = false;
}
}
//
async function loadAvailableVersions() {
loadingVersions.value = true;
try {
const res = await axios.get('/api/stat/changelog/list');
if (res.data.status === 'ok') {
availableVersions.value = res.data.data.versions || [];
}
} catch (err) {
console.error('Failed to load versions:', err);
} finally {
loadingVersions.value = false;
}
}
//
function onVersionChange() {
if (selectedVersion.value) {
loadChangelog(selectedVersion.value);
}
}
//
watch(dialog, async (newValue) => {
if (newValue) {
//
await loadAvailableVersions();
//
if (!changelogVersion.value) {
await getCurrentVersion();
}
//
if (changelogVersion.value && availableVersions.value.includes(changelogVersion.value)) {
selectedVersion.value = changelogVersion.value;
await loadChangelog();
} else if (availableVersions.value.length > 0) {
//
selectedVersion.value = availableVersions.value[0];
await loadChangelog(availableVersions.value[0]);
}
} else {
//
changelogContent.value = '';
changelogError.value = '';
}
});
//
getCurrentVersion();
</script>
<template>
<v-dialog
:model-value="dialog"
@update:model-value="dialog = $event"
:width="$vuetify.display.smAndDown ? '100%' : '800'"
:fullscreen="$vuetify.display.xs"
max-width="1000"
>
<v-card>
<v-card-title class="d-flex justify-space-between align-center">
<span class="text-h3">{{ t('core.navigation.changelogDialog.title') }}</span>
<v-btn icon @click="dialog = false" flat>
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text>
<!-- 版本选择器 -->
<div class="mb-4">
<v-select
v-model="selectedVersion"
:items="availableVersions"
:label="t('core.navigation.changelogDialog.selectVersion')"
:loading="loadingVersions"
variant="outlined"
density="compact"
@update:model-value="onVersionChange"
>
<template v-slot:item="{ item, props }">
<v-list-item v-bind="props" :title="`v${item.value}`">
<template v-slot:append v-if="item.value === changelogVersion">
<v-chip size="x-small" color="primary" variant="tonal">
{{ t('core.navigation.changelogDialog.current') }}
</v-chip>
</template>
</v-list-item>
</template>
<template v-slot:selection="{ item }">
<span>v{{ item.value }}</span>
</template>
</v-select>
</div>
<!-- 更新日志内容 -->
<div style="max-height: 70vh; overflow-y: auto;">
<div v-if="changelogLoading" class="text-center py-8">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
<div class="mt-4">{{ t('core.navigation.changelogDialog.loading') }}</div>
</div>
<v-alert v-else-if="changelogError" type="error" variant="tonal" border="start">
{{ changelogError }}
</v-alert>
<div v-else-if="changelogContent" class="changelog-content">
<MarkdownRender :content="changelogContent" :typewriter="false" class="markdown-content" />
</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="blue-darken-1" variant="text" @click="dialog = false">
{{ t('core.common.close') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style>
.changelog-content {
padding: 8px 0;
}
</style>
@@ -1,6 +1,7 @@
<script setup> <script setup>
import { useCommonStore } from '@/stores/common'; import { useCommonStore } from '@/stores/common';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import axios from 'axios';
</script> </script>
<template> <template>
@@ -24,8 +25,6 @@ import { storeToRefs } from 'pinia';
export default { export default {
name: 'ConsoleDisplayer', name: 'ConsoleDisplayer',
data() { data() {
const commonStore = useCommonStore();
const { log_cache } = storeToRefs(commonStore);
return { return {
autoScroll: true, // autoScroll: true, //
logColorAnsiMap: { logColorAnsiMap: {
@@ -38,7 +37,6 @@ export default {
'\u001b[32m': 'color: #00FF00;', // green '\u001b[32m': 'color: #00FF00;', // green
'default': 'color: #FFFFFF;' 'default': 'color: #FFFFFF;'
}, },
logCache: log_cache,
historyNum_: -1, historyNum_: -1,
logLevels: ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], logLevels: ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
selectedLevels: [0, 1, 2, 3, 4], // selectedLevels: [0, 1, 2, 3, 4], //
@@ -48,7 +46,17 @@ export default {
'WARNING': 'amber', 'WARNING': 'amber',
'ERROR': 'red', 'ERROR': 'red',
'CRITICAL': 'purple' 'CRITICAL': 'purple'
} },
lastProcessedTime: 0, //
localLogCache: [], //
}
},
computed: {
commonStore() {
return useCommonStore();
},
logCache() {
return this.commonStore.log_cache;
} }
}, },
props: { props: {
@@ -63,13 +71,39 @@ export default {
}, },
watch: { watch: {
logCache: { logCache: {
handler(val) { handler(newVal) {
const lastLog = val[this.logCache.length - 1]; // timestamp
if (lastLog && this.isLevelSelected(lastLog.level)) { if (newVal && newVal.length > 0) {
this.printLog(lastLog.data); // DOM
this.$nextTick(() => {
//
const newLogs = newVal.filter(log => log.time > this.lastProcessedTime);
if (newLogs.length > 0) {
this.localLogCache.push(...newLogs);
//
this.localLogCache.sort((a, b) => a.time - b.time);
// log_cache_max_len
if (this.localLogCache.length > this.commonStore.log_cache_max_len) {
this.localLogCache.splice(0, this.localLogCache.length - this.commonStore.log_cache_max_len);
}
//
newLogs.forEach(logItem => {
if (this.isLevelSelected(logItem.level)) {
this.printLog(logItem.data);
}
});
//
this.lastProcessedTime = Math.max(...newLogs.map(log => log.time));
}
});
} }
}, },
deep: true deep: true,
immediate: false
}, },
selectedLevels: { selectedLevels: {
handler() { handler() {
@@ -78,14 +112,37 @@ export default {
deep: true deep: true
} }
}, },
mounted() { async mounted() {
if (this.logCache.length === 0) { //
this.delayInit() await this.fetchLogHistory();
} else {
this.init() // DOM
} this.$nextTick(() => {
if (this.localLogCache.length > 0) {
this.localLogCache.forEach(logItem => {
if (this.isLevelSelected(logItem.level)) {
this.printLog(logItem.data);
}
});
//
this.lastProcessedTime = Math.max(...this.localLogCache.map(log => log.time));
}
});
}, },
methods: { methods: {
async fetchLogHistory() {
try {
const res = await axios.get('/api/log-history');
if (res.data.data.logs && res.data.data.logs.length > 0) {
this.localLogCache = [...res.data.data.logs];
//
this.localLogCache.sort((a, b) => a.time - b.time);
}
} catch (err) {
console.error('Failed to fetch log history:', err);
}
},
getLevelColor(level) { getLevelColor(level) {
return this.levelColors[level] || 'grey'; return this.levelColors[level] || 'grey';
}, },
@@ -101,41 +158,22 @@ export default {
}, },
refreshDisplay() { refreshDisplay() {
//
const termElement = document.getElementById('term'); const termElement = document.getElementById('term');
if (termElement) { if (termElement) {
termElement.innerHTML = ''; termElement.innerHTML = '';
}
//
// if (this.localLogCache && this.localLogCache.length > 0) {
this.init(); this.localLogCache.forEach(logItem => {
}, if (this.isLevelSelected(logItem.level)) {
this.printLog(logItem.data);
delayInit() { }
if (this.logCache.length === 0) { });
setTimeout(() => {
this.delayInit()
}, 500)
} else {
this.init()
}
},
init() {
this.historyNum_ = parseInt(this.historyNum)
let i = 0
for (let log of this.logCache) {
if (this.isLevelSelected(log.level)) { //
if (this.historyNum_ != -1 && i >= this.logCache.length - this.historyNum_) {
this.printLog(log.data)
++i
} else if (this.historyNum_ == -1) {
this.printLog(log.data)
}
} }
} }
}, },
toggleAutoScroll() { toggleAutoScroll() {
this.autoScroll = !this.autoScroll; this.autoScroll = !this.autoScroll;
}, },
@@ -143,6 +181,11 @@ export default {
printLog(log) { printLog(log) {
// append span termblock // append span termblock
let ele = document.getElementById('term') let ele = document.getElementById('term')
if (!ele) {
console.warn('term element not found, skipping log print');
return;
}
let span = document.createElement('pre') let span = document.createElement('pre')
let style = this.logColorAnsiMap['default'] let style = this.logColorAnsiMap['default']
for (let key in this.logColorAnsiMap) { for (let key in this.logColorAnsiMap) {
@@ -1,5 +1,5 @@
<template> <template>
<v-menu offset="12" location="bottom center"> <StyledMenu offset="12" location="bottom center">
<template v-slot:activator="{ props: activatorProps }"> <template v-slot:activator="{ props: activatorProps }">
<v-btn <v-btn
v-bind="activatorProps" v-bind="activatorProps"
@@ -22,25 +22,21 @@
</v-btn> </v-btn>
</template> </template>
<v-card class="language-dropdown" elevation="8" rounded="lg"> <v-list-item
<v-list density="compact" class="pa-1"> v-for="lang in languages"
<v-list-item :key="lang.code"
v-for="lang in languages" :value="lang.code"
:key="lang.code" @click="changeLanguage(lang.code)"
:value="lang.code" :class="{ 'styled-menu-item-active': currentLocale === lang.code }"
@click="changeLanguage(lang.code)" class="styled-menu-item"
:class="{ 'v-list-item--active': currentLocale === lang.code, 'language-item-selected': currentLocale === lang.code }" rounded="md"
class="language-item" >
rounded="md" <template v-slot:prepend>
> <span class="language-flag">{{ lang.flag }}</span>
<template v-slot:prepend> </template>
<span class="language-flag">{{ lang.flag }}</span> <v-list-item-title>{{ lang.name }}</v-list-item-title>
</template> </v-list-item>
<v-list-item-title>{{ lang.name }}</v-list-item-title> </StyledMenu>
</v-list-item>
</v-list>
</v-card>
</v-menu>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -48,6 +44,7 @@ import { computed } from 'vue'
import { useI18n, useLanguageSwitcher } from '@/i18n/composables' import { useI18n, useLanguageSwitcher } from '@/i18n/composables'
import { useCustomizerStore } from '@/stores/customizer' import { useCustomizerStore } from '@/stores/customizer'
import type { Locale } from '@/i18n/types' import type { Locale } from '@/i18n/types'
import StyledMenu from '@/components/shared/StyledMenu.vue'
// props // props
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
@@ -110,49 +107,4 @@ const changeLanguage = async (langCode: string) => {
:deep(.v-theme--PurpleThemeDark) .language-switcher--default:hover { :deep(.v-theme--PurpleThemeDark) .language-switcher--default:hover {
background: rgba(114, 46, 209, 0.12) !important; background: rgba(114, 46, 209, 0.12) !important;
} }
.language-dropdown {
min-width: 100px;
width: fit-content;
border: 1px solid rgba(94, 53, 177, 0.15) !important;
background: #f8f6fc !important;
backdrop-filter: blur(10px);
}
/* 深色模式下的下拉框样式 */
:deep(.v-theme--PurpleThemeDark) .language-dropdown {
background: #2a2733 !important;
border: 1px solid rgba(110, 60, 180, 0.692) !important;
}
.language-item {
margin: 2px 0;
transition: all 0.2s ease;
}
.language-item:hover {
background: rgba(94, 53, 177, 0.08) !important;
}
.language-item-selected {
background: rgba(94, 53, 177, 0.15) !important;
font-weight: 500;
}
.language-item-selected:hover {
background: rgba(94, 53, 177, 0.2) !important;
}
/* 深色模式下的列表项悬停效果 */
:deep(.v-theme--PurpleThemeDark) .language-item:hover {
background: rgba(114, 46, 209, 0.12) !important;
}
:deep(.v-theme--PurpleThemeDark) .language-item-selected {
background: rgba(114, 46, 209, 0.2) !important;
}
:deep(.v-theme--PurpleThemeDark) .language-item-selected:hover {
background: rgba(114, 46, 209, 0.25) !important;
}
</style> </style>
@@ -1,6 +1,15 @@
<template> <template>
<div class="d-flex align-center justify-space-between"> <div class="d-flex align-center justify-space-between ga-2">
<div> <div v-if="isSingleItemMode" class="flex-grow-1 d-flex align-center ga-2">
<v-text-field
v-model="singleItemValue"
hide-details
variant="outlined"
density="compact"
class="flex-grow-1"
></v-text-field>
</div>
<div v-else>
<span v-if="!modelValue || modelValue.length === 0" style="color: rgb(var(--v-theme-primaryText));"> <span v-if="!modelValue || modelValue.length === 0" style="color: rgb(var(--v-theme-primaryText));">
{{ t('core.common.list.noItems') }} {{ t('core.common.list.noItems') }}
</span> </span>
@@ -14,7 +23,7 @@
</div> </div>
</div> </div>
<v-btn size="small" color="primary" variant="tonal" @click="openDialog"> <v-btn size="small" color="primary" variant="tonal" @click="openDialog">
{{ buttonText || t('core.common.list.modifyButton') }} {{ preferSingleItem ? '添加更多' : (buttonText || t('core.common.list.modifyButton')) }}
</v-btn> </v-btn>
</div> </div>
@@ -167,6 +176,10 @@ const props = defineProps({
maxDisplayItems: { maxDisplayItems: {
type: Number, type: Number,
default: 1 default: 1
},
preferSingleItem: {
type: Boolean,
default: true
} }
}) })
@@ -180,6 +193,21 @@ const editIndex = ref(-1)
const editItem = ref('') const editItem = ref('')
const showBatchImport = ref(false) const showBatchImport = ref(false)
const batchImportText = ref('') const batchImportText = ref('')
const isSingleItemMode = computed(() => (props.modelValue?.length ?? 0) <= 1 && props.preferSingleItem)
const singleItemValue = computed({
get: () => props.modelValue?.[0] ?? '',
set: (value) => {
const newItems = [...(props.modelValue || [])]
if (newItems.length === 0) {
newItems.push(value)
} else {
newItems[0] = value
}
emit('update:modelValue', newItems)
}
})
// //
const displayItems = computed(() => { const displayItems = computed(() => {
@@ -14,8 +14,20 @@
<!-- Provider Selection Dialog --> <!-- Provider Selection Dialog -->
<v-dialog v-model="dialog" max-width="600px"> <v-dialog v-model="dialog" max-width="600px">
<v-card> <v-card>
<v-card-title class="text-h3 py-4" style="font-weight: normal;"> <v-card-title
{{ tm('providerSelector.dialogTitle') }} class="text-h3 py-4 d-flex align-center justify-space-between gap-4 flex-wrap"
style="font-weight: normal;"
>
<span>{{ tm('providerSelector.dialogTitle') }}</span>
<v-btn
size="small"
color="primary"
variant="tonal"
prepend-icon="mdi-plus"
@click="openProviderDrawer"
>
{{ tm('providerSelector.createProvider') }}
</v-btn>
</v-card-title> </v-card-title>
<v-card-text class="pa-0" style="max-height: 400px; overflow-y: auto;"> <v-card-text class="pa-0" style="max-height: 400px; overflow-y: auto;">
@@ -51,7 +63,7 @@
<v-list-item-title>{{ provider.id }}</v-list-item-title> <v-list-item-title>{{ provider.id }}</v-list-item-title>
<v-list-item-subtitle> <v-list-item-subtitle>
{{ provider.type || provider.provider_type || tm('providerSelector.unknownType') }} {{ provider.type || provider.provider_type || tm('providerSelector.unknownType') }}
<span v-if="provider.model_config?.model">- {{ provider.model_config.model }}</span> <span v-if="provider.model">- {{ provider.model }}</span>
</v-list-item-subtitle> </v-list-item-subtitle>
<template v-slot:append> <template v-slot:append>
@@ -79,12 +91,33 @@
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-dialog> </v-dialog>
<v-overlay
v-model="providerDrawer"
class="provider-drawer-overlay"
location="right"
transition="slide-x-reverse-transition"
:scrim="true"
@click:outside="closeProviderDrawer"
>
<v-card class="provider-drawer-card" elevation="12">
<div class="provider-drawer-header">
<v-btn icon variant="text" @click="closeProviderDrawer">
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
<div class="provider-drawer-content">
<ProviderPage :default-tab="defaultTab" />
</div>
</v-card>
</v-overlay>
</template> </template>
<script setup> <script setup>
import { ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import axios from 'axios' import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables' import { useModuleI18n } from '@/i18n/composables'
import ProviderPage from '@/views/ProviderPage.vue'
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
@@ -112,12 +145,26 @@ const dialog = ref(false)
const providerList = ref([]) const providerList = ref([])
const loading = ref(false) const loading = ref(false)
const selectedProvider = ref('') const selectedProvider = ref('')
const providerDrawer = ref(false)
const defaultTab = computed(() => {
if (props.providerType === 'agent_runner' && props.providerSubtype) {
return `select_agent_runner_provider:${props.providerSubtype}`
}
return props.providerType || 'chat_completion'
})
// modelValue selectedProvider // modelValue selectedProvider
watch(() => props.modelValue, (newValue) => { watch(() => props.modelValue, (newValue) => {
selectedProvider.value = newValue || '' selectedProvider.value = newValue || ''
}, { immediate: true }) }, { immediate: true })
watch(providerDrawer, (isOpen, wasOpen) => {
if (!isOpen && wasOpen) {
loadProviders()
}
})
async function openDialog() { async function openDialog() {
selectedProvider.value = props.modelValue || '' selectedProvider.value = props.modelValue || ''
dialog.value = true dialog.value = true
@@ -170,6 +217,14 @@ function cancelSelection() {
selectedProvider.value = props.modelValue || '' selectedProvider.value = props.modelValue || ''
dialog.value = false dialog.value = false
} }
function openProviderDrawer() {
providerDrawer.value = true
}
function closeProviderDrawer() {
providerDrawer.value = false
}
</script> </script>
<style scoped> <style scoped>
@@ -184,4 +239,35 @@ function cancelSelection() {
.v-list-item.v-list-item--active { .v-list-item.v-list-item--active {
background-color: rgba(var(--v-theme-primary), 0.08); background-color: rgba(var(--v-theme-primary), 0.08);
} }
.provider-drawer-overlay {
align-items: stretch;
justify-content: flex-end;
}
.provider-drawer-card {
width: clamp(360px, 70vw, 1200px);
height: calc(100vh - 32px);
margin: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.provider-drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 12px 20px;
}
.provider-drawer-content {
flex: 1;
overflow: hidden;
}
.provider-drawer-content > * {
height: 100%;
overflow: auto;
}
</style> </style>
@@ -1,11 +1,15 @@
<script setup> <script setup>
import { ref, watch, onMounted, computed } from 'vue'; import { ref, watch, onMounted, computed } from 'vue';
import axios from 'axios'; import axios from 'axios';
import MarkdownIt from 'markdown-it'; import { MarkdownRender, enableKatex, enableMermaid } from 'markstream-vue';
import hljs from 'highlight.js'; import 'markstream-vue/index.css';
import 'katex/dist/katex.min.css';
import 'highlight.js/styles/github.css'; import 'highlight.js/styles/github.css';
import { useI18n } from '@/i18n/composables'; import { useI18n } from '@/i18n/composables';
enableKatex();
enableMermaid();
const props = defineProps({ const props = defineProps({
show: { show: {
type: Boolean, type: Boolean,
@@ -74,29 +78,6 @@ function openRepoInNewTab() {
} }
} }
// markdown-it
const md = new MarkdownIt({
html: true, // HTML
breaks: true, // <br>
linkify: true, //
typographer: false, //
highlight: function(code, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(code, { language: lang }).value;
} catch (e) {
console.error(e);
}
}
return hljs.highlightAuto(code).value;
}
});
// Markdown
function renderMarkdown(content) {
if (!content) return '';
return md.render(content);
}
// README // README
function refreshReadme() { function refreshReadme() {
@@ -115,7 +96,7 @@ const _show = computed({
</script> </script>
<template> <template>
<v-dialog v-model="_show" width="800" persistent> <v-dialog v-model="_show" width="800">
<v-card> <v-card>
<v-card-title class="d-flex justify-space-between align-center"> <v-card-title class="d-flex justify-space-between align-center">
<span class="text-h5">{{ t('core.common.readme.title') }}</span> <span class="text-h5">{{ t('core.common.readme.title') }}</span>
@@ -150,7 +131,9 @@ const _show = computed({
</div> </div>
<!-- 内容显示 --> <!-- 内容显示 -->
<div v-else-if="content" class="markdown-body" v-html="renderMarkdown(content)"></div> <div v-else-if="content" class="markdown-body">
<MarkdownRender :content="content" :typewriter="false" class="markdown-content" />
</div>
<!-- 错误提示 --> <!-- 错误提示 -->
<div v-else-if="error" class="d-flex flex-column align-center justify-center" style="height: 100%;"> <div v-else-if="error" class="d-flex flex-column align-center justify-center" style="height: 100%;">
@@ -301,6 +284,9 @@ const _show = computed({
<script> <script>
export default { export default {
name: 'ReadmeDialog', name: 'ReadmeDialog',
components: {
MarkdownRender
},
computed: { computed: {
_show: { _show: {
get() { get() {
@@ -121,7 +121,8 @@ import sidebarItems from '@/layouts/full/vertical-sidebar/sidebarItem';
import { import {
getSidebarCustomization, getSidebarCustomization,
setSidebarCustomization, setSidebarCustomization,
clearSidebarCustomization clearSidebarCustomization,
resolveSidebarItems
} from '@/utils/sidebarCustomization'; } from '@/utils/sidebarCustomization';
const { t } = useI18n(); const { t } = useI18n();
@@ -133,35 +134,12 @@ const draggedItem = ref(null);
function initializeItems() { function initializeItems() {
const customization = getSidebarCustomization(); const customization = getSidebarCustomization();
const { mainItems: resolvedMain, moreItems: resolvedMore } = resolveSidebarItems(
if (customization) { sidebarItems,
// Load from customization customization
const allItemsMap = new Map(); );
mainItems.value = resolvedMain;
sidebarItems.forEach(item => { moreItems.value = resolvedMore;
if (item.children) {
item.children.forEach(child => {
allItemsMap.set(child.title, child);
});
} else {
allItemsMap.set(item.title, item);
}
});
mainItems.value = customization.mainItems
.map(title => allItemsMap.get(title))
.filter(item => item);
moreItems.value = customization.moreItems
.map(title => allItemsMap.get(title))
.filter(item => item);
} else {
// Load default structure
mainItems.value = sidebarItems.filter(item => !item.children);
const moreGroup = sidebarItems.find(item => item.title === 'core.navigation.groups.more');
moreItems.value = moreGroup ? [...moreGroup.children] : [];
}
} }
function openDialog() { function openDialog() {
@@ -0,0 +1,79 @@
<template>
<v-menu v-bind="$attrs" :close-on-content-click="closeOnContentClick">
<template v-slot:activator="{ props: activatorProps }">
<slot name="activator" :props="activatorProps"></slot>
</template>
<v-card class="styled-menu-card" elevation="8" rounded="lg">
<v-list density="compact" class="styled-menu-list pa-1">
<slot></slot>
</v-list>
</v-card>
</v-menu>
</template>
<script setup lang="ts">
defineOptions({
inheritAttrs: false
})
withDefaults(defineProps<{
closeOnContentClick?: boolean
}>(), {
closeOnContentClick: true
})
</script>
<style scoped>
.styled-menu-card {
min-width: 100px;
width: fit-content;
border: 1px solid rgba(94, 53, 177, 0.15) !important;
background: #f8f6fc !important;
backdrop-filter: blur(10px);
}
.styled-menu-list {
background: transparent !important;
}
:deep(.styled-menu-item) {
margin: 2px 0;
transition: all 0.2s ease;
border-radius: 6px;
}
:deep(.styled-menu-item:hover) {
background: rgba(94, 53, 177, 0.08) !important;
}
:deep(.styled-menu-item-active) {
background: rgba(94, 53, 177, 0.15) !important;
font-weight: 500;
}
:deep(.styled-menu-item-active:hover) {
background: rgba(94, 53, 177, 0.2) !important;
}
</style>
<style>
/* 深色模式下的下拉框样式 - 需要全局样式才能检测主题 */
.v-theme--PurpleThemeDark .styled-menu-card {
background: #2a2733 !important;
border: 1px solid rgba(110, 60, 180, 0.692) !important;
}
/* 深色模式下的列表项悬停效果 */
.v-theme--PurpleThemeDark .styled-menu-item:hover {
background: rgba(114, 46, 209, 0.12) !important;
}
.v-theme--PurpleThemeDark .styled-menu-item-active {
background: rgba(114, 46, 209, 0.2) !important;
}
.v-theme--PurpleThemeDark .styled-menu-item-active:hover {
background: rgba(114, 46, 209, 0.25) !important;
}
</style>
+260 -162
View File
@@ -2,19 +2,29 @@ import { ref, reactive, type Ref } from 'vue';
import axios from 'axios'; import axios from 'axios';
import { useToast } from '@/utils/toast'; import { useToast } from '@/utils/toast';
// 新格式消息部分的类型定义 // 工具调用信息
export interface MessagePart { export interface ToolCall {
type: 'plain' | 'image' | 'record' | 'file' | 'video' | 'reply'; id: string;
text?: string; // for plain name: string;
attachment_id?: string; // for image, record, file, video args: Record<string, any>;
filename?: string; // for file (filename from backend) ts: number; // 开始时间戳
message_id?: number; // for reply (PlatformSessionHistoryMessage.id) result?: string; // 工具调用结果
finished_ts?: number; // 完成时间戳
} }
// 引用信息 // Token 使用统计
export interface ReplyInfo { export interface TokenUsage {
messageId: number; input_other: number;
messageContent: string; input_cached: number;
output: number;
}
// Agent 统计信息
export interface AgentStats {
token_usage: TokenUsage;
start_time: number;
end_time: number;
time_to_first_token: number;
} }
// 文件信息结构 // 文件信息结构
@@ -24,24 +34,33 @@ export interface FileInfo {
attachment_id?: string; // 用于按需下载 attachment_id?: string; // 用于按需下载
} }
// 引用消息信息 // 消息部分的类型定义
export interface ReplyTo { export interface MessagePart {
message_id: number; type: 'plain' | 'image' | 'record' | 'file' | 'video' | 'reply' | 'tool_call';
message_content?: string; // 被引用消息的内容(解析后填充) text?: string; // for plain
attachment_id?: string; // for image, record, file, video
filename?: string; // for file (filename from backend)
message_id?: number; // for reply (PlatformSessionHistoryMessage.id)
tool_calls?: ToolCall[]; // for tool_call
// embedded fields - 加载后填充
embedded_url?: string; // blob URL for image, record
embedded_file?: FileInfo; // for file (保留 attachment_id 用于按需下载)
reply_content?: string; // for reply - 被引用消息的内容
} }
// 引用信息 (用于发送消息时)
export interface ReplyInfo {
messageId: number;
messageContent: string;
}
// 简化的消息内容结构
export interface MessageContent { export interface MessageContent {
type: string; type: string; // 'user' | 'bot'
message: string | MessagePart[]; // 支持旧格式(string)和新格式(MessagePart[]) message: MessagePart[]; // 消息部分列表 (保持顺序)
reasoning?: string; reasoning?: string; // reasoning content (for bot)
image_url?: string[]; isLoading?: boolean; // loading state
audio_url?: string; agentStats?: AgentStats; // agent 统计信息 (for bot)
file_url?: FileInfo[];
embedded_images?: string[];
embedded_audio?: string;
embedded_files?: FileInfo[];
isLoading?: boolean;
reply_to?: ReplyTo; // 引用的消息
} }
export interface Message { export interface Message {
@@ -93,52 +112,64 @@ export function useMessages(
} }
} }
// 解析新格式消息为旧格式兼容的结构 (用于显示) // 解析消息内容,填充 embedded 字段 (保持原始顺序)
async function parseMessageContent(content: any): Promise<void> { async function parseMessageContent(content: any): Promise<void> {
const message = content.message; const message = content.message;
// 如果 message 是数组 (格式) // 如果 message 是字符串 (格式),转换为数组格式
if (Array.isArray(message)) { if (typeof message === 'string') {
let textParts: string[] = []; const parts: MessagePart[] = [];
let imageUrls: string[] = []; let text = message;
let audioUrl: string | undefined;
let fileInfos: FileInfo[] = [];
let replyTo: ReplyTo | undefined;
// 处理旧格式的特殊标记
if (text.startsWith('[IMAGE]')) {
const img = text.replace('[IMAGE]', '');
const imageUrl = await getMediaFile(img);
parts.push({
type: 'image',
embedded_url: imageUrl
});
} else if (text.startsWith('[RECORD]')) {
const audio = text.replace('[RECORD]', '');
const audioUrl = await getMediaFile(audio);
parts.push({
type: 'record',
embedded_url: audioUrl
});
} else if (text) {
parts.push({
type: 'plain',
text: text
});
}
content.message = parts;
return;
}
// 如果 message 是数组 (新格式),遍历并填充 embedded 字段
if (Array.isArray(message)) {
for (const part of message as MessagePart[]) { for (const part of message as MessagePart[]) {
if (part.type === 'plain' && part.text) { if (part.type === 'image' && part.attachment_id) {
textParts.push(part.text); part.embedded_url = await getAttachment(part.attachment_id);
} else if (part.type === 'image' && part.attachment_id) {
const url = await getAttachment(part.attachment_id);
if (url) imageUrls.push(url);
} else if (part.type === 'record' && part.attachment_id) { } else if (part.type === 'record' && part.attachment_id) {
audioUrl = await getAttachment(part.attachment_id); part.embedded_url = await getAttachment(part.attachment_id);
} else if (part.type === 'file' && part.attachment_id) { } else if (part.type === 'file' && part.attachment_id) {
// file 类型不预加载,保留 attachment_id 以便点击时下载 // file 类型不预加载,保留 attachment_id 以便点击时下载
fileInfos.push({ part.embedded_file = {
attachment_id: part.attachment_id, attachment_id: part.attachment_id,
filename: part.filename || 'file' filename: part.filename || 'file'
}); };
} else if (part.type === 'reply' && part.message_id) {
replyTo = { message_id: part.message_id };
} }
// video 类型可以后续扩展 // plain, reply, tool_call, video 保持原样
}
// 转换为旧格式兼容的结构
content.message = textParts.join('\n');
content.reply_to = replyTo;
if (content.type === 'user') {
content.image_url = imageUrls.length > 0 ? imageUrls : undefined;
content.audio_url = audioUrl;
content.file_url = fileInfos.length > 0 ? fileInfos : undefined;
} else {
content.embedded_images = imageUrls.length > 0 ? imageUrls : undefined;
content.embedded_audio = audioUrl;
content.embedded_files = fileInfos.length > 0 ? fileInfos : undefined;
} }
} }
// 如果 message 是字符串 (旧格式),保持原有处理逻辑
// 处理 agent_stats (snake_case -> camelCase)
if (content.agent_stats) {
content.agentStats = content.agent_stats;
delete content.agent_stats;
}
} }
async function getSessionMessages(sessionId: string, router: any) { async function getSessionMessages(sessionId: string, router: any) {
@@ -161,46 +192,10 @@ export function useMessages(
}, 3000); }, 3000);
} }
// 处理历史消息中的媒体文件 // 处理历史消息
for (let i = 0; i < history.length; i++) { for (let i = 0; i < history.length; i++) {
let content = history[i].content; let content = history[i].content;
// 首先尝试解析新格式消息
await parseMessageContent(content); await parseMessageContent(content);
// 以下是旧格式的兼容处理 (message 是字符串的情况)
if (typeof content.message === 'string') {
if (content.message?.startsWith('[IMAGE]')) {
let img = content.message.replace('[IMAGE]', '');
const imageUrl = await getMediaFile(img);
if (!content.embedded_images) {
content.embedded_images = [];
}
content.embedded_images.push(imageUrl);
content.message = '';
}
if (content.message?.startsWith('[RECORD]')) {
let audio = content.message.replace('[RECORD]', '');
const audioUrl = await getMediaFile(audio);
content.embedded_audio = audioUrl;
content.message = '';
}
}
// 旧格式中的 image_url 和 audio_url 字段处理
if (content.image_url && content.image_url.length > 0) {
for (let j = 0; j < content.image_url.length; j++) {
// 检查是否已经是 blob URL (新格式解析后的结果)
if (!content.image_url[j].startsWith('blob:')) {
content.image_url[j] = await getMediaFile(content.image_url[j]);
}
}
}
if (content.audio_url && !content.audio_url.startsWith('blob:')) {
content.audio_url = await getMediaFile(content.audio_url);
}
} }
messages.value = history; messages.value = history;
@@ -217,47 +212,66 @@ export function useMessages(
selectedModelName: string, selectedModelName: string,
replyTo: ReplyInfo | null = null replyTo: ReplyInfo | null = null
) { ) {
// Create user message // 构建用户消息的 message 部分
const userMessageParts: MessagePart[] = [];
// 添加引用消息段
if (replyTo) {
userMessageParts.push({
type: 'reply',
message_id: replyTo.messageId,
reply_content: replyTo.messageContent
});
}
// 添加纯文本消息段
if (prompt) {
userMessageParts.push({
type: 'plain',
text: prompt
});
}
// 添加文件消息段
for (const f of stagedFiles) {
const partType = f.type === 'image' ? 'image' :
f.type === 'record' ? 'record' : 'file';
// 获取嵌入 URL
const embeddedUrl = await getAttachment(f.attachment_id);
userMessageParts.push({
type: partType as 'image' | 'record' | 'file',
attachment_id: f.attachment_id,
filename: f.original_name,
embedded_url: partType !== 'file' ? embeddedUrl : undefined,
embedded_file: partType === 'file' ? {
attachment_id: f.attachment_id,
filename: f.original_name
} : undefined
});
}
// 添加录音(如果有)
if (audioName) {
userMessageParts.push({
type: 'record',
embedded_url: audioName // 录音使用本地 URL
});
}
// 创建用户消息
const userMessage: MessageContent = { const userMessage: MessageContent = {
type: 'user', type: 'user',
message: prompt, message: userMessageParts
image_url: [],
audio_url: undefined,
file_url: [],
reply_to: replyTo ? { message_id: replyTo.messageId } : undefined
}; };
// 分离图片和文件
const imageFiles = stagedFiles.filter(f => f.type === 'image');
const nonImageFiles = stagedFiles.filter(f => f.type !== 'image');
// 使用 attachment_id 获取图片内容(避免 blob URL 被 revoke 后 404
if (imageFiles.length > 0) {
const imageUrls = await Promise.all(
imageFiles.map(f => getAttachment(f.attachment_id))
);
userMessage.image_url = imageUrls.filter(url => url !== '');
}
// 使用 blob URL 作为音频预览(录音不走 attachment
if (audioName) {
userMessage.audio_url = audioName;
}
// 文件不预加载,只显示文件名和 attachment_id
if (nonImageFiles.length > 0) {
userMessage.file_url = nonImageFiles.map(f => ({
filename: f.original_name,
attachment_id: f.attachment_id
}));
}
messages.value.push({ content: userMessage }); messages.value.push({ content: userMessage });
// 添加一个加载中的机器人消息占位符 // 添加一个加载中的机器人消息占位符
const loadingMessage = reactive({ const loadingMessage = reactive<MessageContent>({
type: 'bot', type: 'bot',
message: '', message: [],
reasoning: '', reasoning: '',
isLoading: true isLoading: true
}); });
@@ -272,12 +286,11 @@ export function useMessages(
// 收集所有 attachment_id // 收集所有 attachment_id
const files = stagedFiles.map(f => f.attachment_id); const files = stagedFiles.map(f => f.attachment_id);
// 构建 message 参数 // 构建发送给后端的 message 参数
// 当 files 或 reply 存在时,message 是 list,否则是 str
let messageToSend: string | MessagePart[]; let messageToSend: string | MessagePart[];
if (files.length > 0 || replyTo) { if (files.length > 0 || replyTo) {
const parts: MessagePart[] = []; const parts: MessagePart[] = [];
// 添加引用消息段 // 添加引用消息段
if (replyTo) { if (replyTo) {
parts.push({ parts.push({
@@ -285,7 +298,7 @@ export function useMessages(
message_id: replyTo.messageId message_id: replyTo.messageId
}); });
} }
// 添加纯文本消息段 // 添加纯文本消息段
if (prompt) { if (prompt) {
parts.push({ parts.push({
@@ -293,17 +306,17 @@ export function useMessages(
text: prompt text: prompt
}); });
} }
// 添加文件消息段 // 添加文件消息段
for (const f of stagedFiles) { for (const f of stagedFiles) {
const partType = f.type === 'image' ? 'image' : const partType = f.type === 'image' ? 'image' :
f.type === 'record' ? 'record' : 'file'; f.type === 'record' ? 'record' : 'file';
parts.push({ parts.push({
type: partType as 'image' | 'record' | 'file', type: partType as 'image' | 'record' | 'file',
attachment_id: f.attachment_id attachment_id: f.attachment_id
}); });
} }
messageToSend = parts; messageToSend = parts;
} else { } else {
messageToSend = prompt; messageToSend = prompt;
@@ -331,7 +344,7 @@ export function useMessages(
const reader = response.body!.getReader(); const reader = response.body!.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let in_streaming = false; let in_streaming = false;
let message_obj: any = null; let message_obj: MessageContent | null = null;
isStreaming.value = true; isStreaming.value = true;
@@ -378,8 +391,10 @@ export function useMessages(
const imageUrl = await getMediaFile(img); const imageUrl = await getMediaFile(img);
let bot_resp: MessageContent = { let bot_resp: MessageContent = {
type: 'bot', type: 'bot',
message: '', message: [{
embedded_images: [imageUrl] type: 'image',
embedded_url: imageUrl
}]
}; };
messages.value.push({ content: bot_resp }); messages.value.push({ content: bot_resp });
} else if (chunk_json.type === 'record') { } else if (chunk_json.type === 'record') {
@@ -387,43 +402,122 @@ export function useMessages(
const audioUrl = await getMediaFile(audio); const audioUrl = await getMediaFile(audio);
let bot_resp: MessageContent = { let bot_resp: MessageContent = {
type: 'bot', type: 'bot',
message: '', message: [{
embedded_audio: audioUrl type: 'record',
embedded_url: audioUrl
}]
}; };
messages.value.push({ content: bot_resp }); messages.value.push({ content: bot_resp });
} else if (chunk_json.type === 'file') { } else if (chunk_json.type === 'file') {
// 格式: [FILE]filename|original_name // 格式: [FILE]filename|original_name
let fileData = chunk_json.data.replace('[FILE]', ''); let fileData = chunk_json.data.replace('[FILE]', '');
let [filename, originalName] = fileData.includes('|') let [filename, originalName] = fileData.includes('|')
? fileData.split('|', 2) ? fileData.split('|', 2)
: [fileData, fileData]; : [fileData, fileData];
const fileUrl = await getMediaFile(filename); const fileUrl = await getMediaFile(filename);
let bot_resp: MessageContent = { let bot_resp: MessageContent = {
type: 'bot', type: 'bot',
message: '', message: [{
embedded_files: [{ type: 'file',
url: fileUrl, embedded_file: {
filename: originalName url: fileUrl,
filename: originalName
}
}] }]
}; };
messages.value.push({ content: bot_resp }); messages.value.push({ content: bot_resp });
} else if (chunk_json.type === 'plain') { } else if (chunk_json.type === 'plain') {
const chain_type = chunk_json.chain_type || 'normal'; const chain_type = chunk_json.chain_type || 'normal';
if (!in_streaming) { if (chain_type === 'tool_call') {
message_obj = reactive({ // 解析工具调用数据
type: 'bot', const toolCallData = JSON.parse(chunk_json.data);
message: chain_type === 'reasoning' ? '' : chunk_json.data, const toolCall: ToolCall = {
reasoning: chain_type === 'reasoning' ? chunk_json.data : '', id: toolCallData.id,
}); name: toolCallData.name,
messages.value.push({ content: message_obj }); args: toolCallData.args,
in_streaming = true; ts: toolCallData.ts
} else { };
if (chain_type === 'reasoning') {
// 使用 reactive 对象,直接修改属性会触发响应式更新 if (!in_streaming) {
message_obj.reasoning = (message_obj.reasoning || '') + chunk_json.data; message_obj = reactive<MessageContent>({
type: 'bot',
message: [{
type: 'tool_call',
tool_calls: [toolCall]
}]
});
messages.value.push({ content: message_obj });
in_streaming = true;
} else { } else {
message_obj.message = (message_obj.message || '') + chunk_json.data; // 找到最后一个 tool_call part 或创建新的
const lastPart = message_obj!.message[message_obj!.message.length - 1];
if (lastPart?.type === 'tool_call') {
// 检查是否已存在相同id的tool_call
const existingIndex = lastPart.tool_calls!.findIndex((tc: ToolCall) => tc.id === toolCall.id);
if (existingIndex === -1) {
lastPart.tool_calls!.push(toolCall);
}
} else {
// 添加新的 tool_call part
message_obj!.message.push({
type: 'tool_call',
tool_calls: [toolCall]
});
}
}
} else if (chain_type === 'tool_call_result') {
// 解析工具调用结果数据
const resultData = JSON.parse(chunk_json.data);
if (message_obj) {
// 遍历所有 tool_call parts 找到对应的 tool_call
for (const part of message_obj.message) {
if (part.type === 'tool_call' && part.tool_calls) {
const toolCall = part.tool_calls.find((tc: ToolCall) => tc.id === resultData.id);
if (toolCall) {
toolCall.result = resultData.result;
toolCall.finished_ts = resultData.ts;
break;
}
}
}
}
} else if (chain_type === 'reasoning') {
if (!in_streaming) {
message_obj = reactive<MessageContent>({
type: 'bot',
message: [],
reasoning: chunk_json.data
});
messages.value.push({ content: message_obj });
in_streaming = true;
} else {
message_obj!.reasoning = (message_obj!.reasoning || '') + chunk_json.data;
}
} else {
// normal text
if (!in_streaming) {
message_obj = reactive<MessageContent>({
type: 'bot',
message: [{
type: 'plain',
text: chunk_json.data
}]
});
messages.value.push({ content: message_obj });
in_streaming = true;
} else {
// 找到最后一个 plain part 或创建新的
const lastPart = message_obj!.message[message_obj!.message.length - 1];
if (lastPart?.type === 'plain') {
lastPart.text = (lastPart.text || '') + chunk_json.data;
} else {
message_obj!.message.push({
type: 'plain',
text: chunk_json.data
});
}
} }
} }
} else if (chunk_json.type === 'update_title') { } else if (chunk_json.type === 'update_title') {
@@ -435,6 +529,11 @@ export function useMessages(
lastBotMsg.id = chunk_json.data.id; lastBotMsg.id = chunk_json.data.id;
lastBotMsg.created_at = chunk_json.data.created_at; lastBotMsg.created_at = chunk_json.data.created_at;
} }
} else if (chunk_json.type === 'agent_stats') {
// 更新当前 bot 消息的 agent 统计信息
if (message_obj) {
message_obj.agentStats = chunk_json.data;
}
} }
if ((chunk_json.type === 'break' && chunk_json.streaming) || !chunk_json.streaming) { if ((chunk_json.type === 'break' && chunk_json.streaming) || !chunk_json.streaming) {
@@ -480,4 +579,3 @@ export function useMessages(
getAttachment getAttachment
}; };
} }
@@ -0,0 +1,659 @@
import { ref, computed, onMounted, nextTick, watch } from 'vue'
import axios from 'axios'
import { getProviderIcon } from '@/utils/providerUtils'
export interface UseProviderSourcesOptions {
defaultTab?: string
tm: (key: string, params?: Record<string, unknown>) => string
showMessage: (message: string, color?: string) => void
}
export function resolveDefaultTab(value?: string) {
const normalized = (value || '').toLowerCase()
if (normalized.startsWith('select_agent_runner_provider') || normalized === 'agent_runner') {
return 'agent_runner'
}
if (normalized === 'select_provider_stt' || normalized === 'speech_to_text' || normalized.includes('stt')) {
return 'speech_to_text'
}
if (normalized === 'select_provider_tts' || normalized === 'text_to_speech' || normalized.includes('tts')) {
return 'text_to_speech'
}
if (normalized.includes('embedding')) {
return 'embedding'
}
if (normalized.includes('rerank')) {
return 'rerank'
}
return 'chat_completion'
}
export function useProviderSources(options: UseProviderSourcesOptions) {
const { tm, showMessage } = options
// ===== State =====
const config = ref<Record<string, any>>({})
const metadata = ref<Record<string, any>>({})
const providerSources = ref<any[]>([])
const providers = ref<any[]>([])
const selectedProviderType = ref<string>(resolveDefaultTab(options.defaultTab))
const selectedProviderSource = ref<any | null>(null)
const selectedProviderSourceOriginalId = ref<string | null>(null)
const editableProviderSource = ref<any | null>(null)
const availableModels = ref<any[]>([])
const modelMetadata = ref<Record<string, any>>({})
const loadingModels = ref(false)
const savingSource = ref(false)
const testingProviders = ref<string[]>([])
const isSourceModified = ref(false)
const configSchema = ref<Record<string, any>>({})
const providerTemplates = ref<Record<string, any>>({})
const manualModelId = ref('')
const modelSearch = ref('')
let suppressSourceWatch = false
const providerTypes = [
{ value: 'chat_completion', label: tm('providers.tabs.chatCompletion'), icon: 'mdi-message-text' },
{ value: 'agent_runner', label: tm('providers.tabs.agentRunner'), icon: 'mdi-robot' },
{ value: 'speech_to_text', label: tm('providers.tabs.speechToText'), icon: 'mdi-microphone-message' },
{ value: 'text_to_speech', label: tm('providers.tabs.textToSpeech'), icon: 'mdi-volume-high' },
{ value: 'embedding', label: tm('providers.tabs.embedding'), icon: 'mdi-code-json' },
{ value: 'rerank', label: tm('providers.tabs.rerank'), icon: 'mdi-compare-vertical' }
]
// ===== Computed =====
const availableSourceTypes = computed(() => {
if (!providerTemplates.value || Object.keys(providerTemplates.value).length === 0) {
return []
}
const types: Array<{ value: string; label: string }> = []
for (const [templateName, template] of Object.entries(providerTemplates.value)) {
if (template.provider_type === selectedProviderType.value) {
types.push({ value: templateName, label: templateName })
}
}
return types
})
const filteredProviderSources = computed(() => {
if (!providerSources.value) return []
return providerSources.value.filter((source) =>
source.provider_type === selectedProviderType.value ||
(source.type && isTypeMatchingProviderType(source.type, selectedProviderType.value))
)
})
const displayedProviderSources = computed(() => {
const existing = filteredProviderSources.value || []
const existingProviders = new Set(existing.map((src: any) => src.provider).filter(Boolean))
const placeholders: any[] = []
if (providerTemplates.value && Object.keys(providerTemplates.value).length > 0) {
for (const [templateKey, template] of Object.entries(providerTemplates.value)) {
if (template.provider_type !== selectedProviderType.value) continue
if (!template.provider) continue
if (existingProviders.has(template.provider)) continue
placeholders.push({
id: template.id || templateKey,
provider: template.provider,
provider_type: template.provider_type,
type: template.type,
api_base: template.api_base || '',
templateKey,
isPlaceholder: true
})
}
}
return [...existing, ...placeholders]
})
const sourceProviders = computed(() => {
if (!selectedProviderSource.value || !providers.value) return []
return providers.value.filter((p) => p.provider_source_id === selectedProviderSource.value.id)
})
const existingModelsForSelectedSource = computed(() => {
if (!selectedProviderSource.value) return new Set<string>()
return new Set(sourceProviders.value.map((p: any) => p.model))
})
const sortedAvailableModels = computed(() => {
const existing = existingModelsForSelectedSource.value
return [...(availableModels.value || [])].sort((a, b) => {
const aName = typeof a === 'string' ? a : a?.name
const bName = typeof b === 'string' ? b : b?.name
const aExists = existing.has(aName)
const bExists = existing.has(bName)
if (aExists && !bExists) return -1
if (!aExists && bExists) return 1
return 0
})
})
const mergedModelEntries = computed(() => {
const configuredEntries = (sourceProviders.value || []).map((provider: any) => ({
type: 'configured',
provider,
metadata: getModelMetadata(provider.model)
}))
const availableEntries = (sortedAvailableModels.value || [])
.filter((item: any) => {
const name = typeof item === 'string' ? item : item?.name
return !existingModelsForSelectedSource.value.has(name)
})
.map((item: any) => {
const name = typeof item === 'string' ? item : item?.name
return {
type: 'available',
model: name,
metadata: typeof item === 'object' ? item?.metadata : getModelMetadata(name)
}
})
return [...configuredEntries, ...availableEntries]
})
const filteredMergedModelEntries = computed(() => {
const term = modelSearch.value.trim().toLowerCase()
if (!term) return mergedModelEntries.value
return mergedModelEntries.value.filter((entry: any) => {
if (entry.type === 'configured') {
const id = entry.provider.id?.toLowerCase() || ''
const model = entry.provider.model?.toLowerCase() || ''
return id.includes(term) || model.includes(term)
}
const model = entry.model?.toLowerCase() || ''
return model.includes(term)
})
})
const manualProviderId = computed(() => {
if (!selectedProviderSource.value) return ''
const modelId = manualModelId.value.trim()
if (!modelId) return ''
return `${selectedProviderSource.value.id}/${modelId}`
})
const basicSourceConfig = computed(() => {
if (!editableProviderSource.value) return null
const fields = ['id', 'key', 'api_base']
const basic: Record<string, any> = {}
fields.forEach((field) => {
Object.defineProperty(basic, field, {
get() {
return editableProviderSource.value![field]
},
set(val) {
editableProviderSource.value![field] = val
},
enumerable: true
})
})
return basic
})
const advancedSourceConfig = computed(() => {
if (!editableProviderSource.value) return null
const excluded = ['id', 'key', 'api_base', 'enable', 'type', 'provider_type', 'provider']
const advanced: Record<string, any> = {}
for (const key of Object.keys(editableProviderSource.value)) {
if (excluded.includes(key)) continue
Object.defineProperty(advanced, key, {
get() {
return editableProviderSource.value![key]
},
set(val) {
editableProviderSource.value![key] = val
},
enumerable: true
})
}
return advanced
})
const filteredProviders = computed(() => {
if (!providers.value || selectedProviderType.value === 'chat_completion') {
return []
}
return providers.value.filter((provider: any) => getProviderType(provider) === selectedProviderType.value)
})
// ===== Watches =====
watch(editableProviderSource, () => {
if (suppressSourceWatch) return
if (!editableProviderSource.value) return
isSourceModified.value = true
}, { deep: true })
// ===== Helper Functions =====
function isTypeMatchingProviderType(type?: string, providerType?: string) {
if (!type || !providerType) return false
if (providerType === 'chat_completion') {
return type.includes('chat_completion')
}
return type.includes(providerType)
}
function resolveSourceIcon(source: any) {
if (!source) return ''
return getProviderIcon(source.provider) || ''
}
function getSourceDisplayName(source: any) {
if (!source) return ''
if (source.isPlaceholder) return source.templateKey || source.id || ''
return source.id
}
function getModelMetadata(modelName?: string) {
if (!modelName) return null
return modelMetadata.value?.[modelName] || null
}
function supportsImageInput(meta: any) {
const inputs = meta?.modalities?.input || []
return inputs.includes('image')
}
function supportsToolCall(meta: any) {
return Boolean(meta?.tool_call)
}
function supportsReasoning(meta: any) {
return Boolean(meta?.reasoning)
}
function formatContextLimit(meta: any) {
const ctx = meta?.limit?.context
if (!ctx || typeof ctx !== 'number') return ''
if (ctx >= 1_000_000) return `${Math.round(ctx / 1_000_000)}M`
if (ctx >= 1_000) return `${Math.round(ctx / 1_000)}K`
return `${ctx}`
}
function getProviderType(provider: any) {
if (!provider) return undefined
if (provider.provider_type) {
return provider.provider_type
}
const oldVersionProviderTypeMapping: Record<string, string> = {
openai_chat_completion: 'chat_completion',
anthropic_chat_completion: 'chat_completion',
googlegenai_chat_completion: 'chat_completion',
zhipu_chat_completion: 'chat_completion',
dify: 'agent_runner',
coze: 'agent_runner',
dashscope: 'chat_completion',
openai_whisper_api: 'speech_to_text',
openai_whisper_selfhost: 'speech_to_text',
sensevoice_stt_selfhost: 'speech_to_text',
openai_tts_api: 'text_to_speech',
edge_tts: 'text_to_speech',
gsvi_tts_api: 'text_to_speech',
fishaudio_tts_api: 'text_to_speech',
dashscope_tts: 'text_to_speech',
azure_tts: 'text_to_speech',
minimax_tts_api: 'text_to_speech',
volcengine_tts: 'text_to_speech'
}
return oldVersionProviderTypeMapping[provider.type]
}
function selectProviderSource(source: any) {
if (source?.isPlaceholder && source.templateKey) {
addProviderSource(source.templateKey)
return
}
selectedProviderSource.value = source
selectedProviderSourceOriginalId.value = source?.id || null
suppressSourceWatch = true
editableProviderSource.value = source ? JSON.parse(JSON.stringify(source)) : null
nextTick(() => {
suppressSourceWatch = false
})
availableModels.value = []
modelMetadata.value = {}
isSourceModified.value = false
}
function extractSourceFieldsFromTemplate(template: Record<string, any>) {
const sourceFields: Record<string, any> = {}
const excludeKeys = ['id', 'enable', 'model', 'provider_source_id', 'modalities', 'custom_extra_body']
for (const [key, value] of Object.entries(template)) {
if (!excludeKeys.includes(key)) {
sourceFields[key] = value
}
}
return sourceFields
}
function generateUniqueSourceId(baseId: string) {
const existingIds = new Set(providerSources.value.map((s: any) => s.id))
if (!existingIds.has(baseId)) return baseId
let counter = 1
let candidate = `${baseId}_${counter}`
while (existingIds.has(candidate)) {
counter += 1
candidate = `${baseId}_${counter}`
}
return candidate
}
function addProviderSource(templateKey: string) {
const template = providerTemplates.value[templateKey]
if (!template) {
showMessage('未找到对应的模板配置', 'error')
return
}
const newId = generateUniqueSourceId(template.id)
const newSource = {
...extractSourceFieldsFromTemplate(template),
id: newId,
type: template.type,
provider_type: template.provider_type,
provider: template.provider,
enable: true
}
providerSources.value.push(newSource)
selectedProviderSource.value = newSource
selectedProviderSourceOriginalId.value = newId
editableProviderSource.value = JSON.parse(JSON.stringify(newSource))
availableModels.value = []
modelMetadata.value = {}
isSourceModified.value = true
}
async function deleteProviderSource(source: any) {
if (!confirm(tm('providerSources.deleteConfirm', { id: source.id }))) return
try {
await axios.post(`/api/config/provider_sources/${source.id}/delete`)
providers.value = providers.value.filter((p) => p.provider_source_id !== source.id)
providerSources.value = providerSources.value.filter((s) => s.id !== source.id)
if (selectedProviderSource.value?.id === source.id) {
selectedProviderSource.value = null
selectedProviderSourceOriginalId.value = null
editableProviderSource.value = null
}
showMessage(tm('providerSources.deleteSuccess'))
} catch (error: any) {
showMessage(error.message || tm('providerSources.deleteError'), 'error')
} finally {
await loadConfig()
}
}
async function saveProviderSource() {
if (!selectedProviderSource.value) return
savingSource.value = true
const originalId = selectedProviderSourceOriginalId.value || selectedProviderSource.value.id
try {
const response = await axios.post(`/api/config/provider_sources/${originalId}/update`, {
config: editableProviderSource.value,
original_id: originalId
})
if (response.data.status !== 'ok') {
throw new Error(response.data.message)
}
if (editableProviderSource.value!.id !== originalId) {
providers.value = providers.value.map((p) =>
p.provider_source_id === originalId
? { ...p, provider_source_id: editableProviderSource.value!.id }
: p
)
selectedProviderSourceOriginalId.value = editableProviderSource.value!.id
}
const idx = providerSources.value.findIndex((ps) => ps.id === originalId)
if (idx !== -1) {
providerSources.value[idx] = JSON.parse(JSON.stringify(editableProviderSource.value))
selectedProviderSource.value = providerSources.value[idx]
}
suppressSourceWatch = true
editableProviderSource.value = selectedProviderSource.value
nextTick(() => {
suppressSourceWatch = false
})
isSourceModified.value = false
showMessage(response.data.message || tm('providerSources.saveSuccess'))
return true
} catch (error: any) {
showMessage(error.response?.data?.message || error.message || tm('providerSources.saveError'), 'error')
return false
} finally {
savingSource.value = false
loadConfig()
}
}
async function fetchAvailableModels() {
if (!selectedProviderSource.value) return
if (isSourceModified.value) {
const saved = await saveProviderSource()
if (!saved) {
return
}
}
loadingModels.value = true
try {
const sourceId = editableProviderSource.value?.id || selectedProviderSource.value.id
const response = await axios.get(`/api/config/provider_sources/${sourceId}/models`)
if (response.data.status === 'ok') {
const metadataMap = response.data.data.model_metadata || {}
modelMetadata.value = metadataMap
availableModels.value = (response.data.data.models || []).map((model: string) => ({
name: model,
metadata: metadataMap?.[model] || null
}))
if (availableModels.value.length === 0) {
showMessage(tm('models.noModelsFound'), 'info')
}
} else {
throw new Error(response.data.message)
}
} catch (error: any) {
modelMetadata.value = {}
showMessage(error.response?.data?.message || error.message || tm('models.fetchError'), 'error')
} finally {
loadingModels.value = false
}
}
async function addModelProvider(modelName: string) {
if (!selectedProviderSource.value) return
const sourceId = editableProviderSource.value?.id || selectedProviderSource.value.id
const newId = `${sourceId}/${modelName}`
const modalities = ['text']
if (supportsImageInput(getModelMetadata(modelName))) {
modalities.push('image')
}
if (supportsToolCall(getModelMetadata(modelName))) {
modalities.push('tool_use')
}
const newProvider = {
id: newId,
enable: false,
provider_source_id: sourceId,
model: modelName,
modalities,
custom_extra_body: {}
}
try {
const res = await axios.post('/api/config/provider/new', newProvider)
if (res.data.status === 'error') {
throw new Error(res.data.message)
}
providers.value.push(newProvider)
showMessage(res.data.message || tm('models.addSuccess', { model: modelName }))
} catch (error: any) {
showMessage(error.response?.data?.message || error.message || tm('providerSources.saveError'), 'error')
} finally {
await loadConfig()
}
}
function modelAlreadyConfigured(modelName: string) {
return existingModelsForSelectedSource.value.has(modelName)
}
async function deleteProvider(provider: any) {
if (!confirm(tm('models.deleteConfirm', { id: provider.id }))) return
try {
await axios.post('/api/config/provider/delete', { id: provider.id })
providers.value = providers.value.filter((p) => p.id !== provider.id)
showMessage(tm('models.deleteSuccess'))
} catch (error: any) {
showMessage(error.message || tm('models.deleteError'), 'error')
} finally {
await loadConfig()
}
}
async function testProvider(provider: any) {
testingProviders.value.push(provider.id)
try {
const response = await axios.get('/api/config/provider/check_one', { params: { id: provider.id } })
if (response.data.status === 'ok' && response.data.data.error === null) {
showMessage(tm('models.testSuccess', { id: provider.id }))
} else {
throw new Error(response.data.data.error || tm('models.testError'))
}
} catch (error: any) {
showMessage(error.response?.data?.message || error.message || tm('models.testError'), 'error')
} finally {
testingProviders.value = testingProviders.value.filter((id) => id !== provider.id)
}
}
async function loadConfig() {
loadProviderTemplate()
}
async function loadProviderTemplate() {
try {
const response = await axios.get('/api/config/provider/template')
if (response.data.status === 'ok') {
configSchema.value = response.data.data.config_schema || {}
if (configSchema.value.provider?.config_template) {
providerTemplates.value = configSchema.value.provider.config_template
}
providerSources.value = response.data.data.provider_sources || []
providers.value = response.data.data.providers || []
}
} catch (error) {
console.error('Failed to load provider template:', error)
}
}
function updateDefaultTab(value: string) {
selectedProviderType.value = resolveDefaultTab(value)
}
onMounted(async () => {
await loadProviderTemplate()
})
return {
// state
config,
metadata,
providerSources,
providers,
selectedProviderType,
selectedProviderSource,
selectedProviderSourceOriginalId,
editableProviderSource,
availableModels,
modelMetadata,
loadingModels,
savingSource,
testingProviders,
isSourceModified,
configSchema,
providerTemplates,
manualModelId,
modelSearch,
// computed
providerTypes,
availableSourceTypes,
displayedProviderSources,
sourceProviders,
mergedModelEntries,
filteredMergedModelEntries,
filteredProviders,
basicSourceConfig,
advancedSourceConfig,
manualProviderId,
// helpers
resolveSourceIcon,
getSourceDisplayName,
getModelMetadata,
supportsImageInput,
supportsToolCall,
supportsReasoning,
formatContextLimit,
getProviderType,
// methods
updateDefaultTab,
selectProviderSource,
addProviderSource,
deleteProviderSource,
saveProviderSource,
fetchAvailableModels,
addModelProvider,
deleteProvider,
modelAlreadyConfigured,
testProvider,
loadConfig,
loadProviderTemplate
}
}
@@ -19,5 +19,6 @@
"submit": "Submit", "submit": "Submit",
"reset": "Reset", "reset": "Reset",
"clear": "Clear", "clear": "Clear",
"save": "Save" "save": "Save",
"close": "Close"
} }
@@ -27,6 +27,7 @@
"uninstall": "Uninstall", "uninstall": "Uninstall",
"update": "Update", "update": "Update",
"language": "Language", "language": "Language",
"settings": "Settings",
"locale": "en-US", "locale": "en-US",
"type": "Type", "type": "Type",
"press": "Press", "press": "Press",
@@ -2,6 +2,7 @@
"dashboard": "Dashboard", "dashboard": "Dashboard",
"platforms": "Platforms", "platforms": "Platforms",
"providers": "Providers", "providers": "Providers",
"commands": "Commands",
"persona": "Persona", "persona": "Persona",
"toolUse": "MCP Tools", "toolUse": "MCP Tools",
"config": "Config", "config": "Config",
@@ -14,10 +15,19 @@
"knowledgeBase": "Knowledge Base", "knowledgeBase": "Knowledge Base",
"about": "About", "about": "About",
"settings": "Settings", "settings": "Settings",
"changelog": "Changelog",
"documentation": "Documentation", "documentation": "Documentation",
"github": "GitHub", "github": "GitHub",
"drag": "Drag", "drag": "Drag",
"groups": { "groups": {
"more": "More Features" "more": "More Features"
},
"changelogDialog": {
"title": "Changelog",
"loading": "Loading...",
"error": "Failed to load",
"notFound": "Changelog for this version not found",
"selectVersion": "Select Version",
"current": "Current"
} }
} }
@@ -40,6 +40,8 @@
"cancelSelection": "Cancel", "cancelSelection": "Cancel",
"clearSelection": "None", "clearSelection": "None",
"clearSelectionSubtitle": "Clear current selection", "clearSelectionSubtitle": "Clear current selection",
"unknownType": "Unknown type" "unknownType": "Unknown type",
"createProvider": "Create Provider",
"manageProviders": "Provider Management"
} }
} }
@@ -41,7 +41,8 @@
"editTitle": "Edit Title", "editTitle": "Edit Title",
"fullscreen": "Fullscreen Mode", "fullscreen": "Fullscreen Mode",
"exitFullscreen": "Exit Fullscreen", "exitFullscreen": "Exit Fullscreen",
"reply": "Reply" "reply": "Reply",
"providerConfig": "AI Configuration"
}, },
"conversation": { "conversation": {
"newConversation": "New Conversation", "newConversation": "New Conversation",
@@ -80,6 +81,14 @@
"today": "Today", "today": "Today",
"yesterday": "Yesterday" "yesterday": "Yesterday"
}, },
"stats": {
"tokens": "Tokens",
"inputTokens": "Input Tokens",
"outputTokens": "Output Tokens",
"cachedTokens": "Cached Tokens",
"duration": "Duration",
"ttft": "Time to First Token"
},
"connection": { "connection": {
"title": "Connection Status Notice", "title": "Connection Status Notice",
"message": "The system detected that the chat connection needs to be re-established.", "message": "The system detected that the chat connection needs to be re-established.",
@@ -0,0 +1,91 @@
{
"title": "Command Management",
"summary": {
"total": "Displayed commands",
"disabled": "Disabled",
"conflicts": "Conflicts"
},
"conflictAlert": {
"title": "Command Conflicts Detected",
"description": "There are {count} conflicting commands. Conflicting commands will trigger multiple plugins simultaneously, which may cause unexpected behavior.",
"hint": "Click the \"Rename\" button to rename conflicting commands and resolve conflicts."
},
"table": {
"headers": {
"command": "Command",
"type": "Type",
"plugin": "Plugin",
"description": "Description",
"permission": "Permission",
"status": "Status",
"actions": "Actions"
}
},
"type": {
"command": "Command",
"group": "Group",
"subCommand": "Sub-command"
},
"status": {
"enabled": "Enabled",
"disabled": "Disabled",
"conflict": "Conflict"
},
"permission": {
"everyone": "Everyone",
"admin": "Admin"
},
"tooltips": {
"enable": "Enable command",
"disable": "Disable command",
"rename": "Rename command",
"viewDetails": "View details"
},
"dialogs": {
"rename": {
"title": "Rename Command",
"newName": "New command name",
"cancel": "Cancel",
"confirm": "Confirm"
},
"details": {
"title": "Command Details",
"type": "Command Type",
"handler": "Handler",
"module": "Module Path",
"originalCommand": "Original Command",
"effectiveCommand": "Effective Command",
"parentGroup": "Parent Group",
"subCommands": "Sub-commands",
"aliases": "Aliases",
"permission": "Permission",
"conflictStatus": "Conflict Status"
}
},
"messages": {
"toggleSuccess": "Command status updated",
"toggleFailed": "Failed to update command status",
"renameSuccess": "Command renamed",
"renameFailed": "Rename failed",
"loadFailed": "Failed to load commands"
},
"search": {
"placeholder": "Search commands..."
},
"empty": {
"noCommands": "No Commands",
"noCommandsDesc": "No commands found"
},
"filters": {
"all": "All",
"enabled": "Enabled",
"disabled": "Disabled",
"conflict": "Conflict",
"byPlugin": "Filter by plugin",
"byType": "Filter by type",
"byPermission": "Filter by permission",
"byStatus": "Filter by status",
"showSystemPlugins": "Show system plugins commands",
"systemPluginConflictHint": "System plugin conflicts detected. Resolve conflicts to hide."
}
}
@@ -57,6 +57,9 @@
}, },
"provider_id": { "provider_id": {
"description": "Default Text-to-Speech Model" "description": "Default Text-to-Speech Model"
},
"trigger_probability": {
"description": "TTS Trigger Probability"
} }
} }
}, },

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