Compare commits

..

88 Commits

Author SHA1 Message Date
Soulter 7cedf0d587 chore: improve documentation for extra_user_content_parts in Provider classes 2025-12-26 21:55:44 +08:00
kawayiYokami aeb21f719e claude额外块支持图片模态 2025-12-26 21:54:01 +08:00
Soulter 7c1dbecea5 refactor: unify extra_user_content_parts type to ContentPart across providers and update related handling 2025-12-26 21:47:02 +08:00
kawayiYokami 05012af627 重命名 2025-12-26 20:54:38 +08:00
kawayiYokami 17b52ab5dd 传递链 2025-12-26 18:57:51 +08:00
kawayiYokami 9449ff668b FIX 2025-12-25 13:33:40 +08:00
kawayiYokami c5a2827def feat: 多文本块功能 2025-12-25 03:54:05 +08:00
Soulter 701399c00c docs: update readme xmas 2025-12-24 21:58:04 +08:00
Soulter eaee98d4b8 chore: bump version to 4.10.2 2025-12-24 21:55:05 +08:00
Soulter 76c66000a7 chore: restrict psutil version <7.2.0 to avoid compatibility issues
fixes: #4176
2025-12-24 15:48:58 +08:00
Oscar Shaw 4b365143c0 feat: support for managing command aliases (#4170)
* feat(command): persist aliases on rename and apply to runtime filter

* feat(dashboard-api): support aliases in rename command endpoint

* feat(dashboard-ui): add alias editor to rename command dialog

* feat(dashboard-ui): enhance alias editor UI in rename dialog
2025-12-24 15:37:10 +08:00
Soulter 6e4e5011e2 chore: bump version to 4.10.1 2025-12-23 21:35:40 +08:00
Venus Yan d853bfde84 perf: handle unsupported message types with logging in OneBot adapter (#4164)
* Handle unsupported message types with logging

解决else 分支中对未知消息类型毫无防御,直接索引ComponentTypes[t],导致新类型markdown类信息报错并炸掉事件管道,且对应群聊单群永久不响应插件;尝试支持markdown类型进行支持但未经过测试

* chore: ruff format

* chore: ruff format

---------

Co-authored-by: Soulter <905617992@qq.com>
2025-12-23 21:31:32 +08:00
Soulter a0e856f80f fix: provider source id contains slash will lead to 405 (#4162) 2025-12-22 20:28:20 +08:00
Oscar Shaw 8c94a0010c fix(core): improve error handling of command parser and sync (#4161) 2025-12-22 19:54:26 +08:00
Soulter a44fdaaec0 chore: bump version to 4.10.0 2025-12-22 18:10:30 +08:00
Soulter 60105c76f5 feat: implement router loading progress indicator 2025-12-22 13:20:39 +08:00
Soulter bcf87d3ce4 fix: update provider subtitle for clarity in English and Chinese locales
- Revised the subtitle in the provider feature localization files to provide a more detailed description of functionalities, including chat model configuration and third-party service integrations.
2025-12-22 13:13:42 +08:00
Soulter 4d7c8c8453 style: add active background color for provider source list item in dark theme 2025-12-22 12:59:55 +08:00
Soulter a064a9115f fix: omit thinking params for gemini image generation models (#4151)
- Expanded model name checks to include specific Gemini 2.5 and 3 variants, ensuring correct configuration for thinking parameters based on the model used.
2025-12-22 00:09:30 +08:00
Soulter 6ef99e1553 feat: enhance ChatInput and ConversationSidebar dark theme 2025-12-21 21:19:54 +08:00
Soulter c0dbe5cf65 chore: bump version to 4.10.0-alpha.2 2025-12-21 13:11:32 +08:00
Soulter 3598c51eff fix: enhance provider model menu and sidebar session selection handling (#4144)
- Updated `ProviderModelMenu.vue` to manage menu state and load provider configurations dynamically upon opening.
- Filtered provider configurations to exclude those with `enable` set to false.
- Improved session selection logic in `useSessions.ts` to ensure the currently selected session is highlighted and properly managed during navigation.
2025-12-21 13:05:15 +08:00
Soulter b5cdb8f650 fix: improve error handling in tool execution to prevent infinite tool call loops (#4143)
* fix: improve error handling in tool execution to prevent infinite tool call loops

- Enhanced error handling in `call_local_llm_tool` to provide more informative exceptions for ValueError and TypeError, including detailed parameter information.
- Updated `ToolLoopAgentRunner` to yield appropriate messages for cases with no response or unsupported types, ensuring clearer communication to users.
- Improved logging and messaging consistency across tool execution processes.

* refactor: clean up unused router parameter in message retrieval functions

- Removed the unused `router` parameter from `getSessionMessages` and related function calls in `Chat.vue` and `useMessages.ts`.
- Commented out the `tool_calls` dictionary in `chat.py` for clarity, indicating it is not currently in use.

* fix: enhance exception handling in tool execution for clearer error reporting

- Improved exception handling in `call_local_llm_tool` by chaining exceptions for ValueError and TypeError, providing more context in error messages.
- Ensured that traceback information is preserved in raised exceptions for better debugging.
2025-12-21 12:57:54 +08:00
Yokami fc5b520f9b perf(agent): add max step limit to prevent infinite tool call loops (#4110)
* perf(agent): add max step limit to prevent infinite tool call loops

* feat: implement max step limit handling in main agent runner

- Enhanced the agent runner to enforce a maximum step limit, logging a warning and forcing a final response when the limit is reached.
- Updated message handling to append a user prompt when the tool call limit is exceeded.
- Refactored tool response handling to yield appropriate messages based on the response type, including handling cases with no response or unsupported types.
- Improved conversation message formatting to ensure consistent output in the assistant's responses.

* chore: ruff format

---------

Co-authored-by: Soulter <905617992@qq.com>
2025-12-21 12:30:43 +08:00
Soulter 904f56b32f fix: webui conversation traj data display error (#4142)
fixes: #4141
2025-12-20 23:29:40 +08:00
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 bb45d9cb54 stage 2025-12-13 17:16:07 +08:00
151 changed files with 10259 additions and 3188 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
zip -r dist.zip dist
- name: Archive production artifacts
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: dist-without-markdown
path: |
+26 -1
View File
@@ -33,6 +33,20 @@
- 请使用英文描述您的 PR。
- 标题请使用 `fix: `, `feat: `, `docs: `, `style: `, `refactor: `, `test: `, `chore: ` 等语义化前缀,并简要描述更改内容。如:`fix: correct login page typo`
#### 代码规范
##### Core
我们使用 Ruff 作为代码格式化和静态分析工具。在提交代码之前,请运行以下命令以确保代码符合规范:
```bash
ruff format .
ruff check .
```
如果您使用 VSCode,可以安装 `Ruff` 插件。
## Contributing Guide
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
- 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 .
```
+7 -1
View File
@@ -1,4 +1,4 @@
![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/ffd99b6b-3272-4682-beaa-6fe74250f7d9)
![astrbot-banner-xmas](https://github.com/user-attachments/assets/bf2341de-ec7a-45a7-a04a-02ad36450e99)
<div align="center">
@@ -243,4 +243,10 @@ pre-commit install
</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.9.0"
__version__ = "4.10.2"
+6 -4
View File
@@ -3,7 +3,7 @@
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
@@ -122,10 +122,12 @@ class ToolCall(BaseModel):
extra_content: dict[str, Any] | None = None
"""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:
kwargs.setdefault("exclude", set()).add("extra_content")
return super().model_dump(**kwargs)
data.pop("extra_content", None)
return data
class ToolCallPart(BaseModel):
+22 -1
View File
@@ -1,7 +1,8 @@
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.provider.entities import TokenUsage
class AgentResponseData(T.TypedDict):
@@ -12,3 +13,23 @@ class AgentResponseData(T.TypedDict):
class AgentResponse:
type: str
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)
@dataclass(config={"arbitrary_types_allowed": True})
@dataclass
class ContextWrapper(Generic[TContext]):
"""A context for running an agent, which can be used to pass additional data or state."""
@@ -1,4 +1,5 @@
import sys
import time
import traceback
import typing as T
@@ -12,6 +13,7 @@ from mcp.types import (
)
from astrbot import logger
from astrbot.core.message.components import Json
from astrbot.core.message.message_event_result import (
MessageChain,
)
@@ -24,7 +26,7 @@ from astrbot.core.provider.provider import Provider
from ..hooks import BaseAgentRunHooks
from ..message import AssistantMessageSegment, Message, ToolCallMessageSegment
from ..response import AgentResponseData
from ..response import AgentResponseData, AgentStats
from ..run_context import ContextWrapper, TContext
from ..tool_executor import BaseFunctionToolExecutor
from .base import AgentResponse, AgentState, BaseAgentRunner
@@ -69,14 +71,25 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
)
self.run_context.messages = messages
self.stats = AgentStats()
self.stats.start_time = time.time()
async def _iter_llm_responses(self) -> T.AsyncGenerator[LLMResponse, None]:
"""Yields chunks *and* a final LLMResponse."""
payload = {
"contexts": self.run_context.messages, # list[Message]
"func_tool": self.req.func_tool,
"model": self.req.model, # NOTE: in fact, this arg is None in most cases
"session_id": self.req.session_id,
"extra_user_content_parts": self.req.extra_user_content_parts, # list[ContentPart]
}
if self.streaming:
stream = self.provider.text_chat_stream(**self.req.__dict__)
stream = self.provider.text_chat_stream(**payload)
async for resp in stream: # type: ignore
yield resp
else:
yield await self.provider.text_chat(**self.req.__dict__)
yield await self.provider.text_chat(**payload)
@override
async def step(self):
@@ -98,6 +111,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
async for llm_response in self._iter_llm_responses():
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:
yield AgentResponse(
type="streaming_delta",
@@ -121,6 +138,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
)
continue
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
if not llm_resp_result:
@@ -132,6 +153,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
if llm_resp.role == "err":
# 如果 LLM 响应错误,转换到错误状态
self.final_llm_resp = llm_resp
self.stats.end_time = time.time()
self._transition_state(AgentState.ERROR)
yield AgentResponse(
type="err",
@@ -146,11 +168,12 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
# 如果没有工具调用,转换到完成状态
self.final_llm_resp = llm_resp
self._transition_state(AgentState.DONE)
self.stats.end_time = time.time()
# record the final assistant message
self.run_context.messages.append(
Message(
role="assistant",
content=llm_resp.completion_text or "",
content=llm_resp.completion_text or "*No response*",
),
)
try:
@@ -175,22 +198,19 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
# 如果有工具调用,还需处理工具调用
if llm_resp.tools_call_name:
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):
if isinstance(result, list):
tool_call_result_blocks = result
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(
type="tool_call_result",
type=ar_type,
data=AgentResponseData(chain=result),
)
# 将结果添加到上下文中
@@ -218,6 +238,25 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
async for resp in self.step():
yield resp
# 如果循环结束了但是 agent 还没有完成,说明是达到了 max_step
if not self.done():
logger.warning(
f"Agent reached max steps ({max_step}), forcing a final response."
)
# 拔掉所有工具
if self.req:
self.req.func_tool = None
# 注入提示词
self.run_context.messages.append(
Message(
role="user",
content="工具调用次数已达到上限,请停止使用工具,并根据已经收集到的信息,对你的任务和发现进行总结,然后直接回复用户。",
)
)
# 再执行最后一步
async for resp in self.step():
yield resp
async def _handle_function_tools(
self,
req: ProviderRequest,
@@ -233,6 +272,19 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
llm_response.tools_call_args,
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:
if not req.func_tool:
return
@@ -306,7 +358,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
content=res.content[0].text,
),
)
yield MessageChain().message(res.content[0].text)
elif isinstance(res.content[0], ImageContent):
tool_call_result_blocks.append(
ToolCallMessageSegment(
@@ -328,7 +379,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
content=resource.text,
),
)
yield MessageChain().message(resource.text)
elif (
isinstance(resource, BlobResourceContents)
and resource.mimeType
@@ -352,20 +402,34 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
content="返回的数据类型不受支持",
),
)
yield MessageChain().message("返回的数据类型不受支持。")
elif resp is None:
# Tool 直接请求发送消息给用户
# 这里我们将直接结束 Agent Loop。
# 发送消息逻辑在 ToolExecutor 中处理了。
logger.warning(
f"{func_tool_name} 没有没有返回值或者将结果直接发送给用户,此工具调用不会被记录到历史中"
f"{func_tool_name} 没有没有返回值或者将结果直接发送给用户。"
)
self._transition_state(AgentState.DONE)
self.stats.end_time = time.time()
tool_call_result_blocks.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content="*工具没有返回值或者将结果直接发送给了用户*",
),
)
else:
# 不应该出现其他类型
logger.warning(
f"Tool 返回了不支持的类型: {type(resp)},将忽略",
f"Tool 返回了不支持的类型: {type(resp)}",
)
tool_call_result_blocks.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content="*工具返回了不支持的类型,请告诉用户检查这个工具的定义和实现。*",
),
)
try:
@@ -387,6 +451,22 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
),
)
# 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,
}
)
],
)
# 处理函数调用响应
if tool_call_result_blocks:
yield tool_call_result_blocks
+3 -1
View File
@@ -6,8 +6,10 @@ from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.star.context import Context
@dataclass(config={"arbitrary_types_allowed": True})
@dataclass
class AstrAgentContext:
__pydantic_config__ = {"arbitrary_types_allowed": True}
context: Context
"""The star context instance"""
event: AstrMessageEvent
+42 -3
View File
@@ -2,8 +2,10 @@ import traceback
from collections.abc import AsyncGenerator
from astrbot.core import logger
from astrbot.core.agent.message import Message
from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.message.components import Json
from astrbot.core.message.message_event_result import (
MessageChain,
MessageEventResult,
@@ -23,8 +25,25 @@ async def run_agent(
) -> AsyncGenerator[MessageChain | None, None]:
step_idx = 0
astr_event = agent_runner.run_context.context.event
while step_idx < max_step:
while step_idx < max_step + 1:
step_idx += 1
if step_idx == max_step + 1:
logger.warning(
f"Agent reached max steps ({max_step}), forcing a final response."
)
if not agent_runner.done():
# 拔掉所有工具
if agent_runner.req:
agent_runner.req.func_tool = None
# 注入提示词
agent_runner.run_context.messages.append(
Message(
role="user",
content="工具调用次数已达到上限,请停止使用工具,并根据已经收集到的信息,对你的任务和发现进行总结,然后直接回复用户。",
)
)
try:
async for resp in agent_runner.step():
if astr_event.is_stopped():
@@ -33,16 +52,27 @@ async def run_agent(
msg_chain = resp.data["chain"]
if msg_chain.type == "tool_direct_result":
# tool_direct_result 用于标记 llm tool 需要直接发送给用户的内容
await astr_event.send(resp.data["chain"])
await astr_event.send(msg_chain)
continue
if astr_event.get_platform_id() == "webchat":
await astr_event.send(msg_chain)
# 对于其他情况,暂时先不处理
continue
elif resp.type == "tool_call":
if agent_runner.streaming:
# 用来标记流式响应需要分节
yield MessageChain(chain=[], type="break")
if show_tool_use:
if astr_event.get_platform_name() == "webchat":
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
if stream_to_general and resp.type == "streaming_delta":
@@ -69,6 +99,15 @@ async def run_agent(
continue
yield resp.data["chain"] # MessageChain
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
except Exception as e:
+34 -4
View File
@@ -209,12 +209,42 @@ async def call_local_llm_tool(
else:
raise ValueError(f"未知的方法名: {method_name}")
except ValueError as e:
logger.error(f"调用本地 LLM 工具时出错: {e}", exc_info=True)
except TypeError:
logger.error("处理函数参数不匹配,请检查 handler 的定义。", exc_info=True)
raise Exception(f"Tool execution ValueError: {e}") from e
except TypeError as e:
# 获取函数的签名(包括类型),除了第一个 event/context 参数。
try:
sig = inspect.signature(handler)
params = list(sig.parameters.values())
# 跳过第一个参数(event 或 context
if params:
params = params[1:]
param_strs = []
for param in params:
param_str = param.name
if param.annotation != inspect.Parameter.empty:
# 获取类型注解的字符串表示
if isinstance(param.annotation, type):
type_str = param.annotation.__name__
else:
type_str = str(param.annotation)
param_str += f": {type_str}"
if param.default != inspect.Parameter.empty:
param_str += f" = {param.default!r}"
param_strs.append(param_str)
handler_param_str = (
", ".join(param_strs) if param_strs else "(no additional parameters)"
)
except Exception:
handler_param_str = "(unable to inspect signature)"
raise Exception(
f"Tool handler parameter mismatch, please check the handler definition. Handler parameters: {handler_param_str}"
) from e
except Exception as e:
trace_ = traceback.format_exc()
logger.error(f"调用本地 LLM 工具时出错: {e}\n{trace_}")
raise Exception(f"Tool execution error: {e}. Traceback: {trace_}") from e
if not ready_to_call:
return
+168 -212
View File
@@ -1,10 +1,11 @@
"""如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。"""
import os
from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.9.0"
VERSION = "4.10.2"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -61,7 +62,8 @@ DEFAULT_CONFIG = {
"ignore_bot_self_message": False,
"ignore_at_all": False,
},
"provider": [],
"provider_sources": [], # provider sources
"provider": [], # models from provider_sources
"provider_settings": {
"enable": True,
"default_provider_id": "",
@@ -108,6 +110,7 @@ DEFAULT_CONFIG = {
"provider_id": "",
"dual_output": False,
"use_file_service": False,
"trigger_probability": 1.0,
},
"provider_ltm_settings": {
"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 时代的配置元数据,目前仅承担以下功能:
@@ -208,7 +227,7 @@ CONFIG_METADATA_2 = {
"callback_server_host": "0.0.0.0",
"port": 6196,
},
"QQ 个人号(OneBot v11)": {
"OneBot v11": {
"id": "default",
"type": "aiocqhttp",
"enable": False,
@@ -843,6 +862,7 @@ CONFIG_METADATA_2 = {
"metadata": {
"provider": {
"type": "list",
# provider sources templates
"config_template": {
"OpenAI": {
"id": "openai",
@@ -853,107 +873,10 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://api.openai.com/v1",
"timeout": 120,
"model_config": {"model": "gpt-4o-mini", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
"hint": "也兼容所有与 OpenAI API 兼容的服务。",
},
"Azure OpenAI": {
"id": "azure",
"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",
"Google Gemini": {
"id": "google_gemini",
"provider": "google",
"type": "googlegenai_chat_completion",
"provider_type": "chat_completion",
@@ -961,10 +884,6 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://generativelanguage.googleapis.com/",
"timeout": 120,
"model_config": {
"model": "gemini-2.0-flash-exp",
"temperature": 0.4,
},
"gm_resp_image_modal": False,
"gm_native_search": False,
"gm_native_coderunner": False,
@@ -975,13 +894,43 @@ CONFIG_METADATA_2 = {
"sexually_explicit": "BLOCK_MEDIUM_AND_ABOVE",
"dangerous_content": "BLOCK_MEDIUM_AND_ABOVE",
},
"gm_thinking_config": {
"budget": 0,
},
"modalities": ["text", "image", "tool_use"],
"gm_thinking_config": {"budget": 0, "level": "HIGH"},
},
"Anthropic": {
"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": {
"id": "deepseek_default",
"id": "deepseek",
"provider": "deepseek",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
@@ -989,13 +938,75 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://api.deepseek.com/v1",
"timeout": 120,
"model_config": {"model": "deepseek-chat", "temperature": 0.4},
"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": {
"id": "groq_default",
"id": "groq",
"provider": "groq",
"type": "groq_chat_completion",
"provider_type": "chat_completion",
@@ -1003,13 +1014,7 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://api.groq.com/openai/v1",
"timeout": 120,
"model_config": {
"model": "openai/gpt-oss-20b",
"temperature": 0.4,
},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "tool_use"],
},
"302.AI": {
"id": "302ai",
@@ -1020,12 +1025,9 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://api.302.ai/v1",
"timeout": 120,
"model_config": {"model": "gpt-4.1-mini", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"硅基流动": {
"SiliconFlow": {
"id": "siliconflow",
"provider": "siliconflow",
"type": "openai_chat_completion",
@@ -1034,15 +1036,9 @@ CONFIG_METADATA_2 = {
"key": [],
"timeout": 120,
"api_base": "https://api.siliconflow.cn/v1",
"model_config": {
"model": "deepseek-ai/DeepSeek-V3",
"temperature": 0.4,
},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"PPIO派欧云": {
"PPIO": {
"id": "ppio",
"provider": "ppio",
"type": "openai_chat_completion",
@@ -1051,14 +1047,9 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://api.ppinfra.com/v3/openai",
"timeout": 120,
"model_config": {
"model": "deepseek/deepseek-r1",
"temperature": 0.4,
},
"custom_headers": {},
"custom_extra_body": {},
},
"小马算力": {
"TokenPony": {
"id": "tokenpony",
"provider": "tokenpony",
"type": "openai_chat_completion",
@@ -1067,14 +1058,9 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://api.tokenpony.cn/v1",
"timeout": 120,
"model_config": {
"model": "kimi-k2-instruct-0905",
"temperature": 0.7,
},
"custom_headers": {},
"custom_extra_body": {},
},
"优云智算": {
"Compshare": {
"id": "compshare",
"provider": "compshare",
"type": "openai_chat_completion",
@@ -1083,42 +1069,18 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://api.modelverse.cn/v1",
"timeout": 120,
"model_config": {
"model": "moonshotai/Kimi-K2-Instruct",
},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"Kimi": {
"id": "moonshot",
"provider": "moonshot",
"ModelScope": {
"id": "modelscope",
"provider": "modelscope",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"timeout": 120,
"api_base": "https://api.moonshot.cn/v1",
"model_config": {"model": "moonshot-v1-8k", "temperature": 0.4},
"api_base": "https://api-inference.modelscope.cn/v1",
"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": {
"id": "dify_app_default",
@@ -1133,7 +1095,6 @@ CONFIG_METADATA_2 = {
"dify_query_input_key": "astrbot_text_query",
"variables": {},
"timeout": 60,
"hint": "请确保你在 AstrBot 里设置的 APP 类型和 Dify 里面创建的应用的类型一致!",
},
"Coze": {
"id": "coze",
@@ -1164,20 +1125,6 @@ CONFIG_METADATA_2 = {
"variables": {},
"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": {
"id": "fastgpt",
"provider": "fastgpt",
@@ -1201,7 +1148,6 @@ CONFIG_METADATA_2 = {
"model": "whisper-1",
},
"Whisper(Local)": {
"hint": "启用前请 pip 安装 openai-whisper 库(N卡用户大约下载 2GB,主要是 torch 和 cudaCPU 用户大约下载 1 GB),并且安装 ffmpeg。否则将无法正常转文字。",
"provider": "openai",
"type": "openai_whisper_selfhost",
"provider_type": "speech_to_text",
@@ -1210,7 +1156,6 @@ CONFIG_METADATA_2 = {
"model": "tiny",
},
"SenseVoice(Local)": {
"hint": "启用前请 pip 安装 funasr、funasr_onnx、torchaudio、torch、modelscope、jieba 库(默认使用CPU,大约下载 1 GB),并且安装 ffmpeg。否则将无法正常转文字。",
"type": "sensevoice_stt_selfhost",
"provider": "sensevoice",
"provider_type": "speech_to_text",
@@ -1232,7 +1177,6 @@ CONFIG_METADATA_2 = {
"timeout": "20",
},
"Edge TTS": {
"hint": "提示:使用这个服务前需要安装有 ffmpeg,并且可以直接在终端调用 ffmpeg 指令。",
"id": "edge_tts",
"provider": "microsoft",
"type": "edge_tts",
@@ -1448,6 +1392,10 @@ CONFIG_METADATA_2 = {
},
},
"items": {
"provider_source_id": {
"invisible": True,
"type": "string",
},
"xai_native_search": {
"description": "启用原生搜索功能",
"type": "bool",
@@ -1818,13 +1766,24 @@ CONFIG_METADATA_2 = {
},
},
"gm_thinking_config": {
"description": "Gemini思考设置",
"description": "Thinking Config",
"type": "object",
"items": {
"budget": {
"description": "思考预算",
"description": "Thinking Budget",
"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": {
"description": "ID",
"type": "string",
"hint": "模型提供商名字。",
},
"type": {
"description": "模型提供商种类",
@@ -2025,29 +1983,15 @@ CONFIG_METADATA_2 = {
"description": "API Key",
"type": "list",
"items": {"type": "string"},
"hint": "提供商 API Key。",
},
"api_base": {
"description": "API Base URL",
"type": "string",
"hint": "API Base URL 请在模型提供商处获得。如出现 404 报错,尝试在地址末尾加上 /v1",
},
"model_config": {
"description": "模型配置",
"type": "object",
"items": {
"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"},
},
"model": {
"description": "模型 ID",
"type": "string",
"hint": "模型名称,如 gpt-4o-mini, deepseek-chat。",
},
"dify_api_key": {
"description": "API Key",
@@ -2209,6 +2153,9 @@ CONFIG_METADATA_2 = {
"use_file_service": {
"type": "bool",
},
"trigger_probability": {
"type": "float",
},
},
},
"provider_ltm_settings": {
@@ -2419,6 +2366,14 @@ CONFIG_METADATA_3 = {
"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": {
"description": "图片转述提示词",
"type": "text",
@@ -2986,6 +2941,7 @@ CONFIG_METADATA_3 = {
"description": "回复概率",
"type": "float",
"hint": "0.0-1.0 之间的数值",
"slider": {"min": 0, "max": 1, "step": 0.05},
"condition": {
"provider_ltm_settings.active_reply.enable": True,
},
+1
View File
@@ -79,6 +79,7 @@ class ConfigMetadataI18n:
"_special",
"invisible",
"options",
"slider",
]:
if attr in field_data:
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.umop_config_router import UmopConfigRouter
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 . import astrbot_config, html_renderer
@@ -185,6 +186,8 @@ class AstrBotCoreLifecycle:
# 初始化关闭控制面板的事件
self.dashboard_shutdown_event = asyncio.Event()
asyncio.create_task(update_llm_metadata())
def _load(self) -> None:
"""加载事件总线和任务并初始化."""
# 创建一个异步任务来执行事件总线的 dispatch() 方法
+72
View File
@@ -9,6 +9,8 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
from astrbot.core.db.po import (
Attachment,
CommandConfig,
CommandConflict,
ConversationV2,
Persona,
PlatformMessageHistory,
@@ -314,6 +316,76 @@ class BaseDatabase(abc.ABC):
"""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
# async def insert_llm_message(
# 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
class Conversation:
"""LLM 对话类
+240
View File
@@ -1,6 +1,7 @@
import asyncio
import threading
import typing as T
from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta, timezone
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.po import (
Attachment,
CommandConfig,
CommandConflict,
ConversationV2,
Persona,
PlatformMessageHistory,
@@ -26,6 +29,7 @@ from astrbot.core.db.po import (
)
NOT_GIVEN = T.TypeVar("NOT_GIVEN")
TxResult = T.TypeVar("TxResult")
class SQLiteDatabase(BaseDatabase):
@@ -670,6 +674,242 @@ class SQLiteDatabase(BaseDatabase):
)
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
# ====
+4 -5
View File
@@ -629,12 +629,11 @@ class Nodes(BaseMessageComponent):
class Json(BaseMessageComponent):
type = ComponentType.Json
data: str | dict
resid: int | None = 0
data: dict
def __init__(self, data, **_):
if isinstance(data, dict):
data = json.dumps(data)
def __init__(self, data: str | dict, **_):
if isinstance(data, str):
data = json.loads(data)
super().__init__(data=data, **_)
@@ -321,7 +321,12 @@ class InternalAgentSubStage(Stage):
elif isinstance(req.tool_calls_result, list):
for tcr in req.tool_calls_result:
messages.extend(tcr.to_openai_messages())
messages.append({"role": "assistant", "content": llm_response.completion_text})
messages.append(
{
"role": "assistant",
"content": llm_response.completion_text or "*No response*",
}
)
messages = list(filter(lambda item: "_no_save" not in item, messages))
await self.conv_manager.update_conversation(
event.unified_msg_origin,
+5 -1
View File
@@ -119,7 +119,7 @@ class RespondStage(Stage):
if (result := event.get_result()) is None:
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
if event.get_platform_name() in [
@@ -158,7 +158,11 @@ class RespondStage(Stage):
result = event.get_result()
if result is None:
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:
event.set_extra("_streaming_finished", True)
return
logger.info(
+21 -1
View File
@@ -1,3 +1,4 @@
import random
import re
import time
import traceback
@@ -42,6 +43,18 @@ class ResultDecorateStage(Stage):
"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(
ctx.astrbot_config["platform_settings"]["segmented_reply"][
@@ -246,7 +259,14 @@ class ResultDecorateStage(Stage):
and result.is_llm_result()
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(
f"会话 {event.unified_msg_origin} 未配置文本转语音模型。",
)
@@ -385,10 +385,25 @@ class AiocqhttpAdapter(Platform):
logger.error(f"获取 @ 用户信息失败: {e},此消息段将被忽略。")
message_str += "".join(at_parts)
elif t == "markdown":
text = m["data"].get("markdown") or m["data"].get("content", "")
abm.message.append(Plain(text=text))
message_str += text
else:
for m in m_group:
a = ComponentTypes[t](**m["data"])
abm.message.append(a)
try:
if t not in ComponentTypes:
logger.warning(
f"不支持的消息段类型,已忽略: {t}, data={m['data']}"
)
continue
a = ComponentTypes[t](**m["data"])
abm.message.append(a)
except Exception as e:
logger.exception(
f"消息段解析失败: type={t}, data={m['data']}. {e}"
)
continue
abm.timestamp = int(time.time())
abm.message_str = message_str
@@ -81,7 +81,12 @@ class LarkPlatformAdapter(Platform):
)
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
@@ -200,6 +200,15 @@ class TelegramPlatformEvent(AstrMessageEvent):
if isinstance(chain, MessageChain):
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
delta = "" # 重置 delta
continue
@@ -1,11 +1,12 @@
import base64
import json
import os
import shutil
import uuid
from astrbot.api import logger
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 .webchat_queue_mgr import webchat_queue_mgr
@@ -41,12 +42,20 @@ class WebChatMessageEvent(AstrMessageEvent):
await web_chat_back_queue.put(
{
"type": "plain",
"cid": cid,
"data": data,
"streaming": streaming,
"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):
# save image to local
filename = f"{str(uuid.uuid4())}.jpg"
@@ -58,7 +67,6 @@ class WebChatMessageEvent(AstrMessageEvent):
await web_chat_back_queue.put(
{
"type": "image",
"cid": cid,
"data": data,
"streaming": streaming,
},
@@ -74,7 +82,6 @@ class WebChatMessageEvent(AstrMessageEvent):
await web_chat_back_queue.put(
{
"type": "record",
"cid": cid,
"data": data,
"streaming": streaming,
},
@@ -91,7 +98,6 @@ class WebChatMessageEvent(AstrMessageEvent):
await web_chat_back_queue.put(
{
"type": "file",
"cid": cid,
"data": data,
"streaming": streaming,
},
@@ -111,18 +117,17 @@ class WebChatMessageEvent(AstrMessageEvent):
cid = self.session_id.split("!")[-1]
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
async for chain in generator:
if chain.type == "break" and final_data:
# 分割符
await web_chat_back_queue.put(
{
"type": "break", # break means a segment end
"data": final_data,
"streaming": True,
"cid": cid,
},
)
final_data = ""
continue
# if chain.type == "break" and final_data:
# # 分割符
# await web_chat_back_queue.put(
# {
# "type": "break", # break means a segment end
# "data": final_data,
# "streaming": True,
# },
# )
# final_data = ""
# continue
r = await WebChatMessageEvent._send(
chain,
@@ -142,7 +147,6 @@ class WebChatMessageEvent(AstrMessageEvent):
"data": final_data,
"reasoning": reasoning_content,
"streaming": True,
"cid": cid,
},
)
await super().send_streaming(generator, use_fallback)
+73 -9
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import base64
import enum
import json
@@ -12,6 +14,7 @@ import astrbot.core.message.components as Comp
from astrbot import logger
from astrbot.core.agent.message import (
AssistantMessageSegment,
ContentPart,
ToolCall,
ToolCallMessageSegment,
)
@@ -90,6 +93,8 @@ class ProviderRequest:
"""会话 ID"""
image_urls: list[str] = field(default_factory=list)
"""图片 URL 列表"""
extra_user_content_parts: list[ContentPart] = field(default_factory=list)
"""额外的用户消息内容部分列表,用于在用户消息后添加额外的内容块(如系统提醒、指令等)。"""
func_tool: ToolSet | None = None
"""可用的函数工具"""
contexts: list[dict] = field(default_factory=list)
@@ -164,13 +169,23 @@ class ProviderRequest:
async def assemble_context(self) -> dict:
"""将请求(prompt 和 image_urls)包装成 OpenAI 的消息格式。"""
# 构建内容块列表
content_blocks = []
# 1. 用户原始发言(OpenAI 建议:用户发言在前)
if self.prompt and self.prompt.strip():
content_blocks.append({"type": "text", "text": self.prompt})
elif self.image_urls:
# 如果没有文本但有图片,添加占位文本
content_blocks.append({"type": "text", "text": "[图片]"})
# 2. 额外的内容块(系统提醒、指令等)
if self.extra_user_content_parts:
for part in self.extra_user_content_parts:
content_blocks.append(part.model_dump())
# 3. 图片内容
if self.image_urls:
user_content = {
"role": "user",
"content": [
{"type": "text", "text": self.prompt if self.prompt else "[图片]"},
],
}
for image_url in self.image_urls:
if image_url.startswith("http"):
image_path = await download_image_by_url(image_url)
@@ -183,11 +198,21 @@ class ProviderRequest:
if not image_data:
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
continue
user_content["content"].append(
content_blocks.append(
{"type": "image_url", "image_url": {"url": image_data}},
)
return user_content
return {"role": "user", "content": self.prompt}
# 只有当只有一个来自 prompt 的文本块且没有额外内容块时,才降级为简单格式以保持向后兼容
if (
len(content_blocks) == 1
and content_blocks[0]["type"] == "text"
and not self.extra_user_content_parts
and not self.image_urls
):
return {"role": "user", "content": content_blocks[0]["text"]}
# 否则返回多模态格式
return {"role": "user", "content": content_blocks}
async def _encode_image_bs64(self, image_url: str) -> str:
"""将图片转换为 base64"""
@@ -199,6 +224,38 @@ class ProviderRequest:
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
class LLMResponse:
role: str
@@ -227,6 +284,11 @@ class LLMResponse:
is_chunk: bool = False
"""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__(
self,
role: str,
@@ -241,6 +303,8 @@ class LLMResponse:
| AnthropicMessage
| None = None,
is_chunk: bool = False,
id: str | None = None,
usage: TokenUsage | None = None,
):
"""初始化 LLMResponse
+203 -93
View File
@@ -1,4 +1,5 @@
import asyncio
import copy
import traceback
from typing import Protocol, runtime_checkable
@@ -32,10 +33,12 @@ class ProviderManager:
persona_mgr: PersonaManager,
):
self.reload_lock = asyncio.Lock()
self.resource_lock = asyncio.Lock()
self.persona_mgr = persona_mgr
self.acm = acm
config = acm.confs["default"]
self.providers_config: list = config["provider"]
self.provider_sources_config: list = config.get("provider_sources", [])
self.provider_settings: dict = config["provider_settings"]
self.provider_stt_settings: dict = config.get("provider_stt_settings", {})
self.provider_tts_settings: dict = config.get("provider_tts_settings", {})
@@ -148,6 +151,7 @@ class ProviderManager:
"""
provider = None
provider_id = None
if umo:
provider_id = sp.get(
f"provider_perf_{provider_type.value}",
@@ -185,6 +189,12 @@ class ProviderManager:
)
else:
raise ValueError(f"Unknown provider type: {provider_type}")
if not provider and provider_id:
logger.warning(
f"没有找到 ID 为 {provider_id} 的提供商,这可能是由于您修改了提供商(模型)ID 导致的。"
)
return provider
async def initialize(self):
@@ -251,7 +261,136 @@ class ProviderManager:
# 初始化 MCP Client 连接
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):
# 如果 provider_source_id 存在且不为空,则从 provider_sources 中找到对应的配置并合并
provider_config = self.get_merged_provider_config(provider_config)
if not provider_config["enable"]:
logger.info(f"Provider {provider_config['id']} is disabled, skipping")
return
@@ -264,99 +403,7 @@ class ProviderManager:
# 动态导入
try:
match 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,
)
self.dynamic_import_provider(provider_config["type"])
except (ImportError, ModuleNotFoundError) as e:
logger.critical(
f"加载 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}。可能是因为有未安装的依赖。",
@@ -499,6 +546,7 @@ class ProviderManager:
# 和配置文件保持同步
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]
logger.info(f"providers in user's config: {config_ids}")
for key in list(self.inst_map.keys()):
@@ -570,6 +618,68 @@ class ProviderManager:
)
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):
for provider_inst in self.provider_insts:
if hasattr(provider_inst, "terminate"):
+5 -1
View File
@@ -4,7 +4,7 @@ import os
from collections.abc import AsyncGenerator
from typing import TypeAlias, Union
from astrbot.core.agent.message import Message
from astrbot.core.agent.message import ContentPart, Message
from astrbot.core.agent.tool import ToolSet
from astrbot.core.provider.entities import (
LLMResponse,
@@ -103,6 +103,7 @@ class Provider(AbstractProvider):
system_prompt: str | None = None,
tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None,
model: str | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
**kwargs,
) -> LLMResponse:
"""获得 LLM 的文本对话结果。会使用当前的模型进行对话。
@@ -114,6 +115,7 @@ class Provider(AbstractProvider):
tools: tool set
contexts: 上下文,和 prompt 二选一使用
tool_calls_result: 回传给 LLM 的工具调用结果。参考: https://platform.openai.com/docs/guides/function-calling
extra_user_content_parts: 额外的用户内容块列表,用于在用户消息后添加额外的文本块(如系统提醒、指令等)
kwargs: 其他参数
Notes:
@@ -133,6 +135,7 @@ class Provider(AbstractProvider):
system_prompt: str | None = None,
tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None,
model: str | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
**kwargs,
) -> AsyncGenerator[LLMResponse, None]:
"""获得 LLM 的流式文本对话结果。会使用当前的模型进行对话。在生成的最后会返回一次完整的结果。
@@ -144,6 +147,7 @@ class Provider(AbstractProvider):
tools: tool set
contexts: 上下文,和 prompt 二选一使用
tool_calls_result: 回传给 LLM 的工具调用结果。参考: https://platform.openai.com/docs/guides/function-calling
extra_user_content_parts: 额外的用户内容块列表,用于在用户消息后添加额外的文本块(如系统提醒、指令等)
kwargs: 其他参数
Notes:
+166 -47
View File
@@ -6,10 +6,13 @@ from mimetypes import guess_type
import anthropic
from anthropic import AsyncAnthropic
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.api.provider import Provider
from astrbot.core.provider.entities import LLMResponse
from astrbot.core.agent.message import ContentPart
from astrbot.core.provider.entities import LLMResponse, TokenUsage
from astrbot.core.provider.func_tool_manager import ToolSet
from astrbot.core.utils.io import download_image_by_url
@@ -45,7 +48,7 @@ class ProviderAnthropic(Provider):
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]):
"""准备 Anthropic API 的请求 payload
@@ -107,12 +110,32 @@ class ProviderAnthropic(Provider):
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:
if tools:
if tool_list := tools.get_func_desc_anthropic_style():
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)
logger.debug(f"completion: {completion}")
@@ -131,6 +154,10 @@ class ProviderAnthropic(Provider):
llm_response.tools_call_args.append(content_block.input)
llm_response.tools_call_name.append(content_block.name)
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 情况
if not llm_response.completion_text and not llm_response.tools_call_args:
raise Exception(f"Anthropic API 返回的 completion 无法解析:{completion}")
@@ -151,10 +178,19 @@ class ProviderAnthropic(Provider):
# 用于累积最终结果
final_text = ""
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)
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.content_block.type == "text":
# 文本块开始
@@ -162,6 +198,8 @@ class ProviderAnthropic(Provider):
role="assistant",
completion_text="",
is_chunk=True,
usage=usage,
id=id,
)
elif event.content_block.type == "tool_use":
# 工具使用块开始,初始化缓冲区
@@ -179,6 +217,8 @@ class ProviderAnthropic(Provider):
role="assistant",
completion_text=event.delta.text,
is_chunk=True,
usage=usage,
id=id,
)
elif event.delta.type == "input_json_delta":
# 工具调用参数增量
@@ -215,6 +255,8 @@ class ProviderAnthropic(Provider):
tools_call_name=[tool_info["name"]],
tools_call_ids=[tool_info["id"]],
is_chunk=True,
usage=usage,
id=id,
)
except json.JSONDecodeError:
# JSON 解析失败,跳过这个工具调用
@@ -223,11 +265,17 @@ class ProviderAnthropic(Provider):
# 清理缓冲区
del tool_use_buffer[event.index]
elif event.type == "message_delta":
if event.usage:
self._update_usage(usage, event.usage)
# 返回最终的完整结果
final_response = LLMResponse(
role="assistant",
completion_text=final_text,
is_chunk=False,
usage=usage,
id=id,
)
if final_tool_calls:
@@ -249,13 +297,16 @@ class ProviderAnthropic(Provider):
system_prompt=None,
tool_calls_result=None,
model=None,
extra_user_content_parts=None,
**kwargs,
) -> LLMResponse:
if contexts is None:
contexts = []
new_record = None
if prompt is not None:
new_record = await self.assemble_context(prompt, image_urls)
new_record = await self.assemble_context(
prompt, image_urls, extra_user_content_parts
)
context_query = self._ensure_message_to_dicts(contexts)
if new_record:
context_query.append(new_record)
@@ -277,10 +328,9 @@ class ProviderAnthropic(Provider):
system_prompt, new_messages = self._prepare_payload(context_query)
model_config = self.provider_config.get("model_config", {})
model_config["model"] = model or self.get_model()
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
if system_prompt:
@@ -290,7 +340,6 @@ class ProviderAnthropic(Provider):
try:
llm_response = await self._query(payloads, func_tool)
except Exception as e:
# logger.error(f"发生了错误。Provider 配置如下: {model_config}")
raise e
return llm_response
@@ -305,13 +354,16 @@ class ProviderAnthropic(Provider):
system_prompt=None,
tool_calls_result=None,
model=None,
extra_user_content_parts=None,
**kwargs,
):
if contexts is None:
contexts = []
new_record = None
if prompt is not None:
new_record = await self.assemble_context(prompt, image_urls)
new_record = await self.assemble_context(
prompt, image_urls, extra_user_content_parts
)
context_query = self._ensure_message_to_dicts(contexts)
if new_record:
context_query.append(new_record)
@@ -332,10 +384,9 @@ class ProviderAnthropic(Provider):
system_prompt, new_messages = self._prepare_payload(context_query)
model_config = self.provider_config.get("model_config", {})
model_config["model"] = model or self.get_model()
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
if system_prompt:
@@ -344,48 +395,116 @@ class ProviderAnthropic(Provider):
async for llm_response in self._query_stream(payloads, func_tool):
yield llm_response
async def assemble_context(self, text: str, image_urls: list[str] | None = None):
async def assemble_context(
self,
text: str,
image_urls: list[str] | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
):
"""组装上下文,支持文本和图片"""
if not image_urls:
return {"role": "user", "content": text}
content = []
content.append({"type": "text", "text": text})
for image_url in image_urls:
if image_url.startswith("http"):
image_path = await download_image_by_url(image_url)
image_data = await self.encode_image_bs64(image_path)
elif image_url.startswith("file:///"):
image_path = image_url.replace("file:///", "")
image_data = await self.encode_image_bs64(image_path)
else:
image_data = await self.encode_image_bs64(image_url)
# 1. 用户原始发言(OpenAI 建议:用户发言在前)
if text:
content.append({"type": "text", "text": text})
elif image_urls:
# 如果没有文本但有图片,添加占位文本
content.append({"type": "text", "text": "[图片]"})
elif extra_user_content_parts:
# 如果只有额外内容块,也需要添加占位文本
content.append({"type": "text", "text": " "})
if not image_data:
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
continue
# 2. 额外的内容块(系统提醒、指令等)
if extra_user_content_parts:
for block in extra_user_content_parts:
block_type = block.get("type")
# Get mime type for the image
mime_type, _ = guess_type(image_url)
if not mime_type:
mime_type = "image/jpeg" # Default to JPEG if can't determine
if block_type == "text":
# 文本直接添加
content.append(block)
content.append(
{
"type": "image",
"source": {
"type": "base64",
"media_type": mime_type,
"data": (
image_data.split("base64,")[1]
if "base64," in image_data
else image_data
),
elif block_type == "image_url":
# 转换 OpenAI 格式的图片为 Anthropic 格式
image_url_data = block.get("image_url", {})
if isinstance(image_url_data, dict):
url = image_url_data.get("url", "")
else:
# 兼容直接传 URL 字符串的情况
url = str(image_url_data)
if url and url.startswith("data:"):
try:
# 提取 MIME 类型和 base64 数据
mime_type = url.split(":")[1].split(";")[0]
base64_data = (
url.split("base64,")[1] if "base64," in url else url
)
content.append(
{
"type": "image",
"source": {
"type": "base64",
"media_type": mime_type,
"data": base64_data,
},
}
)
except Exception as e:
logger.warning(f"转换 image_url 到 Anthropic 格式失败: {e}")
else:
logger.warning(f"image_url 不是有效的 data URI: {url[:50]}...")
else:
# 其他类型(如 audio_urlAnthropic 不支持,记录警告
logger.debug(f"Anthropic 不支持的内容类型 '{block_type}',已忽略")
# 3. 图片内容
if image_urls:
for image_url in image_urls:
if image_url.startswith("http"):
image_path = await download_image_by_url(image_url)
image_data = await self.encode_image_bs64(image_path)
elif image_url.startswith("file:///"):
image_path = image_url.replace("file:///", "")
image_data = await self.encode_image_bs64(image_path)
else:
image_data = await self.encode_image_bs64(image_url)
if not image_data:
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
continue
# Get mime type for the image
mime_type, _ = guess_type(image_url)
if not mime_type:
mime_type = "image/jpeg" # Default to JPEG if can't determine
content.append(
{
"type": "image",
"source": {
"type": "base64",
"media_type": mime_type,
"data": (
image_data.split("base64,")[1]
if "base64," in image_data
else image_data
),
},
},
},
)
)
# 如果只有主文本且没有额外内容块和图片,返回简单格式以保持向后兼容
if (
text
and not extra_user_content_parts
and not image_urls
and len(content) == 1
and content[0]["type"] == "text"
):
return {"role": "user", "content": content[0]["text"]}
# 否则返回多模态格式
return {"role": "user", "content": content}
async def encode_image_bs64(self, image_url: str) -> str:
+132 -43
View File
@@ -13,8 +13,9 @@ from google.genai.errors import APIError
import astrbot.core.message.components as Comp
from astrbot import logger
from astrbot.api.provider import Provider
from astrbot.core.agent.message import ContentPart
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.utils.io import download_image_by_url
@@ -68,7 +69,7 @@ class ProviderGoogleGenAI(Provider):
self.api_base = self.api_base[:-1]
self._init_client()
self.set_model(provider_config["model_config"]["model"])
self.set_model(provider_config.get("model", "unknown"))
self._init_safety_settings()
def _init_client(self) -> None:
@@ -138,7 +139,7 @@ class ProviderGoogleGenAI(Provider):
modalities = ["TEXT"]
tool_list: list[types.Tool] | None = []
model_name = self.get_model()
model_name = cast(str, payloads.get("model", self.get_model()))
native_coderunner = self.provider_config.get("gm_native_coderunner", False)
native_search = self.provider_config.get("gm_native_search", False)
url_context = self.provider_config.get("gm_url_context", False)
@@ -197,6 +198,53 @@ class ProviderGoogleGenAI(Provider):
types.Tool(function_declarations=func_desc["function_declarations"]),
]
# oper thinking config
thinking_config = None
if model_name in [
"gemini-2.5-pro",
"gemini-2.5-pro-preview",
"gemini-2.5-flash",
"gemini-2.5-flash-preview",
"gemini-2.5-flash-lite",
"gemini-2.5-flash-lite-preview",
"gemini-robotics-er-1.5-preview",
"gemini-live-2.5-flash-preview-native-audio-09-2025",
]:
# 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 in [
"gemini-3-pro",
"gemini-3-pro-preview",
"gemini-3-flash",
"gemini-3-flash-preview",
"gemini-3-flash-lite",
"gemini-3-flash-lite-preview",
]:
# 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(
system_instruction=system_instruction,
temperature=temperature,
@@ -216,22 +264,7 @@ class ProviderGoogleGenAI(Provider):
response_modalities=modalities,
tools=cast(types.ToolListUnion | None, tool_list),
safety_settings=self.safety_settings if self.safety_settings else None,
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
),
thinking_config=thinking_config,
automatic_function_calling=types.AutomaticFunctionCallingConfig(
disable=True,
),
@@ -347,6 +380,16 @@ class ProviderGoogleGenAI(Provider):
]
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(
self,
candidate: types.Candidate,
@@ -431,6 +474,8 @@ class ProviderGoogleGenAI(Provider):
None,
)
model = payloads.get("model", self.get_model())
modalities = ["TEXT"]
if self.provider_config.get("gm_resp_image_modal", False):
modalities.append("IMAGE")
@@ -449,7 +494,7 @@ class ProviderGoogleGenAI(Provider):
temperature,
)
result = await self.client.models.generate_content(
model=self.get_model(),
model=model,
contents=cast(types.ContentListUnion, conversation),
config=config,
)
@@ -475,11 +520,11 @@ class ProviderGoogleGenAI(Provider):
e.message = ""
if "Developer instruction is not enabled" in e.message:
logger.warning(
f"{self.get_model()} 不支持 system prompt,已自动去除(影响人格设置)",
f"{model} 不支持 system prompt,已自动去除(影响人格设置)",
)
system_instruction = None
elif "Function calling is not enabled" in e.message:
logger.warning(f"{self.get_model()} 不支持函数调用,已自动去除")
logger.warning(f"{model} 不支持函数调用,已自动去除")
tools = None
elif (
"Multi-modal output is not supported" in e.message
@@ -488,7 +533,7 @@ class ProviderGoogleGenAI(Provider):
or "only supports text output" in e.message
):
logger.warning(
f"{self.get_model()} 不支持多模态输出,降级为文本模态",
f"{model} 不支持多模态输出,降级为文本模态",
)
modalities = ["TEXT"]
else:
@@ -501,6 +546,9 @@ class ProviderGoogleGenAI(Provider):
result.candidates[0],
llm_response,
)
llm_response.id = result.response_id
if result.usage_metadata:
llm_response.usage = self._extract_usage(result.usage_metadata)
return llm_response
async def _query_stream(
@@ -513,7 +561,7 @@ class ProviderGoogleGenAI(Provider):
(msg["content"] for msg in payloads["messages"] if msg["role"] == "system"),
None,
)
model = payloads.get("model", self.get_model())
conversation = self._prepare_conversation(payloads)
result = None
@@ -525,7 +573,7 @@ class ProviderGoogleGenAI(Provider):
system_instruction,
)
result = await self.client.models.generate_content_stream(
model=self.get_model(),
model=model,
contents=cast(types.ContentListUnion, conversation),
config=config,
)
@@ -535,11 +583,11 @@ class ProviderGoogleGenAI(Provider):
e.message = ""
if "Developer instruction is not enabled" in e.message:
logger.warning(
f"{self.get_model()} 不支持 system prompt,已自动去除(影响人格设置)",
f"{model} 不支持 system prompt,已自动去除(影响人格设置)",
)
system_instruction = None
elif "Function calling is not enabled" in e.message:
logger.warning(f"{self.get_model()} 不支持函数调用,已自动去除")
logger.warning(f"{model} 不支持函数调用,已自动去除")
tools = None
else:
raise
@@ -569,6 +617,9 @@ class ProviderGoogleGenAI(Provider):
chunk.candidates[0],
llm_response,
)
llm_response.id = chunk.response_id
if chunk.usage_metadata:
llm_response.usage = self._extract_usage(chunk.usage_metadata)
yield llm_response
return
@@ -596,6 +647,9 @@ class ProviderGoogleGenAI(Provider):
chunk.candidates[0],
final_response,
)
final_response.id = chunk.response_id
if chunk.usage_metadata:
final_response.usage = self._extract_usage(chunk.usage_metadata)
break
# Yield final complete response with accumulated text
@@ -627,13 +681,16 @@ class ProviderGoogleGenAI(Provider):
system_prompt=None,
tool_calls_result=None,
model=None,
extra_user_content_parts=None,
**kwargs,
) -> LLMResponse:
if contexts is None:
contexts = []
new_record = None
if prompt is not None:
new_record = await self.assemble_context(prompt, image_urls)
new_record = await self.assemble_context(
prompt, image_urls, extra_user_content_parts
)
context_query = self._ensure_message_to_dicts(contexts)
if new_record:
context_query.append(new_record)
@@ -652,10 +709,9 @@ class ProviderGoogleGenAI(Provider):
for tcr in tool_calls_result:
context_query.extend(tcr.to_openai_messages())
model_config = self.provider_config.get("model_config", {})
model_config["model"] = model or self.get_model()
model = model or self.get_model()
payloads = {"messages": context_query, **model_config}
payloads = {"messages": context_query, "model": model}
retry = 10
keys = self.api_keys.copy()
@@ -680,13 +736,16 @@ class ProviderGoogleGenAI(Provider):
system_prompt=None,
tool_calls_result=None,
model=None,
extra_user_content_parts=None,
**kwargs,
) -> AsyncGenerator[LLMResponse, None]:
if contexts is None:
contexts = []
new_record = None
if prompt is not None:
new_record = await self.assemble_context(prompt, image_urls)
new_record = await self.assemble_context(
prompt, image_urls, extra_user_content_parts
)
context_query = self._ensure_message_to_dicts(contexts)
if new_record:
context_query.append(new_record)
@@ -705,10 +764,9 @@ class ProviderGoogleGenAI(Provider):
for tcr in tool_calls_result:
context_query.extend(tcr.to_openai_messages())
model_config = self.provider_config.get("model_config", {})
model_config["model"] = model or self.get_model()
model = model or self.get_model()
payloads = {"messages": context_query, **model_config}
payloads = {"messages": context_query, "model": model}
retry = 10
keys = self.api_keys.copy()
@@ -746,13 +804,33 @@ class ProviderGoogleGenAI(Provider):
self.chosen_api_key = key
self._init_client()
async def assemble_context(self, text: str, image_urls: list[str] | None = None):
async def assemble_context(
self,
text: str,
image_urls: list[str] | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
):
"""组装上下文。"""
# 构建内容块列表
content_blocks = []
# 1. 用户原始发言(OpenAI 建议:用户发言在前)
if text:
content_blocks.append({"type": "text", "text": text})
elif image_urls:
# 如果没有文本但有图片,添加占位文本
content_blocks.append({"type": "text", "text": "[图片]"})
elif extra_user_content_parts:
# 如果只有额外内容块,也需要添加占位文本
content_blocks.append({"type": "text", "text": " "})
# 2. 额外的内容块(系统提醒、指令等)
if extra_user_content_parts:
for part in extra_user_content_parts:
content_blocks.append(part.model_dump())
# 3. 图片内容
if image_urls:
user_content = {
"role": "user",
"content": [{"type": "text", "text": text if text else "[图片]"}],
}
for image_url in image_urls:
if image_url.startswith("http"):
image_path = await download_image_by_url(image_url)
@@ -765,14 +843,25 @@ class ProviderGoogleGenAI(Provider):
if not image_data:
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
continue
user_content["content"].append(
content_blocks.append(
{
"type": "image_url",
"image_url": {"url": image_data},
},
)
return user_content
return {"role": "user", "content": text}
# 如果只有主文本且没有额外内容块和图片,返回简单格式以保持向后兼容
if (
text
and not extra_user_content_parts
and not image_urls
and len(content_blocks) == 1
and content_blocks[0]["type"] == "text"
):
return {"role": "user", "content": content_blocks[0]["text"]}
# 否则返回多模态格式
return {"role": "user", "content": content_blocks}
async def encode_image_bs64(self, image_url: str) -> str:
"""将图片转换为 base64"""
+64 -15
View File
@@ -12,14 +12,15 @@ from openai._exceptions import NotFoundError
from openai.lib.streaming.chat._completions import ChatCompletionStreamState
from openai.types.chat.chat_completion import ChatCompletion
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
from openai.types.completion_usage import CompletionUsage
import astrbot.core.message.components as Comp
from astrbot import logger
from astrbot.api.provider import Provider
from astrbot.core.agent.message import Message
from astrbot.core.agent.message import ContentPart, Message
from astrbot.core.agent.tool import ToolSet
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 ..register import register_provider_adapter
@@ -68,8 +69,7 @@ class ProviderOpenAIOfficial(Provider):
self.client.chat.completions.create,
).parameters.keys()
model_config = provider_config.get("model_config", {})
model = model_config.get("model", "unknown")
model = provider_config.get("model", "unknown")
self.set_model(model)
self.reasoning_key = "reasoning_content"
@@ -208,6 +208,7 @@ class ProviderOpenAIOfficial(Provider):
# handle the content delta
reasoning = self._extract_reasoning_content(chunk)
_y = False
llm_response.id = chunk.id
if reasoning:
llm_response.reasoning_content = reasoning
_y = True
@@ -217,6 +218,8 @@ class ProviderOpenAIOfficial(Provider):
chain=[Comp.Plain(completion_text)],
)
_y = True
if chunk.usage:
llm_response.usage = self._extract_usage(chunk.usage)
if _y:
yield llm_response
@@ -245,6 +248,15 @@ class ProviderOpenAIOfficial(Provider):
reasoning_text = str(reasoning_attr)
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(
self, completion: ChatCompletion, tools: ToolSet | None
) -> LLMResponse:
@@ -321,6 +333,10 @@ class ProviderOpenAIOfficial(Provider):
raise Exception(f"API 返回的 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
@@ -332,6 +348,7 @@ class ProviderOpenAIOfficial(Provider):
system_prompt: str | None = None,
tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None,
model: str | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
**kwargs,
) -> tuple:
"""准备聊天所需的有效载荷和上下文"""
@@ -339,7 +356,9 @@ class ProviderOpenAIOfficial(Provider):
contexts = []
new_record = None
if prompt is not None:
new_record = await self.assemble_context(prompt, image_urls)
new_record = await self.assemble_context(
prompt, image_urls, extra_user_content_parts
)
context_query = self._ensure_message_to_dicts(contexts)
if new_record:
context_query.append(new_record)
@@ -358,10 +377,9 @@ class ProviderOpenAIOfficial(Provider):
for tcr in tool_calls_result:
context_query.extend(tcr.to_openai_messages())
model_config = self.provider_config.get("model_config", {})
model_config["model"] = model or self.get_model()
model = model or self.get_model()
payloads = {"messages": context_query, **model_config}
payloads = {"messages": context_query, "model": model}
# xAI origin search tool inject
self._maybe_inject_xai_search(payloads, **kwargs)
@@ -461,6 +479,7 @@ class ProviderOpenAIOfficial(Provider):
system_prompt=None,
tool_calls_result=None,
model=None,
extra_user_content_parts=None,
**kwargs,
) -> LLMResponse:
payloads, context_query = await self._prepare_chat_payload(
@@ -470,6 +489,7 @@ class ProviderOpenAIOfficial(Provider):
system_prompt,
tool_calls_result,
model=model,
extra_user_content_parts=extra_user_content_parts,
**kwargs,
)
@@ -524,6 +544,7 @@ class ProviderOpenAIOfficial(Provider):
system_prompt=None,
tool_calls_result=None,
model=None,
extra_user_content_parts=None,
**kwargs,
) -> AsyncGenerator[LLMResponse, None]:
"""流式对话,与服务商交互并逐步返回结果"""
@@ -534,6 +555,7 @@ class ProviderOpenAIOfficial(Provider):
system_prompt,
tool_calls_result,
model=model,
extra_user_content_parts=extra_user_content_parts,
**kwargs,
)
@@ -609,13 +631,29 @@ class ProviderOpenAIOfficial(Provider):
self,
text: str,
image_urls: list[str] | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
) -> dict:
"""组装成符合 OpenAI 格式的 role 为 user 的消息段"""
# 构建内容块列表
content_blocks = []
# 1. 用户原始发言(OpenAI 建议:用户发言在前)
if text:
content_blocks.append({"type": "text", "text": text})
elif image_urls:
# 如果没有文本但有图片,添加占位文本
content_blocks.append({"type": "text", "text": "[图片]"})
elif extra_user_content_parts:
# 如果只有额外内容块,也需要添加占位文本
content_blocks.append({"type": "text", "text": " "})
# 2. 额外的内容块(系统提醒、指令等)
if extra_user_content_parts:
for part in extra_user_content_parts:
content_blocks.append(part.model_dump())
# 3. 图片内容
if image_urls:
user_content = {
"role": "user",
"content": [{"type": "text", "text": text if text else "[图片]"}],
}
for image_url in image_urls:
if image_url.startswith("http"):
image_path = await download_image_by_url(image_url)
@@ -628,14 +666,25 @@ class ProviderOpenAIOfficial(Provider):
if not image_data:
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
continue
user_content["content"].append(
content_blocks.append(
{
"type": "image_url",
"image_url": {"url": image_data},
},
)
return user_content
return {"role": "user", "content": text}
# 如果只有主文本且没有额外内容块和图片,返回简单格式以保持向后兼容
if (
text
and not extra_user_content_parts
and not image_urls
and len(content_blocks) == 1
and content_blocks[0]["type"] == "text"
):
return {"role": "user", "content": content_blocks[0]["text"]}
# 否则返回多模态格式
return {"role": "user", "content": content_blocks}
async def encode_image_bs64(self, image_url: str) -> str:
"""将图片转换为 base64"""
+5 -1
View File
@@ -2,15 +2,19 @@ from astrbot.core import html_renderer
from astrbot.core.provider import Provider
from astrbot.core.star.star_tools import StarTools
from astrbot.core.utils.command_parser import CommandParserMixin
from astrbot.core.utils.plugin_kv_store import PluginKVStoreMixin
from .context import Context
from .star import StarMetadata, star_map, star_registry
from .star_manager import PluginManager
class Star(CommandParserMixin):
class Star(CommandParserMixin, PluginKVStoreMixin):
"""所有插件(Star)的父类,所有插件都应该继承于这个类"""
author: str
name: str
def __init__(self, context: Context, config: dict | None = None):
StarTools.initialize(context)
self.context = context
+496
View File
@@ -0,0 +1,496 @@
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Any
from astrbot.core import db_helper, logger
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,
aliases: list[str] | None = None,
) -> 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(f"指令名 '{candidate_full}' 已被其他指令占用。")
# 校验别名
if aliases:
for alias in aliases:
alias = alias.strip()
if not alias:
continue
alias_full = _compose_command(descriptor.parent_signature, alias)
if _is_command_in_use(handler_full_name, alias_full):
raise ValueError(f"别名 '{alias_full}' 已被其他指令占用。")
existing_cfg = await db_helper.get_command_config(handler_full_name)
merged_extra = dict(existing_cfg.extra_data or {}) if existing_cfg else {}
merged_extra["resolved_aliases"] = aliases or []
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=merged_extra,
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:
try:
desc = _build_descriptor(handler)
if not desc:
continue
if not include_sub_commands and desc.is_sub_command:
continue
descriptors.append(desc)
except Exception as e:
logger.warning(
f"解析指令处理函数 {handler.handler_full_name} 失败,跳过该指令。原因: {e!s}"
)
continue
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,
)
extra = config.extra_data or {}
resolved_aliases = extra.get("resolved_aliases")
if isinstance(resolved_aliases, list):
descriptor.aliases = [str(x) for x in resolved_aliases if str(x).strip()]
def _apply_config_to_runtime(
descriptor: CommandDescriptor,
config: CommandConfig,
) -> None:
descriptor.handler.enabled = config.enabled
if descriptor.filter_ref:
if descriptor.current_fragment:
_set_filter_fragment(descriptor.filter_ref, descriptor.current_fragment)
extra = config.extra_data or {}
resolved_aliases = extra.get("resolved_aliases")
if isinstance(resolved_aliases, list):
_set_filter_aliases(
descriptor.filter_ref,
[str(x) for x in resolved_aliases if str(x).strip()],
)
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 _set_filter_aliases(
filter_ref: CommandFilter | CommandGroupFilter,
aliases: list[str],
) -> None:
current_aliases = getattr(filter_ref, "alias", set())
if set(aliases) == current_aliases:
return
setattr(filter_ref, "alias", set(aliases))
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。"""
prov = self.provider_manager.inst_map.get(provider_id)
if provider_id and not prov:
logger.warning(
f"没有找到 ID 为 {provider_id} 的提供商,这可能是由于您修改了提供商(模型)ID 导致的。"
)
return prov
def get_all_providers(self) -> list[Provider]:
+1
View File
@@ -40,6 +40,7 @@ class CommandFilter(HandlerFilter):
):
self.command_name = command_name
self.alias = alias if alias else set()
self._original_command_name = command_name
self.parent_command_names = (
parent_command_names if parent_command_names is not None else [""]
)
@@ -18,6 +18,7 @@ class CommandGroupFilter(HandlerFilter):
):
self.group_name = group_name
self.alias = alias if alias else set()
self._original_group_name = group_name
self.sub_command_filters: list[CommandFilter | CommandGroupFilter] = []
self.custom_filter_list: list[CustomFilter] = []
self.parent_group = parent_group
+4
View File
@@ -118,6 +118,8 @@ class StarHandlerRegistry(Generic[T]):
# 过滤事件类型
if handler.event_type != event_type:
continue
if not handler.enabled:
continue
# 过滤启用状态
if only_activated:
plugin = star_map.get(handler.handler_module_path)
@@ -220,6 +222,8 @@ class StarHandlerMetadata(Generic[H]):
extras_configs: dict = field(default_factory=dict)
"""插件注册的一些其他的信息, 如 priority 等"""
enabled: bool = True
def __lt__(self, other: StarHandlerMetadata):
"""定义小于运算符以支持优先队列"""
return self.extras_configs.get("priority", 0) < other.extras_configs.get(
+18
View File
@@ -23,6 +23,7 @@ from astrbot.core.utils.astrbot_path import (
from astrbot.core.utils.io import remove_dir
from . import StarMetadata
from .command_management import sync_command_configs
from .context import Context
from .filter.permission import PermissionType, PermissionTypeFilter
from .star import star_map, star_registry
@@ -467,6 +468,18 @@ class PluginManager:
metadata.star_cls = metadata.star_cls_type(
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:
logger.info(f"插件 {metadata.name} 已被禁用。")
@@ -618,6 +631,11 @@ class PluginManager:
# 清除 pip.main 导致的多余的 logging handlers
for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
try:
await sync_command_configs()
except Exception as e:
logger.error(f"同步指令配置失败: {e!s}")
logger.error(traceback.format_exc())
if not fail_rec:
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())
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(
db, astrbot_config_mgr, umop_config_router, acm: AstrBotConfigManager
) -> None:
@@ -71,3 +157,10 @@ async def migra(
for conf in acm.confs.values():
_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 .chat import ChatRoute
from .command import CommandRoute
from .config import ConfigRoute
from .conversation import ConversationRoute
from .file import FileRoute
@@ -17,6 +18,7 @@ from .update import UpdateRoute
__all__ = [
"AuthRoute",
"ChatRoute",
"CommandRoute",
"ConfigRoute",
"ConversationRoute",
"FileRoute",
+56 -13
View File
@@ -227,16 +227,19 @@ class ChatRoute(Route):
text: str,
media_parts: list,
reasoning: str,
agent_stats: dict,
):
"""保存 bot 消息到历史记录,返回保存的记录"""
bot_message_parts = []
bot_message_parts.extend(media_parts)
if text:
bot_message_parts.append({"type": "plain", "text": text})
bot_message_parts.extend(media_parts)
new_his = {"type": "bot", "message": bot_message_parts}
if reasoning:
new_his["reasoning"] = reasoning
if agent_stats:
new_his["agent_stats"] = agent_stats
record = await self.platform_history_mgr.insert(
platform_id="webchat",
@@ -294,7 +297,8 @@ class ChatRoute(Route):
accumulated_parts = []
accumulated_text = ""
accumulated_reasoning = ""
tool_calls = {}
agent_stats = {}
try:
async with track_conversation(self.running_convs, webchat_conv_id):
while True:
@@ -314,6 +318,16 @@ class ChatRoute(Route):
result_text = result["data"]
msg_type = result.get("type")
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 数据
try:
@@ -335,11 +349,35 @@ class ChatRoute(Route):
# 累积消息部分
if msg_type == "plain":
chain_type = result.get("chain_type", "normal")
if chain_type == "reasoning":
chain_type = result.get("chain_type")
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
else:
elif streaming:
accumulated_text += result_text
else:
accumulated_text = result_text
elif msg_type == "image":
filename = result_text.replace("[IMAGE]", "")
part = await self._create_attachment_from_file(
@@ -367,15 +405,20 @@ class ChatRoute(Route):
if msg_type == "end":
break
elif (
(streaming and msg_type == "complete")
or not streaming
or msg_type == "break"
(streaming and msg_type == "complete") or not streaming
# or msg_type == "break"
):
if (
chain_type == "tool_call"
or chain_type == "tool_call_result"
):
continue
saved_record = await self._save_bot_message(
webchat_conv_id,
accumulated_text,
accumulated_parts,
accumulated_reasoning,
agent_stats,
)
# 发送保存的消息信息给前端
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"
except Exception:
pass
# 重置累积变量 (对于 break 后的下一段消息)
if msg_type == "break":
accumulated_parts = []
accumulated_text = ""
accumulated_reasoning = ""
accumulated_parts = []
accumulated_text = ""
accumulated_reasoning = ""
# tool_calls = {}
agent_stats = {}
except BaseException as e:
logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True)
+83
View File
@@ -0,0 +1,83 @@
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")
aliases = data.get("aliases")
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, aliases=aliases)
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 {}
+303 -33
View File
@@ -6,7 +6,7 @@ from typing import Any
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.default import (
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.register import provider_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 .route import Response, Route, RouteContext
@@ -179,13 +180,157 @@ class ConfigRoute(Route):
"/config/provider/new": ("POST", self.post_new_provider),
"/config/provider/update": ("POST", self.post_update_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/list": ("GET", self.get_provider_config_list),
"/config/provider/model_list": ("GET", self.get_provider_model_list),
"/config/provider/get_embedding_dim": ("POST", self.get_embedding_dim),
"/config/provider_sources/models": (
"GET",
self.get_provider_source_models,
),
"/config/provider_sources/update": (
"POST",
self.update_provider_source,
),
"/config/provider_sources/delete": (
"POST",
self.delete_provider_source,
),
}
self.register_routes()
async def delete_provider_source(self):
"""删除 provider_source,并更新关联的 providers"""
post_data = await request.json
if not post_data:
return Response().error("缺少配置数据").__dict__
provider_source_id = post_data.get("id")
if not provider_source_id:
return Response().error("缺少 provider_source_id").__dict__
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,并重载关联的 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 = post_data.get("original_id")
if not original_id:
return Response().error("缺少 original_id").__dict__
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):
"""获取 UMOP 配置路由表"""
return Response().ok({"routing": self.ucr.umop_to_conf_id}).__dict__
@@ -433,9 +578,25 @@ class ConfigRoute(Route):
return Response().error("缺少参数 provider_type").__dict__
provider_type_ls = provider_type.split(",")
provider_list = []
astrbot_config = self.core_lifecycle.astrbot_config
for provider in astrbot_config["provider"]:
if provider.get("provider_type", None) in provider_type_ls:
ps = self.core_lifecycle.provider_manager.providers_config
p_source_pt = {
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)
return Response().ok(provider_list).__dict__
@@ -458,9 +619,18 @@ class ConfigRoute(Route):
try:
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 = {
"models": models,
"provider_id": provider_id,
"model_metadata": metadata_map,
}
return Response().ok(ret).__dict__
except Exception as e:
@@ -522,6 +692,104 @@ class ConfigRoute(Route):
logger.error(traceback.format_exc())
return Response().error(f"获取嵌入维度失败: {e!s}").__dict__
async def get_provider_source_models(self):
"""获取指定 provider_source 支持的模型列表
本质上会临时初始化一个 Provider 实例调用 get_models() 获取模型列表然后销毁实例
"""
provider_source_id = request.args.get("source_id")
if not provider_source_id:
return Response().error("缺少参数 source_id").__dict__
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):
"""获取所有平台的列表"""
platform_list = []
@@ -533,7 +801,15 @@ class ConfigRoute(Route):
data = await request.json
config = data.get("config", None)
conf_id = data.get("conf_id", None)
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.core_lifecycle.reload_pipeline_scheduler(conf_id)
return Response().ok(None, "保存成功~").__dict__
@@ -573,28 +849,30 @@ class ConfigRoute(Route):
async def post_new_provider(self):
new_provider_config = await request.json
self.config["provider"].append(new_provider_config)
try:
save_config(self.config, self.config, is_core=True)
await self.core_lifecycle.provider_manager.load_provider(
new_provider_config,
await self.core_lifecycle.provider_manager.create_provider(
new_provider_config
)
except Exception as e:
return Response().error(str(e)).__dict__
return Response().ok(None, "新增服务提供商配置成功~").__dict__
return Response().ok(None, "新增服务提供商配置成功").__dict__
async def post_update_platform(self):
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)
if not platform_id or not new_config:
if not origin_platform_id or not new_config:
return Response().error("参数错误").__dict__
if origin_platform_id != new_config.get("id", None):
return Response().error("机器人名称不允许修改").__dict__
# 如果是支持统一 webhook 模式的平台,且启用了统一 webhook 模式,确保有 webhook_uuid
ensure_platform_webhook_config(new_config)
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
break
else:
@@ -609,21 +887,15 @@ class ConfigRoute(Route):
async def post_update_provider(self):
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)
if not provider_id or not new_config:
if not origin_provider_id or not new_config:
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:
save_config(self.config, self.config, is_core=True)
await self.core_lifecycle.provider_manager.reload(new_config)
await self.core_lifecycle.provider_manager.update_provider(
origin_provider_id, new_config
)
except Exception as e:
return Response().error(str(e)).__dict__
return Response().ok(None, "更新成功,已经实时生效~").__dict__
@@ -646,19 +918,17 @@ class ConfigRoute(Route):
async def post_delete_provider(self):
provider_id = await request.json
provider_id = provider_id.get("id")
for i, provider in enumerate(self.config["provider"]):
if provider["id"] == provider_id:
del self.config["provider"][i]
break
else:
return Response().error("未找到对应服务提供商").__dict__
provider_id = provider_id.get("id", "")
if not provider_id:
return Response().error("缺少参数 id").__dict__
try:
save_config(self.config, self.config, is_core=True)
await self.core_lifecycle.provider_manager.terminate_provider(provider_id)
await self.core_lifecycle.provider_manager.delete_provider(
provider_id=provider_id
)
except Exception as e:
return Response().error(str(e)).__dict__
return Response().ok(None, "删除成功,已经实时生效~").__dict__
return Response().ok(None, "删除成功,已经实时生效").__dict__
async def get_llm_tools(self):
"""获取函数调用工具。包含了本地加载的以及 MCP 服务的工具"""
+91 -1
View File
@@ -1,7 +1,9 @@
import json
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.core_lifecycle import AstrBotCoreLifecycle
@@ -30,6 +32,7 @@ class ConversationRoute(Route):
"POST",
self.update_history,
),
"/conversation/export": ("POST", self.export_conversations),
}
self.db_helper = db_helper
self.conv_mgr = core_lifecycle.conversation_manager
@@ -283,3 +286,90 @@ class ConversationRoute(Route):
except Exception as e:
logger.error(f"更新对话历史失败: {e!s}\n{traceback.format_exc()}")
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__
+5 -1
View File
@@ -124,7 +124,11 @@ class PluginRoute(Route):
session.get(url) as response,
):
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 (
+96
View File
@@ -1,6 +1,9 @@
import os
import re
import threading
import time
import traceback
from functools import cmp_to_key
import aiohttp
import psutil
@@ -11,7 +14,9 @@ from astrbot.core.config import VERSION
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db import BaseDatabase
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.version_comparator import VersionComparator
from .route import Response, Route, RouteContext
@@ -30,6 +35,8 @@ class StatRoute(Route):
"/stat/start-time": ("GET", self.get_start_time),
"/stat/restart-core": ("POST", self.restart_core),
"/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.register_routes()
@@ -183,3 +190,92 @@ class StatRoute(Route):
except Exception as e:
logger.error(traceback.format_exc())
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 astrbot.core import logger
from astrbot.core.agent.mcp_client import MCPTool
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.star import star_map
@@ -296,15 +297,30 @@ class ToolsRoute(Route):
"""获取所有注册的工具列表"""
try:
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,
"description": tool.description,
"parameters": tool.parameters,
"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__
except Exception as e:
logger.error(traceback.format_exc())
+1
View File
@@ -67,6 +67,7 @@ class AstrBotDashboard:
core_lifecycle,
core_lifecycle.plugin_manager,
)
self.command_route = CommandRoute(self.context)
self.cr = ConfigRoute(self.context, core_lifecycle)
self.lr = LogRoute(self.context, core_lifecycle.log_broker)
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 历史版本更新日志。
- 🎄
+44
View File
@@ -0,0 +1,44 @@
## What's Changed
> 📢 在升级前,请**完整阅读**本次更新日志。
>
> **特别提醒:**
> 1. 该版本为 alpha.2 预览版本。
> 2. 本次升级**如果再降级**,会由于提供商配置的变更,导致提供商配置错乱,需要手动删除后重新添加。
> 3. 此版本 WebUI 包体相较上一个版本增加约 **193%**,共约 **9.8 MB**,升级可能会需要一些时间。
## alpha.1 -> alpha.2
- 修复:“对话数据”页对话轨迹详情显示异常的问题
- 优化:当 Agent 达到最大步数时的处理。在达到最大步数后,会移除所有请求中的 tools 并告知模型根据上下文进行最终总结。
- 优化:LLM tools 执行的错误处理,减少工具调用无限循环的问题。
- 优化:ChatUI 打开模型选择菜单时,会重新获取提供商配置。
- 优化:ChatUI 新建对话并发送消息后,对话列表页自动选中该对话。
## 4.10.0 变化
### 重构与优化
- 重构 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 历史版本更新日志。
- 🎄
+40
View File
@@ -0,0 +1,40 @@
## What's Changed
> 📢 在升级前,请**完整阅读**本次更新日志。
>
> **特别提醒:**
> 1. 本次升级**如果再降级**,会由于提供商配置的变更,导致提供商配置错乱,需要手动删除后重新添加。
> 2. 此版本 WebUI 包体相较上一个版本增加约 **193%**,共约 **9.8 MB**,升级可能会需要一些时间。
> 3. **升级后请务必确保 WebUI 和 AstrBot Core 版本一致**,否则会产生预期之外的情况。(判断方法:日志中出现 `WebUI 版本已是最新。` 即为一致的版本,`检测到 WebUI 版本 (xxx) 与当前 AstrBot 版本 (xxx) 不符。` 即为不一致的版本。此版本的判断方法也可通查看 WebUI 右上角是否出现 Bot / Chat 的切换按钮控件来判断是否是新版本的 WebUI)。
> 4. 如果有任何问题请提交 [Issue](https://github.com/AstrBotDevs/AstrBot/issues) 并附带 `v4.10.0` tag。
### 重构与优化
- 重构 Provider 页面和提供商的配置结构,将 Chat Provider 配置拆分为 Provider Source(提供商源)和 Provider(代表提供商源的各个模型),引入了提供商模型自动发现、模型元数据自动发现的功能,**提供更加便捷的模型添加体验**。
- ⚠️ 将 “MCP” 页面移动到了 “插件” 页面中
- ⚠️ 将 “MCP” 页面中的工具管理移动到了 “插件” -> “管理行为” 中。
- ⚠️ 将 “QQ 个人号(OneBot v11)” 机器人适配器类型更名为 “OneBot v11”,并将其 Logo 更改为 OneBot 的 Logo。
- ⚠️ AstrBot WebChat 升级为 **AstrBot ChatUI**,入口从边栏修改为顶部(右上角)切换按钮。
- 优化引用消息的逻辑,减少对模型输入缓存的破坏。
- 优化当 Agent 达到最大步数时的处理。在达到最大步数后,会移除所有请求中的 tools 并告知模型根据上下文进行最终总结。
- 优化 LLM tools 执行的错误处理,减少工具调用无限循环的问题。
### 修复
- ‼️ 修复部分情况下,分段回复无法正常分段的问题。
- 修复处理工具返回结果的过程中,导致一些直接发送图片的工具(如生图工具)无法正确发送到用户的问题。
- 修复 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 历史版本更新日志。
- 🎄
Merry Christmas!
+46
View File
@@ -0,0 +1,46 @@
## What's Changed
> 📢 在升级前,请**完整阅读**本次更新日志。
>
> **特别提醒:**
> 1. 本次升级**如果再降级**,会由于提供商配置的变更,导致提供商配置错乱,需要手动删除后重新添加。
> 2. 此版本 WebUI 包体相较上一个版本增加约 **193%**,共约 **9.8 MB**,升级可能会需要一些时间。
> 3. **升级后请务必确保 WebUI 和 AstrBot Core 版本一致**,否则会产生预期之外的情况。(判断方法:日志中出现 `WebUI 版本已是最新。` 即为一致的版本,`检测到 WebUI 版本 (xxx) 与当前 AstrBot 版本 (xxx) 不符。` 即为不一致的版本。此版本的判断方法也可通查看 WebUI 右上角是否出现 Bot / Chat 的切换按钮控件来判断是否是新版本的 WebUI)。
> 4. 如果有任何问题请提交 [Issue](https://github.com/AstrBotDevs/AstrBot/issues) 并附带 `v4.10.0` tag。
## 4.10.0 -> 4.10.1
- fix(core): 修复极少数情况下由于指令管理导致的 AstrBot 启动失败的问题
- fix(core): 修复当提供商源带有斜杠(“/”)时,无法删除 / 更新提供商源的问题(报错 405)
- perf(core): 优化 OneBot 适配器的消息段解析逻辑,修复部分情况下无法正确解析消息段的问题
### 重构与优化
- 重构 Provider 页面和提供商的配置结构,将 Chat Provider 配置拆分为 Provider Source(提供商源)和 Provider(代表提供商源的各个模型),引入了提供商模型自动发现、模型元数据自动发现的功能,**提供更加便捷的模型添加体验**。
- ⚠️ 将 “MCP” 页面移动到了 “插件” 页面中
- ⚠️ 将 “MCP” 页面中的工具管理移动到了 “插件” -> “管理行为” 中。
- ⚠️ 将 “QQ 个人号(OneBot v11)” 机器人适配器类型更名为 “OneBot v11”,并将其 Logo 更改为 OneBot 的 Logo。
- ⚠️ AstrBot WebChat 升级为 **AstrBot ChatUI**,入口从边栏修改为顶部(右上角)切换按钮。
- 优化引用消息的逻辑,减少对模型输入缓存的破坏。
- 优化当 Agent 达到最大步数时的处理。在达到最大步数后,会移除所有请求中的 tools 并告知模型根据上下文进行最终总结。
- 优化 LLM tools 执行的错误处理,减少工具调用无限循环的问题。
### 修复
- ‼️ 修复部分情况下,分段回复无法正常分段的问题。
- 修复处理工具返回结果的过程中,导致一些直接发送图片的工具(如生图工具)无法正确发送到用户的问题。
- 修复 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 历史版本更新日志。
- 🎄
Merry Christmas!
+9
View File
@@ -0,0 +1,9 @@
## What's Changed
### 修复
1. ‼️‼️ 修复了由 `psutil` 新版本导致的启动时报错的问题。
### 新增
1. 插件指令管理支持管理别名。
+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" />
<link
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>
</head>
+8 -5
View File
@@ -14,22 +14,26 @@
},
"dependencies": {
"@guolao/vue-monaco-editor": "^1.5.4",
"@mdit/plugin-katex": "^0.24.1",
"@tiptap/starter-kit": "2.1.7",
"@tiptap/vue-3": "2.1.7",
"apexcharts": "3.42.0",
"axios": ">=1.6.2 <1.10.0 || >1.10.0 <2.0.0",
"axios-mock-adapter": "^1.22.0",
"chance": "1.1.11",
"d3": "^7.9.0",
"date-fns": "2.30.0",
"highlight.js": "^11.11.1",
"js-md5": "^0.8.3",
"katex": "^0.16.27",
"lodash": "4.17.21",
"marked": "^15.0.7",
"markdown-it": "^14.1.0",
"pinyin-pro": "^3.26.0",
"markstream-vue": "0.0.3-beta.7",
"mermaid": "^11.12.2",
"pinia": "2.1.6",
"pinyin-pro": "^3.26.0",
"remixicon": "3.5.0",
"shiki": "^3.20.0",
"stream-markdown": "^0.0.11",
"stream-monaco": "^0.0.8",
"vee-validate": "4.11.3",
"vite-plugin-vuetify": "1.0.2",
"vue": "3.3.4",
@@ -44,7 +48,6 @@
"@mdi/font": "7.2.96",
"@rushstack/eslint-patch": "1.3.3",
"@types/chance": "1.1.3",
"@types/markdown-it": "^14.1.2",
"@types/node": "^20.5.7",
"@vitejs/plugin-vue": "4.3.3",
"@vue/eslint-config-prettier": "8.0.0",
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"
@deleteConversation="handleDeleteConversation"
@closeMobileSidebar="closeMobileSidebar"
@toggleTheme="toggleTheme"
@toggleFullscreen="toggleFullscreen"
/>
<!-- 右侧聊天内容区域 -->
<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-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>
<MessageList v-if="messages && messages.length > 0" :messages="messages" :isDark="isDark"
:isStreaming="isStreaming || isConvRunning" @openImagePreview="openImagePreview"
@replyMessage="handleReplyMessage"
ref="messageList" />
<div class="message-list-wrapper" v-if="messages && messages.length > 0">
<MessageList :messages="messages" :isDark="isDark"
:isStreaming="isStreaming || isConvRunning"
: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-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 class="bot-name">AstrBot </span>
</div>
@@ -173,6 +149,7 @@ const isMobile = ref(false);
const mobileMenuOpen = ref(false);
const imagePreviewDialog = ref(false);
const previewImageUrl = ref('');
const isLoadingMessages = ref(false);
// 使 composables
const {
@@ -260,6 +237,14 @@ function toggleTheme() {
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) {
previewImageUrl.value = imageUrl;
imagePreviewDialog.value = true;
@@ -303,11 +288,14 @@ function clearReply() {
async function handleSelectConversation(sessionIds: string[]) {
if (!sessionIds[0]) return;
//
currSessionId.value = sessionIds[0];
selectedSessions.value = [sessionIds[0]];
// URL
const basePath = props.chatboxMode ? '/chatbox' : '/chat';
if (route.path !== `${basePath}/${sessionIds[0]}`) {
router.push(`${basePath}/${sessionIds[0]}`);
return;
}
//
@@ -317,11 +305,15 @@ async function handleSelectConversation(sessionIds: string[]) {
//
clearReply();
currSessionId.value = sessionIds[0];
selectedSessions.value = [sessionIds[0]];
await getSessionMsg(sessionIds[0], router);
//
isLoadingMessages.value = true;
try {
await getSessionMsg(sessionIds[0]);
} finally {
isLoadingMessages.value = false;
}
nextTick(() => {
messageList.value?.scrollToBottom();
@@ -510,6 +502,29 @@ onBeforeUnmount(() => {
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 {
display: flex;
justify-content: space-between;
@@ -543,6 +558,7 @@ onBeforeUnmount(() => {
justify-content: center;
align-items: center;
flex-direction: column;
position: relative;
}
.welcome-title {
@@ -550,6 +566,12 @@ onBeforeUnmount(() => {
margin-bottom: 16px;
}
.loading-overlay-welcome {
display: flex;
justify-content: center;
align-items: center;
}
.bot-name {
font-weight: 700;
margin-left: 8px;
@@ -575,5 +597,9 @@ onBeforeUnmount(() => {
.chat-page-container {
padding: 0 !important;
}
.conversation-header {
padding: 2px;
}
}
</style>
+20 -8
View File
@@ -1,7 +1,15 @@
<template>
<div class="input-area fade-in">
<div class="input-container"
style="width: 85%; max-width: 900px; margin: 0 auto; border: 1px solid #e0e0e0; border-radius: 24px;">
:style="{
width: '85%',
maxWidth: '900px',
margin: '0 auto',
border: isDark ? 'none' : '1px solid #e0e0e0',
borderRadius: '24px',
boxShadow: isDark ? 'none' : '0px 2px 2px rgba(0, 0, 0, 0.1)',
backgroundColor: isDark ? '#2d2d2d' : 'transparent'
}">
<!-- 引用预览区 -->
<div class="reply-preview" v-if="props.replyTo">
<div class="reply-content">
@@ -16,8 +24,8 @@
@keydown="handleKeyDown"
:disabled="disabled"
placeholder="Ask AstrBot..."
style="width: 100%; resize: none; outline: none; border: 1px solid var(--v-theme-border); border-radius: 12px; padding: 8px 16px; min-height: 40px; font-family: inherit; font-size: 16px; background-color: var(--v-theme-surface);"></textarea>
<div style="display: flex; justify-content: space-between; align-items: center; padding: 0px 12px;">
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: 6px 14px;">
<div style="display: flex; justify-content: flex-start; margin-top: 4px; align-items: center; gap: 8px;">
<ConfigSelector
:session-id="sessionId || null"
@@ -26,7 +34,9 @@
:initial-config-id="props.configId"
@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">
<template v-slot:activator="{ props }">
@@ -84,8 +94,9 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import ProviderModelSelector from './ProviderModelSelector.vue';
import { useCustomizerStore } from '@/stores/customizer';
import ConfigSelector from './ConfigSelector.vue';
import ProviderModelMenu from './ProviderModelMenu.vue';
import type { Session } from '@/composables/useSessions';
interface StagedFileInfo {
@@ -138,10 +149,11 @@ const emit = defineEmits<{
}>();
const { tm } = useModuleI18n('features/chat');
const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark');
const inputField = ref<HTMLTextAreaElement | 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 localPrompt = computed({
@@ -234,7 +246,7 @@ function getCurrentSelection() {
if (!showProviderSelector.value) {
return null;
}
return providerModelSelectorRef.value?.getCurrentSelection();
return providerModelMenuRef.value?.getCurrentSelection();
}
onMounted(() => {
@@ -259,7 +271,7 @@ defineExpose({
<style scoped>
.input-area {
padding: 16px;
background-color: var(--v-theme-surface);
background-color: transparent;
position: relative;
border-top: 1px solid var(--v-theme-border);
flex-shrink: 0;
@@ -17,7 +17,7 @@
</template>
</v-tooltip>
<v-dialog v-model="dialog" max-width="480" persistent>
<v-dialog v-model="dialog" max-width="480">
<v-card>
<v-card-title class="d-flex align-center justify-space-between">
<span>选择配置文件</span>
@@ -5,21 +5,11 @@
'mobile-sidebar-open': isMobile && mobileMenuOpen,
'mobile-sidebar': isMobile
}"
:style="{ 'background-color': isDark ? sidebarCollapsed ? '#1e1e1e' : '#2d2d2d' : sidebarCollapsed ? '#ffffff' : '#f1f4f9' }"
@mouseenter="handleSidebarMouseEnter"
@mouseleave="handleSidebarMouseLeave">
<div style="display: flex; align-items: center; justify-content: center; padding: 16px; padding-bottom: 0px;"
v-if="chatboxMode">
<img width="50" src="@/assets/images/icon-no-shadow.svg" alt="AstrBot Logo">
<span v-if="!sidebarCollapsed"
style="font-weight: 1000; font-size: 26px; margin-left: 8px;">AstrBot</span>
</div>
:style="{ 'background-color': isDark ? sidebarCollapsed ? '#1e1e1e' : '#2d2d2d' : sidebarCollapsed ? '#ffffff' : '#f1f4f9' }">
<div class="sidebar-collapse-btn-container" v-if="!isMobile">
<v-btn icon class="sidebar-collapse-btn" @click="toggleSidebar" variant="text" color="deep-purple">
<v-icon>{{ (sidebarCollapsed || (!sidebarCollapsed && sidebarHoverExpanded)) ?
'mdi-chevron-right' : 'mdi-chevron-left' }}</v-icon>
<v-icon>{{ sidebarCollapsed ? 'mdi-chevron-right' : 'mdi-chevron-left' }}</v-icon>
</v-btn>
</div>
@@ -30,19 +20,14 @@
</v-btn>
</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-if="!sidebarCollapsed || isMobile" prepend-icon="mdi-plus"
style="background-color: transparent !important; border-radius: 4px;">{{ tm('actions.newChat') }}</v-btn>
<v-btn icon="mdi-plus" rounded="lg" @click="$emit('newChat')" :disabled="!currSessionId"
v-if="!sidebarCollapsed || isMobile" prepend-icon="mdi-square-edit-outline">{{ tm('actions.newChat') }}</v-btn>
<v-btn icon="mdi-square-edit-outline" rounded="xl" @click="$emit('newChat')" :disabled="!currSessionId"
v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn>
</div>
<div v-if="!sidebarCollapsed || isMobile">
<v-divider class="mx-4"></v-divider>
</div>
<div style="overflow-y: auto; flex-grow: 1;" :class="{ 'fade-in': sidebarHoverExpanded }"
<div style="overflow-y: auto; flex-grow: 1;"
v-if="!sidebarCollapsed || isMobile">
<v-card v-if="sessions.length > 0" flat style="background-color: transparent;">
<v-list density="compact" nav class="conversation-list"
@@ -50,18 +35,19 @@
@update:selected="$emit('selectConversation', $event)">
<v-list-item v-for="item in sessions" :key="item.session_id" :value="item.session_id"
rounded="lg" class="conversation-item" active-color="secondary">
<v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title">
<v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title"
:style="{ color: isDark ? '#ffffff' : '#000000' }">
{{ item.display_name || tm('conversation.newConversation') }}
</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() }}
</v-list-item-subtitle>
</v-list-item-subtitle> -->
<template v-if="!sidebarCollapsed || isMobile" v-slot:append>
<div class="conversation-actions">
<v-btn icon="mdi-pencil" size="x-small" variant="text"
class="edit-title-btn"
@click.stop="$emit('editTitle', item.session_id, item.display_name)" />
@click.stop="$emit('editTitle', item.session_id, item.display_name ?? '')" />
<v-btn icon="mdi-delete" size="x-small" variant="text"
class="delete-conversation-btn" color="error"
@click.stop="handleDeleteConversation(item)" />
@@ -74,19 +60,83 @@
<v-fade-transition>
<div class="no-conversations" v-if="sessions.length === 0">
<v-icon icon="mdi-message-text-outline" size="large" color="grey-lighten-1"></v-icon>
<div class="no-conversations-text" v-if="!sidebarCollapsed || sidebarHoverExpanded || isMobile">
<div class="no-conversations-text" v-if="!sidebarCollapsed || isMobile">
{{ tm('conversation.noHistory') }}
</div>
</div>
</v-fade-transition>
</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>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import { useI18n, useModuleI18n } from '@/i18n/composables';
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 {
sessions: Session[];
@@ -106,15 +156,15 @@ const emit = defineEmits<{
editTitle: [sessionId: string, title: string];
deleteConversation: [sessionId: string];
closeMobileSidebar: [];
toggleTheme: [];
toggleFullscreen: [];
}>();
const { t } = useI18n();
const { tm } = useModuleI18n('features/chat');
const sidebarCollapsed = ref(true);
const sidebarHovered = ref(false);
const sidebarHoverTimer = ref<number | null>(null);
const sidebarHoverExpanded = ref(false);
const sidebarHoverDelay = 100;
const showProviderConfigDialog = ref(false);
// localStorage
const savedCollapsedState = localStorage.getItem('sidebarCollapsed');
@@ -125,40 +175,10 @@ if (savedCollapsedState !== null) {
}
function toggleSidebar() {
if (sidebarHoverExpanded.value) {
sidebarHoverExpanded.value = false;
return;
}
sidebarCollapsed.value = !sidebarCollapsed.value;
localStorage.setItem('sidebarCollapsed', JSON.stringify(sidebarCollapsed.value));
}
function handleSidebarMouseEnter() {
if (!sidebarCollapsed.value || props.isMobile) return;
sidebarHovered.value = true;
sidebarHoverTimer.value = window.setTimeout(() => {
if (sidebarHovered.value) {
sidebarHoverExpanded.value = true;
sidebarCollapsed.value = false;
}
}, sidebarHoverDelay);
}
function handleSidebarMouseLeave() {
sidebarHovered.value = false;
if (sidebarHoverTimer.value) {
clearTimeout(sidebarHoverTimer.value);
sidebarHoverTimer.value = null;
}
if (sidebarHoverExpanded.value) {
sidebarCollapsed.value = true;
}
sidebarHoverExpanded.value = false;
}
function handleDeleteConversation(session: Session) {
const sessionTitle = session.display_name || tm('conversation.newConversation');
const message = tm('conversation.confirmDelete', { name: sessionTitle });
@@ -184,8 +204,8 @@ function handleDeleteConversation(session: Session) {
}
.sidebar-collapsed {
max-width: 75px;
min-width: 75px;
max-width: 60px;
min-width: 60px;
transition: all 0.3s ease;
}
@@ -206,7 +226,7 @@ function handleDeleteConversation(session: Session) {
}
.sidebar-collapse-btn-container {
margin: 16px;
margin: 8px;
margin-bottom: 0px;
z-index: 10;
}
@@ -218,13 +238,19 @@ function handleDeleteConversation(session: Session) {
padding: 0;
}
.conversation-item {
margin-bottom: 4px;
border-radius: 8px !important;
transition: all 0.2s ease;
height: auto !important;
min-height: 56px;
.new-chat-btn {
justify-content: flex-start;
background-color: transparent !important;
border-radius: 20px;
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;
}
@@ -287,17 +313,31 @@ function handleDeleteConversation(session: Session) {
transition: opacity 0.25s ease;
}
.fade-in {
animation: fadeInContent 0.3s ease;
.sidebar-spacer {
flex-grow: 1;
}
@keyframes fadeInContent {
from {
opacity: 0;
}
to {
opacity: 1;
}
.sidebar-footer {
padding: 8px 8px;
padding-bottom: 16px;
flex-shrink: 0;
}
.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>
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,217 @@
<template>
<v-menu v-model="menuOpen" :close-on-content-click="false" location="top" @update:model-value="handleMenuToggle">
<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;
enable?: boolean;
}
const providerConfigs = ref<ProviderConfig[]>([]);
const selectedProviderId = ref('');
const searchQuery = ref('');
const menuOpen = ref(false);
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') {
// enable false
providerConfigs.value = (response.data.data || []).filter(
(p: ProviderConfig) => p.enable !== false
);
}
}).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 || ''
};
}
function handleMenuToggle(isOpen: boolean) {
if (isOpen) {
//
loadProviderConfigs();
}
}
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">
<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"
@click="showMcpServerDialog = true" rounded="xl" size="x-large">
@click="showMcpServerDialog = true" >
{{ tm('mcpServers.buttons.add') }}
</v-btn>
<v-btn color="success" prepend-icon="mdi-refresh" variant="tonal" @click="showSyncMcpServerDialog = true"
rounded="xl" size="x-large">
>
{{ tm('mcpServers.buttons.sync') }}
</v-btn>
</div>
</v-row>
<!-- 本地服务器列表 -->
<!-- MCP 服务器部分 -->
<div v-if="mcpServers.length === 0" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">mdi-server-off</v-icon>
<p class="text-grey mt-4">{{ tm('mcpServers.empty') }}</p>
@@ -57,7 +33,6 @@
</span>
</div>
<div class="d-flex" style="gap: 8px;">
<div>
<div v-if="item.tools && item.tools.length > 0">
@@ -67,8 +42,7 @@
<template v-slot:activator="{ props: listToolsProps }">
<span class="text-caption text-medium-emphasis cursor-pointer" v-bind="listToolsProps"
style="text-decoration: underline;">
{{ tm('mcpServers.status.availableTools', { count: item.tools.length }) }} ({{
item.tools.length }})
{{ tm('mcpServers.status.availableTools', { count: item.tools.length }) }} ({{ item.tools.length }})
</span>
</template>
<template v-slot:default="{ isActive }">
@@ -78,10 +52,7 @@
</v-card-title>
<v-card-text>
<ul>
<li v-for="(tool, idx) in item.tools" :key="idx" style="margin: 8px 0px;">{{
tool
}}
</li>
<li v-for="(tool, idx) in item.tools" :key="idx" style="margin: 8px 0px;">{{ tool }}</li>
</ul>
</v-card-text>
<v-card-actions class="d-flex justify-end">
@@ -91,8 +62,6 @@
</v-card-actions>
</v-card>
</template>
</v-dialog>
</div>
</div>
@@ -105,8 +74,6 @@
<v-progress-circular indeterminate color="primary" size="16"></v-progress-circular>
</div>
</div>
</template>
</item-card>
</v-col>
@@ -183,8 +150,7 @@
</v-card>
</v-dialog>
<!-- 添加/编辑 MCP 服务器对话框 -->
<!-- 同步 MCP 服务器对话框 -->
<v-dialog v-model="showSyncMcpServerDialog" max-width="500px" persistent>
<v-card>
<v-card-title class="bg-primary text-white py-3">
@@ -240,115 +206,8 @@
</v-card>
</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"
location="top">
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack" location="top">
{{ save_message }}
</v-snackbar>
</div>
@@ -356,15 +215,13 @@
<script>
import axios from 'axios';
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
import { VueMonacoEditor } from '@guolao/vue-monaco-editor';
import ItemCard from '@/components/shared/ItemCard.vue';
import { useI18n, useModuleI18n } from '@/i18n/composables';
export default {
name: 'ToolUsePage',
name: 'McpServersSection',
components: {
AstrBotConfig,
VueMonacoEditor,
ItemCard
},
@@ -377,20 +234,15 @@ export default {
return {
refreshInterval: null,
mcpServers: [],
tools: [],
showMcpServerDialog: false,
selectedMcpServerProvider: "modelscope",
mcpServerProviderList: ["modelscope"],
selectedMcpServerProvider: 'modelscope',
mcpServerProviderList: ['modelscope'],
mcpProviderToken: '',
showSyncMcpServerDialog: false,
addServerDialogMessage: "",
showToolsDialog: false,
showTools: true,
addServerDialogMessage: '',
loading: false,
loadingGettingServers: false,
mcpServerUpdateLoaders: {}, // record loading state for each server update
mcpServerUpdateLoaders: {},
isEditMode: false,
serverConfigJson: '',
jsonError: null,
@@ -400,87 +252,50 @@ export default {
tools: []
},
save_message_snack: false,
save_message: "",
save_message_success: "success",
toolSearch: '',
openedPanel: [], //
}
save_message: '',
save_message_success: 'success'
};
},
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() {
return !!this.currentServer.name && !this.jsonError;
},
//
getServerConfigSummary() {
return (server) => {
if (server.command) {
return `${server.command} ${(server.args || []).join(' ')}`;
}
// command
const configKeys = Object.keys(server).filter(key =>
!['name', 'active', 'tools'].includes(key)
);
if (configKeys.length > 0) {
return this.tm('mcpServers.status.configSummary', { keys: configKeys.join(', ') });
}
return this.tm('mcpServers.status.noConfig');
}
},
};
}
},
mounted() {
this.getServers();
this.getTools();
this.refreshInterval = setInterval(() => {
this.getServers();
this.getTools();
}, 5000);
},
unmounted() {
// if it exists
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
},
methods: {
openurl(url) {
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() {
this.loadingGettingServers = true;
axios.get('/api/tools/mcp/servers')
.then(response => {
this.mcpServers = response.data.data || [];
this.mcpServers.forEach(server => {
// Ensure each server has a loader state
if (!this.mcpServerUpdateLoaders[server.name]) {
this.mcpServerUpdateLoaders[server.name] = false;
}
@@ -492,24 +307,12 @@ export default {
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() {
try {
if (!this.serverConfigJson.trim()) {
this.jsonError = this.tm('dialogs.addServer.errors.configEmpty');
return false;
}
JSON.parse(this.serverConfigJson);
this.jsonError = null;
return true;
@@ -518,61 +321,51 @@ export default {
return false;
}
},
setConfigTemplate(type = 'stdio') {
let template = {};
if (type === 'streamable_http') {
template = {
transport: "streamable_http",
url: "your mcp server url",
transport: 'streamable_http',
url: 'your mcp server url',
headers: {},
timeout: 5,
sse_read_timeout: 300,
sse_read_timeout: 300
};
} else if (type === 'sse') {
template = {
transport: "sse",
url: "your mcp server url",
transport: 'sse',
url: 'your mcp server url',
headers: {},
timeout: 5,
sse_read_timeout: 300,
sse_read_timeout: 300
};
} else {
template = {
command: "python",
args: ["-m", "your_module"],
command: 'python',
args: ['-m', 'your_module']
};
}
this.serverConfigJson = JSON.stringify(template, null, 2);
},
saveServer() {
if (!this.validateJson()) {
return;
}
this.loading = true;
// JSON
try {
const configObj = JSON.parse(this.serverConfigJson);
//
const serverData = {
name: this.currentServer.name,
active: this.currentServer.active,
...configObj
};
const endpoint = this.isEditMode ? '/api/tools/mcp/update' : '/api/tools/mcp/add';
axios.post(endpoint, serverData)
.then(response => {
this.loading = false;
this.showMcpServerDialog = false;
this.addServerDialogMessage = "";
this.addServerDialogMessage = '';
this.getServers();
this.getTools();
this.showSuccess(response.data.message || this.tm('messages.saveSuccess'));
this.resetForm();
})
@@ -585,14 +378,12 @@ export default {
this.showError(this.tm('dialogs.addServer.errors.jsonParse', { error: e.message }));
}
},
deleteServer(server) {
let serverName = server.name || server;
const serverName = server.name || server;
if (confirm(this.tm('dialogs.confirmDelete', { name: serverName }))) {
axios.post('/api/tools/mcp/delete', { name: serverName })
.then(response => {
this.getServers();
this.getTools();
this.showSuccess(response.data.message || this.tm('messages.deleteSuccess'));
})
.catch(error => {
@@ -600,37 +391,22 @@ export default {
});
}
},
editServer(server) {
//
const configCopy = { ...server };
//
try {
delete configCopy.name;
delete configCopy.active;
delete configCopy.tools;
delete configCopy.errlogs;
} catch (e) {
console.error("Error removing basic fields: ", e);
}
//
delete configCopy.name;
delete configCopy.active;
delete configCopy.tools;
delete configCopy.errlogs;
this.currentServer = {
name: server.name,
active: server.active,
tools: server.tools || []
};
// JSON
this.serverConfigJson = JSON.stringify(configCopy, null, 2);
this.isEditMode = true;
this.showMcpServerDialog = true;
},
updateServerStatus(server) {
//
this.mcpServerUpdateLoaders[server.name] = true;
server.active = !server.active;
axios.post('/api/tools/mcp/update', server)
@@ -646,20 +422,16 @@ export default {
this.mcpServerUpdateLoaders[server.name] = false;
});
},
closeServerDialog() {
this.showMcpServerDialog = false;
this.addServerDialogMessage = '';
this.resetForm();
},
testServerConnection() {
if (!this.validateJson()) {
return;
}
this.loading = true;
let configObj;
try {
configObj = JSON.parse(this.serverConfigJson);
@@ -668,9 +440,8 @@ export default {
this.showError(this.tm('dialogs.addServer.errors.jsonParse', { error: e.message }));
return;
}
axios.post('/api/tools/mcp/test', {
"mcp_server_config": configObj,
mcp_server_config: configObj
})
.then(response => {
this.loading = false;
@@ -681,7 +452,6 @@ export default {
this.showError(this.tm('messages.testError', { error: error.response?.data?.message || error.message }));
});
},
resetForm() {
this.currentServer = {
name: '',
@@ -692,58 +462,26 @@ export default {
this.jsonError = null;
this.isEditMode = false;
},
showSuccess(message) {
this.save_message = message;
this.save_message_success = "success";
this.save_message_success = 'success';
this.save_message_snack = true;
},
showError(message) {
this.save_message = message;
this.save_message_success = "error";
this.save_message_success = 'error';
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() {
if (!this.selectedMcpServerProvider) {
this.showError(this.tm('syncProvider.status.selectProvider'));
return;
}
this.loading = true;
try {
const requestData = {
name: this.selectedMcpServerProvider
};
//
if (this.selectedMcpServerProvider === 'modelscope') {
if (!this.mcpProviderToken.trim()) {
this.showError(this.tm('syncProvider.status.enterToken'));
@@ -752,61 +490,33 @@ export default {
}
requestData.access_token = this.mcpProviderToken.trim();
}
const response = await axios.post('/api/tools/mcp/sync-provider', requestData);
if (response.data.status === 'ok') {
this.showSuccess(response.data.message || this.tm('syncProvider.messages.syncSuccess'));
this.showSyncMcpServerDialog = false;
this.mcpProviderToken = '';
//
this.getServers();
this.getTools();
} else {
this.showError(response.data.message || this.tm('syncProvider.messages.syncError', { error: 'Unknown error' }));
}
} catch (error) {
console.error('同步 MCP 服务器失败:', error);
this.showError(this.tm('syncProvider.messages.syncError', {
error: error.response?.data?.message || error.message || '网络连接或访问令牌问题'
this.showError(this.tm('syncProvider.messages.syncError', {
error: error.response?.data?.message || error.message || '网络连接或访问令牌问题'
}));
} finally {
this.loading = false;
}
}
}
}
};
</script>
<style scoped>
.tools-page {
padding: 20px;
padding: 0px;
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 {
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 8px;
@@ -814,4 +524,4 @@ export default {
margin-top: 4px;
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,131 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import type { CommandItem } from '../types';
const { tm } = useModuleI18n('features/command');
// Props
const props = defineProps<{
show: boolean;
command: CommandItem | null;
newName: string;
aliases: string[];
loading: boolean;
}>();
// Emits
const emit = defineEmits<{
(e: 'update:show', value: boolean): void;
(e: 'update:newName', value: string): void;
(e: 'update:aliases', value: string[]): void;
(e: 'confirm'): void;
}>();
const addAlias = () => {
emit('update:aliases', [...props.aliases, '']);
};
const removeAlias = (index: number) => {
const newAliases = [...props.aliases];
newAliases.splice(index, 1);
emit('update:aliases', newAliases);
};
const updateAlias = (index: number, value: string) => {
const newAliases = [...props.aliases];
newAliases[index] = value;
emit('update:aliases', newAliases);
};
const hasAliases = computed(() => (props.aliases || []).some(a => (a ?? '').toString().trim()));
const showAliasEditor = ref(false);
const aliasEditorEverOpened = ref(false);
watch(
() => props.show,
(open) => {
if (!open) return;
//
showAliasEditor.value = hasAliases.value;
},
);
watch(showAliasEditor, (open) => {
if (open) aliasEditorEverOpened.value = true;
});
</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
class="mb-2"
/>
<v-card variant="outlined" class="mt-2" elevation="0">
<div
class="d-flex align-center justify-space-between px-4 py-3"
role="button"
tabindex="0"
@click="showAliasEditor = !showAliasEditor"
@keydown.enter.prevent="showAliasEditor = !showAliasEditor"
@keydown.space.prevent="showAliasEditor = !showAliasEditor"
>
<div class="text-subtitle-1">{{ tm('dialogs.rename.aliases') }}</div>
<v-icon size="20">{{ showAliasEditor ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
</div>
<v-divider v-if="showAliasEditor" />
<v-slide-y-transition>
<div v-if="aliasEditorEverOpened" v-show="showAliasEditor" class="px-4 py-3">
<div v-for="(alias, index) in aliases" :key="index" class="d-flex align-center mb-2">
<v-text-field
:model-value="alias"
@update:model-value="updateAlias(index, $event)"
variant="outlined"
density="compact"
hide-details
class="flex-grow-1 mr-2"
/>
<v-btn icon="mdi-delete" variant="text" color="error" density="compact" @click="removeAlias(index)" />
</div>
<v-btn
prepend-icon="mdi-plus"
variant="outlined"
color="primary"
block
size="small"
class="mt-2"
@click="addAlias"
>
{{ tm('dialogs.rename.addAlias') }}
</v-btn>
</div>
</v-slide-y-transition>
</v-card>
</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,180 @@
/**
* 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: '',
aliases: [],
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.aliases = [...(cmd.aliases || [])];
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(),
aliases: renameDialog.aliases.filter(a => a.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,309 @@
<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"
:aliases="renameDialog.aliases"
@update:aliases="renameDialog.aliases = $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,103 @@
/**
* -
*/
/** 指令项接口 */
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;
aliases: 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,
configDrawerTargetId: null,
// ID ID
originalUpdatingPlatformId: null,
};
},
setup() {
@@ -481,6 +484,7 @@ export default {
updatingPlatformConfig: {
handler(newConfig) {
if (this.updatingMode && newConfig && newConfig.id) {
this.originalUpdatingPlatformId = newConfig.id;
this.getPlatformConfigs(newConfig.id);
}
},
@@ -533,6 +537,8 @@ export default {
this.showConfigDrawer = false;
this.configDrawerTargetId = null;
this.originalUpdatingPlatformId = null;
},
closeDialog() {
this.resetForm();
@@ -624,7 +630,7 @@ export default {
}
},
async updatePlatform() {
let id = this.updatingPlatformConfig.id;
const id = this.originalUpdatingPlatformId || this.updatingPlatformConfig.id;
if (!id) {
this.loading = false;
this.showError('更新失败,缺少平台 ID。');
@@ -633,11 +639,15 @@ export default {
try {
//
await axios.post('/api/config/platform/update', {
let resp = await axios.post('/api/config/platform/update', {
id: id,
config: this.updatingPlatformConfig
});
})
if (resp.data.status === 'error') {
throw new Error(resp.data.message || '平台更新失败');
}
//
await this.saveRoutesInternal();
@@ -885,7 +895,10 @@ export default {
//
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');
}
@@ -895,9 +908,11 @@ export default {
const fullRoutingTable = routesRes.data.data.routing;
//
const platformId = this.updatingPlatformConfig.id;
for (const umop in fullRoutingTable) {
if (this.isUmopMatchPlatform(umop, platformId)) {
if (
(originalPlatformId && this.isUmopMatchPlatform(umop, originalPlatformId)) ||
(newPlatformId && this.isUmopMatchPlatform(umop, newPlatformId))
) {
delete fullRoutingTable[umop];
}
}
@@ -906,7 +921,8 @@ export default {
for (const route of this.platformRoutes) {
const messageType = route.messageType === '*' ? '*' : route.messageType;
const sessionId = route.sessionId === '*' ? '*' : route.sessionId;
const newUmop = `${platformId}:${messageType}:${sessionId}`;
const platformIdForRoute = newPlatformId || originalPlatformId;
const newUmop = `${platformIdForRoute}:${messageType}:${sessionId}`;
if (route.configId) {
fullRoutingTable[newUmop] = route.configId;
@@ -3,10 +3,6 @@
<v-card :title="tm('dialogs.addProvider.title')">
<v-card-text style="overflow-y: auto;">
<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-icon start>mdi-cogs</v-icon>
{{ tm('dialogs.addProvider.tabs.agentRunner') }}
@@ -116,7 +112,7 @@ export default {
//
getTemplatesByType(type) {
const templates = this.metadata['provider_group']?.metadata?.provider?.config_template || {};
const templates = this.metadata.provider.config_template || {};
const filtered = {};
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,157 @@
<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>
<style>
.v-theme--PurpleThemeDark .provider-source-list-item--active {
background-color: #2d2d2d;
border: none;
}
</style>
@@ -162,7 +162,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
<!-- Regular Property -->
<template v-else>
<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-title class="property-name">
<span v-if="metadata[metadataKey].items[key]?.description">
@@ -180,7 +180,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
</v-list-item>
</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">
<!-- Special handling for specific metadata types -->
<div v-if="metadata[metadataKey].items[key]?._special === 'select_provider'">
@@ -304,16 +304,32 @@ function hasVisibleItemsAfter(items, currentIndex) {
hide-details
></v-text-field>
<!-- Numeric input -->
<v-text-field
<!-- Numeric input with optional slider -->
<div
v-else-if="(metadata[metadataKey].items[key]?.type === 'int' || metadata[metadataKey].items[key]?.type === 'float') && !metadata[metadataKey]?.invisible"
v-model="iterable[key]"
density="compact"
variant="outlined"
class="config-field"
type="number"
hide-details
></v-text-field>
class="d-flex align-center gap-3"
>
<v-slider
v-if="metadata[metadataKey].items[key]?.slider"
v-model.number="iterable[key]"
:min="metadata[metadataKey].items[key]?.slider?.min ?? 0"
: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 -->
<v-textarea
@@ -413,16 +429,32 @@ function hasVisibleItemsAfter(items, currentIndex) {
hide-details
></v-text-field>
<!-- Numeric input -->
<v-text-field
<!-- Numeric input with optional slider -->
<div
v-else-if="(metadata[metadataKey]?.type === 'int' || metadata[metadataKey]?.type === 'float') && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]"
density="compact"
variant="outlined"
class="config-field"
type="number"
hide-details
></v-text-field>
class="d-flex align-center gap-3"
>
<v-slider
v-if="metadata[metadataKey]?.slider"
v-model.number="iterable[metadataKey]"
:min="metadata[metadataKey]?.slider?.min ?? 0"
: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 -->
<v-textarea
@@ -508,6 +540,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
font-size: 0.85em;
opacity: 0.7;
font-weight: normal;
display: none;
}
.important-hint {
@@ -541,7 +574,6 @@ function hasVisibleItemsAfter(items, currentIndex) {
align-items: center;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.2s;
}
.config-row:hover {
@@ -245,10 +245,29 @@ function getSpecialSubtype(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>
<!-- Numeric input for JSON selector -->
<v-text-field v-else-if="itemMeta?.type === 'int' || itemMeta?.type === 'float'"
v-model="createSelectorModel(itemKey).value" density="compact" variant="outlined" class="config-field"
type="number" hide-details></v-text-field>
<!-- Numeric input with optional slider for JSON selector -->
<div v-else-if="itemMeta?.type === 'int' || itemMeta?.type === 'float'" class="d-flex align-center gap-3">
<v-slider
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 -->
<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,5 +1,5 @@
<template>
<v-menu offset="12" location="bottom center">
<StyledMenu offset="12" location="bottom center">
<template v-slot:activator="{ props: activatorProps }">
<v-btn
v-bind="activatorProps"
@@ -22,25 +22,21 @@
</v-btn>
</template>
<v-card class="language-dropdown" elevation="8" rounded="lg">
<v-list density="compact" class="pa-1">
<v-list-item
v-for="lang in languages"
:key="lang.code"
:value="lang.code"
@click="changeLanguage(lang.code)"
:class="{ 'v-list-item--active': currentLocale === lang.code, 'language-item-selected': currentLocale === lang.code }"
class="language-item"
rounded="md"
>
<template v-slot:prepend>
<span class="language-flag">{{ lang.flag }}</span>
</template>
<v-list-item-title>{{ lang.name }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<v-list-item
v-for="lang in languages"
:key="lang.code"
:value="lang.code"
@click="changeLanguage(lang.code)"
:class="{ 'styled-menu-item-active': currentLocale === lang.code }"
class="styled-menu-item"
rounded="md"
>
<template v-slot:prepend>
<span class="language-flag">{{ lang.flag }}</span>
</template>
<v-list-item-title>{{ lang.name }}</v-list-item-title>
</v-list-item>
</StyledMenu>
</template>
<script setup lang="ts">
@@ -48,6 +44,7 @@ import { computed } from 'vue'
import { useI18n, useLanguageSwitcher } from '@/i18n/composables'
import { useCustomizerStore } from '@/stores/customizer'
import type { Locale } from '@/i18n/types'
import StyledMenu from '@/components/shared/StyledMenu.vue'
// props
const props = withDefaults(defineProps<{
@@ -110,49 +107,4 @@ const changeLanguage = async (langCode: string) => {
:deep(.v-theme--PurpleThemeDark) .language-switcher--default:hover {
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>
@@ -1,6 +1,15 @@
<template>
<div class="d-flex align-center justify-space-between">
<div>
<div class="d-flex align-center justify-space-between ga-2">
<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));">
{{ t('core.common.list.noItems') }}
</span>
@@ -14,7 +23,7 @@
</div>
</div>
<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>
</div>
@@ -167,6 +176,10 @@ const props = defineProps({
maxDisplayItems: {
type: Number,
default: 1
},
preferSingleItem: {
type: Boolean,
default: true
}
})
@@ -180,6 +193,21 @@ const editIndex = ref(-1)
const editItem = ref('')
const showBatchImport = ref(false)
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(() => {
@@ -14,8 +14,20 @@
<!-- Provider Selection Dialog -->
<v-dialog v-model="dialog" max-width="600px">
<v-card>
<v-card-title class="text-h3 py-4" style="font-weight: normal;">
{{ tm('providerSelector.dialogTitle') }}
<v-card-title
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-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-subtitle>
{{ 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>
<template v-slot:append>
@@ -79,12 +91,33 @@
</v-card-actions>
</v-card>
</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>
<script setup>
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
import ProviderPage from '@/views/ProviderPage.vue'
const props = defineProps({
modelValue: {
@@ -112,12 +145,26 @@ const dialog = ref(false)
const providerList = ref([])
const loading = ref(false)
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
watch(() => props.modelValue, (newValue) => {
selectedProvider.value = newValue || ''
}, { immediate: true })
watch(providerDrawer, (isOpen, wasOpen) => {
if (!isOpen && wasOpen) {
loadProviders()
}
})
async function openDialog() {
selectedProvider.value = props.modelValue || ''
dialog.value = true
@@ -170,6 +217,14 @@ function cancelSelection() {
selectedProvider.value = props.modelValue || ''
dialog.value = false
}
function openProviderDrawer() {
providerDrawer.value = true
}
function closeProviderDrawer() {
providerDrawer.value = false
}
</script>
<style scoped>
@@ -184,4 +239,35 @@ function cancelSelection() {
.v-list-item.v-list-item--active {
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>
@@ -1,11 +1,15 @@
<script setup>
import { ref, watch, onMounted, computed } from 'vue';
import axios from 'axios';
import MarkdownIt from 'markdown-it';
import hljs from 'highlight.js';
import { MarkdownRender, enableKatex, enableMermaid } from 'markstream-vue';
import 'markstream-vue/index.css';
import 'katex/dist/katex.min.css';
import 'highlight.js/styles/github.css';
import { useI18n } from '@/i18n/composables';
enableKatex();
enableMermaid();
const props = defineProps({
show: {
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
function refreshReadme() {
@@ -115,7 +96,7 @@ const _show = computed({
</script>
<template>
<v-dialog v-model="_show" width="800" persistent>
<v-dialog v-model="_show" width="800">
<v-card>
<v-card-title class="d-flex justify-space-between align-center">
<span class="text-h5">{{ t('core.common.readme.title') }}</span>
@@ -150,7 +131,9 @@ const _show = computed({
</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%;">
@@ -301,6 +284,9 @@ const _show = computed({
<script>
export default {
name: 'ReadmeDialog',
components: {
MarkdownRender
},
computed: {
_show: {
get() {
@@ -121,7 +121,8 @@ import sidebarItems from '@/layouts/full/vertical-sidebar/sidebarItem';
import {
getSidebarCustomization,
setSidebarCustomization,
clearSidebarCustomization
clearSidebarCustomization,
resolveSidebarItems
} from '@/utils/sidebarCustomization';
const { t } = useI18n();
@@ -133,35 +134,12 @@ const draggedItem = ref(null);
function initializeItems() {
const customization = getSidebarCustomization();
if (customization) {
// Load from customization
const allItemsMap = new Map();
sidebarItems.forEach(item => {
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] : [];
}
const { mainItems: resolvedMain, moreItems: resolvedMore } = resolveSidebarItems(
sidebarItems,
customization
);
mainItems.value = resolvedMain;
moreItems.value = resolvedMore;
}
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>
+266 -164
View File
@@ -2,19 +2,29 @@ import { ref, reactive, type Ref } from 'vue';
import axios from 'axios';
import { useToast } from '@/utils/toast';
// 新格式消息部分的类型定义
export interface MessagePart {
type: 'plain' | 'image' | 'record' | 'file' | 'video' | 'reply';
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)
// 工具调用信息
export interface ToolCall {
id: string;
name: string;
args: Record<string, any>;
ts: number; // 开始时间戳
result?: string; // 工具调用结果
finished_ts?: number; // 完成时间戳
}
// 引用信息
export interface ReplyInfo {
messageId: number;
messageContent: string;
// Token 使用统计
export interface TokenUsage {
input_other: number;
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; // 用于按需下载
}
// 引用消息信息
export interface ReplyTo {
message_id: number;
message_content?: string; // 被引用消息的内容(解析后填充)
// 消息部分的类型定义
export interface MessagePart {
type: 'plain' | 'image' | 'record' | 'file' | 'video' | 'reply' | 'tool_call';
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 {
type: string;
message: string | MessagePart[]; // 支持旧格式(string)和新格式(MessagePart[])
reasoning?: string;
image_url?: string[];
audio_url?: string;
file_url?: FileInfo[];
embedded_images?: string[];
embedded_audio?: string;
embedded_files?: FileInfo[];
isLoading?: boolean;
reply_to?: ReplyTo; // 引用的消息
type: string; // 'user' | 'bot'
message: MessagePart[]; // 消息部分列表 (保持顺序)
reasoning?: string; // reasoning content (for bot)
isLoading?: boolean; // loading state
agentStats?: AgentStats; // agent 统计信息 (for bot)
}
export interface Message {
@@ -93,55 +112,67 @@ export function useMessages(
}
}
// 解析新格式消息为旧格式兼容的结构 (用于显示)
// 解析消息内容,填充 embedded 字段 (保持原始顺序)
async function parseMessageContent(content: any): Promise<void> {
const message = content.message;
// 如果 message 是数组 (格式)
if (Array.isArray(message)) {
let textParts: string[] = [];
let imageUrls: string[] = [];
let audioUrl: string | undefined;
let fileInfos: FileInfo[] = [];
let replyTo: ReplyTo | undefined;
// 如果 message 是字符串 (格式),转换为数组格式
if (typeof message === 'string') {
const parts: MessagePart[] = [];
let text = message;
// 处理旧格式的特殊标记
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[]) {
if (part.type === 'plain' && part.text) {
textParts.push(part.text);
} else if (part.type === 'image' && part.attachment_id) {
const url = await getAttachment(part.attachment_id);
if (url) imageUrls.push(url);
if (part.type === 'image' && part.attachment_id) {
part.embedded_url = await getAttachment(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) {
// file 类型不预加载,保留 attachment_id 以便点击时下载
fileInfos.push({
part.embedded_file = {
attachment_id: part.attachment_id,
filename: part.filename || 'file'
});
} else if (part.type === 'reply' && part.message_id) {
replyTo = { message_id: part.message_id };
};
}
// 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;
// plain, reply, tool_call, video 保持原样
}
}
// 如果 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) {
if (!sessionId) return;
try {
@@ -157,50 +188,14 @@ export function useMessages(
// 如果会话还在运行,3秒后重新获取消息
setTimeout(() => {
getSessionMessages(currSessionId.value, router);
getSessionMessages(currSessionId.value);
}, 3000);
}
// 处理历史消息中的媒体文件
// 处理历史消息
for (let i = 0; i < history.length; i++) {
let content = history[i].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;
@@ -217,47 +212,66 @@ export function useMessages(
selectedModelName: string,
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 = {
type: 'user',
message: prompt,
image_url: [],
audio_url: undefined,
file_url: [],
reply_to: replyTo ? { message_id: replyTo.messageId } : undefined
message: userMessageParts
};
// 分离图片和文件
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 });
// 添加一个加载中的机器人消息占位符
const loadingMessage = reactive({
const loadingMessage = reactive<MessageContent>({
type: 'bot',
message: '',
message: [],
reasoning: '',
isLoading: true
});
@@ -272,12 +286,11 @@ export function useMessages(
// 收集所有 attachment_id
const files = stagedFiles.map(f => f.attachment_id);
// 构建 message 参数
// 当 files 或 reply 存在时,message 是 list,否则是 str
// 构建发送给后端的 message 参数
let messageToSend: string | MessagePart[];
if (files.length > 0 || replyTo) {
const parts: MessagePart[] = [];
// 添加引用消息段
if (replyTo) {
parts.push({
@@ -285,7 +298,7 @@ export function useMessages(
message_id: replyTo.messageId
});
}
// 添加纯文本消息段
if (prompt) {
parts.push({
@@ -293,17 +306,17 @@ export function useMessages(
text: prompt
});
}
// 添加文件消息段
for (const f of stagedFiles) {
const partType = f.type === 'image' ? 'image' :
f.type === 'record' ? 'record' : 'file';
const partType = f.type === 'image' ? 'image' :
f.type === 'record' ? 'record' : 'file';
parts.push({
type: partType as 'image' | 'record' | 'file',
attachment_id: f.attachment_id
});
}
messageToSend = parts;
} else {
messageToSend = prompt;
@@ -331,7 +344,7 @@ export function useMessages(
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let in_streaming = false;
let message_obj: any = null;
let message_obj: MessageContent | null = null;
isStreaming.value = true;
@@ -340,6 +353,10 @@ export function useMessages(
const { done, value } = await reader.read();
if (done) {
console.log('SSE stream completed');
// 流式传输结束后,获取最终消息并重新渲染
if (currSessionId.value) {
await getSessionMessages(currSessionId.value);
}
break;
}
@@ -378,8 +395,10 @@ export function useMessages(
const imageUrl = await getMediaFile(img);
let bot_resp: MessageContent = {
type: 'bot',
message: '',
embedded_images: [imageUrl]
message: [{
type: 'image',
embedded_url: imageUrl
}]
};
messages.value.push({ content: bot_resp });
} else if (chunk_json.type === 'record') {
@@ -387,43 +406,122 @@ export function useMessages(
const audioUrl = await getMediaFile(audio);
let bot_resp: MessageContent = {
type: 'bot',
message: '',
embedded_audio: audioUrl
message: [{
type: 'record',
embedded_url: audioUrl
}]
};
messages.value.push({ content: bot_resp });
} else if (chunk_json.type === 'file') {
// 格式: [FILE]filename|original_name
let fileData = chunk_json.data.replace('[FILE]', '');
let [filename, originalName] = fileData.includes('|')
? fileData.split('|', 2)
let [filename, originalName] = fileData.includes('|')
? fileData.split('|', 2)
: [fileData, fileData];
const fileUrl = await getMediaFile(filename);
let bot_resp: MessageContent = {
type: 'bot',
message: '',
embedded_files: [{
url: fileUrl,
filename: originalName
message: [{
type: 'file',
embedded_file: {
url: fileUrl,
filename: originalName
}
}]
};
messages.value.push({ content: bot_resp });
} else if (chunk_json.type === 'plain') {
const chain_type = chunk_json.chain_type || 'normal';
if (!in_streaming) {
message_obj = reactive({
type: 'bot',
message: chain_type === 'reasoning' ? '' : chunk_json.data,
reasoning: chain_type === 'reasoning' ? chunk_json.data : '',
});
messages.value.push({ content: message_obj });
in_streaming = true;
} else {
if (chain_type === 'reasoning') {
// 使用 reactive 对象,直接修改属性会触发响应式更新
message_obj.reasoning = (message_obj.reasoning || '') + chunk_json.data;
if (chain_type === 'tool_call') {
// 解析工具调用数据
const toolCallData = JSON.parse(chunk_json.data);
const toolCall: ToolCall = {
id: toolCallData.id,
name: toolCallData.name,
args: toolCallData.args,
ts: toolCallData.ts
};
if (!in_streaming) {
message_obj = reactive<MessageContent>({
type: 'bot',
message: [{
type: 'tool_call',
tool_calls: [toolCall]
}]
});
messages.value.push({ content: message_obj });
in_streaming = true;
} 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') {
@@ -435,6 +533,11 @@ export function useMessages(
lastBotMsg.id = chunk_json.data.id;
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) {
@@ -480,4 +583,3 @@ export function useMessages(
getAttachment
};
}
@@ -0,0 +1,661 @@
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/delete', { id: source.id })
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/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/models', {
params: { source_id: sourceId }
})
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
}
}
+11 -1
View File
@@ -41,7 +41,13 @@ export function useSessions(chatboxMode: boolean = false) {
selectedSessions.value = [pendingSessionId.value];
pendingSessionId.value = null;
}
} else if (!currSessionId.value && sessions.value.length > 0) {
} else if (currSessionId.value) {
// 如果当前有选中的会话,确保它在列表中并被选中
const session = sessions.value.find(s => s.session_id === currSessionId.value);
if (session) {
selectedSessions.value = [currSessionId.value];
}
} else if (sessions.value.length > 0) {
// 默认选择第一个会话
const firstSession = sessions.value[0];
selectedSessions.value = [firstSession.session_id];
@@ -65,6 +71,10 @@ export function useSessions(chatboxMode: boolean = false) {
router.push(`${basePath}/${sessionId}`);
await getSessions();
// 确保新创建的会话被选中高亮
selectedSessions.value = [sessionId];
return sessionId;
} catch (err) {
console.error(err);
@@ -19,5 +19,6 @@
"submit": "Submit",
"reset": "Reset",
"clear": "Clear",
"save": "Save"
"save": "Save",
"close": "Close"
}
@@ -27,6 +27,7 @@
"uninstall": "Uninstall",
"update": "Update",
"language": "Language",
"settings": "Settings",
"locale": "en-US",
"type": "Type",
"press": "Press",
@@ -2,6 +2,7 @@
"dashboard": "Dashboard",
"platforms": "Platforms",
"providers": "Providers",
"commands": "Commands",
"persona": "Persona",
"toolUse": "MCP Tools",
"config": "Config",
@@ -14,10 +15,19 @@
"knowledgeBase": "Knowledge Base",
"about": "About",
"settings": "Settings",
"changelog": "Changelog",
"documentation": "Documentation",
"github": "GitHub",
"drag": "Drag",
"groups": {
"more": "More Features"
},
"changelogDialog": {
"title": "Changelog",
"loading": "Loading...",
"error": "Failed to load",
"notFound": "Changelog for this version not found",
"selectVersion": "Select Version",
"current": "Current"
}
}

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