Compare commits

...

63 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 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 4d046f8490 delete: remove backup of ProviderPage.vue 2025-12-17 11:34:12 +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
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 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 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
91 changed files with 5654 additions and 2406 deletions
+1 -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"> <div align="center">
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.9.2" __version__ = "4.10.2"
@@ -76,12 +76,20 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
async def _iter_llm_responses(self) -> T.AsyncGenerator[LLMResponse, None]: async def _iter_llm_responses(self) -> T.AsyncGenerator[LLMResponse, None]:
"""Yields chunks *and* a final LLMResponse.""" """Yields chunks *and* a final LLMResponse."""
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: 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 async for resp in stream: # type: ignore
yield resp yield resp
else: else:
yield await self.provider.text_chat(**self.req.__dict__) yield await self.provider.text_chat(**payload)
@override @override
async def step(self): async def step(self):
@@ -165,7 +173,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
self.run_context.messages.append( self.run_context.messages.append(
Message( Message(
role="assistant", role="assistant",
content=llm_resp.completion_text or "", content=llm_resp.completion_text or "*No response*",
), ),
) )
try: try:
@@ -230,6 +238,25 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
async for resp in self.step(): async for resp in self.step():
yield resp 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( async def _handle_function_tools(
self, self,
req: ProviderRequest, req: ProviderRequest,
@@ -376,35 +403,33 @@ 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,
}
)
],
)
elif resp is None: elif resp is None:
# Tool 直接请求发送消息给用户 # Tool 直接请求发送消息给用户
# 这里我们将直接结束 Agent Loop。 # 这里我们将直接结束 Agent Loop。
# 发送消息逻辑在 ToolExecutor 中处理了。 # 发送消息逻辑在 ToolExecutor 中处理了。
logger.warning( logger.warning(
f"{func_tool_name} 没有没有返回值或者将结果直接发送给用户,此工具调用不会被记录到历史中" f"{func_tool_name} 没有没有返回值或者将结果直接发送给用户。"
) )
self._transition_state(AgentState.DONE) self._transition_state(AgentState.DONE)
self.stats.end_time = time.time() self.stats.end_time = time.time()
tool_call_result_blocks.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content="*工具没有返回值或者将结果直接发送给了用户*",
),
)
else: else:
# 不应该出现其他类型 # 不应该出现其他类型
logger.warning( 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: try:
@@ -426,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: if tool_call_result_blocks:
yield tool_call_result_blocks yield tool_call_result_blocks
+19 -1
View File
@@ -2,6 +2,7 @@ import traceback
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from astrbot.core import logger 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.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner
from astrbot.core.astr_agent_context import AstrAgentContext from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.message.components import Json from astrbot.core.message.components import Json
@@ -24,8 +25,25 @@ async def run_agent(
) -> AsyncGenerator[MessageChain | None, None]: ) -> AsyncGenerator[MessageChain | None, None]:
step_idx = 0 step_idx = 0
astr_event = agent_runner.run_context.context.event astr_event = agent_runner.run_context.context.event
while step_idx < max_step: while step_idx < max_step + 1:
step_idx += 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: try:
async for resp in agent_runner.step(): async for resp in agent_runner.step():
if astr_event.is_stopped(): if astr_event.is_stopped():
+34 -4
View File
@@ -209,12 +209,42 @@ async def call_local_llm_tool(
else: else:
raise ValueError(f"未知的方法名: {method_name}") raise ValueError(f"未知的方法名: {method_name}")
except ValueError as e: except ValueError as e:
logger.error(f"调用本地 LLM 工具时出错: {e}", exc_info=True) raise Exception(f"Tool execution ValueError: {e}") from e
except TypeError: except TypeError as e:
logger.error("处理函数参数不匹配,请检查 handler 的定义。", exc_info=True) # 获取函数的签名(包括类型),除了第一个 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: except Exception as e:
trace_ = traceback.format_exc() 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: if not ready_to_call:
return return
+139 -205
View File
@@ -1,10 +1,11 @@
"""如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。""" """如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。"""
import os import os
from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.9.2" VERSION = "4.10.2"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db") DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [ WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -61,7 +62,8 @@ DEFAULT_CONFIG = {
"ignore_bot_self_message": False, "ignore_bot_self_message": False,
"ignore_at_all": False, "ignore_at_all": False,
}, },
"provider": [], "provider_sources": [], # provider sources
"provider": [], # models from provider_sources
"provider_settings": { "provider_settings": {
"enable": True, "enable": True,
"default_provider_id": "", "default_provider_id": "",
@@ -171,6 +173,22 @@ DEFAULT_CONFIG = {
} }
class ChatProviderTemplate(TypedDict):
id: str
provider_source_id: str
model: str
modalities: list
custom_extra_body: dict[str, Any]
CHAT_PROVIDER_TEMPLATE = {
"id": "",
"provide_source_id": "",
"model": "",
"modalities": [],
"custom_extra_body": {},
}
""" """
AstrBot v3 时代的配置元数据,目前仅承担以下功能: AstrBot v3 时代的配置元数据,目前仅承担以下功能:
@@ -844,6 +862,7 @@ CONFIG_METADATA_2 = {
"metadata": { "metadata": {
"provider": { "provider": {
"type": "list", "type": "list",
# provider sources templates
"config_template": { "config_template": {
"OpenAI": { "OpenAI": {
"id": "openai", "id": "openai",
@@ -854,107 +873,10 @@ CONFIG_METADATA_2 = {
"key": [], "key": [],
"api_base": "https://api.openai.com/v1", "api_base": "https://api.openai.com/v1",
"timeout": 120, "timeout": 120,
"model_config": {"model": "gpt-4o-mini", "temperature": 0.4},
"custom_headers": {}, "custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
"hint": "也兼容所有与 OpenAI API 兼容的服务。",
}, },
"Azure OpenAI": { "Google Gemini": {
"id": "azure", "id": "google_gemini",
"provider": "azure",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"api_version": "2024-05-01-preview",
"key": [],
"api_base": "",
"timeout": 120,
"model_config": {"model": "gpt-4o-mini", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"xAI": {
"id": "xai",
"provider": "xai",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.x.ai/v1",
"timeout": 120,
"model_config": {"model": "grok-2-latest", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"xai_native_search": False,
"modalities": ["text", "image", "tool_use"],
},
"Anthropic": {
"hint": "注意Claude系列模型的温度调节范围为0到1.0,超出可能导致报错",
"id": "claude",
"provider": "anthropic",
"type": "anthropic_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.anthropic.com/v1",
"timeout": 120,
"model_config": {
"model": "claude-3-5-sonnet-latest",
"max_tokens": 4096,
"temperature": 0.2,
},
"modalities": ["text", "image", "tool_use"],
},
"Ollama": {
"hint": "启用前请确保已正确安装并运行 Ollama 服务端,Ollama默认不带鉴权,无需修改key",
"id": "ollama_default",
"provider": "ollama",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": ["ollama"], # ollama 的 key 默认是 ollama
"api_base": "http://localhost:11434/v1",
"model_config": {"model": "llama3.1-8b", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"LM Studio": {
"id": "lm_studio",
"provider": "lm_studio",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": ["lmstudio"],
"api_base": "http://localhost:1234/v1",
"model_config": {
"model": "llama-3.1-8b",
},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"Gemini(OpenAI兼容)": {
"id": "gemini_default",
"provider": "google",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://generativelanguage.googleapis.com/v1beta/openai/",
"timeout": 120,
"model_config": {
"model": "gemini-3-flash-preview",
"temperature": 0.4,
},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"Gemini": {
"id": "gemini_default",
"provider": "google", "provider": "google",
"type": "googlegenai_chat_completion", "type": "googlegenai_chat_completion",
"provider_type": "chat_completion", "provider_type": "chat_completion",
@@ -962,10 +884,6 @@ CONFIG_METADATA_2 = {
"key": [], "key": [],
"api_base": "https://generativelanguage.googleapis.com/", "api_base": "https://generativelanguage.googleapis.com/",
"timeout": 120, "timeout": 120,
"model_config": {
"model": "gemini-3-flash-preview",
"temperature": 0.4,
},
"gm_resp_image_modal": False, "gm_resp_image_modal": False,
"gm_native_search": False, "gm_native_search": False,
"gm_native_coderunner": False, "gm_native_coderunner": False,
@@ -977,10 +895,42 @@ CONFIG_METADATA_2 = {
"dangerous_content": "BLOCK_MEDIUM_AND_ABOVE", "dangerous_content": "BLOCK_MEDIUM_AND_ABOVE",
}, },
"gm_thinking_config": {"budget": 0, "level": "HIGH"}, "gm_thinking_config": {"budget": 0, "level": "HIGH"},
"modalities": ["text", "image", "tool_use"], },
"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": { "DeepSeek": {
"id": "deepseek_default", "id": "deepseek",
"provider": "deepseek", "provider": "deepseek",
"type": "openai_chat_completion", "type": "openai_chat_completion",
"provider_type": "chat_completion", "provider_type": "chat_completion",
@@ -988,13 +938,75 @@ CONFIG_METADATA_2 = {
"key": [], "key": [],
"api_base": "https://api.deepseek.com/v1", "api_base": "https://api.deepseek.com/v1",
"timeout": 120, "timeout": 120,
"model_config": {"model": "deepseek-chat", "temperature": 0.4},
"custom_headers": {}, "custom_headers": {},
"custom_extra_body": {}, },
"modalities": ["text", "tool_use"], "Zhipu": {
"id": "zhipu",
"provider": "zhipu",
"type": "zhipu_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"timeout": 120,
"api_base": "https://open.bigmodel.cn/api/paas/v4/",
"custom_headers": {},
},
"Azure OpenAI": {
"id": "azure_openai",
"provider": "azure",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"api_version": "2024-05-01-preview",
"key": [],
"api_base": "",
"timeout": 120,
"custom_headers": {},
},
"Ollama": {
"id": "ollama",
"provider": "ollama",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": ["ollama"], # ollama 的 key 默认是 ollama
"api_base": "http://127.0.0.1:11434/v1",
"custom_headers": {},
},
"LM Studio": {
"id": "lm_studio",
"provider": "lm_studio",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": ["lmstudio"],
"api_base": "http://127.0.0.1:1234/v1",
"custom_headers": {},
},
"ModelStack": {
"id": "modelstack",
"provider": "modelstack",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://modelstack.app/v1",
"timeout": 120,
"custom_headers": {},
},
"Gemini_OpenAI_API": {
"id": "google_gemini_openai",
"provider": "google",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://generativelanguage.googleapis.com/v1beta/openai/",
"timeout": 120,
"custom_headers": {},
}, },
"Groq": { "Groq": {
"id": "groq_default", "id": "groq",
"provider": "groq", "provider": "groq",
"type": "groq_chat_completion", "type": "groq_chat_completion",
"provider_type": "chat_completion", "provider_type": "chat_completion",
@@ -1002,13 +1014,7 @@ CONFIG_METADATA_2 = {
"key": [], "key": [],
"api_base": "https://api.groq.com/openai/v1", "api_base": "https://api.groq.com/openai/v1",
"timeout": 120, "timeout": 120,
"model_config": {
"model": "openai/gpt-oss-20b",
"temperature": 0.4,
},
"custom_headers": {}, "custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "tool_use"],
}, },
"302.AI": { "302.AI": {
"id": "302ai", "id": "302ai",
@@ -1019,12 +1025,9 @@ CONFIG_METADATA_2 = {
"key": [], "key": [],
"api_base": "https://api.302.ai/v1", "api_base": "https://api.302.ai/v1",
"timeout": 120, "timeout": 120,
"model_config": {"model": "gpt-4.1-mini", "temperature": 0.4},
"custom_headers": {}, "custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
}, },
"硅基流动": { "SiliconFlow": {
"id": "siliconflow", "id": "siliconflow",
"provider": "siliconflow", "provider": "siliconflow",
"type": "openai_chat_completion", "type": "openai_chat_completion",
@@ -1033,15 +1036,9 @@ CONFIG_METADATA_2 = {
"key": [], "key": [],
"timeout": 120, "timeout": 120,
"api_base": "https://api.siliconflow.cn/v1", "api_base": "https://api.siliconflow.cn/v1",
"model_config": {
"model": "deepseek-ai/DeepSeek-V3",
"temperature": 0.4,
},
"custom_headers": {}, "custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
}, },
"PPIO派欧云": { "PPIO": {
"id": "ppio", "id": "ppio",
"provider": "ppio", "provider": "ppio",
"type": "openai_chat_completion", "type": "openai_chat_completion",
@@ -1050,14 +1047,9 @@ CONFIG_METADATA_2 = {
"key": [], "key": [],
"api_base": "https://api.ppinfra.com/v3/openai", "api_base": "https://api.ppinfra.com/v3/openai",
"timeout": 120, "timeout": 120,
"model_config": {
"model": "deepseek/deepseek-r1",
"temperature": 0.4,
},
"custom_headers": {}, "custom_headers": {},
"custom_extra_body": {},
}, },
"小马算力": { "TokenPony": {
"id": "tokenpony", "id": "tokenpony",
"provider": "tokenpony", "provider": "tokenpony",
"type": "openai_chat_completion", "type": "openai_chat_completion",
@@ -1066,14 +1058,9 @@ CONFIG_METADATA_2 = {
"key": [], "key": [],
"api_base": "https://api.tokenpony.cn/v1", "api_base": "https://api.tokenpony.cn/v1",
"timeout": 120, "timeout": 120,
"model_config": {
"model": "kimi-k2-instruct-0905",
"temperature": 0.7,
},
"custom_headers": {}, "custom_headers": {},
"custom_extra_body": {},
}, },
"优云智算": { "Compshare": {
"id": "compshare", "id": "compshare",
"provider": "compshare", "provider": "compshare",
"type": "openai_chat_completion", "type": "openai_chat_completion",
@@ -1082,42 +1069,18 @@ CONFIG_METADATA_2 = {
"key": [], "key": [],
"api_base": "https://api.modelverse.cn/v1", "api_base": "https://api.modelverse.cn/v1",
"timeout": 120, "timeout": 120,
"model_config": {
"model": "moonshotai/Kimi-K2-Instruct",
},
"custom_headers": {}, "custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
}, },
"Kimi": { "ModelScope": {
"id": "moonshot", "id": "modelscope",
"provider": "moonshot", "provider": "modelscope",
"type": "openai_chat_completion", "type": "openai_chat_completion",
"provider_type": "chat_completion", "provider_type": "chat_completion",
"enable": True, "enable": True,
"key": [], "key": [],
"timeout": 120, "timeout": 120,
"api_base": "https://api.moonshot.cn/v1", "api_base": "https://api-inference.modelscope.cn/v1",
"model_config": {"model": "moonshot-v1-8k", "temperature": 0.4},
"custom_headers": {}, "custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"智谱 AI": {
"id": "zhipu_default",
"provider": "zhipu",
"type": "zhipu_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"timeout": 120,
"api_base": "https://open.bigmodel.cn/api/paas/v4/",
"model_config": {
"model": "glm-4-flash",
},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
}, },
"Dify": { "Dify": {
"id": "dify_app_default", "id": "dify_app_default",
@@ -1132,7 +1095,6 @@ CONFIG_METADATA_2 = {
"dify_query_input_key": "astrbot_text_query", "dify_query_input_key": "astrbot_text_query",
"variables": {}, "variables": {},
"timeout": 60, "timeout": 60,
"hint": "请确保你在 AstrBot 里设置的 APP 类型和 Dify 里面创建的应用的类型一致!",
}, },
"Coze": { "Coze": {
"id": "coze", "id": "coze",
@@ -1163,20 +1125,6 @@ CONFIG_METADATA_2 = {
"variables": {}, "variables": {},
"timeout": 60, "timeout": 60,
}, },
"ModelScope": {
"id": "modelscope",
"provider": "modelscope",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"timeout": 120,
"api_base": "https://api-inference.modelscope.cn/v1",
"model_config": {"model": "Qwen/Qwen3-32B", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"FastGPT": { "FastGPT": {
"id": "fastgpt", "id": "fastgpt",
"provider": "fastgpt", "provider": "fastgpt",
@@ -1200,7 +1148,6 @@ CONFIG_METADATA_2 = {
"model": "whisper-1", "model": "whisper-1",
}, },
"Whisper(Local)": { "Whisper(Local)": {
"hint": "启用前请 pip 安装 openai-whisper 库(N卡用户大约下载 2GB,主要是 torch 和 cudaCPU 用户大约下载 1 GB),并且安装 ffmpeg。否则将无法正常转文字。",
"provider": "openai", "provider": "openai",
"type": "openai_whisper_selfhost", "type": "openai_whisper_selfhost",
"provider_type": "speech_to_text", "provider_type": "speech_to_text",
@@ -1209,7 +1156,6 @@ CONFIG_METADATA_2 = {
"model": "tiny", "model": "tiny",
}, },
"SenseVoice(Local)": { "SenseVoice(Local)": {
"hint": "启用前请 pip 安装 funasr、funasr_onnx、torchaudio、torch、modelscope、jieba 库(默认使用CPU,大约下载 1 GB),并且安装 ffmpeg。否则将无法正常转文字。",
"type": "sensevoice_stt_selfhost", "type": "sensevoice_stt_selfhost",
"provider": "sensevoice", "provider": "sensevoice",
"provider_type": "speech_to_text", "provider_type": "speech_to_text",
@@ -1231,7 +1177,6 @@ CONFIG_METADATA_2 = {
"timeout": "20", "timeout": "20",
}, },
"Edge TTS": { "Edge TTS": {
"hint": "提示:使用这个服务前需要安装有 ffmpeg,并且可以直接在终端调用 ffmpeg 指令。",
"id": "edge_tts", "id": "edge_tts",
"provider": "microsoft", "provider": "microsoft",
"type": "edge_tts", "type": "edge_tts",
@@ -1447,6 +1392,10 @@ CONFIG_METADATA_2 = {
}, },
}, },
"items": { "items": {
"provider_source_id": {
"invisible": True,
"type": "string",
},
"xai_native_search": { "xai_native_search": {
"description": "启用原生搜索功能", "description": "启用原生搜索功能",
"type": "bool", "type": "bool",
@@ -2015,7 +1964,6 @@ CONFIG_METADATA_2 = {
"id": { "id": {
"description": "ID", "description": "ID",
"type": "string", "type": "string",
"hint": "模型提供商名字。",
}, },
"type": { "type": {
"description": "模型提供商种类", "description": "模型提供商种类",
@@ -2035,29 +1983,15 @@ CONFIG_METADATA_2 = {
"description": "API Key", "description": "API Key",
"type": "list", "type": "list",
"items": {"type": "string"}, "items": {"type": "string"},
"hint": "提供商 API Key。",
}, },
"api_base": { "api_base": {
"description": "API Base URL", "description": "API Base URL",
"type": "string", "type": "string",
"hint": "API Base URL 请在模型提供商处获得。如出现 404 报错,尝试在地址末尾加上 /v1",
}, },
"model_config": { "model": {
"description": "模型配置", "description": "模型 ID",
"type": "object", "type": "string",
"items": { "hint": "模型名称,如 gpt-4o-mini, deepseek-chat。",
"model": {
"description": "模型名称",
"type": "string",
"hint": "模型名称,如 gpt-4o-mini, deepseek-chat。",
},
"max_tokens": {
"description": "模型最大输出长度(tokens",
"type": "int",
},
"temperature": {"description": "温度", "type": "float"},
"top_p": {"description": "Top P值", "type": "float"},
},
}, },
"dify_api_key": { "dify_api_key": {
"description": "API Key", "description": "API Key",
+3
View File
@@ -33,6 +33,7 @@ from astrbot.core.star.context import Context
from astrbot.core.star.star_handler import EventType, star_handlers_registry, star_map from astrbot.core.star.star_handler import EventType, star_handlers_registry, star_map
from astrbot.core.umop_config_router import UmopConfigRouter from astrbot.core.umop_config_router import UmopConfigRouter
from astrbot.core.updator import AstrBotUpdator from astrbot.core.updator import AstrBotUpdator
from astrbot.core.utils.llm_metadata import update_llm_metadata
from astrbot.core.utils.migra_helper import migra from astrbot.core.utils.migra_helper import migra
from . import astrbot_config, html_renderer from . import astrbot_config, html_renderer
@@ -185,6 +186,8 @@ class AstrBotCoreLifecycle:
# 初始化关闭控制面板的事件 # 初始化关闭控制面板的事件
self.dashboard_shutdown_event = asyncio.Event() self.dashboard_shutdown_event = asyncio.Event()
asyncio.create_task(update_llm_metadata())
def _load(self) -> None: def _load(self) -> None:
"""加载事件总线和任务并初始化.""" """加载事件总线和任务并初始化."""
# 创建一个异步任务来执行事件总线的 dispatch() 方法 # 创建一个异步任务来执行事件总线的 dispatch() 方法
@@ -321,7 +321,12 @@ class InternalAgentSubStage(Stage):
elif isinstance(req.tool_calls_result, list): elif isinstance(req.tool_calls_result, list):
for tcr in req.tool_calls_result: for tcr in req.tool_calls_result:
messages.extend(tcr.to_openai_messages()) 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)) messages = list(filter(lambda item: "_no_save" not in item, messages))
await self.conv_manager.update_conversation( await self.conv_manager.update_conversation(
event.unified_msg_origin, event.unified_msg_origin,
@@ -385,10 +385,25 @@ class AiocqhttpAdapter(Platform):
logger.error(f"获取 @ 用户信息失败: {e},此消息段将被忽略。") logger.error(f"获取 @ 用户信息失败: {e},此消息段将被忽略。")
message_str += "".join(at_parts) 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: else:
for m in m_group: for m in m_group:
a = ComponentTypes[t](**m["data"]) try:
abm.message.append(a) 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.timestamp = int(time.time())
abm.message_str = message_str abm.message_str = message_str
+32 -9
View File
@@ -14,6 +14,7 @@ import astrbot.core.message.components as Comp
from astrbot import logger from astrbot import logger
from astrbot.core.agent.message import ( from astrbot.core.agent.message import (
AssistantMessageSegment, AssistantMessageSegment,
ContentPart,
ToolCall, ToolCall,
ToolCallMessageSegment, ToolCallMessageSegment,
) )
@@ -92,6 +93,8 @@ class ProviderRequest:
"""会话 ID""" """会话 ID"""
image_urls: list[str] = field(default_factory=list) image_urls: list[str] = field(default_factory=list)
"""图片 URL 列表""" """图片 URL 列表"""
extra_user_content_parts: list[ContentPart] = field(default_factory=list)
"""额外的用户消息内容部分列表,用于在用户消息后添加额外的内容块(如系统提醒、指令等)。"""
func_tool: ToolSet | None = None func_tool: ToolSet | None = None
"""可用的函数工具""" """可用的函数工具"""
contexts: list[dict] = field(default_factory=list) contexts: list[dict] = field(default_factory=list)
@@ -166,13 +169,23 @@ class ProviderRequest:
async def assemble_context(self) -> dict: async def assemble_context(self) -> dict:
"""将请求(prompt 和 image_urls)包装成 OpenAI 的消息格式。""" """将请求(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: 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: for image_url in self.image_urls:
if image_url.startswith("http"): if image_url.startswith("http"):
image_path = await download_image_by_url(image_url) image_path = await download_image_by_url(image_url)
@@ -185,11 +198,21 @@ class ProviderRequest:
if not image_data: if not image_data:
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。") logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
continue continue
user_content["content"].append( content_blocks.append(
{"type": "image_url", "image_url": {"url": image_data}}, {"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: async def _encode_image_bs64(self, image_url: str) -> str:
"""将图片转换为 base64""" """将图片转换为 base64"""
+203 -93
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
import copy
import traceback import traceback
from typing import Protocol, runtime_checkable from typing import Protocol, runtime_checkable
@@ -32,10 +33,12 @@ class ProviderManager:
persona_mgr: PersonaManager, persona_mgr: PersonaManager,
): ):
self.reload_lock = asyncio.Lock() self.reload_lock = asyncio.Lock()
self.resource_lock = asyncio.Lock()
self.persona_mgr = persona_mgr self.persona_mgr = persona_mgr
self.acm = acm self.acm = acm
config = acm.confs["default"] config = acm.confs["default"]
self.providers_config: list = config["provider"] self.providers_config: list = config["provider"]
self.provider_sources_config: list = config.get("provider_sources", [])
self.provider_settings: dict = config["provider_settings"] self.provider_settings: dict = config["provider_settings"]
self.provider_stt_settings: dict = config.get("provider_stt_settings", {}) self.provider_stt_settings: dict = config.get("provider_stt_settings", {})
self.provider_tts_settings: dict = config.get("provider_tts_settings", {}) self.provider_tts_settings: dict = config.get("provider_tts_settings", {})
@@ -148,6 +151,7 @@ class ProviderManager:
""" """
provider = None provider = None
provider_id = None
if umo: if umo:
provider_id = sp.get( provider_id = sp.get(
f"provider_perf_{provider_type.value}", f"provider_perf_{provider_type.value}",
@@ -185,6 +189,12 @@ class ProviderManager:
) )
else: else:
raise ValueError(f"Unknown provider type: {provider_type}") raise ValueError(f"Unknown provider type: {provider_type}")
if not provider and provider_id:
logger.warning(
f"没有找到 ID 为 {provider_id} 的提供商,这可能是由于您修改了提供商(模型)ID 导致的。"
)
return provider return provider
async def initialize(self): async def initialize(self):
@@ -251,7 +261,136 @@ class ProviderManager:
# 初始化 MCP Client 连接 # 初始化 MCP Client 连接
asyncio.create_task(self.llm_tools.init_mcp_clients(), name="init_mcp_clients") asyncio.create_task(self.llm_tools.init_mcp_clients(), name="init_mcp_clients")
def dynamic_import_provider(self, type: str):
"""动态导入提供商适配器模块
Args:
type (str): 提供商请求类型
Raises:
ImportError: 如果提供商类型未知或无法导入对应模块则抛出异常
"""
match type:
case "openai_chat_completion":
from .sources.openai_source import (
ProviderOpenAIOfficial as ProviderOpenAIOfficial,
)
case "zhipu_chat_completion":
from .sources.zhipu_source import ProviderZhipu as ProviderZhipu
case "groq_chat_completion":
from .sources.groq_source import ProviderGroq as ProviderGroq
case "anthropic_chat_completion":
from .sources.anthropic_source import (
ProviderAnthropic as ProviderAnthropic,
)
case "googlegenai_chat_completion":
from .sources.gemini_source import (
ProviderGoogleGenAI as ProviderGoogleGenAI,
)
case "sensevoice_stt_selfhost":
from .sources.sensevoice_selfhosted_source import (
ProviderSenseVoiceSTTSelfHost as ProviderSenseVoiceSTTSelfHost,
)
case "openai_whisper_api":
from .sources.whisper_api_source import (
ProviderOpenAIWhisperAPI as ProviderOpenAIWhisperAPI,
)
case "openai_whisper_selfhost":
from .sources.whisper_selfhosted_source import (
ProviderOpenAIWhisperSelfHost as ProviderOpenAIWhisperSelfHost,
)
case "xinference_stt":
from .sources.xinference_stt_provider import (
ProviderXinferenceSTT as ProviderXinferenceSTT,
)
case "openai_tts_api":
from .sources.openai_tts_api_source import (
ProviderOpenAITTSAPI as ProviderOpenAITTSAPI,
)
case "edge_tts":
from .sources.edge_tts_source import (
ProviderEdgeTTS as ProviderEdgeTTS,
)
case "gsv_tts_selfhost":
from .sources.gsv_selfhosted_source import (
ProviderGSVTTS as ProviderGSVTTS,
)
case "gsvi_tts_api":
from .sources.gsvi_tts_source import (
ProviderGSVITTS as ProviderGSVITTS,
)
case "fishaudio_tts_api":
from .sources.fishaudio_tts_api_source import (
ProviderFishAudioTTSAPI as ProviderFishAudioTTSAPI,
)
case "dashscope_tts":
from .sources.dashscope_tts import (
ProviderDashscopeTTSAPI as ProviderDashscopeTTSAPI,
)
case "azure_tts":
from .sources.azure_tts_source import (
AzureTTSProvider as AzureTTSProvider,
)
case "minimax_tts_api":
from .sources.minimax_tts_api_source import (
ProviderMiniMaxTTSAPI as ProviderMiniMaxTTSAPI,
)
case "volcengine_tts":
from .sources.volcengine_tts import (
ProviderVolcengineTTS as ProviderVolcengineTTS,
)
case "gemini_tts":
from .sources.gemini_tts_source import (
ProviderGeminiTTSAPI as ProviderGeminiTTSAPI,
)
case "openai_embedding":
from .sources.openai_embedding_source import (
OpenAIEmbeddingProvider as OpenAIEmbeddingProvider,
)
case "gemini_embedding":
from .sources.gemini_embedding_source import (
GeminiEmbeddingProvider as GeminiEmbeddingProvider,
)
case "vllm_rerank":
from .sources.vllm_rerank_source import (
VLLMRerankProvider as VLLMRerankProvider,
)
case "xinference_rerank":
from .sources.xinference_rerank_source import (
XinferenceRerankProvider as XinferenceRerankProvider,
)
case "bailian_rerank":
from .sources.bailian_rerank_source import (
BailianRerankProvider as BailianRerankProvider,
)
def get_merged_provider_config(self, provider_config: dict) -> dict:
"""获取 provider 配置和 provider_source 配置合并后的结果
Returns:
dict: 合并后的 provider 配置key provider idvalue 为合并后的配置字典
"""
pc = copy.deepcopy(provider_config)
provider_source_id = pc.get("provider_source_id", "")
if provider_source_id:
provider_source = None
for ps in self.provider_sources_config:
if ps.get("id") == provider_source_id:
provider_source = ps
break
if provider_source:
# 合并配置,provider 的配置优先级更高
merged_config = {**provider_source, **pc}
# 保持 id 为 provider 的 id,而不是 source 的 id
merged_config["id"] = pc["id"]
pc = merged_config
return pc
async def load_provider(self, provider_config: dict): async def load_provider(self, provider_config: dict):
# 如果 provider_source_id 存在且不为空,则从 provider_sources 中找到对应的配置并合并
provider_config = self.get_merged_provider_config(provider_config)
if not provider_config["enable"]: if not provider_config["enable"]:
logger.info(f"Provider {provider_config['id']} is disabled, skipping") logger.info(f"Provider {provider_config['id']} is disabled, skipping")
return return
@@ -264,99 +403,7 @@ class ProviderManager:
# 动态导入 # 动态导入
try: try:
match provider_config["type"]: self.dynamic_import_provider(provider_config["type"])
case "openai_chat_completion":
from .sources.openai_source import (
ProviderOpenAIOfficial as ProviderOpenAIOfficial,
)
case "zhipu_chat_completion":
from .sources.zhipu_source import ProviderZhipu as ProviderZhipu
case "groq_chat_completion":
from .sources.groq_source import ProviderGroq as ProviderGroq
case "anthropic_chat_completion":
from .sources.anthropic_source import (
ProviderAnthropic as ProviderAnthropic,
)
case "googlegenai_chat_completion":
from .sources.gemini_source import (
ProviderGoogleGenAI as ProviderGoogleGenAI,
)
case "sensevoice_stt_selfhost":
from .sources.sensevoice_selfhosted_source import (
ProviderSenseVoiceSTTSelfHost as ProviderSenseVoiceSTTSelfHost,
)
case "openai_whisper_api":
from .sources.whisper_api_source import (
ProviderOpenAIWhisperAPI as ProviderOpenAIWhisperAPI,
)
case "openai_whisper_selfhost":
from .sources.whisper_selfhosted_source import (
ProviderOpenAIWhisperSelfHost as ProviderOpenAIWhisperSelfHost,
)
case "xinference_stt":
from .sources.xinference_stt_provider import (
ProviderXinferenceSTT as ProviderXinferenceSTT,
)
case "openai_tts_api":
from .sources.openai_tts_api_source import (
ProviderOpenAITTSAPI as ProviderOpenAITTSAPI,
)
case "edge_tts":
from .sources.edge_tts_source import (
ProviderEdgeTTS as ProviderEdgeTTS,
)
case "gsv_tts_selfhost":
from .sources.gsv_selfhosted_source import (
ProviderGSVTTS as ProviderGSVTTS,
)
case "gsvi_tts_api":
from .sources.gsvi_tts_source import (
ProviderGSVITTS as ProviderGSVITTS,
)
case "fishaudio_tts_api":
from .sources.fishaudio_tts_api_source import (
ProviderFishAudioTTSAPI as ProviderFishAudioTTSAPI,
)
case "dashscope_tts":
from .sources.dashscope_tts import (
ProviderDashscopeTTSAPI as ProviderDashscopeTTSAPI,
)
case "azure_tts":
from .sources.azure_tts_source import (
AzureTTSProvider as AzureTTSProvider,
)
case "minimax_tts_api":
from .sources.minimax_tts_api_source import (
ProviderMiniMaxTTSAPI as ProviderMiniMaxTTSAPI,
)
case "volcengine_tts":
from .sources.volcengine_tts import (
ProviderVolcengineTTS as ProviderVolcengineTTS,
)
case "gemini_tts":
from .sources.gemini_tts_source import (
ProviderGeminiTTSAPI as ProviderGeminiTTSAPI,
)
case "openai_embedding":
from .sources.openai_embedding_source import (
OpenAIEmbeddingProvider as OpenAIEmbeddingProvider,
)
case "gemini_embedding":
from .sources.gemini_embedding_source import (
GeminiEmbeddingProvider as GeminiEmbeddingProvider,
)
case "vllm_rerank":
from .sources.vllm_rerank_source import (
VLLMRerankProvider as VLLMRerankProvider,
)
case "xinference_rerank":
from .sources.xinference_rerank_source import (
XinferenceRerankProvider as XinferenceRerankProvider,
)
case "bailian_rerank":
from .sources.bailian_rerank_source import (
BailianRerankProvider as BailianRerankProvider,
)
except (ImportError, ModuleNotFoundError) as e: except (ImportError, ModuleNotFoundError) as e:
logger.critical( logger.critical(
f"加载 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}。可能是因为有未安装的依赖。", f"加载 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}。可能是因为有未安装的依赖。",
@@ -499,6 +546,7 @@ class ProviderManager:
# 和配置文件保持同步 # 和配置文件保持同步
self.providers_config = astrbot_config["provider"] self.providers_config = astrbot_config["provider"]
self.provider_sources_config = astrbot_config.get("provider_sources", [])
config_ids = [provider["id"] for provider in self.providers_config] config_ids = [provider["id"] for provider in self.providers_config]
logger.info(f"providers in user's config: {config_ids}") logger.info(f"providers in user's config: {config_ids}")
for key in list(self.inst_map.keys()): for key in list(self.inst_map.keys()):
@@ -570,6 +618,68 @@ class ProviderManager:
) )
del self.inst_map[provider_id] del self.inst_map[provider_id]
async def delete_provider(
self, provider_id: str | None = None, provider_source_id: str | None = None
):
"""Delete provider and/or provider source from config and terminate the instances. Config will be saved after deletion."""
async with self.resource_lock:
# delete from config
target_prov_ids = []
if provider_id:
target_prov_ids.append(provider_id)
else:
for prov in self.providers_config:
if prov.get("provider_source_id") == provider_source_id:
target_prov_ids.append(prov.get("id"))
config = self.acm.default_conf
for tpid in target_prov_ids:
await self.terminate_provider(tpid)
config["provider"] = [
prov for prov in config["provider"] if prov.get("id") != tpid
]
config.save_config()
logger.info(f"Provider {target_prov_ids} 已从配置中删除。")
async def update_provider(self, origin_provider_id: str, new_config: dict):
"""Update provider config and reload the instance. Config will be saved after update."""
async with self.resource_lock:
npid = new_config.get("id", None)
if not npid:
raise ValueError("New provider config must have an 'id' field")
config = self.acm.default_conf
for provider in config["provider"]:
if (
provider.get("id", None) == npid
and provider.get("id", None) != origin_provider_id
):
raise ValueError(f"Provider ID {npid} already exists")
# update config
for idx, provider in enumerate(config["provider"]):
if provider.get("id", None) == origin_provider_id:
config["provider"][idx] = new_config
break
else:
raise ValueError(f"Provider ID {origin_provider_id} not found")
config.save_config()
# reload instance
await self.reload(new_config)
async def create_provider(self, new_config: dict):
"""Add new provider config and load the instance. Config will be saved after addition."""
async with self.resource_lock:
npid = new_config.get("id", None)
if not npid:
raise ValueError("New provider config must have an 'id' field")
config = self.acm.default_conf
for provider in config["provider"]:
if provider.get("id", None) == npid:
raise ValueError(f"Provider ID {npid} already exists")
# add to config
config["provider"].append(new_config)
config.save_config()
# load instance
await self.load_provider(new_config)
async def terminate(self): async def terminate(self):
for provider_inst in self.provider_insts: for provider_inst in self.provider_insts:
if hasattr(provider_inst, "terminate"): if hasattr(provider_inst, "terminate"):
+5 -1
View File
@@ -4,7 +4,7 @@ import os
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from typing import TypeAlias, Union 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.agent.tool import ToolSet
from astrbot.core.provider.entities import ( from astrbot.core.provider.entities import (
LLMResponse, LLMResponse,
@@ -103,6 +103,7 @@ class Provider(AbstractProvider):
system_prompt: str | None = None, system_prompt: str | None = None,
tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None, tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None,
model: str | None = None, model: str | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
**kwargs, **kwargs,
) -> LLMResponse: ) -> LLMResponse:
"""获得 LLM 的文本对话结果。会使用当前的模型进行对话。 """获得 LLM 的文本对话结果。会使用当前的模型进行对话。
@@ -114,6 +115,7 @@ class Provider(AbstractProvider):
tools: tool set tools: tool set
contexts: 上下文 prompt 二选一使用 contexts: 上下文 prompt 二选一使用
tool_calls_result: 回传给 LLM 的工具调用结果参考: https://platform.openai.com/docs/guides/function-calling tool_calls_result: 回传给 LLM 的工具调用结果参考: https://platform.openai.com/docs/guides/function-calling
extra_user_content_parts: 额外的用户内容块列表用于在用户消息后添加额外的文本块如系统提醒指令等
kwargs: 其他参数 kwargs: 其他参数
Notes: Notes:
@@ -133,6 +135,7 @@ class Provider(AbstractProvider):
system_prompt: str | None = None, system_prompt: str | None = None,
tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None, tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None,
model: str | None = None, model: str | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
**kwargs, **kwargs,
) -> AsyncGenerator[LLMResponse, None]: ) -> AsyncGenerator[LLMResponse, None]:
"""获得 LLM 的流式文本对话结果。会使用当前的模型进行对话。在生成的最后会返回一次完整的结果。 """获得 LLM 的流式文本对话结果。会使用当前的模型进行对话。在生成的最后会返回一次完整的结果。
@@ -144,6 +147,7 @@ class Provider(AbstractProvider):
tools: tool set tools: tool set
contexts: 上下文 prompt 二选一使用 contexts: 上下文 prompt 二选一使用
tool_calls_result: 回传给 LLM 的工具调用结果参考: https://platform.openai.com/docs/guides/function-calling tool_calls_result: 回传给 LLM 的工具调用结果参考: https://platform.openai.com/docs/guides/function-calling
extra_user_content_parts: 额外的用户内容块列表用于在用户消息后添加额外的文本块如系统提醒指令等
kwargs: 其他参数 kwargs: 其他参数
Notes: Notes:
+125 -47
View File
@@ -11,6 +11,7 @@ from anthropic.types.usage import Usage
from astrbot import logger from astrbot import logger
from astrbot.api.provider import Provider from astrbot.api.provider import Provider
from astrbot.core.agent.message import ContentPart
from astrbot.core.provider.entities import LLMResponse, TokenUsage from astrbot.core.provider.entities import LLMResponse, TokenUsage
from astrbot.core.provider.func_tool_manager import ToolSet from astrbot.core.provider.func_tool_manager import ToolSet
from astrbot.core.utils.io import download_image_by_url from astrbot.core.utils.io import download_image_by_url
@@ -47,7 +48,7 @@ class ProviderAnthropic(Provider):
base_url=self.base_url, base_url=self.base_url,
) )
self.set_model(provider_config["model_config"]["model"]) self.set_model(provider_config.get("model", "unknown"))
def _prepare_payload(self, messages: list[dict]): def _prepare_payload(self, messages: list[dict]):
"""准备 Anthropic API 的请求 payload """准备 Anthropic API 的请求 payload
@@ -130,7 +131,11 @@ class ProviderAnthropic(Provider):
if tool_list := tools.get_func_desc_anthropic_style(): if tool_list := tools.get_func_desc_anthropic_style():
payloads["tools"] = tool_list payloads["tools"] = tool_list
completion = await self.client.messages.create(**payloads, stream=False) extra_body = self.provider_config.get("custom_extra_body", {})
completion = await self.client.messages.create(
**payloads, stream=False, extra_body=extra_body
)
assert isinstance(completion, Message) assert isinstance(completion, Message)
logger.debug(f"completion: {completion}") logger.debug(f"completion: {completion}")
@@ -173,11 +178,13 @@ class ProviderAnthropic(Provider):
# 用于累积最终结果 # 用于累积最终结果
final_text = "" final_text = ""
final_tool_calls = [] final_tool_calls = []
id = None id = None
usage = TokenUsage() usage = TokenUsage()
extra_body = self.provider_config.get("custom_extra_body", {})
async with self.client.messages.stream(**payloads) as stream: async with self.client.messages.stream(
**payloads, extra_body=extra_body
) as stream:
assert isinstance(stream, anthropic.AsyncMessageStream) assert isinstance(stream, anthropic.AsyncMessageStream)
async for event in stream: async for event in stream:
if event.type == "message_start": if event.type == "message_start":
@@ -290,13 +297,16 @@ class ProviderAnthropic(Provider):
system_prompt=None, system_prompt=None,
tool_calls_result=None, tool_calls_result=None,
model=None, model=None,
extra_user_content_parts=None,
**kwargs, **kwargs,
) -> LLMResponse: ) -> LLMResponse:
if contexts is None: if contexts is None:
contexts = [] contexts = []
new_record = None new_record = None
if prompt is not 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) context_query = self._ensure_message_to_dicts(contexts)
if new_record: if new_record:
context_query.append(new_record) context_query.append(new_record)
@@ -318,10 +328,9 @@ class ProviderAnthropic(Provider):
system_prompt, new_messages = self._prepare_payload(context_query) system_prompt, new_messages = self._prepare_payload(context_query)
model_config = self.provider_config.get("model_config", {}) model = model or self.get_model()
model_config["model"] = model or self.get_model()
payloads = {"messages": new_messages, **model_config} payloads = {"messages": new_messages, "model": model}
# Anthropic has a different way of handling system prompts # Anthropic has a different way of handling system prompts
if system_prompt: if system_prompt:
@@ -331,7 +340,6 @@ class ProviderAnthropic(Provider):
try: try:
llm_response = await self._query(payloads, func_tool) llm_response = await self._query(payloads, func_tool)
except Exception as e: except Exception as e:
# logger.error(f"发生了错误。Provider 配置如下: {model_config}")
raise e raise e
return llm_response return llm_response
@@ -346,13 +354,16 @@ class ProviderAnthropic(Provider):
system_prompt=None, system_prompt=None,
tool_calls_result=None, tool_calls_result=None,
model=None, model=None,
extra_user_content_parts=None,
**kwargs, **kwargs,
): ):
if contexts is None: if contexts is None:
contexts = [] contexts = []
new_record = None new_record = None
if prompt is not 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) context_query = self._ensure_message_to_dicts(contexts)
if new_record: if new_record:
context_query.append(new_record) context_query.append(new_record)
@@ -373,10 +384,9 @@ class ProviderAnthropic(Provider):
system_prompt, new_messages = self._prepare_payload(context_query) system_prompt, new_messages = self._prepare_payload(context_query)
model_config = self.provider_config.get("model_config", {}) model = model or self.get_model()
model_config["model"] = model or self.get_model()
payloads = {"messages": new_messages, **model_config} payloads = {"messages": new_messages, "model": model}
# Anthropic has a different way of handling system prompts # Anthropic has a different way of handling system prompts
if system_prompt: if system_prompt:
@@ -385,48 +395,116 @@ class ProviderAnthropic(Provider):
async for llm_response in self._query_stream(payloads, func_tool): async for llm_response in self._query_stream(payloads, func_tool):
yield llm_response 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 = []
content.append({"type": "text", "text": text})
for image_url in image_urls: # 1. 用户原始发言(OpenAI 建议:用户发言在前)
if image_url.startswith("http"): if text:
image_path = await download_image_by_url(image_url) content.append({"type": "text", "text": text})
image_data = await self.encode_image_bs64(image_path) elif image_urls:
elif image_url.startswith("file:///"): # 如果没有文本但有图片,添加占位文本
image_path = image_url.replace("file:///", "") content.append({"type": "text", "text": "[图片]"})
image_data = await self.encode_image_bs64(image_path) elif extra_user_content_parts:
else: # 如果只有额外内容块,也需要添加占位文本
image_data = await self.encode_image_bs64(image_url) content.append({"type": "text", "text": " "})
if not image_data: # 2. 额外的内容块(系统提醒、指令等)
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。") if extra_user_content_parts:
continue for block in extra_user_content_parts:
block_type = block.get("type")
# Get mime type for the image if block_type == "text":
mime_type, _ = guess_type(image_url) # 文本直接添加
if not mime_type: content.append(block)
mime_type = "image/jpeg" # Default to JPEG if can't determine
content.append( elif block_type == "image_url":
{ # 转换 OpenAI 格式的图片为 Anthropic 格式
"type": "image", image_url_data = block.get("image_url", {})
"source": { if isinstance(image_url_data, dict):
"type": "base64", url = image_url_data.get("url", "")
"media_type": mime_type, else:
"data": ( # 兼容直接传 URL 字符串的情况
image_data.split("base64,")[1] url = str(image_url_data)
if "base64," in image_data
else image_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} return {"role": "user", "content": content}
async def encode_image_bs64(self, image_url: str) -> str: async def encode_image_bs64(self, image_url: str) -> str:
+72 -20
View File
@@ -13,6 +13,7 @@ from google.genai.errors import APIError
import astrbot.core.message.components as Comp import astrbot.core.message.components as Comp
from astrbot import logger from astrbot import logger
from astrbot.api.provider import Provider from astrbot.api.provider import Provider
from astrbot.core.agent.message import ContentPart
from astrbot.core.message.message_event_result import MessageChain from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import LLMResponse, TokenUsage from astrbot.core.provider.entities import LLMResponse, TokenUsage
from astrbot.core.provider.func_tool_manager import ToolSet from astrbot.core.provider.func_tool_manager import ToolSet
@@ -68,7 +69,7 @@ class ProviderGoogleGenAI(Provider):
self.api_base = self.api_base[:-1] self.api_base = self.api_base[:-1]
self._init_client() self._init_client()
self.set_model(provider_config["model_config"]["model"]) self.set_model(provider_config.get("model", "unknown"))
self._init_safety_settings() self._init_safety_settings()
def _init_client(self) -> None: def _init_client(self) -> None:
@@ -138,7 +139,7 @@ class ProviderGoogleGenAI(Provider):
modalities = ["TEXT"] modalities = ["TEXT"]
tool_list: list[types.Tool] | None = [] tool_list: list[types.Tool] | None = []
model_name = payloads.get("model", self.get_model()) model_name = cast(str, payloads.get("model", self.get_model()))
native_coderunner = self.provider_config.get("gm_native_coderunner", False) native_coderunner = self.provider_config.get("gm_native_coderunner", False)
native_search = self.provider_config.get("gm_native_search", False) native_search = self.provider_config.get("gm_native_search", False)
url_context = self.provider_config.get("gm_url_context", False) url_context = self.provider_config.get("gm_url_context", False)
@@ -199,7 +200,16 @@ class ProviderGoogleGenAI(Provider):
# oper thinking config # oper thinking config
thinking_config = None thinking_config = None
if model_name.startswith("gemini-2.5"): 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 # The thinkingBudget parameter, introduced with the Gemini 2.5 series
thinking_budget = self.provider_config.get("gm_thinking_config", {}).get( thinking_budget = self.provider_config.get("gm_thinking_config", {}).get(
"budget", 0 "budget", 0
@@ -208,7 +218,14 @@ class ProviderGoogleGenAI(Provider):
thinking_config = types.ThinkingConfig( thinking_config = types.ThinkingConfig(
thinking_budget=thinking_budget, thinking_budget=thinking_budget,
) )
elif model_name.startswith("gemini-3"): 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 # The thinkingLevel parameter, recommended for Gemini 3 models and onwards
# Gemini 2.5 series models don't support thinkingLevel; use thinkingBudget instead. # Gemini 2.5 series models don't support thinkingLevel; use thinkingBudget instead.
thinking_level = self.provider_config.get("gm_thinking_config", {}).get( thinking_level = self.provider_config.get("gm_thinking_config", {}).get(
@@ -664,13 +681,16 @@ class ProviderGoogleGenAI(Provider):
system_prompt=None, system_prompt=None,
tool_calls_result=None, tool_calls_result=None,
model=None, model=None,
extra_user_content_parts=None,
**kwargs, **kwargs,
) -> LLMResponse: ) -> LLMResponse:
if contexts is None: if contexts is None:
contexts = [] contexts = []
new_record = None new_record = None
if prompt is not 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) context_query = self._ensure_message_to_dicts(contexts)
if new_record: if new_record:
context_query.append(new_record) context_query.append(new_record)
@@ -689,10 +709,9 @@ class ProviderGoogleGenAI(Provider):
for tcr in tool_calls_result: for tcr in tool_calls_result:
context_query.extend(tcr.to_openai_messages()) context_query.extend(tcr.to_openai_messages())
model_config = self.provider_config.get("model_config", {}) model = model or self.get_model()
model_config["model"] = model or self.get_model()
payloads = {"messages": context_query, **model_config} payloads = {"messages": context_query, "model": model}
retry = 10 retry = 10
keys = self.api_keys.copy() keys = self.api_keys.copy()
@@ -717,13 +736,16 @@ class ProviderGoogleGenAI(Provider):
system_prompt=None, system_prompt=None,
tool_calls_result=None, tool_calls_result=None,
model=None, model=None,
extra_user_content_parts=None,
**kwargs, **kwargs,
) -> AsyncGenerator[LLMResponse, None]: ) -> AsyncGenerator[LLMResponse, None]:
if contexts is None: if contexts is None:
contexts = [] contexts = []
new_record = None new_record = None
if prompt is not 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) context_query = self._ensure_message_to_dicts(contexts)
if new_record: if new_record:
context_query.append(new_record) context_query.append(new_record)
@@ -742,10 +764,9 @@ class ProviderGoogleGenAI(Provider):
for tcr in tool_calls_result: for tcr in tool_calls_result:
context_query.extend(tcr.to_openai_messages()) context_query.extend(tcr.to_openai_messages())
model_config = self.provider_config.get("model_config", {}) model = model or self.get_model()
model_config["model"] = model or self.get_model()
payloads = {"messages": context_query, **model_config} payloads = {"messages": context_query, "model": model}
retry = 10 retry = 10
keys = self.api_keys.copy() keys = self.api_keys.copy()
@@ -783,13 +804,33 @@ class ProviderGoogleGenAI(Provider):
self.chosen_api_key = key self.chosen_api_key = key
self._init_client() 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: if image_urls:
user_content = {
"role": "user",
"content": [{"type": "text", "text": text if text else "[图片]"}],
}
for image_url in image_urls: for image_url in image_urls:
if image_url.startswith("http"): if image_url.startswith("http"):
image_path = await download_image_by_url(image_url) image_path = await download_image_by_url(image_url)
@@ -802,14 +843,25 @@ class ProviderGoogleGenAI(Provider):
if not image_data: if not image_data:
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。") logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
continue continue
user_content["content"].append( content_blocks.append(
{ {
"type": "image_url", "type": "image_url",
"image_url": {"url": image_data}, "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: async def encode_image_bs64(self, image_url: str) -> str:
"""将图片转换为 base64""" """将图片转换为 base64"""
+46 -14
View File
@@ -17,7 +17,7 @@ from openai.types.completion_usage import CompletionUsage
import astrbot.core.message.components as Comp import astrbot.core.message.components as Comp
from astrbot import logger from astrbot import logger
from astrbot.api.provider import Provider 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.agent.tool import ToolSet
from astrbot.core.message.message_event_result import MessageChain from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import LLMResponse, TokenUsage, ToolCallsResult from astrbot.core.provider.entities import LLMResponse, TokenUsage, ToolCallsResult
@@ -69,8 +69,7 @@ class ProviderOpenAIOfficial(Provider):
self.client.chat.completions.create, self.client.chat.completions.create,
).parameters.keys() ).parameters.keys()
model_config = provider_config.get("model_config", {}) model = provider_config.get("model", "unknown")
model = model_config.get("model", "unknown")
self.set_model(model) self.set_model(model)
self.reasoning_key = "reasoning_content" self.reasoning_key = "reasoning_content"
@@ -349,6 +348,7 @@ class ProviderOpenAIOfficial(Provider):
system_prompt: str | None = None, system_prompt: str | None = None,
tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None, tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None,
model: str | None = None, model: str | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
**kwargs, **kwargs,
) -> tuple: ) -> tuple:
"""准备聊天所需的有效载荷和上下文""" """准备聊天所需的有效载荷和上下文"""
@@ -356,7 +356,9 @@ class ProviderOpenAIOfficial(Provider):
contexts = [] contexts = []
new_record = None new_record = None
if prompt is not 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) context_query = self._ensure_message_to_dicts(contexts)
if new_record: if new_record:
context_query.append(new_record) context_query.append(new_record)
@@ -375,10 +377,9 @@ class ProviderOpenAIOfficial(Provider):
for tcr in tool_calls_result: for tcr in tool_calls_result:
context_query.extend(tcr.to_openai_messages()) context_query.extend(tcr.to_openai_messages())
model_config = self.provider_config.get("model_config", {}) model = model or self.get_model()
model_config["model"] = model or self.get_model()
payloads = {"messages": context_query, **model_config} payloads = {"messages": context_query, "model": model}
# xAI origin search tool inject # xAI origin search tool inject
self._maybe_inject_xai_search(payloads, **kwargs) self._maybe_inject_xai_search(payloads, **kwargs)
@@ -478,6 +479,7 @@ class ProviderOpenAIOfficial(Provider):
system_prompt=None, system_prompt=None,
tool_calls_result=None, tool_calls_result=None,
model=None, model=None,
extra_user_content_parts=None,
**kwargs, **kwargs,
) -> LLMResponse: ) -> LLMResponse:
payloads, context_query = await self._prepare_chat_payload( payloads, context_query = await self._prepare_chat_payload(
@@ -487,6 +489,7 @@ class ProviderOpenAIOfficial(Provider):
system_prompt, system_prompt,
tool_calls_result, tool_calls_result,
model=model, model=model,
extra_user_content_parts=extra_user_content_parts,
**kwargs, **kwargs,
) )
@@ -541,6 +544,7 @@ class ProviderOpenAIOfficial(Provider):
system_prompt=None, system_prompt=None,
tool_calls_result=None, tool_calls_result=None,
model=None, model=None,
extra_user_content_parts=None,
**kwargs, **kwargs,
) -> AsyncGenerator[LLMResponse, None]: ) -> AsyncGenerator[LLMResponse, None]:
"""流式对话,与服务商交互并逐步返回结果""" """流式对话,与服务商交互并逐步返回结果"""
@@ -551,6 +555,7 @@ class ProviderOpenAIOfficial(Provider):
system_prompt, system_prompt,
tool_calls_result, tool_calls_result,
model=model, model=model,
extra_user_content_parts=extra_user_content_parts,
**kwargs, **kwargs,
) )
@@ -626,13 +631,29 @@ class ProviderOpenAIOfficial(Provider):
self, self,
text: str, text: str,
image_urls: list[str] | None = None, image_urls: list[str] | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
) -> dict: ) -> dict:
"""组装成符合 OpenAI 格式的 role 为 user 的消息段""" """组装成符合 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: if image_urls:
user_content = {
"role": "user",
"content": [{"type": "text", "text": text if text else "[图片]"}],
}
for image_url in image_urls: for image_url in image_urls:
if image_url.startswith("http"): if image_url.startswith("http"):
image_path = await download_image_by_url(image_url) image_path = await download_image_by_url(image_url)
@@ -645,14 +666,25 @@ class ProviderOpenAIOfficial(Provider):
if not image_data: if not image_data:
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。") logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
continue continue
user_content["content"].append( content_blocks.append(
{ {
"type": "image_url", "type": "image_url",
"image_url": {"url": image_data}, "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: async def encode_image_bs64(self, image_url: str) -> str:
"""将图片转换为 base64""" """将图片转换为 base64"""
+57 -10
View File
@@ -4,7 +4,7 @@ from collections import defaultdict
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
from astrbot.core import db_helper from astrbot.core import db_helper, logger
from astrbot.core.db.po import CommandConfig from astrbot.core.db.po import CommandConfig
from astrbot.core.star.filter.command import CommandFilter from astrbot.core.star.filter.command import CommandFilter
from astrbot.core.star.filter.command_group import CommandGroupFilter from astrbot.core.star.filter.command_group import CommandGroupFilter
@@ -90,6 +90,7 @@ async def toggle_command(handler_full_name: str, enabled: bool) -> CommandDescri
async def rename_command( async def rename_command(
handler_full_name: str, handler_full_name: str,
new_fragment: str, new_fragment: str,
aliases: list[str] | None = None,
) -> CommandDescriptor: ) -> CommandDescriptor:
descriptor = _build_descriptor_by_full_name(handler_full_name) descriptor = _build_descriptor_by_full_name(handler_full_name)
if not descriptor: if not descriptor:
@@ -99,9 +100,24 @@ async def rename_command(
if not new_fragment: if not new_fragment:
raise ValueError("指令名不能为空。") raise ValueError("指令名不能为空。")
# 校验主指令名
candidate_full = _compose_command(descriptor.parent_signature, new_fragment) candidate_full = _compose_command(descriptor.parent_signature, new_fragment)
if _is_command_in_use(handler_full_name, candidate_full): if _is_command_in_use(handler_full_name, candidate_full):
raise ValueError("新的指令名已被其他指令占用,请换一个名称") 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( config = await db_helper.upsert_command_config(
handler_full_name=handler_full_name, handler_full_name=handler_full_name,
@@ -114,7 +130,7 @@ async def rename_command(
conflict_key=descriptor.original_command, conflict_key=descriptor.original_command,
resolution_strategy="manual_rename", resolution_strategy="manual_rename",
note=None, note=None,
extra_data=None, extra_data=merged_extra,
auto_managed=False, auto_managed=False,
) )
_bind_descriptor_with_config(descriptor, config) _bind_descriptor_with_config(descriptor, config)
@@ -192,12 +208,18 @@ def _collect_descriptors(include_sub_commands: bool) -> list[CommandDescriptor]:
"""收集指令,按需包含子指令。""" """收集指令,按需包含子指令。"""
descriptors: list[CommandDescriptor] = [] descriptors: list[CommandDescriptor] = []
for handler in star_handlers_registry: for handler in star_handlers_registry:
desc = _build_descriptor(handler) try:
if not desc: 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 continue
if not include_sub_commands and desc.is_sub_command:
continue
descriptors.append(desc)
return descriptors return descriptors
@@ -357,14 +379,27 @@ def _apply_config_to_descriptor(
new_fragment, 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( def _apply_config_to_runtime(
descriptor: CommandDescriptor, descriptor: CommandDescriptor,
config: CommandConfig, config: CommandConfig,
) -> None: ) -> None:
descriptor.handler.enabled = config.enabled descriptor.handler.enabled = config.enabled
if descriptor.filter_ref and descriptor.current_fragment: if descriptor.filter_ref:
_set_filter_fragment(descriptor.filter_ref, descriptor.current_fragment) 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( def _bind_configs_to_descriptors(
@@ -403,6 +438,18 @@ def _set_filter_fragment(
filter_ref._cmpl_cmd_names = None 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( def _is_command_in_use(
target_handler_full_name: str, target_handler_full_name: str,
candidate_full_command: str, candidate_full_command: str,
+4 -4
View File
@@ -267,6 +267,10 @@ class Context:
): ):
"""通过 ID 获取对应的 LLM Provider。""" """通过 ID 获取对应的 LLM Provider。"""
prov = self.provider_manager.inst_map.get(provider_id) prov = self.provider_manager.inst_map.get(provider_id)
if provider_id and not prov:
logger.warning(
f"没有找到 ID 为 {provider_id} 的提供商,这可能是由于您修改了提供商(模型)ID 导致的。"
)
return prov return prov
def get_all_providers(self) -> list[Provider]: def get_all_providers(self) -> list[Provider]:
@@ -296,10 +300,6 @@ class Context:
provider_type=ProviderType.CHAT_COMPLETION, provider_type=ProviderType.CHAT_COMPLETION,
umo=umo, umo=umo,
) )
if prov is None:
raise ProviderNotFoundError(
"provider not found, please choose provider first"
)
if not isinstance(prov, Provider): if not isinstance(prov, Provider):
raise ValueError("返回的 Provider 不是 Provider 类型") raise ValueError("返回的 Provider 不是 Provider 类型")
return prov return prov
+5 -1
View File
@@ -631,7 +631,11 @@ class PluginManager:
# 清除 pip.main 导致的多余的 logging handlers # 清除 pip.main 导致的多余的 logging handlers
for handler in logging.root.handlers[:]: for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler) logging.root.removeHandler(handler)
await sync_command_configs() try:
await sync_command_configs()
except Exception as e:
logger.error(f"同步指令配置失败: {e!s}")
logger.error(traceback.format_exc())
if not fail_rec: if not fail_rec:
return True, None return True, None
+63
View File
@@ -0,0 +1,63 @@
from typing import Literal, TypedDict
import aiohttp
from astrbot.core import logger
class LLMModalities(TypedDict):
input: list[Literal["text", "image", "audio", "video"]]
output: list[Literal["text", "image", "audio", "video"]]
class LLMLimit(TypedDict):
context: int
output: int
class LLMMetadata(TypedDict):
id: str
reasoning: bool
tool_call: bool
knowledge: str
release_date: str
modalities: LLMModalities
open_weights: bool
limit: LLMLimit
LLM_METADATAS: dict[str, LLMMetadata] = {}
async def update_llm_metadata():
url = "https://models.dev/api.json"
try:
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
data = await response.json()
global LLM_METADATAS
models = {}
for info in data.values():
for model in info.get("models", {}).values():
model_id = model.get("id")
if not model_id:
continue
models[model_id] = LLMMetadata(
id=model_id,
reasoning=model.get("reasoning", False),
tool_call=model.get("tool_call", False),
knowledge=model.get("knowledge", "none"),
release_date=model.get("release_date", ""),
modalities=model.get(
"modalities", {"input": [], "output": []}
),
open_weights=model.get("open_weights", False),
limit=model.get("limit", {"context": 0, "output": 0}),
)
# Replace the global cache in-place so references remain valid
LLM_METADATAS.clear()
LLM_METADATAS.update(models)
logger.info(f"Successfully fetched metadata for {len(models)} LLMs.")
except Exception as e:
logger.error(f"Failed to fetch LLM metadata: {e}")
return
+93
View File
@@ -32,6 +32,92 @@ def _migra_agent_runner_configs(conf: AstrBotConfig, ids_map: dict) -> None:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
def _migra_provider_to_source_structure(conf: AstrBotConfig) -> None:
"""
Migrate old provider structure to new provider-source separation.
Provider only keeps: id, provider_source_id, model, modalities, custom_extra_body
All other fields move to provider_sources.
"""
providers = conf.get("provider", [])
provider_sources = conf.get("provider_sources", [])
# Track if any migration happened
migrated = False
# Provider-only fields that should stay in provider
provider_only_fields = {
"id",
"provider_source_id",
"model",
"modalities",
"custom_extra_body",
"enable",
}
# Fields that should not go to source
source_exclude_fields = provider_only_fields | {"model_config"}
for provider in providers:
# Skip if already has provider_source_id
if provider.get("provider_source_id"):
continue
# Skip non-chat-completion types (they don't need source separation)
provider_type = provider.get("provider_type", "")
if provider_type != "chat_completion":
# For old types without provider_type, check type field
old_type = provider.get("type", "")
if "chat_completion" not in old_type:
continue
migrated = True
logger.info(f"Migrating provider {provider.get('id')} to new structure")
# Extract source fields from provider
source_fields = {}
for key, value in list(provider.items()):
if key not in source_exclude_fields:
source_fields[key] = value
# Create new provider_source
source_id = provider.get("id", "") + "_source"
new_source = {"id": source_id, **source_fields}
# Update provider to only keep necessary fields
provider["provider_source_id"] = source_id
# Extract model from model_config if exists
if "model_config" in provider and isinstance(provider["model_config"], dict):
model_config = provider["model_config"]
provider["model"] = model_config.get("model", "")
# Put other model_config fields into custom_extra_body
extra_body_fields = {k: v for k, v in model_config.items() if k != "model"}
if extra_body_fields:
if "custom_extra_body" not in provider:
provider["custom_extra_body"] = {}
provider["custom_extra_body"].update(extra_body_fields)
# Initialize new fields if not present
if "modalities" not in provider:
provider["modalities"] = []
if "custom_extra_body" not in provider:
provider["custom_extra_body"] = {}
# Remove fields that should be in source
keys_to_remove = [k for k in provider.keys() if k not in provider_only_fields]
for key in keys_to_remove:
del provider[key]
# Add source to provider_sources
provider_sources.append(new_source)
if migrated:
conf["provider_sources"] = provider_sources
conf.save_config()
logger.info("Provider-source structure migration completed")
async def migra( async def migra(
db, astrbot_config_mgr, umop_config_router, acm: AstrBotConfigManager db, astrbot_config_mgr, umop_config_router, acm: AstrBotConfigManager
) -> None: ) -> None:
@@ -71,3 +157,10 @@ async def migra(
for conf in acm.confs.values(): for conf in acm.confs.values():
_migra_agent_runner_configs(conf, ids_map) _migra_agent_runner_configs(conf, ids_map)
# Migrate providers to new structure: extract source fields to provider_sources
try:
_migra_provider_to_source_structure(astrbot_config)
except Exception as e:
logger.error(f"Migration for provider-source structure failed: {e!s}")
logger.error(traceback.format_exc())
+1 -1
View File
@@ -436,7 +436,7 @@ class ChatRoute(Route):
accumulated_parts = [] accumulated_parts = []
accumulated_text = "" accumulated_text = ""
accumulated_reasoning = "" accumulated_reasoning = ""
tool_calls = {} # tool_calls = {}
agent_stats = {} agent_stats = {}
except BaseException as e: except BaseException as e:
logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True) logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True)
+2 -1
View File
@@ -61,12 +61,13 @@ class CommandRoute(Route):
data = await request.get_json() data = await request.get_json()
handler_full_name = data.get("handler_full_name") handler_full_name = data.get("handler_full_name")
new_name = data.get("new_name") new_name = data.get("new_name")
aliases = data.get("aliases")
if not handler_full_name or not new_name: if not handler_full_name or not new_name:
return Response().error("handler_full_name 与 new_name 均为必填。").__dict__ return Response().error("handler_full_name 与 new_name 均为必填。").__dict__
try: try:
await rename_command_service(handler_full_name, new_name) await rename_command_service(handler_full_name, new_name, aliases=aliases)
except ValueError as exc: except ValueError as exc:
return Response().error(str(exc)).__dict__ return Response().error(str(exc)).__dict__
+303 -33
View File
@@ -6,7 +6,7 @@ from typing import Any
from quart import request from quart import request
from astrbot.core import file_token_service, logger from astrbot.core import astrbot_config, file_token_service, logger
from astrbot.core.config.astrbot_config import AstrBotConfig from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.config.default import ( from astrbot.core.config.default import (
CONFIG_METADATA_2, CONFIG_METADATA_2,
@@ -21,6 +21,7 @@ from astrbot.core.platform.register import platform_cls_map, platform_registry
from astrbot.core.provider import Provider from astrbot.core.provider import Provider
from astrbot.core.provider.register import provider_registry from astrbot.core.provider.register import provider_registry
from astrbot.core.star.star import star_registry from astrbot.core.star.star import star_registry
from astrbot.core.utils.llm_metadata import LLM_METADATAS
from astrbot.core.utils.webhook_utils import ensure_platform_webhook_config from astrbot.core.utils.webhook_utils import ensure_platform_webhook_config
from .route import Response, Route, RouteContext from .route import Response, Route, RouteContext
@@ -179,13 +180,157 @@ class ConfigRoute(Route):
"/config/provider/new": ("POST", self.post_new_provider), "/config/provider/new": ("POST", self.post_new_provider),
"/config/provider/update": ("POST", self.post_update_provider), "/config/provider/update": ("POST", self.post_update_provider),
"/config/provider/delete": ("POST", self.post_delete_provider), "/config/provider/delete": ("POST", self.post_delete_provider),
"/config/provider/template": ("GET", self.get_provider_template),
"/config/provider/check_one": ("GET", self.check_one_provider_status), "/config/provider/check_one": ("GET", self.check_one_provider_status),
"/config/provider/list": ("GET", self.get_provider_config_list), "/config/provider/list": ("GET", self.get_provider_config_list),
"/config/provider/model_list": ("GET", self.get_provider_model_list), "/config/provider/model_list": ("GET", self.get_provider_model_list),
"/config/provider/get_embedding_dim": ("POST", self.get_embedding_dim), "/config/provider/get_embedding_dim": ("POST", self.get_embedding_dim),
"/config/provider_sources/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() 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): async def get_uc_table(self):
"""获取 UMOP 配置路由表""" """获取 UMOP 配置路由表"""
return Response().ok({"routing": self.ucr.umop_to_conf_id}).__dict__ return Response().ok({"routing": self.ucr.umop_to_conf_id}).__dict__
@@ -433,9 +578,25 @@ class ConfigRoute(Route):
return Response().error("缺少参数 provider_type").__dict__ return Response().error("缺少参数 provider_type").__dict__
provider_type_ls = provider_type.split(",") provider_type_ls = provider_type.split(",")
provider_list = [] provider_list = []
astrbot_config = self.core_lifecycle.astrbot_config ps = self.core_lifecycle.provider_manager.providers_config
for provider in astrbot_config["provider"]: p_source_pt = {
if provider.get("provider_type", None) in provider_type_ls: psrc["id"]: psrc["provider_type"]
for psrc in self.core_lifecycle.provider_manager.provider_sources_config
}
for provider in ps:
ps_id = provider.get("provider_source_id", None)
if (
ps_id
and ps_id in p_source_pt
and p_source_pt[ps_id] in provider_type_ls
):
# chat
prov = self.core_lifecycle.provider_manager.get_merged_provider_config(
provider
)
provider_list.append(prov)
elif not ps_id and provider.get("provider_type", None) in provider_type_ls:
# agent runner, embedding, etc
provider_list.append(provider) provider_list.append(provider)
return Response().ok(provider_list).__dict__ return Response().ok(provider_list).__dict__
@@ -458,9 +619,18 @@ class ConfigRoute(Route):
try: try:
models = await provider.get_models() models = await provider.get_models()
models = models or []
metadata_map = {}
for model_id in models:
meta = LLM_METADATAS.get(model_id)
if meta:
metadata_map[model_id] = meta
ret = { ret = {
"models": models, "models": models,
"provider_id": provider_id, "provider_id": provider_id,
"model_metadata": metadata_map,
} }
return Response().ok(ret).__dict__ return Response().ok(ret).__dict__
except Exception as e: except Exception as e:
@@ -522,6 +692,104 @@ class ConfigRoute(Route):
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return Response().error(f"获取嵌入维度失败: {e!s}").__dict__ return Response().error(f"获取嵌入维度失败: {e!s}").__dict__
async def get_provider_source_models(self):
"""获取指定 provider_source 支持的模型列表
本质上会临时初始化一个 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): async def get_platform_list(self):
"""获取所有平台的列表""" """获取所有平台的列表"""
platform_list = [] platform_list = []
@@ -533,7 +801,15 @@ class ConfigRoute(Route):
data = await request.json data = await request.json
config = data.get("config", None) config = data.get("config", None)
conf_id = data.get("conf_id", None) conf_id = data.get("conf_id", None)
try: try:
# 不更新 provider_sources, provider, platform
# 这些配置有单独的接口进行更新
if conf_id == "default":
no_update_keys = ["provider_sources", "provider", "platform"]
for key in no_update_keys:
config[key] = self.acm.default_conf[key]
await self._save_astrbot_configs(config, conf_id) await self._save_astrbot_configs(config, conf_id)
await self.core_lifecycle.reload_pipeline_scheduler(conf_id) await self.core_lifecycle.reload_pipeline_scheduler(conf_id)
return Response().ok(None, "保存成功~").__dict__ return Response().ok(None, "保存成功~").__dict__
@@ -573,28 +849,30 @@ class ConfigRoute(Route):
async def post_new_provider(self): async def post_new_provider(self):
new_provider_config = await request.json new_provider_config = await request.json
self.config["provider"].append(new_provider_config)
try: try:
save_config(self.config, self.config, is_core=True) await self.core_lifecycle.provider_manager.create_provider(
await self.core_lifecycle.provider_manager.load_provider( new_provider_config
new_provider_config,
) )
except Exception as e: except Exception as e:
return Response().error(str(e)).__dict__ return Response().error(str(e)).__dict__
return Response().ok(None, "新增服务提供商配置成功~").__dict__ return Response().ok(None, "新增服务提供商配置成功").__dict__
async def post_update_platform(self): async def post_update_platform(self):
update_platform_config = await request.json update_platform_config = await request.json
platform_id = update_platform_config.get("id", None) origin_platform_id = update_platform_config.get("id", None)
new_config = update_platform_config.get("config", None) new_config = update_platform_config.get("config", None)
if not platform_id or not new_config: if not origin_platform_id or not new_config:
return Response().error("参数错误").__dict__ return Response().error("参数错误").__dict__
if origin_platform_id != new_config.get("id", None):
return Response().error("机器人名称不允许修改").__dict__
# 如果是支持统一 webhook 模式的平台,且启用了统一 webhook 模式,确保有 webhook_uuid # 如果是支持统一 webhook 模式的平台,且启用了统一 webhook 模式,确保有 webhook_uuid
ensure_platform_webhook_config(new_config) ensure_platform_webhook_config(new_config)
for i, platform in enumerate(self.config["platform"]): for i, platform in enumerate(self.config["platform"]):
if platform["id"] == platform_id: if platform["id"] == origin_platform_id:
self.config["platform"][i] = new_config self.config["platform"][i] = new_config
break break
else: else:
@@ -609,21 +887,15 @@ class ConfigRoute(Route):
async def post_update_provider(self): async def post_update_provider(self):
update_provider_config = await request.json update_provider_config = await request.json
provider_id = update_provider_config.get("id", None) origin_provider_id = update_provider_config.get("id", None)
new_config = update_provider_config.get("config", None) new_config = update_provider_config.get("config", None)
if not provider_id or not new_config: if not origin_provider_id or not new_config:
return Response().error("参数错误").__dict__ return Response().error("参数错误").__dict__
for i, provider in enumerate(self.config["provider"]):
if provider["id"] == provider_id:
self.config["provider"][i] = new_config
break
else:
return Response().error("未找到对应服务提供商").__dict__
try: try:
save_config(self.config, self.config, is_core=True) await self.core_lifecycle.provider_manager.update_provider(
await self.core_lifecycle.provider_manager.reload(new_config) origin_provider_id, new_config
)
except Exception as e: except Exception as e:
return Response().error(str(e)).__dict__ return Response().error(str(e)).__dict__
return Response().ok(None, "更新成功,已经实时生效~").__dict__ return Response().ok(None, "更新成功,已经实时生效~").__dict__
@@ -646,19 +918,17 @@ class ConfigRoute(Route):
async def post_delete_provider(self): async def post_delete_provider(self):
provider_id = await request.json provider_id = await request.json
provider_id = provider_id.get("id") provider_id = provider_id.get("id", "")
for i, provider in enumerate(self.config["provider"]): if not provider_id:
if provider["id"] == provider_id: return Response().error("缺少参数 id").__dict__
del self.config["provider"][i]
break
else:
return Response().error("未找到对应服务提供商").__dict__
try: try:
save_config(self.config, self.config, is_core=True) await self.core_lifecycle.provider_manager.delete_provider(
await self.core_lifecycle.provider_manager.terminate_provider(provider_id) provider_id=provider_id
)
except Exception as e: except Exception as e:
return Response().error(str(e)).__dict__ return Response().error(str(e)).__dict__
return Response().ok(None, "删除成功,已经实时生效~").__dict__ return Response().ok(None, "删除成功,已经实时生效").__dict__
async def get_llm_tools(self): async def get_llm_tools(self):
"""获取函数调用工具。包含了本地加载的以及 MCP 服务的工具""" """获取函数调用工具。包含了本地加载的以及 MCP 服务的工具"""
+96
View File
@@ -1,6 +1,9 @@
import os
import re
import threading import threading
import time import time
import traceback import traceback
from functools import cmp_to_key
import aiohttp import aiohttp
import psutil import psutil
@@ -11,7 +14,9 @@ from astrbot.core.config import VERSION
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db import BaseDatabase from astrbot.core.db import BaseDatabase
from astrbot.core.db.migration.helper import check_migration_needed_v4 from astrbot.core.db.migration.helper import check_migration_needed_v4
from astrbot.core.utils.astrbot_path import get_astrbot_path
from astrbot.core.utils.io import get_dashboard_version from astrbot.core.utils.io import get_dashboard_version
from astrbot.core.utils.version_comparator import VersionComparator
from .route import Response, Route, RouteContext from .route import Response, Route, RouteContext
@@ -30,6 +35,8 @@ class StatRoute(Route):
"/stat/start-time": ("GET", self.get_start_time), "/stat/start-time": ("GET", self.get_start_time),
"/stat/restart-core": ("POST", self.restart_core), "/stat/restart-core": ("POST", self.restart_core),
"/stat/test-ghproxy-connection": ("POST", self.test_ghproxy_connection), "/stat/test-ghproxy-connection": ("POST", self.test_ghproxy_connection),
"/stat/changelog": ("GET", self.get_changelog),
"/stat/changelog/list": ("GET", self.list_changelog_versions),
} }
self.db_helper = db_helper self.db_helper = db_helper
self.register_routes() self.register_routes()
@@ -183,3 +190,92 @@ class StatRoute(Route):
except Exception as e: except Exception as e:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return Response().error(f"Error: {e!s}").__dict__ return Response().error(f"Error: {e!s}").__dict__
async def get_changelog(self):
"""获取指定版本的更新日志"""
try:
version = request.args.get("version")
if not version:
return Response().error("version parameter is required").__dict__
version = version.lstrip("v")
# 防止路径遍历攻击
if not re.match(r"^[a-zA-Z0-9._-]+$", version):
return Response().error("Invalid version format").__dict__
if ".." in version or "/" in version or "\\" in version:
return Response().error("Invalid version format").__dict__
filename = f"v{version}.md"
project_path = get_astrbot_path()
changelogs_dir = os.path.join(project_path, "changelogs")
changelog_path = os.path.join(changelogs_dir, filename)
# 规范化路径,防止符号链接攻击
changelog_path = os.path.realpath(changelog_path)
changelogs_dir = os.path.realpath(changelogs_dir)
# 验证最终路径在预期的 changelogs 目录内(防止路径遍历)
# 确保规范化后的路径以 changelogs_dir 开头,且是目录内的文件
changelog_path_normalized = os.path.normpath(changelog_path)
changelogs_dir_normalized = os.path.normpath(changelogs_dir)
# 检查路径是否在预期目录内(必须是目录的子文件,不能是目录本身)
expected_prefix = changelogs_dir_normalized + os.sep
if not changelog_path_normalized.startswith(expected_prefix):
logger.warning(
f"Path traversal attempt detected: {version} -> {changelog_path}",
)
return Response().error("Invalid version format").__dict__
if not os.path.exists(changelog_path):
return (
Response()
.error(f"Changelog for version {version} not found")
.__dict__
)
if not os.path.isfile(changelog_path):
return (
Response()
.error(f"Changelog for version {version} not found")
.__dict__
)
with open(changelog_path, encoding="utf-8") as f:
content = f.read()
return Response().ok({"content": content, "version": version}).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"Error: {e!s}").__dict__
async def list_changelog_versions(self):
"""获取所有可用的更新日志版本列表"""
try:
project_path = get_astrbot_path()
changelogs_dir = os.path.join(project_path, "changelogs")
if not os.path.exists(changelogs_dir):
return Response().ok({"versions": []}).__dict__
versions = []
for filename in os.listdir(changelogs_dir):
if filename.endswith(".md") and filename.startswith("v"):
# 提取版本号(去除 v 前缀和 .md 后缀)
version = filename[1:-3] # 去掉 "v" 和 ".md"
# 验证版本号格式
if re.match(r"^[a-zA-Z0-9._-]+$", version):
versions.append(version)
# 按版本号排序(降序,最新的在前)
# 使用项目中的 VersionComparator 进行语义化版本号排序
versions.sort(
key=cmp_to_key(
lambda v1, v2: VersionComparator.compare_version(v2, v1),
),
)
return Response().ok({"versions": versions}).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"Error: {e!s}").__dict__
+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. 插件指令管理支持管理别名。
+1 -1
View File
@@ -8,7 +8,7 @@
<meta name="description" content="AstrBot Dashboard" /> <meta name="description" content="AstrBot Dashboard" />
<link <link
rel="stylesheet" rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Poppins:wght@400;500;600;700&family=Roboto:wght@400;500;700&display=swap" href="https://fonts.googleapis.com/css2?family=Outfit&family=Poppins:wght@400;500;600;700&family=Roboto:wght@400;500;700&display=swap"
/> />
<title>AstrBot - 仪表盘</title> <title>AstrBot - 仪表盘</title>
</head> </head>
+8 -5
View File
@@ -14,22 +14,26 @@
}, },
"dependencies": { "dependencies": {
"@guolao/vue-monaco-editor": "^1.5.4", "@guolao/vue-monaco-editor": "^1.5.4",
"@mdit/plugin-katex": "^0.24.1",
"@tiptap/starter-kit": "2.1.7", "@tiptap/starter-kit": "2.1.7",
"@tiptap/vue-3": "2.1.7", "@tiptap/vue-3": "2.1.7",
"apexcharts": "3.42.0", "apexcharts": "3.42.0",
"axios": ">=1.6.2 <1.10.0 || >1.10.0 <2.0.0", "axios": ">=1.6.2 <1.10.0 || >1.10.0 <2.0.0",
"axios-mock-adapter": "^1.22.0", "axios-mock-adapter": "^1.22.0",
"chance": "1.1.11", "chance": "1.1.11",
"d3": "^7.9.0",
"date-fns": "2.30.0", "date-fns": "2.30.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"js-md5": "^0.8.3", "js-md5": "^0.8.3",
"katex": "^0.16.27",
"lodash": "4.17.21", "lodash": "4.17.21",
"marked": "^15.0.7", "markstream-vue": "0.0.3-beta.7",
"markdown-it": "^14.1.0", "mermaid": "^11.12.2",
"pinyin-pro": "^3.26.0",
"pinia": "2.1.6", "pinia": "2.1.6",
"pinyin-pro": "^3.26.0",
"remixicon": "3.5.0", "remixicon": "3.5.0",
"shiki": "^3.20.0",
"stream-markdown": "^0.0.11",
"stream-monaco": "^0.0.8",
"vee-validate": "4.11.3", "vee-validate": "4.11.3",
"vite-plugin-vuetify": "1.0.2", "vite-plugin-vuetify": "1.0.2",
"vue": "3.3.4", "vue": "3.3.4",
@@ -44,7 +48,6 @@
"@mdi/font": "7.2.96", "@mdi/font": "7.2.96",
"@rushstack/eslint-patch": "1.3.3", "@rushstack/eslint-patch": "1.3.3",
"@types/chance": "1.1.3", "@types/chance": "1.1.3",
"@types/markdown-it": "^14.1.2",
"@types/node": "^20.5.7", "@types/node": "^20.5.7",
"@vitejs/plugin-vue": "4.3.3", "@vitejs/plugin-vue": "4.3.3",
"@vue/eslint-config-prettier": "8.0.0", "@vue/eslint-config-prettier": "8.0.0",
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

+72 -50
View File
@@ -18,63 +18,39 @@
@editTitle="showEditTitleDialog" @editTitle="showEditTitleDialog"
@deleteConversation="handleDeleteConversation" @deleteConversation="handleDeleteConversation"
@closeMobileSidebar="closeMobileSidebar" @closeMobileSidebar="closeMobileSidebar"
@toggleTheme="toggleTheme"
@toggleFullscreen="toggleFullscreen"
/> />
<!-- 右侧聊天内容区域 --> <!-- 右侧聊天内容区域 -->
<div class="chat-content-panel"> <div class="chat-content-panel">
<div class="conversation-header fade-in"> <div class="conversation-header fade-in" v-if="isMobile">
<!-- 手机端菜单按钮 --> <!-- 手机端菜单按钮 -->
<v-btn icon class="mobile-menu-btn" @click="toggleMobileSidebar" v-if="isMobile" variant="text"> <v-btn icon class="mobile-menu-btn" @click="toggleMobileSidebar" variant="text">
<v-icon>mdi-menu</v-icon> <v-icon>mdi-menu</v-icon>
</v-btn> </v-btn>
<!-- <div v-if="currCid && getCurrentConversation">
<h3
style="max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
{{ getCurrentConversation.title || tm('conversation.newConversation') }}</h3>
<span style="font-size: 12px;">{{ formatDate(getCurrentConversation.updated_at) }}</span>
</div> -->
<div class="conversation-header-actions">
<!-- router 推送到 /chatbox -->
<v-tooltip :text="tm('actions.fullscreen')" v-if="!chatboxMode">
<template v-slot:activator="{ props }">
<v-icon v-bind="props"
@click="router.push(currSessionId ? `/chatbox/${currSessionId}` : '/chatbox')"
class="fullscreen-icon">mdi-fullscreen</v-icon>
</template>
</v-tooltip>
<!-- 语言切换按钮 -->
<v-tooltip :text="t('core.common.language')" v-if="chatboxMode">
<template v-slot:activator="{ props }">
<LanguageSwitcher variant="chatbox" />
</template>
</v-tooltip>
<!-- 主题切换按钮 -->
<v-tooltip :text="isDark ? tm('modes.lightMode') : tm('modes.darkMode')" v-if="chatboxMode">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon @click="toggleTheme" class="theme-toggle-icon"
size="small" rounded="sm" style="margin-right: 8px;" variant="text">
<v-icon>{{ isDark ? 'mdi-weather-night' : 'mdi-white-balance-sunny' }}</v-icon>
</v-btn>
</template>
</v-tooltip>
<!-- router 推送到 /chat -->
<v-tooltip :text="tm('actions.exitFullscreen')" v-if="chatboxMode">
<template v-slot:activator="{ props }">
<v-icon v-bind="props" @click="router.push(currSessionId ? `/chat/${currSessionId}` : '/chat')"
class="fullscreen-icon">mdi-fullscreen-exit</v-icon>
</template>
</v-tooltip>
</div>
</div> </div>
<MessageList v-if="messages && messages.length > 0" :messages="messages" :isDark="isDark" <div class="message-list-wrapper" v-if="messages && messages.length > 0">
:isStreaming="isStreaming || isConvRunning" @openImagePreview="openImagePreview" <MessageList :messages="messages" :isDark="isDark"
@replyMessage="handleReplyMessage" :isStreaming="isStreaming || isConvRunning"
ref="messageList" /> :isLoadingMessages="isLoadingMessages"
@openImagePreview="openImagePreview"
@replyMessage="handleReplyMessage"
ref="messageList" />
<div class="message-list-fade" :class="{ 'fade-dark': isDark }"></div>
</div>
<div class="welcome-container fade-in" v-else> <div class="welcome-container fade-in" v-else>
<div class="welcome-title"> <div v-if="isLoadingMessages" class="loading-overlay-welcome">
<v-progress-circular
indeterminate
size="48"
width="4"
color="primary"
></v-progress-circular>
</div>
<div v-else class="welcome-title">
<span>Hello, I'm</span> <span>Hello, I'm</span>
<span class="bot-name">AstrBot </span> <span class="bot-name">AstrBot </span>
</div> </div>
@@ -173,6 +149,7 @@ const isMobile = ref(false);
const mobileMenuOpen = ref(false); const mobileMenuOpen = ref(false);
const imagePreviewDialog = ref(false); const imagePreviewDialog = ref(false);
const previewImageUrl = ref(''); const previewImageUrl = ref('');
const isLoadingMessages = ref(false);
// 使 composables // 使 composables
const { const {
@@ -260,6 +237,14 @@ function toggleTheme() {
theme.global.name.value = newTheme; theme.global.name.value = newTheme;
} }
function toggleFullscreen() {
if (props.chatboxMode) {
router.push(currSessionId.value ? `/chat/${currSessionId.value}` : '/chat');
} else {
router.push(currSessionId.value ? `/chatbox/${currSessionId.value}` : '/chatbox');
}
}
function openImagePreview(imageUrl: string) { function openImagePreview(imageUrl: string) {
previewImageUrl.value = imageUrl; previewImageUrl.value = imageUrl;
imagePreviewDialog.value = true; imagePreviewDialog.value = true;
@@ -303,11 +288,14 @@ function clearReply() {
async function handleSelectConversation(sessionIds: string[]) { async function handleSelectConversation(sessionIds: string[]) {
if (!sessionIds[0]) return; if (!sessionIds[0]) return;
//
currSessionId.value = sessionIds[0];
selectedSessions.value = [sessionIds[0]];
// URL // URL
const basePath = props.chatboxMode ? '/chatbox' : '/chat'; const basePath = props.chatboxMode ? '/chatbox' : '/chat';
if (route.path !== `${basePath}/${sessionIds[0]}`) { if (route.path !== `${basePath}/${sessionIds[0]}`) {
router.push(`${basePath}/${sessionIds[0]}`); router.push(`${basePath}/${sessionIds[0]}`);
return;
} }
// //
@@ -318,10 +306,14 @@ async function handleSelectConversation(sessionIds: string[]) {
// //
clearReply(); clearReply();
currSessionId.value = sessionIds[0]; //
selectedSessions.value = [sessionIds[0]]; isLoadingMessages.value = true;
await getSessionMsg(sessionIds[0], router); try {
await getSessionMsg(sessionIds[0]);
} finally {
isLoadingMessages.value = false;
}
nextTick(() => { nextTick(() => {
messageList.value?.scrollToBottom(); messageList.value?.scrollToBottom();
@@ -510,6 +502,29 @@ onBeforeUnmount(() => {
overflow: hidden; overflow: hidden;
} }
.message-list-wrapper {
flex: 1;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
.message-list-fade {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 40px;
background: linear-gradient(to top, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%);
pointer-events: none;
z-index: 1;
}
.message-list-fade.fade-dark {
background: linear-gradient(to top, rgba(30, 30, 30, 1) 0%, rgba(30, 30, 30, 0) 100%);
}
.conversation-header { .conversation-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -543,6 +558,7 @@ onBeforeUnmount(() => {
justify-content: center; justify-content: center;
align-items: center; align-items: center;
flex-direction: column; flex-direction: column;
position: relative;
} }
.welcome-title { .welcome-title {
@@ -550,6 +566,12 @@ onBeforeUnmount(() => {
margin-bottom: 16px; margin-bottom: 16px;
} }
.loading-overlay-welcome {
display: flex;
justify-content: center;
align-items: center;
}
.bot-name { .bot-name {
font-weight: 700; font-weight: 700;
margin-left: 8px; margin-left: 8px;
+20 -8
View File
@@ -1,7 +1,15 @@
<template> <template>
<div class="input-area fade-in"> <div class="input-area fade-in">
<div class="input-container" <div class="input-container"
style="width: 85%; max-width: 900px; margin: 0 auto; border: 1px solid #e0e0e0; border-radius: 24px;"> :style="{
width: '85%',
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-preview" v-if="props.replyTo">
<div class="reply-content"> <div class="reply-content">
@@ -16,8 +24,8 @@
@keydown="handleKeyDown" @keydown="handleKeyDown"
:disabled="disabled" :disabled="disabled"
placeholder="Ask AstrBot..." placeholder="Ask AstrBot..."
style="width: 100%; resize: none; outline: none; border: 1px solid var(--v-theme-border); border-radius: 12px; padding: 8px 16px; min-height: 40px; font-family: inherit; font-size: 16px; background-color: var(--v-theme-surface);"></textarea> style="width: 100%; resize: none; outline: none; border: 1px solid var(--v-theme-border); border-radius: 12px; padding: 12px 16px; min-height: 40px; font-family: inherit; font-size: 16px; background-color: var(--v-theme-surface);"></textarea>
<div style="display: flex; justify-content: space-between; align-items: center; padding: 0px 12px;"> <div style="display: flex; justify-content: space-between; align-items: center; padding: 6px 14px;">
<div style="display: flex; justify-content: flex-start; margin-top: 4px; align-items: center; gap: 8px;"> <div style="display: flex; justify-content: flex-start; margin-top: 4px; align-items: center; gap: 8px;">
<ConfigSelector <ConfigSelector
:session-id="sessionId || null" :session-id="sessionId || null"
@@ -26,7 +34,9 @@
:initial-config-id="props.configId" :initial-config-id="props.configId"
@config-changed="handleConfigChange" @config-changed="handleConfigChange"
/> />
<ProviderModelSelector v-if="showProviderSelector" ref="providerModelSelectorRef" />
<!-- Provider/Model Selector Menu -->
<ProviderModelMenu v-if="showProviderSelector" ref="providerModelMenuRef" />
<v-tooltip :text="enableStreaming ? tm('streaming.enabled') : tm('streaming.disabled')" location="top"> <v-tooltip :text="enableStreaming ? tm('streaming.enabled') : tm('streaming.disabled')" location="top">
<template v-slot:activator="{ props }"> <template v-slot:activator="{ props }">
@@ -84,8 +94,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'; import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useModuleI18n } from '@/i18n/composables'; import { useModuleI18n } from '@/i18n/composables';
import ProviderModelSelector from './ProviderModelSelector.vue'; import { useCustomizerStore } from '@/stores/customizer';
import ConfigSelector from './ConfigSelector.vue'; import ConfigSelector from './ConfigSelector.vue';
import ProviderModelMenu from './ProviderModelMenu.vue';
import type { Session } from '@/composables/useSessions'; import type { Session } from '@/composables/useSessions';
interface StagedFileInfo { interface StagedFileInfo {
@@ -138,10 +149,11 @@ const emit = defineEmits<{
}>(); }>();
const { tm } = useModuleI18n('features/chat'); const { tm } = useModuleI18n('features/chat');
const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark');
const inputField = ref<HTMLTextAreaElement | null>(null); const inputField = ref<HTMLTextAreaElement | null>(null);
const imageInputRef = ref<HTMLInputElement | null>(null); const imageInputRef = ref<HTMLInputElement | null>(null);
const providerModelSelectorRef = ref<InstanceType<typeof ProviderModelSelector> | null>(null); const providerModelMenuRef = ref<InstanceType<typeof ProviderModelMenu> | null>(null);
const showProviderSelector = ref(true); const showProviderSelector = ref(true);
const localPrompt = computed({ const localPrompt = computed({
@@ -234,7 +246,7 @@ function getCurrentSelection() {
if (!showProviderSelector.value) { if (!showProviderSelector.value) {
return null; return null;
} }
return providerModelSelectorRef.value?.getCurrentSelection(); return providerModelMenuRef.value?.getCurrentSelection();
} }
onMounted(() => { onMounted(() => {
@@ -259,7 +271,7 @@ defineExpose({
<style scoped> <style scoped>
.input-area { .input-area {
padding: 16px; padding: 16px;
background-color: var(--v-theme-surface); background-color: transparent;
position: relative; position: relative;
border-top: 1px solid var(--v-theme-border); border-top: 1px solid var(--v-theme-border);
flex-shrink: 0; flex-shrink: 0;
@@ -17,7 +17,7 @@
</template> </template>
</v-tooltip> </v-tooltip>
<v-dialog v-model="dialog" max-width="480" persistent> <v-dialog v-model="dialog" max-width="480">
<v-card> <v-card>
<v-card-title class="d-flex align-center justify-space-between"> <v-card-title class="d-flex align-center justify-space-between">
<span>选择配置文件</span> <span>选择配置文件</span>
@@ -5,21 +5,11 @@
'mobile-sidebar-open': isMobile && mobileMenuOpen, 'mobile-sidebar-open': isMobile && mobileMenuOpen,
'mobile-sidebar': isMobile 'mobile-sidebar': isMobile
}" }"
:style="{ 'background-color': isDark ? sidebarCollapsed ? '#1e1e1e' : '#2d2d2d' : sidebarCollapsed ? '#ffffff' : '#f1f4f9' }" :style="{ 'background-color': isDark ? sidebarCollapsed ? '#1e1e1e' : '#2d2d2d' : sidebarCollapsed ? '#ffffff' : '#f1f4f9' }">
@mouseenter="handleSidebarMouseEnter"
@mouseleave="handleSidebarMouseLeave">
<div style="display: flex; align-items: center; justify-content: center; padding: 16px; padding-bottom: 0px;"
v-if="chatboxMode">
<img width="50" src="@/assets/images/icon-no-shadow.svg" alt="AstrBot Logo">
<span v-if="!sidebarCollapsed"
style="font-weight: 1000; font-size: 26px; margin-left: 8px;">AstrBot</span>
</div>
<div class="sidebar-collapse-btn-container" v-if="!isMobile"> <div class="sidebar-collapse-btn-container" v-if="!isMobile">
<v-btn icon class="sidebar-collapse-btn" @click="toggleSidebar" variant="text" color="deep-purple"> <v-btn icon class="sidebar-collapse-btn" @click="toggleSidebar" variant="text" color="deep-purple">
<v-icon>{{ (sidebarCollapsed || (!sidebarCollapsed && sidebarHoverExpanded)) ? <v-icon>{{ sidebarCollapsed ? 'mdi-chevron-right' : 'mdi-chevron-left' }}</v-icon>
'mdi-chevron-right' : 'mdi-chevron-left' }}</v-icon>
</v-btn> </v-btn>
</div> </div>
@@ -30,19 +20,14 @@
</v-btn> </v-btn>
</div> </div>
<div style="padding: 16px; padding-top: 8px;"> <div style="padding: 8px; opacity: 0.6;">
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId" <v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId"
v-if="!sidebarCollapsed || isMobile" prepend-icon="mdi-plus" v-if="!sidebarCollapsed || isMobile" prepend-icon="mdi-square-edit-outline">{{ tm('actions.newChat') }}</v-btn>
style="background-color: transparent !important; border-radius: 4px;">{{ tm('actions.newChat') }}</v-btn> <v-btn icon="mdi-square-edit-outline" rounded="xl" @click="$emit('newChat')" :disabled="!currSessionId"
<v-btn icon="mdi-plus" rounded="lg" @click="$emit('newChat')" :disabled="!currSessionId"
v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn> v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn>
</div> </div>
<div v-if="!sidebarCollapsed || isMobile"> <div style="overflow-y: auto; flex-grow: 1;"
<v-divider class="mx-4"></v-divider>
</div>
<div style="overflow-y: auto; flex-grow: 1;" :class="{ 'fade-in': sidebarHoverExpanded }"
v-if="!sidebarCollapsed || isMobile"> v-if="!sidebarCollapsed || isMobile">
<v-card v-if="sessions.length > 0" flat style="background-color: transparent;"> <v-card v-if="sessions.length > 0" flat style="background-color: transparent;">
<v-list density="compact" nav class="conversation-list" <v-list density="compact" nav class="conversation-list"
@@ -50,18 +35,19 @@
@update:selected="$emit('selectConversation', $event)"> @update:selected="$emit('selectConversation', $event)">
<v-list-item v-for="item in sessions" :key="item.session_id" :value="item.session_id" <v-list-item v-for="item in sessions" :key="item.session_id" :value="item.session_id"
rounded="lg" class="conversation-item" active-color="secondary"> rounded="lg" class="conversation-item" active-color="secondary">
<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') }} {{ item.display_name || tm('conversation.newConversation') }}
</v-list-item-title> </v-list-item-title>
<v-list-item-subtitle v-if="!sidebarCollapsed || isMobile" class="timestamp"> <!-- <v-list-item-subtitle v-if="!sidebarCollapsed || isMobile" class="timestamp">
{{ new Date(item.updated_at).toLocaleString() }} {{ new Date(item.updated_at).toLocaleString() }}
</v-list-item-subtitle> </v-list-item-subtitle> -->
<template v-if="!sidebarCollapsed || isMobile" v-slot:append> <template v-if="!sidebarCollapsed || isMobile" v-slot:append>
<div class="conversation-actions"> <div class="conversation-actions">
<v-btn icon="mdi-pencil" size="x-small" variant="text" <v-btn icon="mdi-pencil" size="x-small" variant="text"
class="edit-title-btn" class="edit-title-btn"
@click.stop="$emit('editTitle', item.session_id, item.display_name)" /> @click.stop="$emit('editTitle', item.session_id, item.display_name ?? '')" />
<v-btn icon="mdi-delete" size="x-small" variant="text" <v-btn icon="mdi-delete" size="x-small" variant="text"
class="delete-conversation-btn" color="error" class="delete-conversation-btn" color="error"
@click.stop="handleDeleteConversation(item)" /> @click.stop="handleDeleteConversation(item)" />
@@ -74,19 +60,83 @@
<v-fade-transition> <v-fade-transition>
<div class="no-conversations" v-if="sessions.length === 0"> <div class="no-conversations" v-if="sessions.length === 0">
<v-icon icon="mdi-message-text-outline" size="large" color="grey-lighten-1"></v-icon> <v-icon icon="mdi-message-text-outline" size="large" color="grey-lighten-1"></v-icon>
<div class="no-conversations-text" v-if="!sidebarCollapsed || sidebarHoverExpanded || isMobile"> <div class="no-conversations-text" v-if="!sidebarCollapsed || isMobile">
{{ tm('conversation.noHistory') }} {{ tm('conversation.noHistory') }}
</div> </div>
</div> </div>
</v-fade-transition> </v-fade-transition>
</div> </div>
<!-- 收起时的占位元素 -->
<div class="sidebar-spacer" v-if="sidebarCollapsed && !isMobile"></div>
<!-- 底部设置按钮 -->
<div class="sidebar-footer">
<StyledMenu location="top" :close-on-content-click="false">
<template v-slot:activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
:icon="sidebarCollapsed && !isMobile"
:block="!sidebarCollapsed || isMobile"
variant="text"
class="settings-btn"
:class="{ 'settings-btn-collapsed': sidebarCollapsed && !isMobile }"
:prepend-icon="(!sidebarCollapsed || isMobile) ? 'mdi-cog-outline' : undefined"
>
<v-icon v-if="sidebarCollapsed && !isMobile">mdi-cog-outline</v-icon>
<template v-if="!sidebarCollapsed || isMobile">{{ t('core.common.settings') }}</template>
</v-btn>
</template>
<!-- 语言切换 -->
<v-list-item class="styled-menu-item">
<template v-slot:prepend>
<v-icon>mdi-translate</v-icon>
</template>
<v-list-item-title>{{ t('core.common.language') }}</v-list-item-title>
<template v-slot:append>
<LanguageSwitcher variant="chatbox" />
</template>
</v-list-item>
<!-- 主题切换 -->
<v-list-item class="styled-menu-item" @click="$emit('toggleTheme')">
<template v-slot:prepend>
<v-icon>{{ isDark ? 'mdi-weather-night' : 'mdi-white-balance-sunny' }}</v-icon>
</template>
<v-list-item-title>{{ isDark ? tm('modes.lightMode') : tm('modes.darkMode') }}</v-list-item-title>
</v-list-item>
<!-- 全屏/退出全屏 -->
<v-list-item class="styled-menu-item" @click="$emit('toggleFullscreen')">
<template v-slot:prepend>
<v-icon>{{ chatboxMode ? 'mdi-fullscreen-exit' : 'mdi-fullscreen' }}</v-icon>
</template>
<v-list-item-title>{{ chatboxMode ? tm('actions.exitFullscreen') : tm('actions.fullscreen') }}</v-list-item-title>
</v-list-item>
<!-- 提供商配置 -->
<v-list-item class="styled-menu-item" @click="showProviderConfigDialog = true">
<template v-slot:prepend>
<v-icon>mdi-creation</v-icon>
</template>
<v-list-item-title>{{ tm('actions.providerConfig') }}</v-list-item-title>
</v-list-item>
</StyledMenu>
</div>
<!-- 提供商配置对话框 -->
<ProviderConfigDialog v-model="showProviderConfigDialog" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { useModuleI18n } from '@/i18n/composables'; import { useI18n, useModuleI18n } from '@/i18n/composables';
import type { Session } from '@/composables/useSessions'; import type { Session } from '@/composables/useSessions';
import LanguageSwitcher from '@/components/shared/LanguageSwitcher.vue';
import StyledMenu from '@/components/shared/StyledMenu.vue';
import ProviderConfigDialog from '@/components/chat/ProviderConfigDialog.vue';
interface Props { interface Props {
sessions: Session[]; sessions: Session[];
@@ -106,15 +156,15 @@ const emit = defineEmits<{
editTitle: [sessionId: string, title: string]; editTitle: [sessionId: string, title: string];
deleteConversation: [sessionId: string]; deleteConversation: [sessionId: string];
closeMobileSidebar: []; closeMobileSidebar: [];
toggleTheme: [];
toggleFullscreen: [];
}>(); }>();
const { t } = useI18n();
const { tm } = useModuleI18n('features/chat'); const { tm } = useModuleI18n('features/chat');
const sidebarCollapsed = ref(true); const sidebarCollapsed = ref(true);
const sidebarHovered = ref(false); const showProviderConfigDialog = ref(false);
const sidebarHoverTimer = ref<number | null>(null);
const sidebarHoverExpanded = ref(false);
const sidebarHoverDelay = 100;
// localStorage // localStorage
const savedCollapsedState = localStorage.getItem('sidebarCollapsed'); const savedCollapsedState = localStorage.getItem('sidebarCollapsed');
@@ -125,40 +175,10 @@ if (savedCollapsedState !== null) {
} }
function toggleSidebar() { function toggleSidebar() {
if (sidebarHoverExpanded.value) {
sidebarHoverExpanded.value = false;
return;
}
sidebarCollapsed.value = !sidebarCollapsed.value; sidebarCollapsed.value = !sidebarCollapsed.value;
localStorage.setItem('sidebarCollapsed', JSON.stringify(sidebarCollapsed.value)); localStorage.setItem('sidebarCollapsed', JSON.stringify(sidebarCollapsed.value));
} }
function handleSidebarMouseEnter() {
if (!sidebarCollapsed.value || props.isMobile) return;
sidebarHovered.value = true;
sidebarHoverTimer.value = window.setTimeout(() => {
if (sidebarHovered.value) {
sidebarHoverExpanded.value = true;
sidebarCollapsed.value = false;
}
}, sidebarHoverDelay);
}
function handleSidebarMouseLeave() {
sidebarHovered.value = false;
if (sidebarHoverTimer.value) {
clearTimeout(sidebarHoverTimer.value);
sidebarHoverTimer.value = null;
}
if (sidebarHoverExpanded.value) {
sidebarCollapsed.value = true;
}
sidebarHoverExpanded.value = false;
}
function handleDeleteConversation(session: Session) { function handleDeleteConversation(session: Session) {
const sessionTitle = session.display_name || tm('conversation.newConversation'); const sessionTitle = session.display_name || tm('conversation.newConversation');
const message = tm('conversation.confirmDelete', { name: sessionTitle }); const message = tm('conversation.confirmDelete', { name: sessionTitle });
@@ -184,8 +204,8 @@ function handleDeleteConversation(session: Session) {
} }
.sidebar-collapsed { .sidebar-collapsed {
max-width: 75px; max-width: 60px;
min-width: 75px; min-width: 60px;
transition: all 0.3s ease; transition: all 0.3s ease;
} }
@@ -206,7 +226,7 @@ function handleDeleteConversation(session: Session) {
} }
.sidebar-collapse-btn-container { .sidebar-collapse-btn-container {
margin: 16px; margin: 8px;
margin-bottom: 0px; margin-bottom: 0px;
z-index: 10; z-index: 10;
} }
@@ -218,13 +238,19 @@ function handleDeleteConversation(session: Session) {
padding: 0; padding: 0;
} }
.conversation-item { .new-chat-btn {
margin-bottom: 4px; justify-content: flex-start;
border-radius: 8px !important; background-color: transparent !important;
transition: all 0.2s ease; border-radius: 20px;
height: auto !important;
min-height: 56px;
padding: 8px 16px !important; padding: 8px 16px !important;
}
.conversation-item {
/* margin-bottom: 4px; */
border-radius: 20px !important;
height: auto !important;
/* min-height: 56px; */
padding: 0px 16px !important;
position: relative; position: relative;
} }
@@ -287,17 +313,31 @@ function handleDeleteConversation(session: Session) {
transition: opacity 0.25s ease; transition: opacity 0.25s ease;
} }
.fade-in { .sidebar-spacer {
animation: fadeInContent 0.3s ease; flex-grow: 1;
} }
@keyframes fadeInContent { .sidebar-footer {
from { padding: 8px 8px;
opacity: 0; padding-bottom: 16px;
} flex-shrink: 0;
to { }
opacity: 1;
} .settings-btn {
opacity: 0.6;
justify-content: flex-start;
padding: 8px 16px !important;
border-radius: 20px !important;
}
.settings-btn:hover {
opacity: 1;
}
.settings-btn-collapsed {
width: 100%;
display: flex;
justify-content: center;
} }
</style> </style>
+213 -352
View File
@@ -1,7 +1,11 @@
<template> <template>
<div class="messages-container" ref="messageContainer"> <div class="messages-container" ref="messageContainer">
<!-- 加载指示器 -->
<div v-if="isLoadingMessages" class="loading-overlay" :class="{ 'is-dark': isDark }">
<v-progress-circular indeterminate size="48" width="4" color="primary"></v-progress-circular>
</div>
<!-- 聊天消息列表 --> <!-- 聊天消息列表 -->
<div class="message-list"> <div class="message-list" :class="{ 'loading-blur': isLoadingMessages }">
<div class="message-item fade-in" v-for="(msg, index) in messages" :key="index"> <div class="message-item fade-in" v-for="(msg, index) in messages" :key="index">
<!-- 用户消息 --> <!-- 用户消息 -->
<div v-if="msg.content.type == 'user'" class="user-message"> <div v-if="msg.content.type == 'user'" class="user-message">
@@ -40,13 +44,24 @@
<div v-else-if="part.type === 'file' && part.embedded_file" class="file-attachments"> <div v-else-if="part.type === 'file' && part.embedded_file" class="file-attachments">
<div class="file-attachment"> <div class="file-attachment">
<a v-if="part.embedded_file.url" :href="part.embedded_file.url" <a v-if="part.embedded_file.url" :href="part.embedded_file.url"
:download="part.embedded_file.filename" class="file-link"> :download="part.embedded_file.filename" class="file-link"
<v-icon size="small" class="file-icon">mdi-file-document-outline</v-icon> :class="{ 'is-dark': isDark }" :style="isDark ? {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderColor: 'rgba(255, 255, 255, 0.1)',
color: 'var(--v-theme-secondary)'
} : {}">
<v-icon size="small" class="file-icon"
:style="isDark ? { color: 'var(--v-theme-secondary)' } : {}">mdi-file-document-outline</v-icon>
<span class="file-name">{{ part.embedded_file.filename }}</span> <span class="file-name">{{ part.embedded_file.filename }}</span>
</a> </a>
<a v-else @click="downloadFile(part.embedded_file)" <a v-else @click="downloadFile(part.embedded_file)"
class="file-link file-link-download"> class="file-link file-link-download" :class="{ 'is-dark': isDark }" :style="isDark ? {
<v-icon size="small" class="file-icon">mdi-file-document-outline</v-icon> backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderColor: 'rgba(255, 255, 255, 0.1)',
color: 'var(--v-theme-secondary)'
} : {}">
<v-icon size="small" class="file-icon"
:style="isDark ? { color: 'var(--v-theme-secondary)' } : {}">mdi-file-document-outline</v-icon>
<span class="file-name">{{ part.embedded_file.filename }}</span> <span class="file-name">{{ part.embedded_file.filename }}</span>
<v-icon v-if="downloadingFiles.has(part.embedded_file.attachment_id)" <v-icon v-if="downloadingFiles.has(part.embedded_file.attachment_id)"
size="small" class="download-icon">mdi-loading mdi-spin</v-icon> size="small" class="download-icon">mdi-loading mdi-spin</v-icon>
@@ -76,16 +91,19 @@
<template v-else> <template v-else>
<!-- Reasoning Block (Collapsible) - 放在最前面 --> <!-- Reasoning Block (Collapsible) - 放在最前面 -->
<div v-if="msg.content.reasoning && msg.content.reasoning.trim()" <div v-if="msg.content.reasoning && msg.content.reasoning.trim()"
class="reasoning-container"> class="reasoning-container" :class="{ 'is-dark': isDark }"
<div class="reasoning-header" @click="toggleReasoning(index)"> :style="isDark ? { backgroundColor: 'rgba(103, 58, 183, 0.08)' } : {}">
<div class="reasoning-header" :class="{ 'is-dark': isDark }"
@click="toggleReasoning(index)">
<v-icon size="small" class="reasoning-icon"> <v-icon size="small" class="reasoning-icon">
{{ isReasoningExpanded(index) ? 'mdi-chevron-down' : 'mdi-chevron-right' }} {{ isReasoningExpanded(index) ? 'mdi-chevron-down' : 'mdi-chevron-right' }}
</v-icon> </v-icon>
<span class="reasoning-label">{{ tm('reasoning.thinking') }}</span> <span class="reasoning-label">{{ tm('reasoning.thinking') }}</span>
</div> </div>
<div v-if="isReasoningExpanded(index)" class="reasoning-content"> <div v-if="isReasoningExpanded(index)" class="reasoning-content">
<div v-html="md.render(msg.content.reasoning)" <MarkdownRender :content="msg.content.reasoning"
class="markdown-content reasoning-text"></div> class="reasoning-text markdown-content" :typewriter="false"
:style="isDark ? { opacity: '0.85' } : {}" :is-dark="isDark" />
</div> </div>
</div> </div>
@@ -95,12 +113,15 @@
<div v-if="part.type === 'tool_call' && part.tool_calls && part.tool_calls.length > 0" <div v-if="part.type === 'tool_call' && part.tool_calls && part.tool_calls.length > 0"
class="tool-calls-container"> class="tool-calls-container">
<div v-for="(toolCall, tcIndex) in part.tool_calls" :key="toolCall.id" <div v-for="(toolCall, tcIndex) in part.tool_calls" :key="toolCall.id"
class="tool-call-card"> class="tool-call-card" :class="{ 'is-dark': isDark }" :style="isDark ? {
<div class="tool-call-header" backgroundColor: 'rgba(40, 60, 100, 0.4)',
borderColor: 'rgba(100, 140, 200, 0.4)'
} : {}">
<div class="tool-call-header" :class="{ 'is-dark': isDark }"
@click="toggleToolCall(index, partIndex, tcIndex)"> @click="toggleToolCall(index, partIndex, tcIndex)">
<v-icon size="small" class="tool-call-expand-icon"> <v-icon size="small" class="tool-call-expand-icon">
{{ isToolCallExpanded(index, partIndex, tcIndex) ? {{ isToolCallExpanded(index, partIndex, tcIndex) ?
'mdi-chevron-down' : 'mdi-chevron-right' }} 'mdi-chevron-down' : 'mdi-chevron-right' }}
</v-icon> </v-icon>
<v-icon size="small" class="tool-call-icon">mdi-wrench-outline</v-icon> <v-icon size="small" class="tool-call-icon">mdi-wrench-outline</v-icon>
<div class="tool-call-info"> <div class="tool-call-info">
@@ -121,28 +142,36 @@
</span> </span>
</div> </div>
<div v-if="isToolCallExpanded(index, partIndex, tcIndex)" <div v-if="isToolCallExpanded(index, partIndex, tcIndex)"
class="tool-call-details"> class="tool-call-details" :style="isDark ? {
borderTopColor: 'rgba(100, 140, 200, 0.3)',
backgroundColor: 'rgba(30, 45, 70, 0.5)'
} : {}">
<div class="tool-call-detail-row"> <div class="tool-call-detail-row">
<span class="detail-label">ID:</span> <span class="detail-label">ID:</span>
<code class="detail-value">{{ toolCall.id }}</code> <code class="detail-value"
:style="isDark ? { backgroundColor: 'transparent' } : {}">{{ toolCall.id
}}</code>
</div> </div>
<div class="tool-call-detail-row"> <div class="tool-call-detail-row">
<span class="detail-label">Args:</span> <span class="detail-label">Args:</span>
<pre <pre class="detail-value detail-json"
class="detail-value detail-json">{{ JSON.stringify(toolCall.args, null, 2) }}</pre> :style="isDark ? { backgroundColor: 'transparent' } : {}">{{
JSON.stringify(toolCall.args, null, 2) }}</pre>
</div> </div>
<div v-if="toolCall.result" class="tool-call-detail-row"> <div v-if="toolCall.result" class="tool-call-detail-row">
<span class="detail-label">Result:</span> <span class="detail-label">Result:</span>
<pre <pre class="detail-value detail-json detail-result"
class="detail-value detail-json detail-result">{{ formatToolResult(toolCall.result) }}</pre> :style="isDark ? { backgroundColor: 'transparent' } : {}">{{ formatToolResult(toolCall.result) }}
</pre>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Text (Markdown) --> <!-- Text (Markdown) -->
<div v-else-if="part.type === 'plain' && part.text && part.text.trim()" <MarkdownRender v-else-if="part.type === 'plain' && part.text && part.text.trim()"
v-html="md.render(part.text)" class="markdown-content"></div> :content="part.text" :typewriter="false" class="markdown-content"
:is-dark="isDark" :monacoOptions="{ theme: isDark ? 'vs-dark' : 'vs-light' }" />
<!-- Image --> <!-- Image -->
<div v-else-if="part.type === 'image' && part.embedded_url" class="embedded-images"> <div v-else-if="part.type === 'image' && part.embedded_url" class="embedded-images">
@@ -164,15 +193,25 @@
<div v-else-if="part.type === 'file' && part.embedded_file" class="embedded-files"> <div v-else-if="part.type === 'file' && part.embedded_file" class="embedded-files">
<div class="embedded-file"> <div class="embedded-file">
<a v-if="part.embedded_file.url" :href="part.embedded_file.url" <a v-if="part.embedded_file.url" :href="part.embedded_file.url"
:download="part.embedded_file.filename" class="file-link"> :download="part.embedded_file.filename" class="file-link"
<v-icon size="small" :class="{ 'is-dark': isDark }" :style="isDark ? {
class="file-icon">mdi-file-document-outline</v-icon> backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderColor: 'rgba(255, 255, 255, 0.1)',
color: 'var(--v-theme-secondary)'
} : {}">
<v-icon size="small" class="file-icon"
:style="isDark ? { color: 'var(--v-theme-secondary)' } : {}">mdi-file-document-outline</v-icon>
<span class="file-name">{{ part.embedded_file.filename }}</span> <span class="file-name">{{ part.embedded_file.filename }}</span>
</a> </a>
<a v-else @click="downloadFile(part.embedded_file)" <a v-else @click="downloadFile(part.embedded_file)"
class="file-link file-link-download"> class="file-link file-link-download" :class="{ 'is-dark': isDark }"
<v-icon size="small" :style="isDark ? {
class="file-icon">mdi-file-document-outline</v-icon> backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderColor: 'rgba(255, 255, 255, 0.1)',
color: 'var(--v-theme-secondary)'
} : {}">
<v-icon size="small" class="file-icon"
:style="isDark ? { color: 'var(--v-theme-secondary)' } : {}">mdi-file-document-outline</v-icon>
<span class="file-name">{{ part.embedded_file.filename }}</span> <span class="file-name">{{ part.embedded_file.filename }}</span>
<v-icon v-if="downloadingFiles.has(part.embedded_file.attachment_id)" <v-icon v-if="downloadingFiles.has(part.embedded_file.attachment_id)"
size="small" class="download-icon">mdi-loading mdi-spin</v-icon> size="small" class="download-icon">mdi-loading mdi-spin</v-icon>
@@ -185,33 +224,42 @@
</div> </div>
<div class="message-actions" v-if="!msg.content.isLoading || index === messages.length - 1"> <div class="message-actions" v-if="!msg.content.isLoading || index === messages.length - 1">
<span class="message-time" v-if="msg.created_at">{{ formatMessageTime(msg.created_at) <span class="message-time" v-if="msg.created_at">{{ formatMessageTime(msg.created_at)
}}</span> }}</span>
<!-- Agent Stats Menu --> <!-- Agent Stats Menu -->
<v-menu v-if="msg.content.agentStats" location="bottom" open-on-hover :close-on-content-click="false"> <v-menu v-if="msg.content.agentStats" location="bottom" open-on-hover
:close-on-content-click="false">
<template v-slot:activator="{ props }"> <template v-slot:activator="{ props }">
<v-icon v-bind="props" size="x-small" class="stats-info-icon">mdi-information-outline</v-icon> <v-icon v-bind="props" size="x-small"
class="stats-info-icon">mdi-information-outline</v-icon>
</template> </template>
<v-card class="stats-menu-card" variant="elevated" elevation="3"> <v-card class="stats-menu-card" variant="elevated" elevation="3">
<v-card-text class="stats-menu-content"> <v-card-text class="stats-menu-content">
<div class="stats-menu-row"> <div class="stats-menu-row">
<span class="stats-menu-label">{{ tm('stats.inputTokens') }}</span> <span class="stats-menu-label">{{ tm('stats.inputTokens') }}</span>
<span class="stats-menu-value">{{ getInputTokens(msg.content.agentStats.token_usage) }}</span> <span class="stats-menu-value">{{
getInputTokens(msg.content.agentStats.token_usage) }}</span>
</div> </div>
<div class="stats-menu-row"> <div class="stats-menu-row">
<span class="stats-menu-label">{{ tm('stats.outputTokens') }}</span> <span class="stats-menu-label">{{ tm('stats.outputTokens') }}</span>
<span class="stats-menu-value">{{ msg.content.agentStats.token_usage.output || 0 }}</span> <span class="stats-menu-value">{{ msg.content.agentStats.token_usage.output
|| 0 }}</span>
</div> </div>
<div class="stats-menu-row" v-if="msg.content.agentStats.token_usage.input_cached > 0"> <div class="stats-menu-row"
v-if="msg.content.agentStats.token_usage.input_cached > 0">
<span class="stats-menu-label">{{ tm('stats.cachedTokens') }}</span> <span class="stats-menu-label">{{ tm('stats.cachedTokens') }}</span>
<span class="stats-menu-value">{{ msg.content.agentStats.token_usage.input_cached }}</span> <span class="stats-menu-value">{{
msg.content.agentStats.token_usage.input_cached }}</span>
</div> </div>
<div class="stats-menu-row" v-if="msg.content.agentStats.time_to_first_token > 0"> <div class="stats-menu-row"
v-if="msg.content.agentStats.time_to_first_token > 0">
<span class="stats-menu-label">{{ tm('stats.ttft') }}</span> <span class="stats-menu-label">{{ tm('stats.ttft') }}</span>
<span class="stats-menu-value">{{ formatTTFT(msg.content.agentStats.time_to_first_token) }}</span> <span class="stats-menu-value">{{
formatTTFT(msg.content.agentStats.time_to_first_token) }}</span>
</div> </div>
<div class="stats-menu-row"> <div class="stats-menu-row">
<span class="stats-menu-label">{{ tm('stats.duration') }}</span> <span class="stats-menu-label">{{ tm('stats.duration') }}</span>
<span class="stats-menu-value">{{ formatAgentDuration(msg.content.agentStats) }}</span> <span class="stats-menu-value">{{
formatAgentDuration(msg.content.agentStats) }}</span>
</div> </div>
</v-card-text> </v-card-text>
</v-card> </v-card>
@@ -231,29 +279,20 @@
<script> <script>
import { useI18n, useModuleI18n } from '@/i18n/composables'; import { useI18n, useModuleI18n } from '@/i18n/composables';
import MarkdownIt from 'markdown-it'; import { MarkdownRender, enableKatex, enableMermaid } from 'markstream-vue'
import hljs from 'highlight.js'; import 'markstream-vue/index.css'
import 'katex/dist/katex.min.css'
import 'highlight.js/styles/github.css'; import 'highlight.js/styles/github.css';
import axios from 'axios'; import axios from 'axios';
const md = new MarkdownIt({ enableKatex();
html: false, enableMermaid();
breaks: true,
linkify: true,
highlight: function (code, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(code, { language: lang }).value;
} catch (err) {
console.error('Highlight error:', err);
}
}
return hljs.highlightAuto(code).value;
}
});
export default { export default {
name: 'MessageList', name: 'MessageList',
components: {
MarkdownRender
},
props: { props: {
messages: { messages: {
type: Array, type: Array,
@@ -266,6 +305,10 @@ export default {
isStreaming: { isStreaming: {
type: Boolean, type: Boolean,
default: false default: false
},
isLoadingMessages: {
type: Boolean,
default: false
} }
}, },
emits: ['openImagePreview', 'replyMessage'], emits: ['openImagePreview', 'replyMessage'],
@@ -275,8 +318,7 @@ export default {
return { return {
t, t,
tm, tm
md
}; };
}, },
data() { data() {
@@ -741,6 +783,29 @@ export default {
</script> </script>
<style scoped> <style scoped>
:deep(.hr-node) {
margin-top: 1.25rem;
margin-bottom: 1.25rem;
opacity: 0.5;
border-top-width: .3px;
}
:deep(.paragraph-node) {
margin: .5rem 0;
line-height: 1.7;
margin-block: 1rem;
}
:deep(.list-node) {
margin-top: .5rem;
margin-bottom: .5rem;
}
:deep(.mermaid-block-header) {
gap: 8px;
}
/* 基础动画 */ /* 基础动画 */
@keyframes fadeIn { @keyframes fadeIn {
from { from {
@@ -763,6 +828,31 @@ export default {
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
position: relative;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
z-index: 10;
background-color: rgba(255, 255, 255, 0.7);
transition: opacity 0.3s ease;
}
.loading-overlay.is-dark {
background-color: rgba(30, 30, 30, 0.7);
}
.message-list.loading-blur {
opacity: 0.5;
transition: opacity 0.3s ease;
pointer-events: none;
} }
.message-bubble { .message-bubble {
@@ -770,14 +860,70 @@ export default {
border-radius: 12px; border-radius: 12px;
} }
.loading-container {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
margin-top: 8px;
}
.loading-text {
font-size: 14px;
color: var(--v-theme-secondaryText);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 0.6;
}
50% {
opacity: 1;
}
}
@media (max-width: 768px) { @media (max-width: 768px) {
.messages-container { .messages-container {
padding: 8px;
}
.message-list {
max-width: 100%;
}
.message-item {
padding: 0; padding: 0;
} }
.message-bubble { .message-bubble {
padding: 2px 8px; padding: 2px 12px;
}
.bot-message {
flex-direction: column;
align-items: flex-start;
gap: 8px;
width: 100%;
}
.bot-message-content {
max-width: 100% !important;
width: 100% !important;
}
.bot-bubble {
width: 100% !important;
max-width: 100% !important;
}
.bot-avatar {
margin-left: 4px;
} }
} }
@@ -943,14 +1089,15 @@ export default {
.bot-bubble { .bot-bubble {
border: 1px solid var(--v-theme-border); border: 1px solid var(--v-theme-border);
color: var(--v-theme-primaryText); color: var(--v-theme-primaryText);
font-size: 15px; font-size: 16px;
max-width: 100%; max-width: 100%;
padding-left: 12px;
} }
.user-avatar, .user-avatar,
.bot-avatar { .bot-avatar {
align-self: flex-start; align-self: flex-start;
margin-top: 6px; margin-top: 12px;
} }
/* 附件样式 */ /* 附件样式 */
@@ -1072,19 +1219,9 @@ export default {
white-space: nowrap; white-space: nowrap;
} }
.v-theme--dark .file-link { .file-link.is-dark:hover {
background-color: rgba(255, 255, 255, 0.05); background-color: rgba(255, 255, 255, 0.1) !important;
border-color: rgba(255, 255, 255, 0.1); border-color: rgba(255, 255, 255, 0.2) !important;
color: var(--v-theme-secondary);
}
.v-theme--dark .file-link:hover {
background-color: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
}
.v-theme--dark .file-icon {
color: var(--v-theme-secondary);
} }
/* 动画类 */ /* 动画类 */
@@ -1097,15 +1234,11 @@ export default {
margin-bottom: 12px; margin-bottom: 12px;
margin-top: 6px; margin-top: 6px;
border: 1px solid var(--v-theme-border); border: 1px solid var(--v-theme-border);
border-radius: 8px; border-radius: 20px;
overflow: hidden; overflow: hidden;
width: fit-content; width: fit-content;
} }
.v-theme--dark .reasoning-container {
background-color: rgba(103, 58, 183, 0.08);
}
.reasoning-header { .reasoning-header {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -1113,14 +1246,14 @@ export default {
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
transition: background-color 0.2s ease; transition: background-color 0.2s ease;
border-radius: 8px; border-radius: 20px;
} }
.reasoning-header:hover { .reasoning-header:hover {
background-color: rgba(103, 58, 183, 0.08); background-color: rgba(103, 58, 183, 0.08);
} }
.v-theme--dark .reasoning-header:hover { .reasoning-header.is-dark:hover {
background-color: rgba(103, 58, 183, 0.15); background-color: rgba(103, 58, 183, 0.15);
} }
@@ -1151,10 +1284,6 @@ export default {
color: var(--v-theme-secondaryText); color: var(--v-theme-secondaryText);
} }
.v-theme--dark .reasoning-text {
opacity: 0.85;
}
/* Tool Call Card Styles */ /* Tool Call Card Styles */
.tool-calls-container { .tool-calls-container {
display: flex; display: flex;
@@ -1171,11 +1300,6 @@ export default {
margin: 8px 0px; margin: 8px 0px;
} }
.v-theme--dark .tool-call-card {
background-color: rgba(40, 60, 100, 0.4);
border-color: rgba(100, 140, 200, 0.4);
}
.tool-call-header { .tool-call-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1190,7 +1314,7 @@ export default {
background-color: rgba(169, 194, 219, 0.15); background-color: rgba(169, 194, 219, 0.15);
} }
.v-theme--dark .tool-call-header:hover { .tool-call-header.is-dark:hover {
background-color: rgba(100, 150, 200, 0.2); background-color: rgba(100, 150, 200, 0.2);
} }
@@ -1270,11 +1394,6 @@ export default {
animation: fadeIn 0.2s ease-in-out; animation: fadeIn 0.2s ease-in-out;
} }
.v-theme--dark .tool-call-details {
border-top-color: rgba(100, 140, 200, 0.3);
background-color: rgba(30, 45, 70, 0.5);
}
.tool-call-detail-row { .tool-call-detail-row {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1315,272 +1434,14 @@ export default {
max-height: 300px; max-height: 300px;
background-color: transparent; background-color: transparent;
} }
.v-theme--dark .detail-value {
background-color: transparent;
}
.v-theme--dark .detail-result {
background-color: transparent;
}
</style> </style>
<style> <style>
/* Markdown内容样式 - 需要全局样式 */
.markdown-content { .markdown-content {
font-family: inherit; max-width: 100%;
line-height: 1.6; line-height: 1.6;
} }
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
margin-top: 16px;
margin-bottom: 10px;
font-weight: 600;
color: var(--v-theme-primaryText);
}
.markdown-content h1 {
font-size: 1.8em;
border-bottom: 1px solid var(--v-theme-border);
padding-bottom: 6px;
}
.markdown-content h2 {
font-size: 1.5em;
}
.markdown-content h3 {
font-size: 1.3em;
}
.markdown-content li {
margin-left: 16px;
margin-bottom: 4px;
}
.markdown-content p {
margin-top: .5rem;
margin-bottom: .5rem;
}
.markdown-content pre {
background-color: var(--v-theme-surface);
padding: 12px;
border-radius: 6px;
overflow-x: auto;
margin: 12px 0;
position: relative;
}
.markdown-content code {
background-color: rgb(var(--v-theme-codeBg));
padding: 2px 4px;
border-radius: 4px;
font-family: 'Fira Code', monospace;
font-size: 0.9em;
color: var(--v-theme-code);
}
/* 代码块中的code标签样式 */
.markdown-content pre code {
background-color: transparent;
padding: 0;
border-radius: 0;
font-family: 'Fira Code', 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 0.85em;
color: inherit;
display: block;
overflow-x: auto;
line-height: 1.5;
}
/* 自定义代码高亮样式 */
.markdown-content pre {
border: 1px solid var(--v-theme-border);
background-color: rgb(var(--v-theme-preBg));
border-radius: 16px;
padding: 16px;
}
/* 确保highlight.js的样式正确应用 */
.markdown-content pre code.hljs {
background: transparent !important;
color: inherit;
}
/* 亮色主题下的代码高亮 */
.v-theme--light .markdown-content pre {
background-color: #f6f8fa;
}
/* 暗色主题下的代码块样式 */
.v-theme--dark .markdown-content pre {
background-color: #0d1117 !important;
border-color: rgba(255, 255, 255, 0.1);
}
.v-theme--dark .markdown-content pre code {
color: #e6edf3 !important;
}
/* 暗色主题下的highlight.js样式覆盖 */
.v-theme--dark .hljs {
background: #0d1117 !important;
color: #e6edf3 !important;
}
.v-theme--dark .hljs-keyword,
.v-theme--dark .hljs-selector-tag,
.v-theme--dark .hljs-built_in,
.v-theme--dark .hljs-name,
.v-theme--dark .hljs-tag {
color: #ff7b72 !important;
}
.v-theme--dark .hljs-string,
.v-theme--dark .hljs-title,
.v-theme--dark .hljs-section,
.v-theme--dark .hljs-attribute,
.v-theme--dark .hljs-literal,
.v-theme--dark .hljs-template-tag,
.v-theme--dark .hljs-template-variable,
.v-theme--dark .hljs-type,
.v-theme--dark .hljs-addition {
color: #a5d6ff !important;
}
.v-theme--dark .hljs-comment,
.v-theme--dark .hljs-quote,
.v-theme--dark .hljs-deletion,
.v-theme--dark .hljs-meta {
color: #8b949e !important;
}
.v-theme--dark .hljs-number,
.v-theme--dark .hljs-regexp,
.v-theme--dark .hljs-symbol,
.v-theme--dark .hljs-variable,
.v-theme--dark .hljs-template-variable,
.v-theme--dark .hljs-link,
.v-theme--dark .hljs-selector-attr,
.v-theme--dark .hljs-selector-pseudo {
color: #79c0ff !important;
}
.v-theme--dark .hljs-function,
.v-theme--dark .hljs-class,
.v-theme--dark .hljs-title.class_ {
color: #d2a8ff !important;
}
/* 复制按钮样式 */
.copy-code-btn {
position: absolute;
top: 8px;
right: 8px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 4px;
padding: 6px;
cursor: pointer;
opacity: 0;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
color: #666;
font-size: 12px;
z-index: 10;
backdrop-filter: blur(4px);
}
.copy-code-btn:hover {
background: rgba(255, 255, 255, 1);
color: #333;
transform: scale(1.05);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.copy-code-btn:active {
transform: scale(0.95);
}
.markdown-content pre:hover .copy-code-btn {
opacity: 1;
}
.v-theme--dark .copy-code-btn {
background: rgba(45, 45, 45, 0.9);
border-color: rgba(255, 255, 255, 0.15);
color: #ccc;
}
.v-theme--dark .copy-code-btn:hover {
background: rgba(45, 45, 45, 1);
color: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.markdown-content img {
max-width: 100%;
border-radius: 8px;
margin: 10px 0;
}
.loading-container {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
margin-top: 2px;
}
.loading-text {
font-size: 14px;
color: var(--v-theme-secondaryText);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 0.6;
}
50% {
opacity: 1;
}
}
.markdown-content blockquote {
border-left: 4px solid var(--v-theme-secondary);
padding-left: 16px;
color: var(--v-theme-secondaryText);
margin: 16px 0;
}
.markdown-content table {
border-collapse: collapse;
width: 100%;
margin: 16px 0;
}
.markdown-content th,
.markdown-content td {
border: 1px solid var(--v-theme-background);
padding: 8px 12px;
text-align: left;
}
.markdown-content th {
background-color: var(--v-theme-containerBg);
}
/* Stats Menu 样式 */ /* Stats Menu 样式 */
.stats-menu-card { .stats-menu-card {
@@ -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">
<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>
@@ -1,14 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useModuleI18n } from '@/i18n/composables'; import { useModuleI18n } from '@/i18n/composables';
import type { CommandItem } from '../types'; import type { CommandItem } from '../types';
const { tm } = useModuleI18n('features/command'); const { tm } = useModuleI18n('features/command');
// Props // Props
defineProps<{ const props = defineProps<{
show: boolean; show: boolean;
command: CommandItem | null; command: CommandItem | null;
newName: string; newName: string;
aliases: string[];
loading: boolean; loading: boolean;
}>(); }>();
@@ -16,8 +18,42 @@ defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:show', value: boolean): void; (e: 'update:show', value: boolean): void;
(e: 'update:newName', value: string): void; (e: 'update:newName', value: string): void;
(e: 'update:aliases', value: string[]): void;
(e: 'confirm'): 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> </script>
<template> <template>
@@ -32,7 +68,49 @@ const emit = defineEmits<{
variant="outlined" variant="outlined"
density="compact" density="compact"
autofocus 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-text>
<v-card-actions> <v-card-actions>
<v-spacer /> <v-spacer />
@@ -14,6 +14,7 @@ export function useCommandActions(
show: false, show: false,
command: null, command: null,
newName: '', newName: '',
aliases: [],
loading: false loading: false
}); });
@@ -53,6 +54,7 @@ export function useCommandActions(
const openRenameDialog = (cmd: CommandItem) => { const openRenameDialog = (cmd: CommandItem) => {
renameDialog.command = cmd; renameDialog.command = cmd;
renameDialog.newName = cmd.current_fragment || ''; renameDialog.newName = cmd.current_fragment || '';
renameDialog.aliases = [...(cmd.aliases || [])];
renameDialog.show = true; renameDialog.show = true;
}; };
@@ -66,7 +68,8 @@ export function useCommandActions(
try { try {
const res = await axios.post('/api/commands/rename', { const res = await axios.post('/api/commands/rename', {
handler_full_name: renameDialog.command.handler_full_name, handler_full_name: renameDialog.command.handler_full_name,
new_name: renameDialog.newName.trim() new_name: renameDialog.newName.trim(),
aliases: renameDialog.aliases.filter(a => a.trim())
}); });
if (res.data.status === 'ok') { if (res.data.status === 'ok') {
toast(successMessage, 'success'); toast(successMessage, 'success');
@@ -288,6 +288,8 @@ watch(viewMode, async (mode) => {
@update:show="renameDialog.show = $event" @update:show="renameDialog.show = $event"
:new-name="renameDialog.newName" :new-name="renameDialog.newName"
@update:new-name="renameDialog.newName = $event" @update:new-name="renameDialog.newName = $event"
:aliases="renameDialog.aliases"
@update:aliases="renameDialog.aliases = $event"
:command="renameDialog.command" :command="renameDialog.command"
:loading="renameDialog.loading" :loading="renameDialog.loading"
@confirm="handleConfirmRename" @confirm="handleConfirmRename"
@@ -52,6 +52,7 @@ export interface RenameDialogState {
show: boolean; show: boolean;
command: CommandItem | null; command: CommandItem | null;
newName: string; newName: string;
aliases: string[];
loading: boolean; loading: boolean;
} }
@@ -394,6 +394,9 @@ export default {
// //
showConfigDrawer: false, showConfigDrawer: false,
configDrawerTargetId: null, configDrawerTargetId: null,
// ID ID
originalUpdatingPlatformId: null,
}; };
}, },
setup() { setup() {
@@ -481,6 +484,7 @@ export default {
updatingPlatformConfig: { updatingPlatformConfig: {
handler(newConfig) { handler(newConfig) {
if (this.updatingMode && newConfig && newConfig.id) { if (this.updatingMode && newConfig && newConfig.id) {
this.originalUpdatingPlatformId = newConfig.id;
this.getPlatformConfigs(newConfig.id); this.getPlatformConfigs(newConfig.id);
} }
}, },
@@ -533,6 +537,8 @@ export default {
this.showConfigDrawer = false; this.showConfigDrawer = false;
this.configDrawerTargetId = null; this.configDrawerTargetId = null;
this.originalUpdatingPlatformId = null;
}, },
closeDialog() { closeDialog() {
this.resetForm(); this.resetForm();
@@ -624,7 +630,7 @@ export default {
} }
}, },
async updatePlatform() { async updatePlatform() {
let id = this.updatingPlatformConfig.id; const id = this.originalUpdatingPlatformId || this.updatingPlatformConfig.id;
if (!id) { if (!id) {
this.loading = false; this.loading = false;
this.showError('更新失败,缺少平台 ID。'); this.showError('更新失败,缺少平台 ID。');
@@ -633,10 +639,14 @@ export default {
try { try {
// //
await axios.post('/api/config/platform/update', { let resp = await axios.post('/api/config/platform/update', {
id: id, id: id,
config: this.updatingPlatformConfig config: this.updatingPlatformConfig
}); })
if (resp.data.status === 'error') {
throw new Error(resp.data.message || '平台更新失败');
}
// //
await this.saveRoutesInternal(); await this.saveRoutesInternal();
@@ -885,7 +895,10 @@ export default {
// //
async saveRoutesInternal() { async saveRoutesInternal() {
if (!this.updatingPlatformConfig || !this.updatingPlatformConfig.id) { const originalPlatformId = this.originalUpdatingPlatformId || this.updatingPlatformConfig?.id;
const newPlatformId = this.updatingPlatformConfig?.id || originalPlatformId;
if (!originalPlatformId && !newPlatformId) {
throw new Error('无法获取平台 ID'); throw new Error('无法获取平台 ID');
} }
@@ -895,9 +908,11 @@ export default {
const fullRoutingTable = routesRes.data.data.routing; const fullRoutingTable = routesRes.data.data.routing;
// //
const platformId = this.updatingPlatformConfig.id;
for (const umop in fullRoutingTable) { for (const umop in fullRoutingTable) {
if (this.isUmopMatchPlatform(umop, platformId)) { if (
(originalPlatformId && this.isUmopMatchPlatform(umop, originalPlatformId)) ||
(newPlatformId && this.isUmopMatchPlatform(umop, newPlatformId))
) {
delete fullRoutingTable[umop]; delete fullRoutingTable[umop];
} }
} }
@@ -906,7 +921,8 @@ export default {
for (const route of this.platformRoutes) { for (const route of this.platformRoutes) {
const messageType = route.messageType === '*' ? '*' : route.messageType; const messageType = route.messageType === '*' ? '*' : route.messageType;
const sessionId = route.sessionId === '*' ? '*' : route.sessionId; const sessionId = route.sessionId === '*' ? '*' : route.sessionId;
const newUmop = `${platformId}:${messageType}:${sessionId}`; const platformIdForRoute = newPlatformId || originalPlatformId;
const newUmop = `${platformIdForRoute}:${messageType}:${sessionId}`;
if (route.configId) { if (route.configId) {
fullRoutingTable[newUmop] = route.configId; fullRoutingTable[newUmop] = route.configId;
@@ -3,10 +3,6 @@
<v-card :title="tm('dialogs.addProvider.title')"> <v-card :title="tm('dialogs.addProvider.title')">
<v-card-text style="overflow-y: auto;"> <v-card-text style="overflow-y: auto;">
<v-tabs v-model="activeProviderTab" grow> <v-tabs v-model="activeProviderTab" grow>
<v-tab value="chat_completion" class="font-weight-medium px-3">
<v-icon start>mdi-message-text</v-icon>
{{ tm('dialogs.addProvider.tabs.basic') }}
</v-tab>
<v-tab value="agent_runner" class="font-weight-medium px-3"> <v-tab value="agent_runner" class="font-weight-medium px-3">
<v-icon start>mdi-cogs</v-icon> <v-icon start>mdi-cogs</v-icon>
{{ tm('dialogs.addProvider.tabs.agentRunner') }} {{ tm('dialogs.addProvider.tabs.agentRunner') }}
@@ -116,7 +112,7 @@ export default {
// //
getTemplatesByType(type) { getTemplatesByType(type) {
const templates = this.metadata['provider_group']?.metadata?.provider?.config_template || {}; const templates = this.metadata.provider.config_template || {};
const filtered = {}; const filtered = {};
for (const [name, template] of Object.entries(templates)) { for (const [name, template] of Object.entries(templates)) {
@@ -0,0 +1,211 @@
<template>
<div class="mt-4">
<div class="d-flex align-center ga-2 mb-2">
<h3 class="text-h5 font-weight-bold mb-0">{{ tm('models.configured') }}</h3>
<small style="color: grey;" v-if="availableCount">{{ tm('models.available') }} {{ availableCount }}</small>
<v-text-field
v-model="modelSearchProxy"
density="compact"
prepend-inner-icon="mdi-magnify"
hide-details
variant="solo-filled"
flat
class="ml-1"
style="max-width: 240px;"
:placeholder="tm('models.searchPlaceholder')"
/>
<v-spacer></v-spacer>
<v-btn
color="primary"
prepend-icon="mdi-download"
:loading="loadingModels"
@click="emit('fetch-models')"
variant="tonal"
size="small"
>
{{ isSourceModified ? tm('providerSources.saveAndFetchModels') : tm('providerSources.fetchModels') }}
</v-btn>
<v-btn
color="primary"
prepend-icon="mdi-pencil-plus"
variant="text"
size="small"
class="ml-1"
@click="emit('open-manual-model')"
>
{{ tm('models.manualAddButton') }}
</v-btn>
</div>
<v-list
density="compact"
class="rounded-lg border"
style="max-height: 520px; overflow-y: auto; font-family:system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;"
>
<template v-if="entries.length > 0">
<template v-for="entry in entries" :key="entry.type === 'configured' ? `provider-${entry.provider.id}` : `model-${entry.model}`">
<v-list-item
v-if="entry.type === 'configured'"
class="provider-compact-item"
@click="emit('open-provider-edit', entry.provider)"
>
<v-list-item-title class="font-weight-medium text-truncate">
{{ entry.provider.id }}
</v-list-item-title>
<v-list-item-subtitle class="text-caption text-grey d-flex align-center ga-1" style="font-family: monospace;">
<span>{{ entry.provider.model }}</span>
<v-icon v-if="supportsImageInput(entry.metadata)" size="14" color="grey">
mdi-eye-outline
</v-icon>
<v-icon v-if="supportsToolCall(entry.metadata)" size="14" color="grey">
mdi-wrench
</v-icon>
<v-icon v-if="supportsReasoning(entry.metadata)" size="14" color="grey">
mdi-brain
</v-icon>
<span v-if="formatContextLimit(entry.metadata)">
{{ formatContextLimit(entry.metadata) }}
</span>
</v-list-item-subtitle>
<template #append>
<div class="d-flex align-center ga-1" @click.stop>
<v-switch
v-model="entry.provider.enable"
density="compact"
inset
hide-details
color="primary"
class="mr-1"
@update:modelValue="emit('toggle-provider-enable', entry.provider, $event)"
></v-switch>
<v-tooltip location="top" max-width="300">
{{ tm('availability.test') }}
<template #activator="{ props }">
<v-btn
icon="mdi-wrench"
size="small"
variant="text"
:disabled="!entry.provider.enable"
:loading="isProviderTesting(entry.provider.id)"
v-bind="props"
@click.stop="emit('test-provider', entry.provider)"
></v-btn>
</template>
</v-tooltip>
<v-btn icon="mdi-delete" size="small" variant="text" color="error" @click.stop="emit('delete-provider', entry.provider)"></v-btn>
</div>
</template>
</v-list-item>
<v-list-item v-else class="cursor-pointer" @click="emit('add-model-provider', entry.model)">
<v-list-item-title>{{ entry.model }}</v-list-item-title>
<v-list-item-subtitle class="text-caption text-grey d-flex align-center ga-1">
<span>{{ entry.model }}</span>
<v-icon v-if="supportsImageInput(entry.metadata)" size="14" color="grey">
mdi-eye-outline
</v-icon>
<v-icon v-if="supportsToolCall(entry.metadata)" size="14" color="grey">
mdi-wrench
</v-icon>
<v-icon v-if="supportsReasoning(entry.metadata)" size="14" color="grey">
mdi-brain
</v-icon>
<span v-if="formatContextLimit(entry.metadata)">
{{ formatContextLimit(entry.metadata) }}
</span>
</v-list-item-subtitle>
<template #append>
<v-btn icon="mdi-plus" size="small" variant="text" color="primary"></v-btn>
</template>
</v-list-item>
</template>
</template>
<template v-else>
<div class="text-center pa-4 text-medium-emphasis">
<v-icon size="48" color="grey-lighten-1">mdi-package-variant</v-icon>
<p class="text-grey mt-2">{{ tm('models.empty') }}</p>
</div>
</template>
</v-list>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
entries: {
type: Array,
default: () => []
},
availableCount: {
type: Number,
default: 0
},
modelSearch: {
type: String,
default: ''
},
loadingModels: {
type: Boolean,
default: false
},
isSourceModified: {
type: Boolean,
default: false
},
supportsImageInput: {
type: Function,
required: true
},
supportsToolCall: {
type: Function,
required: true
},
supportsReasoning: {
type: Function,
required: true
},
formatContextLimit: {
type: Function,
required: true
},
testingProviders: {
type: Array,
default: () => []
},
tm: {
type: Function,
required: true
}
})
const emit = defineEmits([
'update:modelSearch',
'fetch-models',
'open-manual-model',
'open-provider-edit',
'toggle-provider-enable',
'test-provider',
'delete-provider',
'add-model-provider'
])
const modelSearchProxy = computed({
get: () => props.modelSearch,
set: (val) => emit('update:modelSearch', val)
})
const isProviderTesting = (providerId) => props.testingProviders.includes(providerId)
</script>
<style scoped>
.border {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.cursor-pointer {
cursor: pointer;
}
</style>
@@ -0,0 +1,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 --> <!-- Regular Property -->
<template v-else> <template v-else>
<v-row v-if="!metadata[metadataKey].items[key]?.invisible && shouldShowItem(metadata[metadataKey].items[key], key)" class="config-row"> <v-row v-if="!metadata[metadataKey].items[key]?.invisible && shouldShowItem(metadata[metadataKey].items[key], key)" class="config-row">
<v-col cols="12" sm="7" class="property-info"> <v-col cols="12" sm="6" class="property-info">
<v-list-item density="compact"> <v-list-item density="compact">
<v-list-item-title class="property-name"> <v-list-item-title class="property-name">
<span v-if="metadata[metadataKey].items[key]?.description"> <span v-if="metadata[metadataKey].items[key]?.description">
@@ -180,7 +180,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
</v-list-item> </v-list-item>
</v-col> </v-col>
<v-col cols="12" sm="5" class="config-input"> <v-col cols="12" sm="6" class="config-input">
<div v-if="metadata[metadataKey].items[key]" class="w-100"> <div v-if="metadata[metadataKey].items[key]" class="w-100">
<!-- Special handling for specific metadata types --> <!-- Special handling for specific metadata types -->
<div v-if="metadata[metadataKey].items[key]?._special === 'select_provider'"> <div v-if="metadata[metadataKey].items[key]?._special === 'select_provider'">
@@ -540,6 +540,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
font-size: 0.85em; font-size: 0.85em;
opacity: 0.7; opacity: 0.7;
font-weight: normal; font-weight: normal;
display: none;
} }
.important-hint { .important-hint {
@@ -573,7 +574,6 @@ function hasVisibleItemsAfter(items, currentIndex) {
align-items: center; align-items: center;
padding: 4px 8px; padding: 4px 8px;
border-radius: 4px; border-radius: 4px;
transition: background-color 0.2s;
} }
.config-row:hover { .config-row:hover {
@@ -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> <template>
<v-menu offset="12" location="bottom center"> <StyledMenu offset="12" location="bottom center">
<template v-slot:activator="{ props: activatorProps }"> <template v-slot:activator="{ props: activatorProps }">
<v-btn <v-btn
v-bind="activatorProps" v-bind="activatorProps"
@@ -22,25 +22,21 @@
</v-btn> </v-btn>
</template> </template>
<v-card class="language-dropdown" elevation="8" rounded="lg"> <v-list-item
<v-list density="compact" class="pa-1"> v-for="lang in languages"
<v-list-item :key="lang.code"
v-for="lang in languages" :value="lang.code"
:key="lang.code" @click="changeLanguage(lang.code)"
:value="lang.code" :class="{ 'styled-menu-item-active': currentLocale === lang.code }"
@click="changeLanguage(lang.code)" class="styled-menu-item"
:class="{ 'v-list-item--active': currentLocale === lang.code, 'language-item-selected': currentLocale === lang.code }" rounded="md"
class="language-item" >
rounded="md" <template v-slot:prepend>
> <span class="language-flag">{{ lang.flag }}</span>
<template v-slot:prepend> </template>
<span class="language-flag">{{ lang.flag }}</span> <v-list-item-title>{{ lang.name }}</v-list-item-title>
</template> </v-list-item>
<v-list-item-title>{{ lang.name }}</v-list-item-title> </StyledMenu>
</v-list-item>
</v-list>
</v-card>
</v-menu>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -48,6 +44,7 @@ import { computed } from 'vue'
import { useI18n, useLanguageSwitcher } from '@/i18n/composables' import { useI18n, useLanguageSwitcher } from '@/i18n/composables'
import { useCustomizerStore } from '@/stores/customizer' import { useCustomizerStore } from '@/stores/customizer'
import type { Locale } from '@/i18n/types' import type { Locale } from '@/i18n/types'
import StyledMenu from '@/components/shared/StyledMenu.vue'
// props // props
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
@@ -110,49 +107,4 @@ const changeLanguage = async (langCode: string) => {
:deep(.v-theme--PurpleThemeDark) .language-switcher--default:hover { :deep(.v-theme--PurpleThemeDark) .language-switcher--default:hover {
background: rgba(114, 46, 209, 0.12) !important; background: rgba(114, 46, 209, 0.12) !important;
} }
.language-dropdown {
min-width: 100px;
width: fit-content;
border: 1px solid rgba(94, 53, 177, 0.15) !important;
background: #f8f6fc !important;
backdrop-filter: blur(10px);
}
/* 深色模式下的下拉框样式 */
:deep(.v-theme--PurpleThemeDark) .language-dropdown {
background: #2a2733 !important;
border: 1px solid rgba(110, 60, 180, 0.692) !important;
}
.language-item {
margin: 2px 0;
transition: all 0.2s ease;
}
.language-item:hover {
background: rgba(94, 53, 177, 0.08) !important;
}
.language-item-selected {
background: rgba(94, 53, 177, 0.15) !important;
font-weight: 500;
}
.language-item-selected:hover {
background: rgba(94, 53, 177, 0.2) !important;
}
/* 深色模式下的列表项悬停效果 */
:deep(.v-theme--PurpleThemeDark) .language-item:hover {
background: rgba(114, 46, 209, 0.12) !important;
}
:deep(.v-theme--PurpleThemeDark) .language-item-selected {
background: rgba(114, 46, 209, 0.2) !important;
}
:deep(.v-theme--PurpleThemeDark) .language-item-selected:hover {
background: rgba(114, 46, 209, 0.25) !important;
}
</style> </style>
@@ -1,6 +1,15 @@
<template> <template>
<div class="d-flex align-center justify-space-between"> <div class="d-flex align-center justify-space-between ga-2">
<div> <div v-if="isSingleItemMode" class="flex-grow-1 d-flex align-center ga-2">
<v-text-field
v-model="singleItemValue"
hide-details
variant="outlined"
density="compact"
class="flex-grow-1"
></v-text-field>
</div>
<div v-else>
<span v-if="!modelValue || modelValue.length === 0" style="color: rgb(var(--v-theme-primaryText));"> <span v-if="!modelValue || modelValue.length === 0" style="color: rgb(var(--v-theme-primaryText));">
{{ t('core.common.list.noItems') }} {{ t('core.common.list.noItems') }}
</span> </span>
@@ -14,7 +23,7 @@
</div> </div>
</div> </div>
<v-btn size="small" color="primary" variant="tonal" @click="openDialog"> <v-btn size="small" color="primary" variant="tonal" @click="openDialog">
{{ buttonText || t('core.common.list.modifyButton') }} {{ preferSingleItem ? '添加更多' : (buttonText || t('core.common.list.modifyButton')) }}
</v-btn> </v-btn>
</div> </div>
@@ -167,6 +176,10 @@ const props = defineProps({
maxDisplayItems: { maxDisplayItems: {
type: Number, type: Number,
default: 1 default: 1
},
preferSingleItem: {
type: Boolean,
default: true
} }
}) })
@@ -180,6 +193,21 @@ const editIndex = ref(-1)
const editItem = ref('') const editItem = ref('')
const showBatchImport = ref(false) const showBatchImport = ref(false)
const batchImportText = ref('') const batchImportText = ref('')
const isSingleItemMode = computed(() => (props.modelValue?.length ?? 0) <= 1 && props.preferSingleItem)
const singleItemValue = computed({
get: () => props.modelValue?.[0] ?? '',
set: (value) => {
const newItems = [...(props.modelValue || [])]
if (newItems.length === 0) {
newItems.push(value)
} else {
newItems[0] = value
}
emit('update:modelValue', newItems)
}
})
// //
const displayItems = computed(() => { const displayItems = computed(() => {
@@ -14,8 +14,20 @@
<!-- Provider Selection Dialog --> <!-- Provider Selection Dialog -->
<v-dialog v-model="dialog" max-width="600px"> <v-dialog v-model="dialog" max-width="600px">
<v-card> <v-card>
<v-card-title class="text-h3 py-4" style="font-weight: normal;"> <v-card-title
{{ tm('providerSelector.dialogTitle') }} class="text-h3 py-4 d-flex align-center justify-space-between gap-4 flex-wrap"
style="font-weight: normal;"
>
<span>{{ tm('providerSelector.dialogTitle') }}</span>
<v-btn
size="small"
color="primary"
variant="tonal"
prepend-icon="mdi-plus"
@click="openProviderDrawer"
>
{{ tm('providerSelector.createProvider') }}
</v-btn>
</v-card-title> </v-card-title>
<v-card-text class="pa-0" style="max-height: 400px; overflow-y: auto;"> <v-card-text class="pa-0" style="max-height: 400px; overflow-y: auto;">
@@ -51,7 +63,7 @@
<v-list-item-title>{{ provider.id }}</v-list-item-title> <v-list-item-title>{{ provider.id }}</v-list-item-title>
<v-list-item-subtitle> <v-list-item-subtitle>
{{ provider.type || provider.provider_type || tm('providerSelector.unknownType') }} {{ provider.type || provider.provider_type || tm('providerSelector.unknownType') }}
<span v-if="provider.model_config?.model">- {{ provider.model_config.model }}</span> <span v-if="provider.model">- {{ provider.model }}</span>
</v-list-item-subtitle> </v-list-item-subtitle>
<template v-slot:append> <template v-slot:append>
@@ -79,12 +91,33 @@
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-dialog> </v-dialog>
<v-overlay
v-model="providerDrawer"
class="provider-drawer-overlay"
location="right"
transition="slide-x-reverse-transition"
:scrim="true"
@click:outside="closeProviderDrawer"
>
<v-card class="provider-drawer-card" elevation="12">
<div class="provider-drawer-header">
<v-btn icon variant="text" @click="closeProviderDrawer">
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
<div class="provider-drawer-content">
<ProviderPage :default-tab="defaultTab" />
</div>
</v-card>
</v-overlay>
</template> </template>
<script setup> <script setup>
import { ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import axios from 'axios' import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables' import { useModuleI18n } from '@/i18n/composables'
import ProviderPage from '@/views/ProviderPage.vue'
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
@@ -112,12 +145,26 @@ const dialog = ref(false)
const providerList = ref([]) const providerList = ref([])
const loading = ref(false) const loading = ref(false)
const selectedProvider = ref('') const selectedProvider = ref('')
const providerDrawer = ref(false)
const defaultTab = computed(() => {
if (props.providerType === 'agent_runner' && props.providerSubtype) {
return `select_agent_runner_provider:${props.providerSubtype}`
}
return props.providerType || 'chat_completion'
})
// modelValue selectedProvider // modelValue selectedProvider
watch(() => props.modelValue, (newValue) => { watch(() => props.modelValue, (newValue) => {
selectedProvider.value = newValue || '' selectedProvider.value = newValue || ''
}, { immediate: true }) }, { immediate: true })
watch(providerDrawer, (isOpen, wasOpen) => {
if (!isOpen && wasOpen) {
loadProviders()
}
})
async function openDialog() { async function openDialog() {
selectedProvider.value = props.modelValue || '' selectedProvider.value = props.modelValue || ''
dialog.value = true dialog.value = true
@@ -170,6 +217,14 @@ function cancelSelection() {
selectedProvider.value = props.modelValue || '' selectedProvider.value = props.modelValue || ''
dialog.value = false dialog.value = false
} }
function openProviderDrawer() {
providerDrawer.value = true
}
function closeProviderDrawer() {
providerDrawer.value = false
}
</script> </script>
<style scoped> <style scoped>
@@ -184,4 +239,35 @@ function cancelSelection() {
.v-list-item.v-list-item--active { .v-list-item.v-list-item--active {
background-color: rgba(var(--v-theme-primary), 0.08); background-color: rgba(var(--v-theme-primary), 0.08);
} }
.provider-drawer-overlay {
align-items: stretch;
justify-content: flex-end;
}
.provider-drawer-card {
width: clamp(360px, 70vw, 1200px);
height: calc(100vh - 32px);
margin: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.provider-drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 12px 20px;
}
.provider-drawer-content {
flex: 1;
overflow: hidden;
}
.provider-drawer-content > * {
height: 100%;
overflow: auto;
}
</style> </style>
@@ -1,11 +1,15 @@
<script setup> <script setup>
import { ref, watch, onMounted, computed } from 'vue'; import { ref, watch, onMounted, computed } from 'vue';
import axios from 'axios'; import axios from 'axios';
import MarkdownIt from 'markdown-it'; import { MarkdownRender, enableKatex, enableMermaid } from 'markstream-vue';
import hljs from 'highlight.js'; import 'markstream-vue/index.css';
import 'katex/dist/katex.min.css';
import 'highlight.js/styles/github.css'; import 'highlight.js/styles/github.css';
import { useI18n } from '@/i18n/composables'; import { useI18n } from '@/i18n/composables';
enableKatex();
enableMermaid();
const props = defineProps({ const props = defineProps({
show: { show: {
type: Boolean, type: Boolean,
@@ -74,29 +78,6 @@ function openRepoInNewTab() {
} }
} }
// markdown-it
const md = new MarkdownIt({
html: true, // HTML
breaks: true, // <br>
linkify: true, //
typographer: false, //
highlight: function(code, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(code, { language: lang }).value;
} catch (e) {
console.error(e);
}
}
return hljs.highlightAuto(code).value;
}
});
// Markdown
function renderMarkdown(content) {
if (!content) return '';
return md.render(content);
}
// README // README
function refreshReadme() { function refreshReadme() {
@@ -150,7 +131,9 @@ const _show = computed({
</div> </div>
<!-- 内容显示 --> <!-- 内容显示 -->
<div v-else-if="content" class="markdown-body" v-html="renderMarkdown(content)"></div> <div v-else-if="content" class="markdown-body">
<MarkdownRender :content="content" :typewriter="false" class="markdown-content" />
</div>
<!-- 错误提示 --> <!-- 错误提示 -->
<div v-else-if="error" class="d-flex flex-column align-center justify-center" style="height: 100%;"> <div v-else-if="error" class="d-flex flex-column align-center justify-center" style="height: 100%;">
@@ -301,6 +284,9 @@ const _show = computed({
<script> <script>
export default { export default {
name: 'ReadmeDialog', name: 'ReadmeDialog',
components: {
MarkdownRender
},
computed: { computed: {
_show: { _show: {
get() { get() {
@@ -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>
+6 -2
View File
@@ -172,7 +172,7 @@ export function useMessages(
} }
} }
async function getSessionMessages(sessionId: string, router: any) { async function getSessionMessages(sessionId: string) {
if (!sessionId) return; if (!sessionId) return;
try { try {
@@ -188,7 +188,7 @@ export function useMessages(
// 如果会话还在运行,3秒后重新获取消息 // 如果会话还在运行,3秒后重新获取消息
setTimeout(() => { setTimeout(() => {
getSessionMessages(currSessionId.value, router); getSessionMessages(currSessionId.value);
}, 3000); }, 3000);
} }
@@ -353,6 +353,10 @@ export function useMessages(
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) { if (done) {
console.log('SSE stream completed'); console.log('SSE stream completed');
// 流式传输结束后,获取最终消息并重新渲染
if (currSessionId.value) {
await getSessionMessages(currSessionId.value);
}
break; break;
} }
@@ -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]; selectedSessions.value = [pendingSessionId.value];
pendingSessionId.value = null; 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]; const firstSession = sessions.value[0];
selectedSessions.value = [firstSession.session_id]; selectedSessions.value = [firstSession.session_id];
@@ -65,6 +71,10 @@ export function useSessions(chatboxMode: boolean = false) {
router.push(`${basePath}/${sessionId}`); router.push(`${basePath}/${sessionId}`);
await getSessions(); await getSessions();
// 确保新创建的会话被选中高亮
selectedSessions.value = [sessionId];
return sessionId; return sessionId;
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -27,6 +27,7 @@
"uninstall": "Uninstall", "uninstall": "Uninstall",
"update": "Update", "update": "Update",
"language": "Language", "language": "Language",
"settings": "Settings",
"locale": "en-US", "locale": "en-US",
"type": "Type", "type": "Type",
"press": "Press", "press": "Press",
@@ -15,10 +15,19 @@
"knowledgeBase": "Knowledge Base", "knowledgeBase": "Knowledge Base",
"about": "About", "about": "About",
"settings": "Settings", "settings": "Settings",
"changelog": "Changelog",
"documentation": "Documentation", "documentation": "Documentation",
"github": "GitHub", "github": "GitHub",
"drag": "Drag", "drag": "Drag",
"groups": { "groups": {
"more": "More Features" "more": "More Features"
},
"changelogDialog": {
"title": "Changelog",
"loading": "Loading...",
"error": "Failed to load",
"notFound": "Changelog for this version not found",
"selectVersion": "Select Version",
"current": "Current"
} }
} }
@@ -40,6 +40,8 @@
"cancelSelection": "Cancel", "cancelSelection": "Cancel",
"clearSelection": "None", "clearSelection": "None",
"clearSelectionSubtitle": "Clear current selection", "clearSelectionSubtitle": "Clear current selection",
"unknownType": "Unknown type" "unknownType": "Unknown type",
"createProvider": "Create Provider",
"manageProviders": "Provider Management"
} }
} }
@@ -41,7 +41,8 @@
"editTitle": "Edit Title", "editTitle": "Edit Title",
"fullscreen": "Fullscreen Mode", "fullscreen": "Fullscreen Mode",
"exitFullscreen": "Exit Fullscreen", "exitFullscreen": "Exit Fullscreen",
"reply": "Reply" "reply": "Reply",
"providerConfig": "AI Configuration"
}, },
"conversation": { "conversation": {
"newConversation": "New Conversation", "newConversation": "New Conversation",
@@ -45,6 +45,8 @@
"rename": { "rename": {
"title": "Rename Command", "title": "Rename Command",
"newName": "New command name", "newName": "New command name",
"aliases": "Manage aliases",
"addAlias": "Add alias",
"cancel": "Cancel", "cancel": "Cancel",
"confirm": "Confirm" "confirm": "Confirm"
}, },
@@ -1,6 +1,6 @@
{ {
"title": "Service Provider Management", "title": "Providers",
"subtitle": "Manage model service providers", "subtitle": "Can configure chat models in \"Chat Completion\". Additionally, \"Agent Runner\" includes integrations with third-party services like Dify, Coze, and Alibaba Bailian(DashScope).",
"providers": { "providers": {
"title": "Service Providers", "title": "Service Providers",
"settings": "Settings", "settings": "Settings",
@@ -85,5 +85,50 @@
"confirm": { "confirm": {
"delete": "Are you sure you want to delete service provider {id}?" "delete": "Are you sure you want to delete service provider {id}?"
} }
},
"providerTypes": {
"title": "Provider Types"
},
"providerSources": {
"title": "Provider Sources",
"empty": "No provider sources",
"selectHint": "Please select a provider source",
"save": "Save Configuration",
"saveAndFetchModels": "Save and Fetch Models",
"fetchModels": "Fetch Model List",
"saveSuccess": "Provider source saved successfully",
"saveError": "Failed to save provider source",
"deleteConfirm": "Are you sure you want to delete provider source {id}? This will also delete all associated model configurations.",
"deleteSuccess": "Provider source deleted successfully",
"deleteError": "Failed to delete provider source",
"enabled": "Enabled",
"disabled": "Disabled",
"advancedConfig": "Advanced Configuration...",
"fields": {
"name": "Name",
"apiKey": "API Key",
"baseUrl": "Base URL"
}
},
"models": {
"available": "Available Models",
"configured": "Configured Models",
"empty": "No configured models yet. Click \"Fetch Models\" above to add.",
"noModelsFound": "No available models found",
"fetchError": "Failed to fetch models",
"addSuccess": "Model {model} added successfully",
"deleteConfirm": "Are you sure you want to delete model {id}?",
"deleteSuccess": "Model deleted successfully",
"deleteError": "Failed to delete model",
"testSuccess": "Model {id} test passed",
"testError": "Model test failed",
"searchPlaceholder": "Search models or ID",
"manualAddButton": "Custom Model",
"manualDialogTitle": "Add Custom Model",
"manualDialogModelLabel": "Model ID (e.g. gpt-4.1-mini)",
"manualDialogPreviewLabel": "Display ID (auto generated)",
"manualDialogPreviewHint": "Generated as sourceId/modelId",
"manualModelRequired": "Please enter a model ID",
"manualModelExists": "Model already exists"
} }
} }
@@ -27,6 +27,7 @@
"uninstall": "卸载", "uninstall": "卸载",
"update": "更新", "update": "更新",
"language": "语言", "language": "语言",
"settings": "设置",
"locale": "zh-CN", "locale": "zh-CN",
"type": "输入", "type": "输入",
"press": "按", "press": "按",
@@ -15,10 +15,19 @@
"knowledgeBase": "知识库", "knowledgeBase": "知识库",
"about": "关于", "about": "关于",
"settings": "设置", "settings": "设置",
"changelog": "更新日志",
"documentation": "官方文档", "documentation": "官方文档",
"github": "GitHub", "github": "GitHub",
"drag": "拖拽", "drag": "拖拽",
"groups": { "groups": {
"more": "更多功能" "more": "更多功能"
},
"changelogDialog": {
"title": "更新日志",
"loading": "加载中...",
"error": "加载失败",
"notFound": "未找到该版本的更新日志",
"selectVersion": "选择版本",
"current": "当前"
} }
} }
@@ -40,6 +40,8 @@
"cancelSelection": "取消", "cancelSelection": "取消",
"clearSelection": "不选择", "clearSelection": "不选择",
"clearSelectionSubtitle": "清除当前选择", "clearSelectionSubtitle": "清除当前选择",
"unknownType": "未知类型" "unknownType": "未知类型",
"createProvider": "创建提供商",
"manageProviders": "提供商管理"
} }
} }
@@ -41,7 +41,8 @@
"editTitle": "编辑标题", "editTitle": "编辑标题",
"fullscreen": "全屏模式", "fullscreen": "全屏模式",
"exitFullscreen": "退出全屏", "exitFullscreen": "退出全屏",
"reply": "引用回复" "reply": "引用回复",
"providerConfig": "AI 配置"
}, },
"conversation": { "conversation": {
"newConversation": "新的聊天", "newConversation": "新的聊天",
@@ -45,6 +45,8 @@
"rename": { "rename": {
"title": "重命名指令", "title": "重命名指令",
"newName": "新指令名", "newName": "新指令名",
"aliases": "管理别名",
"addAlias": "添加别名",
"cancel": "取消", "cancel": "取消",
"confirm": "确认" "confirm": "确认"
}, },
@@ -1,6 +1,6 @@
{ {
"title": "模型提供商", "title": "模型提供商",
"subtitle": "管理模型提供商", "subtitle": "可以在“对话”中配置对话模型。此外,“Agent 执行器”包含了 Dify、Coze、阿里云百炼应用等第三方服务的集成。",
"providers": { "providers": {
"title": "模型提供商", "title": "模型提供商",
"settings": "设置", "settings": "设置",
@@ -86,5 +86,50 @@
"confirm": { "confirm": {
"delete": "确定要删除模型提供商 {id} 吗?" "delete": "确定要删除模型提供商 {id} 吗?"
} }
},
"providerTypes": {
"title": "提供商类型"
},
"providerSources": {
"title": "提供商源",
"empty": "暂无提供商源",
"selectHint": "请选择一个提供商源",
"save": "保存配置",
"saveAndFetchModels": "保存并获取模型",
"fetchModels": "获取模型列表",
"saveSuccess": "提供商源保存成功",
"saveError": "提供商源保存失败",
"deleteConfirm": "确定要删除提供商源 {id} 吗?这将同时删除关联的所有模型配置。",
"deleteSuccess": "提供商源删除成功",
"deleteError": "提供商源删除失败",
"enabled": "已启用",
"disabled": "已禁用",
"advancedConfig": "高级配置...",
"fields": {
"name": "名称",
"apiKey": "API Key",
"baseUrl": "Base URL"
}
},
"models": {
"available": "可用模型",
"configured": "已配置的模型",
"empty": "暂无已配置的模型,点击上方的\"获取模型列表\"添加",
"noModelsFound": "未找到可用模型",
"fetchError": "获取模型列表失败",
"addSuccess": "模型 {model} 添加成功",
"deleteConfirm": "确定要删除模型 {id} 吗?",
"deleteSuccess": "模型删除成功",
"deleteError": "模型删除失败",
"testSuccess": "模型 {id} 测试通过",
"testError": "模型测试失败",
"searchPlaceholder": "搜索模型或 ID",
"manualAddButton": "自定义模型",
"manualDialogTitle": "添加自定义模型",
"manualDialogModelLabel": "模型 ID(如 gpt-4.1-mini",
"manualDialogPreviewLabel": "显示 ID(自动生成)",
"manualDialogPreviewHint": "生成规则:源ID/模型ID",
"manualModelRequired": "请输入模型 ID",
"manualModelExists": "该模型已存在"
} }
} }
+51 -8
View File
@@ -5,15 +5,29 @@ import axios from 'axios';
import VerticalSidebarVue from './vertical-sidebar/VerticalSidebar.vue'; import VerticalSidebarVue from './vertical-sidebar/VerticalSidebar.vue';
import VerticalHeaderVue from './vertical-header/VerticalHeader.vue'; import VerticalHeaderVue from './vertical-header/VerticalHeader.vue';
import MigrationDialog from '@/components/shared/MigrationDialog.vue'; import MigrationDialog from '@/components/shared/MigrationDialog.vue';
import Chat from '@/components/chat/Chat.vue';
import { useCustomizerStore } from '@/stores/customizer'; import { useCustomizerStore } from '@/stores/customizer';
import { useRouterLoadingStore } from '@/stores/routerLoading';
const customizer = useCustomizerStore(); const customizer = useCustomizerStore();
const route = useRoute(); const route = useRoute();
const routerLoadingStore = useRouterLoadingStore();
// //
const isChatPage = computed(() => { const isChatPage = computed(() => {
return route.path.startsWith('/chat'); return route.path.startsWith('/chat');
}); });
// sidebar bot
const showSidebar = computed(() => {
return customizer.viewMode === 'bot';
});
// chat chat
const showChatPage = computed(() => {
return customizer.viewMode === 'chat';
});
const migrationDialog = ref<InstanceType<typeof MigrationDialog> | null>(null); const migrationDialog = ref<InstanceType<typeof MigrationDialog> | null>(null);
// //
@@ -48,15 +62,36 @@ onMounted(() => {
<v-app :theme="useCustomizerStore().uiTheme" <v-app :theme="useCustomizerStore().uiTheme"
:class="[customizer.fontTheme, customizer.mini_sidebar ? 'mini-sidebar' : '', customizer.inputBg ? 'inputWithbg' : '']" :class="[customizer.fontTheme, customizer.mini_sidebar ? 'mini-sidebar' : '', customizer.inputBg ? 'inputWithbg' : '']"
> >
<!-- 路由切换进度条 -->
<v-progress-linear
v-if="routerLoadingStore.isLoading"
:model-value="routerLoadingStore.progress"
color="primary"
height="2"
fixed
top
style="z-index: 9999; position: absolute; opacity: 0.3; "
/>
<VerticalHeaderVue /> <VerticalHeaderVue />
<VerticalSidebarVue /> <VerticalSidebarVue v-if="showSidebar" />
<v-main> <v-main :style="{
<v-container fluid class="page-wrapper" :style="{ height: showChatPage ? 'calc(100vh - 55px)' : undefined,
height: 'calc(100% - 8px)', overflow: showChatPage ? 'hidden' : undefined
padding: isChatPage ? '0' : undefined }">
}"> <v-container
<div style="height: 100%;"> fluid
<RouterView /> class="page-wrapper"
:class="{ 'chat-mode-container': showChatPage }"
:style="{
height: showChatPage ? '100%' : 'calc(100% - 8px)',
padding: (isChatPage || showChatPage) ? '0' : undefined,
minHeight: showChatPage ? 'unset' : undefined
}">
<div :style="{ height: '100%', width: '100%', overflow: showChatPage ? 'hidden' : undefined }">
<div v-if="showChatPage" style="height: 100%; width: 100%; overflow: hidden;">
<Chat />
</div>
<RouterView v-else />
</div> </div>
</v-container> </v-container>
</v-main> </v-main>
@@ -66,3 +101,11 @@ onMounted(() => {
</v-app> </v-app>
</v-locale-provider> </v-locale-provider>
</template> </template>
<style scoped>
.chat-mode-container {
min-height: unset !important;
height: 100% !important;
overflow: hidden !important;
}
</style>
@@ -1,31 +1,35 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'; import { ref, computed, watch } from 'vue';
import { useCustomizerStore } from '@/stores/customizer'; import { useCustomizerStore } from '@/stores/customizer';
import axios from 'axios'; import axios from 'axios';
import Logo from '@/components/shared/Logo.vue'; import Logo from '@/components/shared/Logo.vue';
import LanguageSwitcher from '@/components/shared/LanguageSwitcher.vue';
import { md5 } from 'js-md5'; import { md5 } from 'js-md5';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { useCommonStore } from '@/stores/common'; import { useCommonStore } from '@/stores/common';
import MarkdownIt from 'markdown-it'; 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'; import { useI18n } from '@/i18n/composables';
import { router } from '@/router'; import { router } from '@/router';
import { useRoute } from 'vue-router';
import { useTheme } from 'vuetify'; import { useTheme } from 'vuetify';
import StyledMenu from '@/components/shared/StyledMenu.vue';
import { useLanguageSwitcher } from '@/i18n/composables';
import type { Locale } from '@/i18n/types';
import AboutPage from '@/views/AboutPage.vue';
// markdown-it enableKatex();
const md = new MarkdownIt({ enableMermaid();
html: true, // HTML
breaks: true, // <br>
linkify: true, //
typographer: false //
});
const customizer = useCustomizerStore(); const customizer = useCustomizerStore();
const theme = useTheme(); const theme = useTheme();
const { t } = useI18n(); const { t } = useI18n();
const route = useRoute();
let dialog = ref(false); let dialog = ref(false);
let accountWarning = ref(false) let accountWarning = ref(false)
let updateStatusDialog = ref(false); let updateStatusDialog = ref(false);
let aboutDialog = ref(false);
const username = localStorage.getItem('user'); const username = localStorage.getItem('user');
let password = ref(''); let password = ref('');
let newPassword = ref(''); let newPassword = ref('');
@@ -250,6 +254,14 @@ function openReleaseNotesDialog(body: string, tag: string) {
releaseNotesDialog.value = true; releaseNotesDialog.value = true;
} }
function handleLogoClick() {
if (customizer.viewMode === 'chat') {
aboutDialog.value = true;
} else {
router.push('/about');
}
}
getVersion(); getVersion();
checkUpdate(); checkUpdate();
@@ -257,37 +269,82 @@ const commonStore = useCommonStore();
commonStore.createEventSource(); // log commonStore.createEventSource(); // log
commonStore.getStartTime(); commonStore.getStartTime();
//
const viewMode = computed({
get: () => customizer.viewMode,
set: (value: 'bot' | 'chat') => {
customizer.SET_VIEW_MODE(value);
}
});
// viewMode bot
watch(() => customizer.viewMode, (newMode, oldMode) => {
if (newMode === 'bot' && oldMode === 'chat') {
// chat bot
if (route.path !== '/') {
router.push('/');
}
}
});
// Merry Christmas! 🎄
const isChristmas = computed(() => {
const today = new Date();
const month = today.getMonth() + 1; // getMonth() 0-11
const day = today.getDate();
return month === 12 && day === 25;
});
//
const { languageOptions, currentLanguage, switchLanguage, locale } = useLanguageSwitcher();
const languages = computed(() =>
languageOptions.value.map(lang => ({
code: lang.value,
name: lang.label,
flag: lang.flag
}))
);
const currentLocale = computed(() => locale.value);
const changeLanguage = async (langCode: string) => {
await switchLanguage(langCode as Locale);
};
</script> </script>
<template> <template>
<v-app-bar elevation="0" height="55"> <v-app-bar elevation="0" height="55">
<v-btn v-if="useCustomizerStore().uiTheme === 'PurpleTheme'" style="margin-left: 22px;" <!-- 桌面端 menu 按钮 - 仅在 bot 模式下显示 -->
class="hidden-md-and-down text-secondary" color="lightsecondary" icon rounded="sm" variant="flat" <v-btn v-if="customizer.viewMode === 'bot' && useCustomizerStore().uiTheme === 'PurpleTheme'" style="margin-left: 16px;"
@click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)" size="small"> class="hidden-md-and-down" icon rounded="sm" variant="flat"
@click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)">
<v-icon>mdi-menu</v-icon> <v-icon>mdi-menu</v-icon>
</v-btn> </v-btn>
<v-btn v-else <v-btn v-else-if="customizer.viewMode === 'bot'"
style="margin-left: 22px; color: var(--v-theme-primaryText); background-color: var(--v-theme-secondary)" style="margin-left: 22px;"
class="hidden-md-and-down" icon rounded="sm" variant="flat" class="hidden-md-and-down" icon rounded="sm" variant="flat"
@click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)" size="small"> @click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)">
<v-icon>mdi-menu</v-icon> <v-icon>mdi-menu</v-icon>
</v-btn> </v-btn>
<v-btn v-if="useCustomizerStore().uiTheme === 'PurpleTheme'" class="hidden-lg-and-up ms-3" color="lightsecondary" <!-- 移动端 menu 按钮 - 仅在 bot 模式下显示 -->
icon rounded="sm" variant="flat" @click.stop="customizer.SET_SIDEBAR_DRAWER" size="small"> <v-btn v-if="customizer.viewMode === 'bot' && useCustomizerStore().uiTheme === 'PurpleTheme'" class="hidden-lg-and-up ms-3"
icon rounded="sm" variant="flat" @click.stop="customizer.SET_SIDEBAR_DRAWER">
<v-icon>mdi-menu</v-icon> <v-icon>mdi-menu</v-icon>
</v-btn> </v-btn>
<v-btn v-else class="hidden-lg-and-up ms-3" icon rounded="sm" variant="flat" <v-btn v-else-if="customizer.viewMode === 'bot'" class="hidden-lg-and-up ms-3" icon rounded="sm" variant="flat"
@click.stop="customizer.SET_SIDEBAR_DRAWER" size="small"> @click.stop="customizer.SET_SIDEBAR_DRAWER">
<v-icon>mdi-menu</v-icon> <v-icon>mdi-menu</v-icon>
</v-btn> </v-btn>
<div class="logo-container" :class="{ 'mobile-logo': $vuetify.display.xs }" @click="router.push('/about')"> <div class="logo-container" :class="{ 'mobile-logo': $vuetify.display.xs, 'chat-mode-logo': customizer.viewMode === 'chat' }" @click="handleLogoClick">
<span class="logo-text">Astr<span class="logo-text-light">Bot</span></span> <span class="logo-text Outfit">Astr<span class="logo-text bot-text-wrapper">Bot
<img v-if="isChristmas" src="@/assets/images/xmas-hat.png" alt="Christmas hat" class="xmas-hat" />
</span></span>
<span class="logo-text logo-text-light Outfit" style="color: grey;" v-if="customizer.viewMode === 'chat'">ChatUI</span>
<span class="version-text hidden-xs">{{ botCurrVersion }}</span> <span class="version-text hidden-xs">{{ botCurrVersion }}</span>
</div> </div>
<v-spacer /> <v-spacer />
<!-- 版本提示信息 - 在手机上隐藏 --> <!-- 版本提示信息 - 在手机上隐藏 -->
<div class="mr-4 hidden-xs"> <div class="mr-4 hidden-xs">
@@ -299,25 +356,105 @@ commonStore.getStartTime();
</small> </small>
</div> </div>
<!-- 语言切换器 --> <!-- Bot/Chat 模式切换按钮 -->
<LanguageSwitcher variant="header" /> <v-btn-toggle
v-model="viewMode"
mandatory
variant="outlined"
density="compact"
class="mr-4"
color="primary"
>
<v-btn value="bot" size="small">
<v-icon start>mdi-robot</v-icon>
Bot
</v-btn>
<v-btn value="chat" size="small">
<v-icon start>mdi-chat</v-icon>
Chat
</v-btn>
</v-btn-toggle>
<!-- 主题切换按钮 -->
<v-btn size="small" @click="toggleDarkMode();" class="action-btn" color="var(--v-theme-surface)" variant="flat" <!-- 功能菜单 -->
rounded="sm" icon> <StyledMenu offset="12" location="bottom end">
<v-icon v-if="useCustomizerStore().uiTheme === 'PurpleThemeDark'">mdi-weather-night</v-icon> <template v-slot:activator="{ props: activatorProps }">
<v-icon v-else>mdi-white-balance-sunny</v-icon> <v-btn
</v-btn> v-bind="activatorProps"
size="small"
class="action-btn mr-4"
color="var(--v-theme-surface)"
variant="flat"
rounded="sm"
icon
>
<v-icon>mdi-dots-vertical</v-icon>
</v-btn>
</template>
<!-- 语言切换 -->
<v-list-item
v-for="lang in languages"
:key="lang.code"
:value="lang.code"
@click="changeLanguage(lang.code)"
:class="{ 'styled-menu-item-active': currentLocale === lang.code }"
class="styled-menu-item"
rounded="md"
>
<template v-slot:prepend>
<span class="language-flag">{{ lang.flag }}</span>
</template>
<v-list-item-title>{{ lang.name }}</v-list-item-title>
</v-list-item>
<!-- 主题切换 -->
<v-list-item
@click="toggleDarkMode()"
class="styled-menu-item"
rounded="md"
>
<template v-slot:prepend>
<v-icon>
{{ useCustomizerStore().uiTheme === 'PurpleThemeDark' ? 'mdi-weather-night' : 'mdi-white-balance-sunny' }}
</v-icon>
</template>
<v-list-item-title>
{{ useCustomizerStore().uiTheme === 'PurpleThemeDark' ? t('core.header.buttons.theme.light') : t('core.header.buttons.theme.dark') }}
</v-list-item-title>
</v-list-item>
<!-- 更新按钮 -->
<v-list-item
@click="checkUpdate(); getReleases(); updateStatusDialog = true"
class="styled-menu-item"
rounded="md"
>
<template v-slot:prepend>
<v-icon>mdi-arrow-up-circle</v-icon>
</template>
<v-list-item-title>{{ t('core.header.updateDialog.title') }}</v-list-item-title>
<template v-slot:append v-if="hasNewVersion || dashboardHasNewVersion">
<v-chip size="x-small" color="primary" variant="tonal" class="ml-2">!</v-chip>
</template>
</v-list-item>
<!-- 账户按钮 -->
<v-list-item
@click="dialog = true"
class="styled-menu-item"
rounded="md"
>
<template v-slot:prepend>
<v-icon>mdi-account</v-icon>
</template>
<v-list-item-title>{{ t('core.header.accountDialog.title') }}</v-list-item-title>
</v-list-item>
</StyledMenu>
<!-- 更新对话框 --> <!-- 更新对话框 -->
<v-dialog v-model="updateStatusDialog" :width="$vuetify.display.smAndDown ? '100%' : '1200'" <v-dialog v-model="updateStatusDialog" :width="$vuetify.display.smAndDown ? '100%' : '1200'"
:fullscreen="$vuetify.display.xs"> :fullscreen="$vuetify.display.xs">
<template v-slot:activator="{ props }">
<v-btn size="small" @click="checkUpdate(); getReleases();" class="action-btn"
color="var(--v-theme-surface)" variant="flat" rounded="sm" v-bind="props" icon>
<v-icon>mdi-arrow-up-circle</v-icon>
</v-btn>
</template>
<v-card> <v-card>
<v-card-title class="mobile-card-title"> <v-card-title class="mobile-card-title">
<span class="text-h5">{{ t('core.header.updateDialog.title') }}</span> <span class="text-h5">{{ t('core.header.updateDialog.title') }}</span>
@@ -335,8 +472,8 @@ commonStore.getStartTime();
</div> </div>
<div v-if="releaseMessage" <div v-if="releaseMessage"
style="background-color: #646cff24; padding: 16px; border-radius: 10px; font-size: 14px; max-height: 400px; overflow-y: auto;" style="background-color: #646cff24; padding: 16px; border-radius: 10px; font-size: 14px; max-height: 400px; overflow-y: auto;">
v-html="md.render(releaseMessage)" class="markdown-content"> <MarkdownRender :content="releaseMessage" :typewriter="false" class="markdown-content" />
</div> </div>
<div class="mb-4 mt-4"> <div class="mb-4 mt-4">
@@ -353,7 +490,7 @@ commonStore.getStartTime();
}}</a> {{ t('core.header.updateDialog.dockerTipContinue') }}</small> }}</a> {{ t('core.header.updateDialog.dockerTipContinue') }}</small>
</div> </div>
<v-alert v-if="releases.some(item => isPreRelease(item['tag_name']))" type="warning" variant="tonal" <v-alert v-if="releases.some((item: any) => isPreRelease(item['tag_name']))" type="warning" variant="tonal"
border="start"> border="start">
<template v-slot:prepend> <template v-slot:prepend>
<v-icon>mdi-alert-circle-outline</v-icon> <v-icon>mdi-alert-circle-outline</v-icon>
@@ -369,7 +506,7 @@ commonStore.getStartTime();
</v-alert> </v-alert>
<v-data-table :headers="releasesHeader" :items="releases" item-key="name" :items-per-page="8"> <v-data-table :headers="releasesHeader" :items="releases" item-key="name" :items-per-page="8">
<template v-slot:item.tag_name="{ item }: { item: { tag_name: string } }"> <template v-slot:item.tag_name="{ item }: { item: any }">
<div class="d-flex align-center"> <div class="d-flex align-center">
<span>{{ item.tag_name }}</span> <span>{{ item.tag_name }}</span>
<v-chip v-if="isPreRelease(item.tag_name)" size="x-small" color="warning" variant="tonal" <v-chip v-if="isPreRelease(item.tag_name)" size="x-small" color="warning" variant="tonal"
@@ -433,8 +570,8 @@ commonStore.getStartTime();
{{ t('core.header.updateDialog.releaseNotes.title') }}: {{ selectedReleaseTag }} {{ t('core.header.updateDialog.releaseNotes.title') }}: {{ selectedReleaseTag }}
</v-card-title> </v-card-title>
<v-card-text <v-card-text
style="font-size: 14px; max-height: 400px; overflow-y: auto;" style="font-size: 14px; max-height: 400px; overflow-y: auto;">
v-html="md.render(selectedReleaseNotes)" class="markdown-content"> <MarkdownRender :content="selectedReleaseNotes" :typewriter="false" class="markdown-content" />
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
<v-spacer></v-spacer> <v-spacer></v-spacer>
@@ -447,12 +584,6 @@ commonStore.getStartTime();
<!-- 账户对话框 --> <!-- 账户对话框 -->
<v-dialog v-model="dialog" persistent :max-width="$vuetify.display.xs ? '90%' : '500'"> <v-dialog v-model="dialog" persistent :max-width="$vuetify.display.xs ? '90%' : '500'">
<template v-slot:activator="{ props }">
<v-btn size="small" class="action-btn mr-4" color="var(--v-theme-surface)" variant="flat" rounded="sm"
v-bind="props" icon>
<v-icon>mdi-account</v-icon>
</v-btn>
</template>
<v-card class="account-dialog"> <v-card class="account-dialog">
<v-card-text class="py-6"> <v-card-text class="py-6">
<div class="d-flex flex-column align-center mb-6"> <div class="d-flex flex-column align-center mb-6">
@@ -508,6 +639,16 @@ commonStore.getStartTime();
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-dialog> </v-dialog>
<!-- About 对话框 - 仅在 chat mode 下使用 -->
<v-dialog v-model="aboutDialog"
width="600">
<v-card>
<v-card-text style="overflow-y: auto;">
<AboutPage />
</v-card-text>
</v-card>
</v-dialog>
</v-app-bar> </v-app-bar>
</template> </template>
@@ -555,7 +696,7 @@ commonStore.getStartTime();
/* 响应式布局样式 */ /* 响应式布局样式 */
.logo-container { .logo-container {
margin-left: 16px; margin-left: 10px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
@@ -567,6 +708,10 @@ commonStore.getStartTime();
gap: 4px; gap: 4px;
} }
.chat-mode-logo {
margin-left: 22px;
}
.logo-text { .logo-text {
font-size: 24px; font-size: 24px;
font-weight: 1000; font-weight: 1000;
@@ -576,15 +721,35 @@ commonStore.getStartTime();
font-weight: normal; font-weight: normal;
} }
.bot-text-wrapper {
position: relative;
display: inline-block;
}
.xmas-hat {
position: absolute;
top: -3px;
right: -14px;
width: 24px;
height: 24px;
z-index: 1;
}
.version-text { .version-text {
font-size: 12px; font-size: 12px;
color: var(--v-theme-secondaryText); color: gray;
margin-left: 4px;
} }
.action-btn { .action-btn {
margin-right: 6px; margin-right: 6px;
} }
.language-flag {
font-size: 16px;
margin-right: 8px;
}
/* 移动端对话框标题样式 */ /* 移动端对话框标题样式 */
.mobile-card-title { .mobile-card-title {
display: flex; display: flex;
@@ -616,5 +781,19 @@ commonStore.getStartTime();
padding: 0 10px; padding: 0 10px;
font-size: 0.9rem; font-size: 0.9rem;
} }
/* 移动端模式切换按钮样式 */
.v-btn-toggle {
margin-right: 8px;
}
.v-btn-toggle .v-btn {
font-size: 0.75rem;
padding: 0 8px;
}
.v-btn-toggle .v-icon {
font-size: 16px;
}
} }
</style> </style>
@@ -5,6 +5,7 @@ import { useI18n } from '@/i18n/composables';
import sidebarItems from './sidebarItem'; import sidebarItems from './sidebarItem';
import NavItem from './NavItem.vue'; import NavItem from './NavItem.vue';
import { applySidebarCustomization } from '@/utils/sidebarCustomization'; import { applySidebarCustomization } from '@/utils/sidebarCustomization';
import ChangelogDialog from '@/components/shared/ChangelogDialog.vue';
const { t } = useI18n(); const { t } = useI18n();
@@ -37,6 +38,9 @@ onUnmounted(() => {
const showIframe = ref(false); const showIframe = ref(false);
const starCount = ref(null); const starCount = ref(null);
//
const changelogDialog = ref(false);
const sidebarWidth = ref(235); const sidebarWidth = ref(235);
const minSidebarWidth = 200; const minSidebarWidth = 200;
const maxSidebarWidth = 300; const maxSidebarWidth = 300;
@@ -220,6 +224,11 @@ async function fetchStarCount() {
fetchStarCount(); fetchStarCount();
//
function openChangelogDialog() {
changelogDialog.value = true;
}
</script> </script>
<template> <template>
@@ -243,6 +252,9 @@ fetchStarCount();
<v-btn style="margin-bottom: 8px;" size="small" variant="tonal" color="primary" to="/settings"> <v-btn style="margin-bottom: 8px;" size="small" variant="tonal" color="primary" to="/settings">
🔧 {{ t('core.navigation.settings') }} 🔧 {{ t('core.navigation.settings') }}
</v-btn> </v-btn>
<v-btn style="margin-bottom: 8px;" size="small" variant="plain" @click="openChangelogDialog">
📝 {{ t('core.navigation.changelog') }}
</v-btn>
<v-btn style="margin-bottom: 8px;" size="small" variant="plain" @click="toggleIframe"> <v-btn style="margin-bottom: 8px;" size="small" variant="plain" @click="toggleIframe">
{{ t('core.navigation.documentation') }} {{ t('core.navigation.documentation') }}
</v-btn> </v-btn>
@@ -301,8 +313,11 @@ fetchStarCount();
<iframe <iframe
src="https://astrbot.app" src="https://astrbot.app"
style="width: 100%; height: calc(100% - 56px); border: none; border-bottom-left-radius: 12px; border-bottom-right-radius: 12px;" style="width: 100%; height: calc(100% - 56px); border: none; border-bottom-left-radius: 12px; border-bottom-right-radius: 12px;"
></iframe> ></iframe>
</div> </div>
<!-- 更新日志对话框 -->
<ChangelogDialog v-model="changelogDialog" />
</template> </template>
<style scoped> <style scoped>
@@ -43,11 +43,6 @@ const sidebarItem: menu[] = [
icon: 'mdi-book-open-variant', icon: 'mdi-book-open-variant',
to: '/knowledge-base', to: '/knowledge-base',
}, },
{
title: 'core.navigation.chat',
icon: 'mdi-chat',
to: '/chat'
},
{ {
title: 'core.navigation.groups.more', title: 'core.navigation.groups.more',
icon: 'mdi-dots-horizontal', icon: 'mdi-dots-horizontal',
+11
View File
@@ -3,6 +3,7 @@ import MainRoutes from './MainRoutes';
import AuthRoutes from './AuthRoutes'; import AuthRoutes from './AuthRoutes';
import ChatBoxRoutes from './ChatBoxRoutes'; import ChatBoxRoutes from './ChatBoxRoutes';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { useRouterLoadingStore } from '@/stores/routerLoading';
export const router = createRouter({ export const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL), history: createWebHashHistory(import.meta.env.BASE_URL),
@@ -22,6 +23,11 @@ interface AuthStore {
} }
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to, from, next) => {
if (from.name && from.path !== to.path) {
const loadingStore = useRouterLoadingStore();
loadingStore.start();
}
const publicPages = ['/auth/login']; const publicPages = ['/auth/login'];
const authRequired = !publicPages.includes(to.path); const authRequired = !publicPages.includes(to.path);
const auth: AuthStore = useAuthStore(); const auth: AuthStore = useAuthStore();
@@ -40,3 +46,8 @@ router.beforeEach(async (to, from, next) => {
next(); next();
} }
}); });
router.afterEach(() => {
const loadingStore = useRouterLoadingStore();
loadingStore.finish();
});
@@ -1,13 +1,3 @@
.v-input--density-default,
.v-field--variant-solo,
.v-field--variant-filled {
--v-input-control-height: 51px;
--v-input-padding-top: 14px;
}
.v-input--density-comfortable {
--v-input-control-height: 56px;
--v-input-padding-top: 17px;
}
.v-label { .v-label {
font-size: 0.975rem; font-size: 0.975rem;
} }
+1 -68
View File
@@ -1,70 +1,3 @@
.v-text-field input { .v-text-field input {
font-size: 0.875rem; font-size: 0.8rem;
}
.v-input--density-default {
.v-field__input {
min-height: 51px;
}
}
.v-field__outline {
color: rgb(var(--v-theme-inputBorder));
}
// 亮色主题样式
.v-theme--PurpleTheme .v-field__outline {
--v-field-border-width: 1.2px !important;
--v-field-border-opacity: 1 !important;
border-color: #d1cfcf;
}
.v-theme--PurpleTheme .v-text-field .v-field--focused .v-field__outline {
--v-field-border-width: 2px !important;
--v-field-border-opacity: 1 !important;
}
// 深色主题样式
.v-theme--PurpleThemeDark .v-text-field .v-field {
background-color: rgba(255, 255, 255, 0.08) !important;
}
.v-theme--PurpleThemeDark .v-text-field .v-field__outline {
--v-field-border-width: 2px !important;
--v-field-border-opacity: 1 !important;
color: rgba(255, 255, 255, 0.5) !important;
border-color: rgba(255, 255, 255, 0.5) !important;
}
.v-theme--PurpleThemeDark .v-text-field:hover .v-field__outline {
--v-field-border-width: 2px !important;
--v-field-border-opacity: 1 !important;
color: rgba(255, 255, 255, 0.7) !important;
border-color: rgba(255, 255, 255, 0.7) !important;
}
.v-theme--PurpleThemeDark .v-text-field .v-field--focused .v-field__outline {
--v-field-border-width: 2.5px !important;
--v-field-border-opacity: 1 !important;
color: rgb(129, 102, 176) !important;
border-color: rgb(126, 99, 171) !important;
}
.v-theme--PurpleThemeDark .v-text-field input {
color: #ffffff !important;
font-weight: 500;
}
.v-theme--PurpleThemeDark .v-text-field .v-field__label {
color: rgba(255, 255, 255, 0.8) !important;
}
.v-theme--PurpleThemeDark .v-text-field .v-field__prepend-inner .v-icon,
.v-theme--PurpleThemeDark .v-text-field .v-field__append-inner .v-icon {
color: rgba(255, 255, 255, 0.8) !important;
}
.inputWithbg {
.v-field--variant-outlined {
background-color: rgba(0, 0, 0, 0.025);
}
} }
+5 -1
View File
@@ -21,7 +21,7 @@ html {
.page-wrapper { .page-wrapper {
min-height: calc(100vh - 100px); min-height: calc(100vh - 100px);
padding: 8px; padding: 8px;
border-radius: $border-radius-root; // border-radius: $border-radius-root;
background: rgb(var(--v-theme-containerBg)); background: rgb(var(--v-theme-containerBg));
} }
$sizes: ( $sizes: (
@@ -87,6 +87,10 @@ body {
.Inter { .Inter {
font-family: 'Inter', sans-serif !important; font-family: 'Inter', sans-serif !important;
} }
.Outfit {
font-family: 'Outfit', sans-serif !important;
}
} }
@keyframes blink { @keyframes blink {
-40
View File
@@ -19,24 +19,6 @@
top: -85px; top: -85px;
right: -95px; right: -95px;
} }
// &.bubble-primary-shape {
// &::before {
// background: rgb(var(--v-theme-darkprimary));
// }
// &::after {
// background: rgb(var(--v-theme-darkprimary));
// }
// }
// &.bubble-secondary-shape {
// &::before {
// background: rgb(var(--v-theme-darksecondary));
// }
// &::after {
// background: rgb(var(--v-theme-darksecondary));
// }
// }
} }
.z-1 { .z-1 {
@@ -54,11 +36,6 @@
top: -160px; top: -160px;
right: -130px; right: -130px;
} }
// &.bubble-primary {
// &::before {
// background: linear-gradient(140.9deg, rgb(var(--v-theme-lightprimary)) -14.02%, rgba(var(--v-theme-darkprimary), 0) 77.58%);
// }
// }
&::after { &::after {
content: ''; content: '';
position: absolute; position: absolute;
@@ -68,23 +45,6 @@
top: -30px; top: -30px;
right: -180px; right: -180px;
} }
// &.bubble-primary {
// &::after {
// background: linear-gradient(210.04deg, rgb(var(--v-theme-lightprimary)) -50.94%, rgba(var(--v-theme-darkprimary), 0) 83.49%);
// }
// }
// &.bubble-warning {
// &::before {
// background: linear-gradient(140.9deg, rgb(var(--v-theme-warning)) -14.02%, rgba(144, 202, 249, 0) 70.5%);
// }
// }
// &.bubble-warning {
// &::after {
// background: linear-gradient(210.04deg, rgb(var(--v-theme-warning)) -50.94%, rgba(144, 202, 249, 0) 83.49%);
// }
// }
} }
.rounded-square { .rounded-square {
+6 -1
View File
@@ -9,7 +9,8 @@ export const useCustomizerStore = defineStore({
mini_sidebar: config.mini_sidebar, mini_sidebar: config.mini_sidebar,
fontTheme: "Poppins", fontTheme: "Poppins",
uiTheme: config.uiTheme, uiTheme: config.uiTheme,
inputBg: config.inputBg inputBg: config.inputBg,
viewMode: (localStorage.getItem('viewMode') as 'bot' | 'chat') || 'bot' // 'bot' 或 'chat'
}), }),
getters: {}, getters: {},
@@ -27,5 +28,9 @@ export const useCustomizerStore = defineStore({
this.uiTheme = payload; this.uiTheme = payload;
localStorage.setItem("uiTheme", payload); localStorage.setItem("uiTheme", payload);
}, },
SET_VIEW_MODE(payload: 'bot' | 'chat') {
this.viewMode = payload;
localStorage.setItem("viewMode", payload);
},
} }
}); });
+60
View File
@@ -0,0 +1,60 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
export const useRouterLoadingStore = defineStore('routerLoading', () => {
const isLoading = ref(false);
const progress = ref(0);
let progressInterval: ReturnType<typeof setInterval> | null = null;
function start() {
isLoading.value = true;
progress.value = 0;
if (progressInterval) {
clearInterval(progressInterval);
}
let currentProgress = 0;
progressInterval = setInterval(() => {
if (currentProgress < 80) {
// 快速阶段:0-80%
currentProgress += Math.random() * 20 + 10;
if (currentProgress > 80) {
currentProgress = 80;
}
} else if (currentProgress < 90) {
// 缓慢阶段:80-90%
currentProgress += Math.random() * 3 + 1;
if (currentProgress > 90) {
currentProgress = 90;
}
}
progress.value = Math.min(currentProgress, 90);
}, 50);
}
function finish() {
// 清理interval
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}
// 快速完成到100%
progress.value = 100;
// 延迟隐藏,让用户看到100%
setTimeout(() => {
isLoading.value = false;
progress.value = 0;
}, 300);
}
return {
isLoading,
progress,
start,
finish
};
});
+3
View File
@@ -32,6 +32,9 @@ export function getProviderIcon(type) {
'microsoft': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/microsoft.svg', 'microsoft': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/microsoft.svg',
'vllm': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/vllm.svg', 'vllm': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/vllm.svg',
'groq': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/groq.svg', 'groq': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/groq.svg',
"modelstack": new URL('@/assets/images/provider_logos/modelstack.svg', import.meta.url).href,
"tokenpony": "https://tokenpony.cn/tokenpony-web/logo.png",
"compshare": "https://compshare.cn/favicon.ico"
}; };
return icons[type] || ''; return icons[type] || '';
} }
+2 -2
View File
@@ -5,11 +5,11 @@
<h1 class="font-weight-bold">{{ tm('hero.title') }}</h1> <h1 class="font-weight-bold">{{ tm('hero.title') }}</h1>
<p class="text-subtitle-1" style="color: var(--v-theme-secondaryText);">{{ tm('hero.subtitle') }}</p> <p class="text-subtitle-1" style="color: var(--v-theme-secondaryText);">{{ tm('hero.subtitle') }}</p>
<div style="margin-top: 20px; display: flex; justify-content: center;"> <div style="margin-top: 20px; display: flex; justify-content: center;">
<v-btn @click="open('https://github.com/AstrBotDevs/AstrBot')" color="primary" variant="tonal" <v-btn @click="open('https://github.com/AstrBotDevs/AstrBot')" color="primary" variant="tonal" size="small"
prepend-icon="mdi-star"> prepend-icon="mdi-star">
{{ tm('hero.starButton') }} {{ tm('hero.starButton') }}
</v-btn> </v-btn>
<v-btn class="ml-4" @click="open('https://github.com/AstrBotDevs/AstrBot/issues')" color="secondary" <v-btn class="ml-4" @click="open('https://github.com/AstrBotDevs/AstrBot/issues')" color="secondary" size="small"
variant="tonal" prepend-icon="mdi-comment-question"> variant="tonal" prepend-icon="mdi-comment-question">
{{ tm('hero.issueButton') }} {{ tm('hero.issueButton') }}
</v-btn> </v-btn>
+63 -16
View File
@@ -329,20 +329,11 @@
import axios from 'axios'; import axios from 'axios';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'; import { VueMonacoEditor } from '@guolao/vue-monaco-editor';
import MarkdownIt from 'markdown-it';
import { useCommonStore } from '@/stores/common'; import { useCommonStore } from '@/stores/common';
import { useCustomizerStore } from '@/stores/customizer'; import { useCustomizerStore } from '@/stores/customizer';
import { useI18n, useModuleI18n } from '@/i18n/composables'; import { useI18n, useModuleI18n } from '@/i18n/composables';
import MessageList from '@/components/chat/MessageList.vue'; import MessageList from '@/components/chat/MessageList.vue';
// markdown-it
const md = new MarkdownIt({
html: false, // HTML
breaks: true, // <br>
linkify: true, //
typographer: false //
});
export default { export default {
name: 'ConversationPage', name: 'ConversationPage',
components: { components: {
@@ -508,21 +499,23 @@ export default {
// MessageList // MessageList
formattedMessages() { formattedMessages() {
return this.conversationHistory.map(msg => { return this.conversationHistory.map(msg => {
console.log('处理消息:', msg.role, msg.image_url, msg.audio_url); console.log('处理消息:', msg.role, msg.content);
// MessagePart[]
const messageParts = this.convertContentToMessageParts(msg.content);
if (msg.role === 'user') { if (msg.role === 'user') {
return { return {
content: { content: {
type: 'user', type: 'user',
message: this.extractTextFromContent(msg.content), message: messageParts
image_url: this.extractImagesFromContent(msg.content),
} }
}; };
} else { } else {
return { return {
content: { content: {
type: 'bot', type: 'bot',
message: this.extractTextFromContent(msg.content), message: messageParts
embedded_images: this.extractImagesFromContent(msg.content),
} }
}; };
} }
@@ -999,7 +992,61 @@ export default {
this.showMessage = true; this.showMessage = true;
}, },
// // MessagePart[]
convertContentToMessageParts(content) {
const parts = [];
if (typeof content === 'string') {
//
if (content.trim()) {
parts.push({
type: 'plain',
text: content
});
}
} else if (Array.isArray(content)) {
// OpenAI
content.forEach(item => {
if (item.type === 'text' && item.text) {
parts.push({
type: 'plain',
text: item.text
});
} else if (item.type === 'image_url' && item.image_url?.url) {
parts.push({
type: 'image',
embedded_url: item.image_url.url
});
}
});
} else if (typeof content === 'object' && content !== null) {
//
const textParts = [];
for (const [key, value] of Object.entries(content)) {
if (typeof value === 'string' && value.trim()) {
textParts.push(value);
}
}
if (textParts.length > 0) {
parts.push({
type: 'plain',
text: textParts.join('\n')
});
}
}
//
if (parts.length === 0) {
parts.push({
type: 'plain',
text: ''
});
}
return parts;
},
//
extractTextFromContent(content) { extractTextFromContent(content) {
if (typeof content === 'string') { if (typeof content === 'string') {
return content; return content;
@@ -1013,7 +1060,7 @@ export default {
return ''; return '';
}, },
// URL // URL
extractImagesFromContent(content) { extractImagesFromContent(content) {
if (Array.isArray(content)) { if (Array.isArray(content)) {
return content.filter(item => item.type === 'image_url') return content.filter(item => item.type === 'image_url')
File diff suppressed because it is too large Load Diff
@@ -252,7 +252,7 @@
<script> <script>
import axios from 'axios'; import axios from 'axios';
import * as d3 from "d3"; // npm install d3 // import * as d3 from "d3"; // npm install d3
import { useModuleI18n } from '@/i18n/composables'; import { useModuleI18n } from '@/i18n/composables';
export default { export default {
+44 -16
View File
@@ -7,6 +7,7 @@ from astrbot.api import logger, sp, star
from astrbot.api.event import AstrMessageEvent from astrbot.api.event import AstrMessageEvent
from astrbot.api.message_components import Image, Reply from astrbot.api.message_components import Image, Reply
from astrbot.api.provider import Provider, ProviderRequest from astrbot.api.provider import Provider, ProviderRequest
from astrbot.core.agent.message import TextPart
from astrbot.core.provider.func_tool_manager import ToolSet from astrbot.core.provider.func_tool_manager import ToolSet
@@ -85,7 +86,9 @@ class ProcessLLMRequest:
req.image_urls, req.image_urls,
) )
if caption: if caption:
req.prompt = f"(Image Caption: {caption})\n\n{req.prompt}" req.extra_user_content_parts.append(
TextPart(text=f"<image_caption>{caption}</image_caption>")
)
req.image_urls = [] req.image_urls = []
except Exception as e: except Exception as e:
logger.error(f"处理图片描述失败: {e}") logger.error(f"处理图片描述失败: {e}")
@@ -129,13 +132,14 @@ class ProcessLLMRequest:
else: else:
req.prompt = prefix + req.prompt req.prompt = prefix + req.prompt
# 收集系统提醒信息
system_parts = []
# user identifier # user identifier
if cfg.get("identifier"): if cfg.get("identifier"):
user_id = event.message_obj.sender.user_id user_id = event.message_obj.sender.user_id
user_nickname = event.message_obj.sender.nickname user_nickname = event.message_obj.sender.nickname
req.prompt = ( system_parts.append(f"User ID: {user_id}, Nickname: {user_nickname}")
f"\n[User ID: {user_id}, Nickname: {user_nickname}]\n{req.prompt}"
)
# group name identifier # group name identifier
if cfg.get("group_name_display") and event.message_obj.group_id: if cfg.get("group_name_display") and event.message_obj.group_id:
@@ -146,7 +150,7 @@ class ProcessLLMRequest:
return return
group_name = event.message_obj.group.group_name group_name = event.message_obj.group.group_name
if group_name: if group_name:
req.system_prompt += f"\nGroup name: {group_name}\n" system_parts.append(f"Group name: {group_name}")
# time info # time info
if cfg.get("datetime_system_prompt"): if cfg.get("datetime_system_prompt"):
@@ -162,7 +166,7 @@ class ProcessLLMRequest:
current_time = ( current_time = (
datetime.datetime.now().astimezone().strftime("%Y-%m-%d %H:%M (%Z)") datetime.datetime.now().astimezone().strftime("%Y-%m-%d %H:%M (%Z)")
) )
req.system_prompt += f"\nCurrent datetime: {current_time}\n" system_parts.append(f"Current datetime: {current_time}")
img_cap_prov_id: str = cfg.get("default_image_caption_provider_id") or "" img_cap_prov_id: str = cfg.get("default_image_caption_provider_id") or ""
if req.conversation: if req.conversation:
@@ -181,37 +185,61 @@ class ProcessLLMRequest:
quote = comp quote = comp
break break
if quote: if quote:
sender_info = "" content_parts = []
if quote.sender_nickname:
sender_info = f"(Sent by {quote.sender_nickname})" # 1. 处理引用的文本
message_str = quote.message_str or "[Empty Text]" sender_info = (
req.system_prompt += ( f"({quote.sender_nickname}): " if quote.sender_nickname else ""
f"\nUser is quoting a message{sender_info}.\n"
f"Here are the information of the quoted message: Text Content: {message_str}.\n"
) )
message_str = quote.message_str or "[Empty Text]"
content_parts.append(f"{sender_info}{message_str}")
# 2. 处理引用的图片 (保留原有逻辑,但改变输出目标)
image_seg = None image_seg = None
if quote.chain: if quote.chain:
for comp in quote.chain: for comp in quote.chain:
if isinstance(comp, Image): if isinstance(comp, Image):
image_seg = comp image_seg = comp
break break
if image_seg: if image_seg:
try: try:
# 找到可以生成图片描述的 provider
prov = None prov = None
if img_cap_prov_id: if img_cap_prov_id:
prov = self.ctx.get_provider_by_id(img_cap_prov_id) prov = self.ctx.get_provider_by_id(img_cap_prov_id)
if prov is None: if prov is None:
prov = self.ctx.get_using_provider(event.unified_msg_origin) prov = self.ctx.get_using_provider(event.unified_msg_origin)
# 调用 provider 生成图片描述
if prov and isinstance(prov, Provider): if prov and isinstance(prov, Provider):
llm_resp = await prov.text_chat( llm_resp = await prov.text_chat(
prompt="Please describe the image content.", prompt="Please describe the image content.",
image_urls=[await image_seg.convert_to_file_path()], image_urls=[await image_seg.convert_to_file_path()],
) )
if llm_resp.completion_text: if llm_resp.completion_text:
req.system_prompt += ( # 将图片描述作为文本添加到 content_parts
f"Image Caption: {llm_resp.completion_text}\n" content_parts.append(
f"[Image Caption in quoted message]: {llm_resp.completion_text}"
) )
else: else:
logger.warning("No provider found for image captioning.") logger.warning(
"No provider found for image captioning in quote."
)
except BaseException as e: except BaseException as e:
logger.error(f"处理引用图片失败: {e}") logger.error(f"处理引用图片失败: {e}")
# 3. 将所有部分组合成文本并添加到 extra_user_content_parts 中
# 确保引用内容被正确的标签包裹
quoted_content = "\n".join(content_parts)
# 确保所有内容都在<Quoted Message>标签内
quoted_text = f"<Quoted Message>\n{quoted_content}\n</Quoted Message>"
req.extra_user_content_parts.append(TextPart(text=quoted_text))
# 统一包裹所有系统提醒
if system_parts:
system_content = (
"<system_reminder>" + "\n".join(system_parts) + "</system_reminder>"
)
req.extra_user_content_parts.append(TextPart(text=system_content))
+2 -2
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "AstrBot" name = "AstrBot"
version = "4.9.2" version = "4.10.2"
description = "Easy-to-use multi-platform LLM chatbot and development framework" description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
@@ -34,7 +34,7 @@ dependencies = [
"ormsgpack>=1.9.1", "ormsgpack>=1.9.1",
"pillow>=11.2.1", "pillow>=11.2.1",
"pip>=25.1.1", "pip>=25.1.1",
"psutil>=5.8.0", "psutil>=5.8.0,<7.2.0",
"py-cord>=2.6.1", "py-cord>=2.6.1",
"pydantic~=2.10.3", "pydantic~=2.10.3",
"pydub>=0.25.1", "pydub>=0.25.1",
+1 -1
View File
@@ -27,7 +27,7 @@ openai>=1.78.0
ormsgpack>=1.9.1 ormsgpack>=1.9.1
pillow>=11.2.1 pillow>=11.2.1
pip>=25.1.1 pip>=25.1.1
psutil>=5.8.0 psutil>=5.8.0,<7.2.0
py-cord>=2.6.1 py-cord>=2.6.1
pydantic~=2.10.3 pydantic~=2.10.3
pydub>=0.25.1 pydub>=0.25.1
+326
View File
@@ -0,0 +1,326 @@
import os
import sys
from unittest.mock import AsyncMock
import pytest
# 将项目根目录添加到 sys.path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from astrbot.core.agent.hooks import BaseAgentRunHooks
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner
from astrbot.core.agent.tool import FunctionTool, ToolSet
from astrbot.core.provider.entities import LLMResponse, ProviderRequest, TokenUsage
from astrbot.core.provider.provider import Provider
class MockProvider(Provider):
"""模拟Provider用于测试"""
def __init__(self):
super().__init__({}, {})
self.call_count = 0
self.should_call_tools = True
self.max_calls_before_normal_response = 10
def get_current_key(self) -> str:
return "test_key"
def set_key(self, key: str):
pass
async def get_models(self) -> list[str]:
return ["test_model"]
async def text_chat(self, **kwargs) -> LLMResponse:
self.call_count += 1
# 检查工具是否被禁用
func_tool = kwargs.get("func_tool")
# 如果工具被禁用或超过最大调用次数,返回正常响应
if func_tool is None or self.call_count > self.max_calls_before_normal_response:
return LLMResponse(
role="assistant",
completion_text="这是我的最终回答",
usage=TokenUsage(input_other=10, output=5),
)
# 模拟工具调用响应
if self.should_call_tools:
return LLMResponse(
role="assistant",
completion_text="我需要使用工具来帮助您",
tools_call_name=["test_tool"],
tools_call_args=[{"query": "test"}],
tools_call_ids=["call_123"],
usage=TokenUsage(input_other=10, output=5),
)
# 默认返回正常响应
return LLMResponse(
role="assistant",
completion_text="这是我的最终回答",
usage=TokenUsage(input_other=10, output=5),
)
async def text_chat_stream(self, **kwargs):
response = await self.text_chat(**kwargs)
response.is_chunk = True
yield response
response.is_chunk = False
yield response
class MockToolExecutor:
"""模拟工具执行器"""
@classmethod
def execute(cls, tool, run_context, **tool_args):
async def generator():
# 模拟工具返回结果,使用正确的类型
from mcp.types import CallToolResult, TextContent
result = CallToolResult(
content=[TextContent(type="text", text="工具执行结果")]
)
yield result
return generator()
class MockHooks(BaseAgentRunHooks):
"""模拟钩子函数"""
def __init__(self):
self.agent_begin_called = False
self.agent_done_called = False
self.tool_start_called = False
self.tool_end_called = False
async def on_agent_begin(self, run_context):
self.agent_begin_called = True
async def on_tool_start(self, run_context, tool, tool_args):
self.tool_start_called = True
async def on_tool_end(self, run_context, tool, tool_args, tool_result):
self.tool_end_called = True
async def on_agent_done(self, run_context, llm_response):
self.agent_done_called = True
@pytest.fixture
def mock_provider():
return MockProvider()
@pytest.fixture
def mock_tool_executor():
return MockToolExecutor()
@pytest.fixture
def mock_hooks():
return MockHooks()
@pytest.fixture
def tool_set():
"""创建测试用的工具集"""
tool = FunctionTool(
name="test_tool",
description="测试工具",
parameters={"type": "object", "properties": {"query": {"type": "string"}}},
handler=AsyncMock(),
)
return ToolSet(tools=[tool])
@pytest.fixture
def provider_request(tool_set):
"""创建测试用的ProviderRequest"""
return ProviderRequest(prompt="请帮我查询信息", func_tool=tool_set, contexts=[])
@pytest.fixture
def runner():
"""创建ToolLoopAgentRunner实例"""
return ToolLoopAgentRunner()
@pytest.mark.asyncio
async def test_max_step_limit_functionality(
runner, mock_provider, provider_request, mock_tool_executor, mock_hooks
):
"""测试最大步数限制功能"""
# 设置模拟provider,让它总是返回工具调用
mock_provider.should_call_tools = True
mock_provider.max_calls_before_normal_response = (
100 # 设置一个很大的值,确保不会自然结束
)
# 初始化runner
await runner.reset(
provider=mock_provider,
request=provider_request,
run_context=ContextWrapper(context=None),
tool_executor=mock_tool_executor,
agent_hooks=mock_hooks,
streaming=False,
)
# 设置较小的最大步数来测试限制功能
max_steps = 3
# 收集所有响应
responses = []
async for response in runner.step_until_done(max_steps):
responses.append(response)
# 验证结果
assert runner.done(), "代理应该在达到最大步数后完成"
# 验证工具被禁用(这是最重要的验证点)
assert runner.req.func_tool is None, "达到最大步数后工具应该被禁用"
# 验证有最终响应
final_responses = [r for r in responses if r.type == "llm_result"]
assert len(final_responses) > 0, "应该有最终的LLM响应"
# 验证最后一条消息是assistant的最终回答
last_message = runner.run_context.messages[-1]
assert last_message.role == "assistant", "最后一条消息应该是assistant的最终回答"
@pytest.mark.asyncio
async def test_normal_completion_without_max_step(
runner, mock_provider, provider_request, mock_tool_executor, mock_hooks
):
"""测试正常完成(不触发最大步数限制)"""
# 设置模拟provider,让它在第2次调用时返回正常响应
mock_provider.should_call_tools = True
mock_provider.max_calls_before_normal_response = 2
# 初始化runner
await runner.reset(
provider=mock_provider,
request=provider_request,
run_context=ContextWrapper(context=None),
tool_executor=mock_tool_executor,
agent_hooks=mock_hooks,
streaming=False,
)
# 设置足够大的最大步数
max_steps = 10
# 收集所有响应
responses = []
async for response in runner.step_until_done(max_steps):
responses.append(response)
# 验证结果
assert runner.done(), "代理应该正常完成"
# 验证没有触发最大步数限制 - 通过检查provider调用次数
# mock_provider在第2次调用后返回正常响应,所以不应该达到max_steps(10)
assert mock_provider.call_count < max_steps, (
f"正常完成时调用次数({mock_provider.call_count})应该小于最大步数({max_steps})"
)
# 验证没有最大步数警告消息(注意:实际注入的是user角色的消息)
user_messages = [m for m in runner.run_context.messages if m.role == "user"]
max_step_messages = [
m for m in user_messages if "工具调用次数已达到上限" in m.content
]
assert len(max_step_messages) == 0, "正常完成时不应该有步数限制消息"
# 验证工具仍然可用(没有被禁用)
assert runner.req.func_tool is not None, "正常完成时工具不应该被禁用"
@pytest.mark.asyncio
async def test_max_step_with_streaming(
runner, mock_provider, provider_request, mock_tool_executor, mock_hooks
):
"""测试流式响应下的最大步数限制"""
# 设置模拟provider
mock_provider.should_call_tools = True
mock_provider.max_calls_before_normal_response = 100
# 初始化runner,启用流式响应
await runner.reset(
provider=mock_provider,
request=provider_request,
run_context=ContextWrapper(context=None),
tool_executor=mock_tool_executor,
agent_hooks=mock_hooks,
streaming=True,
)
# 设置较小的最大步数
max_steps = 2
# 收集所有响应
responses = []
async for response in runner.step_until_done(max_steps):
responses.append(response)
# 验证结果
assert runner.done(), "代理应该在达到最大步数后完成"
# 验证有流式响应
streaming_responses = [r for r in responses if r.type == "streaming_delta"]
assert len(streaming_responses) > 0, "应该有流式响应"
# 验证工具被禁用
assert runner.req.func_tool is None, "达到最大步数后工具应该被禁用"
# 验证最后一条消息是assistant的最终回答
last_message = runner.run_context.messages[-1]
assert last_message.role == "assistant", "最后一条消息应该是assistant的最终回答"
@pytest.mark.asyncio
async def test_hooks_called_with_max_step(
runner, mock_provider, provider_request, mock_tool_executor, mock_hooks
):
"""测试达到最大步数时钩子函数是否被正确调用"""
# 设置模拟provider
mock_provider.should_call_tools = True
mock_provider.max_calls_before_normal_response = 100
# 初始化runner
await runner.reset(
provider=mock_provider,
request=provider_request,
run_context=ContextWrapper(context=None),
tool_executor=mock_tool_executor,
agent_hooks=mock_hooks,
streaming=False,
)
# 设置较小的最大步数
max_steps = 2
# 执行步骤
async for response in runner.step_until_done(max_steps):
pass
# 验证钩子函数被调用
assert mock_hooks.agent_begin_called, "on_agent_begin应该被调用"
assert mock_hooks.agent_done_called, "on_agent_done应该被调用"
assert mock_hooks.tool_start_called, "on_tool_start应该被调用"
assert mock_hooks.tool_end_called, "on_tool_end应该被调用"
if __name__ == "__main__":
# 运行测试
pytest.main([__file__, "-v"])