Compare commits

..

43 Commits

Author SHA1 Message Date
Soulter 8b9abc093a refactor: remove unused imports in component panel 2025-12-15 00:50:06 +08:00
Soulter eeec6bcc48 refactor: move mcp and command page to extension page 2025-12-15 00:44:37 +08:00
Soulter bd1c1c7e4f Merge remote-tracking branch 'origin/master' into feature/command-panel 2025-12-14 22:06:13 +08:00
Oscar 2387abe570 refactor(sidebar): 提取侧边栏项目解析逻辑到工具函数复用 2025-12-11 17:14:02 +08:00
Oscar 7e43cca134 perf(db): 优化重构command相关数据库操作 2025-12-11 17:00:15 +08:00
Oscar 89fdb18936 perf(command): 优化指令管理辅助函数和配置绑定逻辑 2025-12-11 16:51:10 +08:00
Oscar e710454d18 perf(command): 优化命令冲突计数逻辑 2025-12-11 15:45:14 +08:00
Oscar 20024cfec9 perf(dashboard): 删除多余的CommandPage.vue文件(已被模块化引用) 2025-12-11 15:36:22 +08:00
Oscar 042c507127 refactor(commandPanel): 移除未使用的 filterState 常量 2025-12-11 15:28:55 +08:00
Oscar 5e83a19ac5 style(builtin_commands): 补充命令描述 2025-12-11 14:48:28 +08:00
Oscar 1f87984133 Merge branch 'master' of https://github.com/ocetars/AstrBot-OscarDev into feature/command-panel 2025-12-11 14:45:01 +08:00
Oscar c6739105c4 refactor(commands): 重构/help指令以动态显示实际命令并补充部分命令描述 2025-12-11 14:34:46 +08:00
Oscar 6fd86eda13 fix(sidebar): 补全新增侧边栏项后的侧边栏位追加逻辑 2025-12-08 17:28:58 +08:00
Ocetars 693f2988be fix(command): 确保新命令配置的事务提交 2025-12-04 16:38:27 +08:00
Ocetars adcffcc466 style(commandPanel): 微调指令面板UI 2025-12-04 16:17:12 +08:00
Ocetars 238aa30331 refactor(commandPanel): 重命名指令模块目录为 commandPanel 2025-12-04 16:05:49 +08:00
Ocetars 26a27776ab refactor(command): 模块化指令管理面板前端代码 2025-12-04 15:58:11 +08:00
Ocetars eb2c88f802 style(extension): 文案修改 2025-12-04 15:23:53 +08:00
Ocetars 81a0e0f28e refactor(command): 移除指令表格内部加载指示器 2025-12-04 15:12:10 +08:00
Ocetars aa61815fcd feat(extension): 添加插件指令冲突检测与提示
- 在插件安装或启用后,自动检测并提示指令冲突。
- 当检测到指令冲突时,显示警告对话框,告知用户冲突数量及可能的影响。
2025-12-03 20:58:53 +08:00
Ocetars f34902574f style(command): 更新空状态描述 2025-12-03 19:55:42 +08:00
Ocetars b1b031077c refactor(command): 更新指令数展示逻辑 2025-12-03 19:48:22 +08:00
Ocetars 7f0e011126 feat(command): 添加系统插件指令过滤与冲突处理 2025-12-03 19:41:27 +08:00
Ocetars 7b7d9f1b8c style(command-page): 优化命令列表UI 2025-12-03 19:19:56 +08:00
Ocetars fe040da7a4 refactor(command): 修改指令列表排序逻辑 2025-12-03 19:10:20 +08:00
Ocetars b98cd1bd72 style(command): 优化指令组子指令数量显示UI 2025-12-03 18:09:12 +08:00
Ocetars 7fa71c538e feat(command-management): 新增指令层级管理与UI展示
- 【后端】
  - `CommandDescriptor` 新增 `parent_group_handler` 和 `sub_commands` 字段,支持指令层级结构定义。
  - `list_commands` 函数重构,实现指令的层级收集与构建,将子指令正确挂载到其父指令组下。
  - 新增 `_collect_all_descriptors` 和 `_find_parent_group_handler` 辅助函数,用于全面收集指令并定位父指令组。
  - `_build_descriptor` 优化指令类型判断逻辑,明确区分普通指令、指令组和子指令。
  - `_descriptor_to_dict` 递归处理子指令,确保 API 返回完整的指令层级数据。
- 【前端】
  - 指令管理页面 (`CommandPage.vue`) 增加指令类型筛选器,并支持指令组的展开/折叠功能。
  - 表格展示优化,为指令组和子指令添加不同的样式和缩进,提升层级结构的视觉可读性。
  - 指令详情对话框新增指令类型、所属指令组和子指令列表的展示。
  - 更新 `CommandItem` 接口,以适配后端提供的层级数据结构。
- 【i18n】
  - 新增指令类型(指令、指令组、子指令)的国际化文本。
  - 更新指令管理相关 UI 文本,包括表格头部、详情对话框字段和筛选器选项。
2025-12-03 17:58:52 +08:00
Ocetars 97c0be85e4 refactor(command): 调整指令管理中的成员权限显示与筛选
- 更新指令筛选逻辑,当选择“所有人”权限筛选时,将同时包含 `everyone` 和 `member` 权限的指令。
2025-12-03 17:12:09 +08:00
Ocetars b1273ff997 style: UI 细节 2025-12-03 15:55:11 +08:00
Ocetars e560f396c5 refactor(command): 优化指令页面布局并更新冲突警告
- 【布局优化】重新组织指令管理页面布局,将筛选器移至顶部独立行
- 【信息展示】将搜索栏与总指令数、已禁用指令数合并显示,提升页面空间利用率
- 【视觉更新】更新指令冲突警告样式
2025-12-03 15:45:12 +08:00
Ocetars 9c842ecd03 style(command-page): 调整命令页面表格样式和图标大小 2025-12-03 15:24:53 +08:00
Ocetars 281ac6dcfe chore(command-page): 禁用命令表格部分列的排序功能 2025-12-03 15:11:36 +08:00
Ocetars 7aa44ba3d8 feat(command): 优化指令冲突显示与提示
- 【功能】新增指令冲突警告提示,当检测到冲突时显示详细信息及解决方案。
- 【优化】调整指令列表排序逻辑,将冲突指令优先显示并分组。
- 【样式】为冲突指令行添加专属高亮样式,提升视觉识别度。
- 【国际化】更新英文和中文多语言文件,增加指令冲突警告相关的翻译文本。
2025-12-03 15:04:30 +08:00
Ocetars 8144b61ae0 fix(command): 排除已禁用指令的冲突检测
- 只有 `effective_command` 存在且 `enabled` 为 `True` 的指令才会被纳入冲突检测范围。
2025-12-03 14:43:18 +08:00
Ocetars 3da0c77e87 fix(command): 修正指令冲突检测逻辑 2025-12-03 14:34:25 +08:00
Ocetars 5e7a0591d9 refactor(command): 移除指令重命名时的别名功能 2025-12-03 14:25:49 +08:00
Ocetars 09d6b715f0 test: 新增命令管理相关测试 2025-12-02 20:57:03 +08:00
Ocetars f0770c5c4d feat: 新增命令管理国际化支持 2025-12-02 20:56:33 +08:00
Ocetars 0858ec4cba feat: 新增命令管理界面页面 2025-12-02 20:56:21 +08:00
Ocetars ae07835da7 feat: 新增命令管理后台 API 2025-12-02 20:56:05 +08:00
Ocetars 6ba1c51cd2 feat: 将命令管理集成到 Star 框架 2025-12-02 20:55:24 +08:00
Ocetars 2dc28eff89 feat: 实现核心命令管理系统 2025-12-02 20:55:14 +08:00
Ocetars 68c1e4ecf9 feat: 新增命令配置数据库模型 2025-12-02 20:53:53 +08:00
171 changed files with 2831 additions and 10838 deletions
+2 -1
View File
@@ -15,6 +15,7 @@ Always reference these instructions first and fallback to search or bash command
### Running the Application
- Run main application: `uv run main.py` -- starts in ~3 seconds
- Application creates WebUI on http://localhost:6185 (default credentials: `astrbot`/`astrbot`)
- Application loads plugins automatically from `packages/` and `data/plugins/` directories
### Dashboard Build (Vue.js/Node.js)
- **Prerequisites**: Node.js 20+ and npm 10+ required
@@ -34,7 +35,7 @@ Always reference these instructions first and fallback to search or bash command
- **ALWAYS** run `uv run ruff check .` and `uv run ruff format .` before committing changes
### Plugin Development
- Plugins load from `astrbot/builtin_stars/` (built-in) and `data/plugins/` (user-installed)
- Plugins load from `packages/` (built-in) and `data/plugins/` (user-installed)
- Plugin system supports function tools and message handlers
- Key plugins: python_interpreter, web_searcher, astrbot, reminder, session_controller
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
zip -r dist.zip dist
- name: Archive production artifacts
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v5
with:
name: dist-without-markdown
path: |
+2 -2
View File
@@ -24,9 +24,9 @@ configs/session
configs/config.yaml
cmd_config.json
# Plugins
# Plugins and packages
addons/plugins
astrbot/builtin_stars/python_interpreter/workplace
packages/python_interpreter/workplace
tests/astrbot_plugin_openai
# Dashboard
+1 -26
View File
@@ -33,20 +33,6 @@
- 请使用英文描述您的 PR。
- 标题请使用 `fix: `, `feat: `, `docs: `, `style: `, `refactor: `, `test: `, `chore: ` 等语义化前缀,并简要描述更改内容。如:`fix: correct login page typo`
#### 代码规范
##### Core
我们使用 Ruff 作为代码格式化和静态分析工具。在提交代码之前,请运行以下命令以确保代码符合规范:
```bash
ruff format .
ruff check .
```
如果您使用 VSCode,可以安装 `Ruff` 插件。
## Contributing Guide
First off, thanks for taking the time to contribute! ❤️
@@ -76,15 +62,4 @@ We use the `fix/` prefix for bug fixes and the `feat/` prefix for new features.
#### PR Description
- Please use English to describe your PR.
- Use semantic prefixes like `fix: `, `feat: `, `docs: `, `style: `, `refactor: `, `test: `, `chore: ` in the title, followed by a brief description of the changes, e.g., `fix: correct login page typo`.
#### Code Style
##### Core
We use Ruff as our code formatter and static analysis tool. Before submitting your code, please run the following commands to ensure your code adheres to the style guidelines:
```bash
ruff format .
ruff check .
```
- Use semantic prefixes like `fix: `, `feat: `, `docs: `, `style: `, `refactor: `, `test: `, `chore: ` in the title, followed by a brief description of the changes, e.g., `fix: correct login page typo`.
-6
View File
@@ -243,10 +243,4 @@ pre-commit install
</details>
<div align="center">
_私は、高性能ですから!_
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
</div
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.10.3"
__version__ = "4.9.0"
+4 -15
View File
@@ -3,7 +3,7 @@
from typing import Any, ClassVar, Literal, cast
from pydantic import BaseModel, GetCoreSchemaHandler, model_serializer, model_validator
from pydantic import BaseModel, GetCoreSchemaHandler, model_validator
from pydantic_core import core_schema
@@ -122,12 +122,10 @@ class ToolCall(BaseModel):
extra_content: dict[str, Any] | None = None
"""Extra metadata for the tool call."""
@model_serializer(mode="wrap")
def serialize(self, handler):
data = handler(self)
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
if self.extra_content is None:
data.pop("extra_content", None)
return data
kwargs.setdefault("exclude", set()).add("extra_content")
return super().model_dump(**kwargs)
class ToolCallPart(BaseModel):
@@ -169,15 +167,6 @@ class Message(BaseModel):
)
return self
@model_serializer(mode="wrap")
def serialize(self, handler):
data = handler(self)
if self.tool_calls is None:
data.pop("tool_calls", None)
if self.tool_call_id is None:
data.pop("tool_call_id", None)
return data
class AssistantMessageSegment(Message):
"""A message segment from the assistant."""
+1 -22
View File
@@ -1,8 +1,7 @@
import typing as T
from dataclasses import dataclass, field
from dataclasses import dataclass
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import TokenUsage
class AgentResponseData(T.TypedDict):
@@ -13,23 +12,3 @@ class AgentResponseData(T.TypedDict):
class AgentResponse:
type: str
data: AgentResponseData
@dataclass
class AgentStats:
token_usage: TokenUsage = field(default_factory=TokenUsage)
start_time: float = 0.0
end_time: float = 0.0
time_to_first_token: float = 0.0
@property
def duration(self) -> float:
return self.end_time - self.start_time
def to_dict(self) -> dict:
return {
"token_usage": self.token_usage.__dict__,
"start_time": self.start_time,
"end_time": self.end_time,
"time_to_first_token": self.time_to_first_token,
}
+1 -1
View File
@@ -9,7 +9,7 @@ from .message import Message
TContext = TypeVar("TContext", default=Any)
@dataclass
@dataclass(config={"arbitrary_types_allowed": True})
class ContextWrapper(Generic[TContext]):
"""A context for running an agent, which can be used to pass additional data or state."""
@@ -1,5 +1,4 @@
import sys
import time
import traceback
import typing as T
@@ -13,7 +12,6 @@ from mcp.types import (
)
from astrbot import logger
from astrbot.core.message.components import Json
from astrbot.core.message.message_event_result import (
MessageChain,
)
@@ -26,7 +24,7 @@ from astrbot.core.provider.provider import Provider
from ..hooks import BaseAgentRunHooks
from ..message import AssistantMessageSegment, Message, ToolCallMessageSegment
from ..response import AgentResponseData, AgentStats
from ..response import AgentResponseData
from ..run_context import ContextWrapper, TContext
from ..tool_executor import BaseFunctionToolExecutor
from .base import AgentResponse, AgentState, BaseAgentRunner
@@ -71,25 +69,14 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
)
self.run_context.messages = messages
self.stats = AgentStats()
self.stats.start_time = time.time()
async def _iter_llm_responses(self) -> T.AsyncGenerator[LLMResponse, None]:
"""Yields chunks *and* a final LLMResponse."""
payload = {
"contexts": self.run_context.messages, # list[Message]
"func_tool": self.req.func_tool,
"model": self.req.model, # NOTE: in fact, this arg is None in most cases
"session_id": self.req.session_id,
"extra_user_content_parts": self.req.extra_user_content_parts, # list[ContentPart]
}
if self.streaming:
stream = self.provider.text_chat_stream(**payload)
stream = self.provider.text_chat_stream(**self.req.__dict__)
async for resp in stream: # type: ignore
yield resp
else:
yield await self.provider.text_chat(**payload)
yield await self.provider.text_chat(**self.req.__dict__)
@override
async def step(self):
@@ -111,10 +98,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
async for llm_response in self._iter_llm_responses():
if llm_response.is_chunk:
# update ttft
if self.stats.time_to_first_token == 0:
self.stats.time_to_first_token = time.time() - self.stats.start_time
if llm_response.result_chain:
yield AgentResponse(
type="streaming_delta",
@@ -138,10 +121,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
)
continue
llm_resp_result = llm_response
if not llm_response.is_chunk and llm_response.usage:
# only count the token usage of the final response for computation purpose
self.stats.token_usage += llm_response.usage
break # got final response
if not llm_resp_result:
@@ -153,7 +132,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
if llm_resp.role == "err":
# 如果 LLM 响应错误,转换到错误状态
self.final_llm_resp = llm_resp
self.stats.end_time = time.time()
self._transition_state(AgentState.ERROR)
yield AgentResponse(
type="err",
@@ -168,12 +146,11 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
# 如果没有工具调用,转换到完成状态
self.final_llm_resp = llm_resp
self._transition_state(AgentState.DONE)
self.stats.end_time = time.time()
# record the final assistant message
self.run_context.messages.append(
Message(
role="assistant",
content=llm_resp.completion_text or "*No response*",
content=llm_resp.completion_text or "",
),
)
try:
@@ -198,19 +175,22 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
# 如果有工具调用,还需处理工具调用
if llm_resp.tools_call_name:
tool_call_result_blocks = []
for tool_call_name in llm_resp.tools_call_name:
yield AgentResponse(
type="tool_call",
data=AgentResponseData(
chain=MessageChain(type="tool_call").message(
f"🔨 调用工具: {tool_call_name}"
),
),
)
async for result in self._handle_function_tools(self.req, llm_resp):
if isinstance(result, list):
tool_call_result_blocks = result
elif isinstance(result, MessageChain):
if result.type is None:
# should not happen
continue
if result.type == "tool_direct_result":
ar_type = "tool_call_result"
else:
ar_type = result.type
result.type = "tool_call_result"
yield AgentResponse(
type=ar_type,
type="tool_call_result",
data=AgentResponseData(chain=result),
)
# 将结果添加到上下文中
@@ -238,25 +218,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
async for resp in self.step():
yield resp
# 如果循环结束了但是 agent 还没有完成,说明是达到了 max_step
if not self.done():
logger.warning(
f"Agent reached max steps ({max_step}), forcing a final response."
)
# 拔掉所有工具
if self.req:
self.req.func_tool = None
# 注入提示词
self.run_context.messages.append(
Message(
role="user",
content="工具调用次数已达到上限,请停止使用工具,并根据已经收集到的信息,对你的任务和发现进行总结,然后直接回复用户。",
)
)
# 再执行最后一步
async for resp in self.step():
yield resp
async def _handle_function_tools(
self,
req: ProviderRequest,
@@ -272,19 +233,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
llm_response.tools_call_args,
llm_response.tools_call_ids,
):
yield MessageChain(
type="tool_call",
chain=[
Json(
data={
"id": func_tool_id,
"name": func_tool_name,
"args": func_tool_args,
"ts": time.time(),
}
)
],
)
try:
if not req.func_tool:
return
@@ -358,6 +306,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
content=res.content[0].text,
),
)
yield MessageChain().message(res.content[0].text)
elif isinstance(res.content[0], ImageContent):
tool_call_result_blocks.append(
ToolCallMessageSegment(
@@ -379,6 +328,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
content=resource.text,
),
)
yield MessageChain().message(resource.text)
elif (
isinstance(resource, BlobResourceContents)
and resource.mimeType
@@ -402,34 +352,20 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
content="返回的数据类型不受支持",
),
)
yield MessageChain().message("返回的数据类型不受支持。")
elif resp is None:
# Tool 直接请求发送消息给用户
# 这里我们将直接结束 Agent Loop。
# 发送消息逻辑在 ToolExecutor 中处理了。
logger.warning(
f"{func_tool_name} 没有没有返回值或者将结果直接发送给用户。"
f"{func_tool_name} 没有没有返回值或者将结果直接发送给用户,此工具调用不会被记录到历史中"
)
self._transition_state(AgentState.DONE)
self.stats.end_time = time.time()
tool_call_result_blocks.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content="*工具没有返回值或者将结果直接发送给了用户*",
),
)
else:
# 不应该出现其他类型
logger.warning(
f"Tool 返回了不支持的类型: {type(resp)}",
)
tool_call_result_blocks.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content="*工具返回了不支持的类型,请告诉用户检查这个工具的定义和实现。*",
),
f"Tool 返回了不支持的类型: {type(resp)},将忽略",
)
try:
@@ -451,22 +387,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
),
)
# yield the last tool call result
if tool_call_result_blocks:
last_tcr_content = str(tool_call_result_blocks[-1].content)
yield MessageChain(
type="tool_call_result",
chain=[
Json(
data={
"id": func_tool_id,
"ts": time.time(),
"result": last_tcr_content,
}
)
],
)
# 处理函数调用响应
if tool_call_result_blocks:
yield tool_call_result_blocks
+1 -3
View File
@@ -6,10 +6,8 @@ from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.star.context import Context
@dataclass
@dataclass(config={"arbitrary_types_allowed": True})
class AstrAgentContext:
__pydantic_config__ = {"arbitrary_types_allowed": True}
context: Context
"""The star context instance"""
event: AstrMessageEvent
+3 -42
View File
@@ -2,10 +2,8 @@ import traceback
from collections.abc import AsyncGenerator
from astrbot.core import logger
from astrbot.core.agent.message import Message
from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.message.components import Json
from astrbot.core.message.message_event_result import (
MessageChain,
MessageEventResult,
@@ -25,25 +23,8 @@ async def run_agent(
) -> AsyncGenerator[MessageChain | None, None]:
step_idx = 0
astr_event = agent_runner.run_context.context.event
while step_idx < max_step + 1:
while step_idx < max_step:
step_idx += 1
if step_idx == max_step + 1:
logger.warning(
f"Agent reached max steps ({max_step}), forcing a final response."
)
if not agent_runner.done():
# 拔掉所有工具
if agent_runner.req:
agent_runner.req.func_tool = None
# 注入提示词
agent_runner.run_context.messages.append(
Message(
role="user",
content="工具调用次数已达到上限,请停止使用工具,并根据已经收集到的信息,对你的任务和发现进行总结,然后直接回复用户。",
)
)
try:
async for resp in agent_runner.step():
if astr_event.is_stopped():
@@ -52,27 +33,16 @@ async def run_agent(
msg_chain = resp.data["chain"]
if msg_chain.type == "tool_direct_result":
# tool_direct_result 用于标记 llm tool 需要直接发送给用户的内容
await astr_event.send(msg_chain)
await astr_event.send(resp.data["chain"])
continue
if astr_event.get_platform_id() == "webchat":
await astr_event.send(msg_chain)
# 对于其他情况,暂时先不处理
continue
elif resp.type == "tool_call":
if agent_runner.streaming:
# 用来标记流式响应需要分节
yield MessageChain(chain=[], type="break")
if astr_event.get_platform_name() == "webchat":
if show_tool_use:
await astr_event.send(resp.data["chain"])
elif show_tool_use:
json_comp = resp.data["chain"].chain[0]
if isinstance(json_comp, Json):
m = f"🔨 调用工具: {json_comp.data.get('name')}"
else:
m = "🔨 调用工具..."
chain = MessageChain(type="tool_call").message(m)
await astr_event.send(chain)
continue
if stream_to_general and resp.type == "streaming_delta":
@@ -99,15 +69,6 @@ async def run_agent(
continue
yield resp.data["chain"] # MessageChain
if agent_runner.done():
# send agent stats to webchat
if astr_event.get_platform_name() == "webchat":
await astr_event.send(
MessageChain(
type="agent_stats",
chain=[Json(data=agent_runner.stats.to_dict())],
)
)
break
except Exception as e:
+4 -34
View File
@@ -209,42 +209,12 @@ async def call_local_llm_tool(
else:
raise ValueError(f"未知的方法名: {method_name}")
except ValueError as e:
raise Exception(f"Tool execution ValueError: {e}") from e
except TypeError as e:
# 获取函数的签名(包括类型),除了第一个 event/context 参数。
try:
sig = inspect.signature(handler)
params = list(sig.parameters.values())
# 跳过第一个参数(event 或 context
if params:
params = params[1:]
param_strs = []
for param in params:
param_str = param.name
if param.annotation != inspect.Parameter.empty:
# 获取类型注解的字符串表示
if isinstance(param.annotation, type):
type_str = param.annotation.__name__
else:
type_str = str(param.annotation)
param_str += f": {type_str}"
if param.default != inspect.Parameter.empty:
param_str += f" = {param.default!r}"
param_strs.append(param_str)
handler_param_str = (
", ".join(param_strs) if param_strs else "(no additional parameters)"
)
except Exception:
handler_param_str = "(unable to inspect signature)"
raise Exception(
f"Tool handler parameter mismatch, please check the handler definition. Handler parameters: {handler_param_str}"
) from e
logger.error(f"调用本地 LLM 工具时出错: {e}", exc_info=True)
except TypeError:
logger.error("处理函数参数不匹配,请检查 handler 的定义。", exc_info=True)
except Exception as e:
trace_ = traceback.format_exc()
raise Exception(f"Tool execution error: {e}. Traceback: {trace_}") from e
logger.error(f"调用本地 LLM 工具时出错: {e}\n{trace_}")
if not ready_to_call:
return
-26
View File
@@ -1,26 +0,0 @@
"""AstrBot 备份与恢复模块
提供数据导出和导入功能,支持用户在服务器迁移时一键备份和恢复所有数据。
"""
# 从 constants 模块导入共享常量
from .constants import (
BACKUP_MANIFEST_VERSION,
KB_METADATA_MODELS,
MAIN_DB_MODELS,
get_backup_directories,
)
# 导入导出器和导入器
from .exporter import AstrBotExporter
from .importer import AstrBotImporter, ImportPreCheckResult
__all__ = [
"AstrBotExporter",
"AstrBotImporter",
"ImportPreCheckResult",
"MAIN_DB_MODELS",
"KB_METADATA_MODELS",
"get_backup_directories",
"BACKUP_MANIFEST_VERSION",
]
-77
View File
@@ -1,77 +0,0 @@
"""AstrBot 备份模块共享常量
此文件定义了导出器和导入器共享的常量,确保两端配置一致。
"""
from sqlmodel import SQLModel
from astrbot.core.db.po import (
Attachment,
CommandConfig,
CommandConflict,
ConversationV2,
Persona,
PlatformMessageHistory,
PlatformSession,
PlatformStat,
Preference,
)
from astrbot.core.knowledge_base.models import (
KBDocument,
KBMedia,
KnowledgeBase,
)
from astrbot.core.utils.astrbot_path import (
get_astrbot_config_path,
get_astrbot_plugin_data_path,
get_astrbot_plugin_path,
get_astrbot_t2i_templates_path,
get_astrbot_temp_path,
get_astrbot_webchat_path,
)
# ============================================================
# 共享常量 - 确保导出和导入端配置一致
# ============================================================
# 主数据库模型类映射
MAIN_DB_MODELS: dict[str, type[SQLModel]] = {
"platform_stats": PlatformStat,
"conversations": ConversationV2,
"personas": Persona,
"preferences": Preference,
"platform_message_history": PlatformMessageHistory,
"platform_sessions": PlatformSession,
"attachments": Attachment,
"command_configs": CommandConfig,
"command_conflicts": CommandConflict,
}
# 知识库元数据模型类映射
KB_METADATA_MODELS: dict[str, type[SQLModel]] = {
"knowledge_bases": KnowledgeBase,
"kb_documents": KBDocument,
"kb_media": KBMedia,
}
def get_backup_directories() -> dict[str, str]:
"""获取需要备份的目录列表
使用 astrbot_path 模块动态获取路径,支持通过环境变量 ASTRBOT_ROOT 自定义根目录。
Returns:
dict: 键为备份文件中的目录名称,值为目录的绝对路径
"""
return {
"plugins": get_astrbot_plugin_path(), # 插件本体
"plugin_data": get_astrbot_plugin_data_path(), # 插件数据
"config": get_astrbot_config_path(), # 配置目录
"t2i_templates": get_astrbot_t2i_templates_path(), # T2I 模板
"webchat": get_astrbot_webchat_path(), # WebChat 数据
"temp": get_astrbot_temp_path(), # 临时文件
}
# 备份清单版本号
BACKUP_MANIFEST_VERSION = "1.1"
-476
View File
@@ -1,476 +0,0 @@
"""AstrBot 数据导出器
负责将所有数据导出为 ZIP 备份文件。
导出格式为 JSON,这是数据库无关的方案,支持未来向 MySQL/PostgreSQL 迁移。
"""
import hashlib
import json
import os
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Any
from sqlalchemy import select
from astrbot.core import logger
from astrbot.core.config.default import VERSION
from astrbot.core.db import BaseDatabase
from astrbot.core.utils.astrbot_path import (
get_astrbot_backups_path,
get_astrbot_data_path,
)
# 从共享常量模块导入
from .constants import (
BACKUP_MANIFEST_VERSION,
KB_METADATA_MODELS,
MAIN_DB_MODELS,
get_backup_directories,
)
if TYPE_CHECKING:
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
CMD_CONFIG_FILE_PATH = os.path.join(get_astrbot_data_path(), "cmd_config.json")
class AstrBotExporter:
"""AstrBot 数据导出器
导出内容:
- 主数据库所有表(data/data_v4.db
- 知识库元数据(data/knowledge_base/kb.db
- 每个知识库的向量文档数据
- 配置文件(data/cmd_config.json
- 附件文件
- 知识库多媒体文件
- 插件目录(data/plugins
- 插件数据目录(data/plugin_data
- 配置目录(data/config
- T2I 模板目录(data/t2i_templates
- WebChat 数据目录(data/webchat
- 临时文件目录(data/temp
"""
def __init__(
self,
main_db: BaseDatabase,
kb_manager: "KnowledgeBaseManager | None" = None,
config_path: str = CMD_CONFIG_FILE_PATH,
):
self.main_db = main_db
self.kb_manager = kb_manager
self.config_path = config_path
self._checksums: dict[str, str] = {}
async def export_all(
self,
output_dir: str | None = None,
progress_callback: Any | None = None,
) -> str:
"""导出所有数据到 ZIP 文件
Args:
output_dir: 输出目录
progress_callback: 进度回调函数,接收参数 (stage, current, total, message)
Returns:
str: 生成的 ZIP 文件路径
"""
if output_dir is None:
output_dir = get_astrbot_backups_path()
# 确保输出目录存在
Path(output_dir).mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
zip_filename = f"astrbot_backup_{timestamp}.zip"
zip_path = os.path.join(output_dir, zip_filename)
logger.info(f"开始导出备份到 {zip_path}")
try:
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
# 1. 导出主数据库
if progress_callback:
await progress_callback("main_db", 0, 100, "正在导出主数据库...")
main_data = await self._export_main_database()
main_db_json = json.dumps(
main_data, ensure_ascii=False, indent=2, default=str
)
zf.writestr("databases/main_db.json", main_db_json)
self._add_checksum("databases/main_db.json", main_db_json)
if progress_callback:
await progress_callback("main_db", 100, 100, "主数据库导出完成")
# 2. 导出知识库数据
kb_meta_data: dict[str, Any] = {
"knowledge_bases": [],
"kb_documents": [],
"kb_media": [],
}
if self.kb_manager:
if progress_callback:
await progress_callback(
"kb_metadata", 0, 100, "正在导出知识库元数据..."
)
kb_meta_data = await self._export_kb_metadata()
kb_meta_json = json.dumps(
kb_meta_data, ensure_ascii=False, indent=2, default=str
)
zf.writestr("databases/kb_metadata.json", kb_meta_json)
self._add_checksum("databases/kb_metadata.json", kb_meta_json)
if progress_callback:
await progress_callback(
"kb_metadata", 100, 100, "知识库元数据导出完成"
)
# 导出每个知识库的文档数据
kb_insts = self.kb_manager.kb_insts
total_kbs = len(kb_insts)
for idx, (kb_id, kb_helper) in enumerate(kb_insts.items()):
if progress_callback:
await progress_callback(
"kb_documents",
idx,
total_kbs,
f"正在导出知识库 {kb_helper.kb.kb_name} 的文档数据...",
)
doc_data = await self._export_kb_documents(kb_helper)
doc_json = json.dumps(
doc_data, ensure_ascii=False, indent=2, default=str
)
doc_path = f"databases/kb_{kb_id}/documents.json"
zf.writestr(doc_path, doc_json)
self._add_checksum(doc_path, doc_json)
# 导出 FAISS 索引文件
await self._export_faiss_index(zf, kb_helper, kb_id)
# 导出知识库多媒体文件
await self._export_kb_media_files(zf, kb_helper, kb_id)
if progress_callback:
await progress_callback(
"kb_documents", total_kbs, total_kbs, "知识库文档导出完成"
)
# 3. 导出配置文件
if progress_callback:
await progress_callback("config", 0, 100, "正在导出配置文件...")
if os.path.exists(self.config_path):
with open(self.config_path, encoding="utf-8") as f:
config_content = f.read()
zf.writestr("config/cmd_config.json", config_content)
self._add_checksum("config/cmd_config.json", config_content)
if progress_callback:
await progress_callback("config", 100, 100, "配置文件导出完成")
# 4. 导出附件文件
if progress_callback:
await progress_callback("attachments", 0, 100, "正在导出附件...")
await self._export_attachments(zf, main_data.get("attachments", []))
if progress_callback:
await progress_callback("attachments", 100, 100, "附件导出完成")
# 5. 导出插件和其他目录
if progress_callback:
await progress_callback(
"directories", 0, 100, "正在导出插件和数据目录..."
)
dir_stats = await self._export_directories(zf)
if progress_callback:
await progress_callback("directories", 100, 100, "目录导出完成")
# 6. 生成 manifest
if progress_callback:
await progress_callback("manifest", 0, 100, "正在生成清单...")
manifest = self._generate_manifest(main_data, kb_meta_data, dir_stats)
manifest_json = json.dumps(manifest, ensure_ascii=False, indent=2)
zf.writestr("manifest.json", manifest_json)
if progress_callback:
await progress_callback("manifest", 100, 100, "清单生成完成")
logger.info(f"备份导出完成: {zip_path}")
return zip_path
except Exception as e:
logger.error(f"备份导出失败: {e}")
# 清理失败的文件
if os.path.exists(zip_path):
os.remove(zip_path)
raise
async def _export_main_database(self) -> dict[str, list[dict]]:
"""导出主数据库所有表"""
export_data: dict[str, list[dict]] = {}
async with self.main_db.get_db() as session:
for table_name, model_class in MAIN_DB_MODELS.items():
try:
result = await session.execute(select(model_class))
records = result.scalars().all()
export_data[table_name] = [
self._model_to_dict(record) for record in records
]
logger.debug(
f"导出表 {table_name}: {len(export_data[table_name])} 条记录"
)
except Exception as e:
logger.warning(f"导出表 {table_name} 失败: {e}")
export_data[table_name] = []
return export_data
async def _export_kb_metadata(self) -> dict[str, list[dict]]:
"""导出知识库元数据库"""
if not self.kb_manager:
return {"knowledge_bases": [], "kb_documents": [], "kb_media": []}
export_data: dict[str, list[dict]] = {}
async with self.kb_manager.kb_db.get_db() as session:
for table_name, model_class in KB_METADATA_MODELS.items():
try:
result = await session.execute(select(model_class))
records = result.scalars().all()
export_data[table_name] = [
self._model_to_dict(record) for record in records
]
logger.debug(
f"导出知识库表 {table_name}: {len(export_data[table_name])} 条记录"
)
except Exception as e:
logger.warning(f"导出知识库表 {table_name} 失败: {e}")
export_data[table_name] = []
return export_data
async def _export_kb_documents(self, kb_helper: Any) -> dict[str, Any]:
"""导出知识库的文档块数据"""
try:
from astrbot.core.db.vec_db.faiss_impl.vec_db import FaissVecDB
vec_db: FaissVecDB = kb_helper.vec_db
if not vec_db or not vec_db.document_storage:
return {"documents": []}
# 获取所有文档
docs = await vec_db.document_storage.get_documents(
metadata_filters={},
offset=0,
limit=None, # 获取全部
)
return {"documents": docs}
except Exception as e:
logger.warning(f"导出知识库文档失败: {e}")
return {"documents": []}
async def _export_faiss_index(
self,
zf: zipfile.ZipFile,
kb_helper: Any,
kb_id: str,
) -> None:
"""导出 FAISS 索引文件"""
try:
index_path = kb_helper.kb_dir / "index.faiss"
if index_path.exists():
archive_path = f"databases/kb_{kb_id}/index.faiss"
zf.write(str(index_path), archive_path)
logger.debug(f"导出 FAISS 索引: {archive_path}")
except Exception as e:
logger.warning(f"导出 FAISS 索引失败: {e}")
async def _export_kb_media_files(
self, zf: zipfile.ZipFile, kb_helper: Any, kb_id: str
) -> None:
"""导出知识库的多媒体文件"""
try:
media_dir = kb_helper.kb_medias_dir
if not media_dir.exists():
return
for root, _, files in os.walk(media_dir):
for file in files:
file_path = Path(root) / file
# 计算相对路径
rel_path = file_path.relative_to(kb_helper.kb_dir)
archive_path = f"files/kb_media/{kb_id}/{rel_path}"
zf.write(str(file_path), archive_path)
except Exception as e:
logger.warning(f"导出知识库媒体文件失败: {e}")
async def _export_directories(
self, zf: zipfile.ZipFile
) -> dict[str, dict[str, int]]:
"""导出插件和其他数据目录
Returns:
dict: 每个目录的统计信息 {dir_name: {"files": count, "size": bytes}}
"""
stats: dict[str, dict[str, int]] = {}
backup_directories = get_backup_directories()
for dir_name, dir_path in backup_directories.items():
full_path = Path(dir_path)
if not full_path.exists():
logger.debug(f"目录不存在,跳过: {full_path}")
continue
file_count = 0
total_size = 0
try:
for root, dirs, files in os.walk(full_path):
# 跳过 __pycache__ 目录
dirs[:] = [d for d in dirs if d != "__pycache__"]
for file in files:
# 跳过 .pyc 文件
if file.endswith(".pyc"):
continue
file_path = Path(root) / file
try:
# 计算相对路径
rel_path = file_path.relative_to(full_path)
archive_path = f"directories/{dir_name}/{rel_path}"
zf.write(str(file_path), archive_path)
file_count += 1
total_size += file_path.stat().st_size
except Exception as e:
logger.warning(f"导出文件 {file_path} 失败: {e}")
stats[dir_name] = {"files": file_count, "size": total_size}
logger.debug(
f"导出目录 {dir_name}: {file_count} 个文件, {total_size} 字节"
)
except Exception as e:
logger.warning(f"导出目录 {dir_path} 失败: {e}")
stats[dir_name] = {"files": 0, "size": 0}
return stats
async def _export_attachments(
self, zf: zipfile.ZipFile, attachments: list[dict]
) -> None:
"""导出附件文件"""
for attachment in attachments:
try:
file_path = attachment.get("path", "")
if file_path and os.path.exists(file_path):
# 使用 attachment_id 作为文件名
attachment_id = attachment.get("attachment_id", "")
ext = os.path.splitext(file_path)[1]
archive_path = f"files/attachments/{attachment_id}{ext}"
zf.write(file_path, archive_path)
except Exception as e:
logger.warning(f"导出附件失败: {e}")
def _model_to_dict(self, record: Any) -> dict:
"""将 SQLModel 实例转换为字典
这是数据库无关的序列化方式,支持未来迁移到其他数据库。
"""
# 使用 SQLModel 内置的 model_dump 方法(如果可用)
if hasattr(record, "model_dump"):
data = record.model_dump(mode="python")
# 处理 datetime 类型
for key, value in data.items():
if isinstance(value, datetime):
data[key] = value.isoformat()
return data
# 回退到手动提取
data = {}
# 使用 inspect 获取表信息
from sqlalchemy import inspect as sa_inspect
mapper = sa_inspect(record.__class__)
for column in mapper.columns:
value = getattr(record, column.name)
# 处理 datetime 类型 - 统一转为 ISO 格式字符串
if isinstance(value, datetime):
value = value.isoformat()
data[column.name] = value
return data
def _add_checksum(self, path: str, content: str | bytes) -> None:
"""计算并添加文件校验和"""
if isinstance(content, str):
content = content.encode("utf-8")
checksum = hashlib.sha256(content).hexdigest()
self._checksums[path] = f"sha256:{checksum}"
def _generate_manifest(
self,
main_data: dict[str, list[dict]],
kb_meta_data: dict[str, list[dict]],
dir_stats: dict[str, dict[str, int]] | None = None,
) -> dict:
"""生成备份清单"""
if dir_stats is None:
dir_stats = {}
# 收集知识库 ID
kb_document_tables = {}
if self.kb_manager:
for kb_id in self.kb_manager.kb_insts.keys():
kb_document_tables[kb_id] = "documents"
# 收集附件文件列表
attachment_files = []
for attachment in main_data.get("attachments", []):
attachment_id = attachment.get("attachment_id", "")
path = attachment.get("path", "")
if attachment_id and path:
ext = os.path.splitext(path)[1]
attachment_files.append(f"{attachment_id}{ext}")
# 收集知识库媒体文件
kb_media_files: dict[str, list[str]] = {}
if self.kb_manager:
for kb_id, kb_helper in self.kb_manager.kb_insts.items():
media_files: list[str] = []
media_dir = kb_helper.kb_medias_dir
if media_dir.exists():
for root, _, files in os.walk(media_dir):
for file in files:
media_files.append(file)
if media_files:
kb_media_files[kb_id] = media_files
manifest = {
"version": BACKUP_MANIFEST_VERSION,
"astrbot_version": VERSION,
"exported_at": datetime.now(timezone.utc).isoformat(),
"schema_version": {
"main_db": "v4",
"kb_db": "v1",
},
"tables": {
"main_db": list(main_data.keys()),
"kb_metadata": list(kb_meta_data.keys()),
"kb_documents": kb_document_tables,
},
"files": {
"attachments": attachment_files,
"kb_media": kb_media_files,
},
"directories": list(dir_stats.keys()),
"checksums": self._checksums,
"statistics": {
"main_db": {
table: len(records) for table, records in main_data.items()
},
"kb_metadata": {
table: len(records) for table, records in kb_meta_data.items()
},
"directories": dir_stats,
},
}
return manifest
-761
View File
@@ -1,761 +0,0 @@
"""AstrBot 数据导入器
负责从 ZIP 备份文件恢复所有数据。
导入时进行版本校验:
- 主版本(前两位)不同时直接拒绝导入
- 小版本(第三位)不同时提示警告,用户可选择强制导入
- 版本匹配时也需要用户确认
"""
import json
import os
import shutil
import zipfile
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any
from sqlalchemy import delete
from astrbot.core import logger
from astrbot.core.config.default import VERSION
from astrbot.core.db import BaseDatabase
from astrbot.core.utils.astrbot_path import (
get_astrbot_data_path,
get_astrbot_knowledge_base_path,
)
from astrbot.core.utils.version_comparator import VersionComparator
# 从共享常量模块导入
from .constants import (
KB_METADATA_MODELS,
MAIN_DB_MODELS,
get_backup_directories,
)
if TYPE_CHECKING:
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
def _get_major_version(version_str: str) -> str:
"""提取版本的主版本部分(前两位)
Args:
version_str: 版本字符串,如 "4.9.1", "4.10.0-beta"
Returns:
主版本字符串,如 "4.9", "4.10"
"""
if not version_str:
return "0.0"
# 移除 v 前缀和预发布标签
version = version_str.lower().replace("v", "").split("-")[0].split("+")[0]
parts = [p for p in version.split(".") if p] # 过滤空字符串
if len(parts) >= 2:
return f"{parts[0]}.{parts[1]}"
elif len(parts) == 1 and parts[0]:
return f"{parts[0]}.0"
return "0.0"
CMD_CONFIG_FILE_PATH = os.path.join(get_astrbot_data_path(), "cmd_config.json")
KB_PATH = get_astrbot_knowledge_base_path()
@dataclass
class ImportPreCheckResult:
"""导入预检查结果
用于在实际导入前检查备份文件的版本兼容性,
并返回确认信息让用户决定是否继续导入。
"""
# 检查是否通过(文件有效且版本可导入)
valid: bool = False
# 是否可以导入(版本兼容)
can_import: bool = False
# 版本状态: match(完全匹配), minor_diff(小版本差异), major_diff(主版本不同,拒绝)
version_status: str = ""
# 备份文件中的 AstrBot 版本
backup_version: str = ""
# 当前运行的 AstrBot 版本
current_version: str = VERSION
# 备份创建时间
backup_time: str = ""
# 确认消息(显示给用户)
confirm_message: str = ""
# 警告消息列表
warnings: list[str] = field(default_factory=list)
# 错误消息(如果检查失败)
error: str = ""
# 备份包含的内容摘要
backup_summary: dict = field(default_factory=dict)
def to_dict(self) -> dict:
return {
"valid": self.valid,
"can_import": self.can_import,
"version_status": self.version_status,
"backup_version": self.backup_version,
"current_version": self.current_version,
"backup_time": self.backup_time,
"confirm_message": self.confirm_message,
"warnings": self.warnings,
"error": self.error,
"backup_summary": self.backup_summary,
}
class ImportResult:
"""导入结果"""
def __init__(self):
self.success = True
self.imported_tables: dict[str, int] = {}
self.imported_files: dict[str, int] = {}
self.imported_directories: dict[str, int] = {}
self.warnings: list[str] = []
self.errors: list[str] = []
def add_warning(self, msg: str) -> None:
self.warnings.append(msg)
logger.warning(msg)
def add_error(self, msg: str) -> None:
self.errors.append(msg)
self.success = False
logger.error(msg)
def to_dict(self) -> dict:
return {
"success": self.success,
"imported_tables": self.imported_tables,
"imported_files": self.imported_files,
"imported_directories": self.imported_directories,
"warnings": self.warnings,
"errors": self.errors,
}
class AstrBotImporter:
"""AstrBot 数据导入器
导入备份文件中的所有数据,包括:
- 主数据库所有表
- 知识库元数据和文档
- 配置文件
- 附件文件
- 知识库多媒体文件
- 插件目录(data/plugins
- 插件数据目录(data/plugin_data
- 配置目录(data/config
- T2I 模板目录(data/t2i_templates
- WebChat 数据目录(data/webchat
- 临时文件目录(data/temp
"""
def __init__(
self,
main_db: BaseDatabase,
kb_manager: "KnowledgeBaseManager | None" = None,
config_path: str = CMD_CONFIG_FILE_PATH,
kb_root_dir: str = KB_PATH,
):
self.main_db = main_db
self.kb_manager = kb_manager
self.config_path = config_path
self.kb_root_dir = kb_root_dir
def pre_check(self, zip_path: str) -> ImportPreCheckResult:
"""预检查备份文件
在实际导入前检查备份文件的有效性和版本兼容性。
返回检查结果供前端显示确认对话框。
Args:
zip_path: ZIP 备份文件路径
Returns:
ImportPreCheckResult: 预检查结果
"""
result = ImportPreCheckResult()
result.current_version = VERSION
if not os.path.exists(zip_path):
result.error = f"备份文件不存在: {zip_path}"
return result
try:
with zipfile.ZipFile(zip_path, "r") as zf:
# 读取 manifest
try:
manifest_data = zf.read("manifest.json")
manifest = json.loads(manifest_data)
except KeyError:
result.error = "备份文件缺少 manifest.json,不是有效的 AstrBot 备份"
return result
except json.JSONDecodeError as e:
result.error = f"manifest.json 格式错误: {e}"
return result
# 提取基本信息
result.backup_version = manifest.get("astrbot_version", "未知")
result.backup_time = manifest.get("exported_at", "未知")
result.valid = True
# 构建备份摘要
result.backup_summary = {
"tables": list(manifest.get("tables", {}).keys()),
"has_knowledge_bases": manifest.get("has_knowledge_bases", False),
"has_config": manifest.get("has_config", False),
"directories": manifest.get("directories", []),
}
# 检查版本兼容性
version_check = self._check_version_compatibility(result.backup_version)
result.version_status = version_check["status"]
result.can_import = version_check["can_import"]
# 版本信息由前端根据 version_status 和 i18n 生成显示
# 不再将版本消息添加到 warnings 列表中,避免中文硬编码
# warnings 列表保留用于其他非版本相关的警告
return result
except zipfile.BadZipFile:
result.error = "无效的 ZIP 文件"
return result
except Exception as e:
result.error = f"检查备份文件失败: {e}"
return result
def _check_version_compatibility(self, backup_version: str) -> dict:
"""检查版本兼容性
规则:
- 主版本(前两位,如 4.9)必须一致,否则拒绝
- 小版本(第三位,如 4.9.1 vs 4.9.2)不同时,警告但允许导入
Returns:
dict: {status, can_import, message}
"""
if not backup_version:
return {
"status": "major_diff",
"can_import": False,
"message": "备份文件缺少版本信息",
}
# 提取主版本(前两位)进行比较
backup_major = _get_major_version(backup_version)
current_major = _get_major_version(VERSION)
# 比较主版本
if VersionComparator.compare_version(backup_major, current_major) != 0:
return {
"status": "major_diff",
"can_import": False,
"message": (
f"主版本不兼容: 备份版本 {backup_version}, 当前版本 {VERSION}"
f"跨主版本导入可能导致数据损坏,请使用相同主版本的 AstrBot。"
),
}
# 比较完整版本
version_cmp = VersionComparator.compare_version(backup_version, VERSION)
if version_cmp != 0:
return {
"status": "minor_diff",
"can_import": True,
"message": (
f"小版本差异: 备份版本 {backup_version}, 当前版本 {VERSION}"
),
}
return {
"status": "match",
"can_import": True,
"message": "版本匹配",
}
async def import_all(
self,
zip_path: str,
mode: str = "replace", # "replace" 清空后导入
progress_callback: Any | None = None,
) -> ImportResult:
"""从 ZIP 文件导入所有数据
Args:
zip_path: ZIP 备份文件路径
mode: 导入模式,目前仅支持 "replace"(清空后导入)
progress_callback: 进度回调函数,接收参数 (stage, current, total, message)
Returns:
ImportResult: 导入结果
"""
result = ImportResult()
if not os.path.exists(zip_path):
result.add_error(f"备份文件不存在: {zip_path}")
return result
logger.info(f"开始从 {zip_path} 导入备份")
try:
with zipfile.ZipFile(zip_path, "r") as zf:
# 1. 读取并验证 manifest
if progress_callback:
await progress_callback("validate", 0, 100, "正在验证备份文件...")
try:
manifest_data = zf.read("manifest.json")
manifest = json.loads(manifest_data)
except KeyError:
result.add_error("备份文件缺少 manifest.json")
return result
except json.JSONDecodeError as e:
result.add_error(f"manifest.json 格式错误: {e}")
return result
# 版本校验
try:
self._validate_version(manifest)
except ValueError as e:
result.add_error(str(e))
return result
if progress_callback:
await progress_callback("validate", 100, 100, "验证完成")
# 2. 导入主数据库
if progress_callback:
await progress_callback("main_db", 0, 100, "正在导入主数据库...")
try:
main_data_content = zf.read("databases/main_db.json")
main_data = json.loads(main_data_content)
if mode == "replace":
await self._clear_main_db()
imported = await self._import_main_database(main_data)
result.imported_tables.update(imported)
except Exception as e:
result.add_error(f"导入主数据库失败: {e}")
return result
if progress_callback:
await progress_callback("main_db", 100, 100, "主数据库导入完成")
# 3. 导入知识库
if self.kb_manager and "databases/kb_metadata.json" in zf.namelist():
if progress_callback:
await progress_callback("kb", 0, 100, "正在导入知识库...")
try:
kb_meta_content = zf.read("databases/kb_metadata.json")
kb_meta_data = json.loads(kb_meta_content)
if mode == "replace":
await self._clear_kb_data()
await self._import_knowledge_bases(zf, kb_meta_data, result)
except Exception as e:
result.add_warning(f"导入知识库失败: {e}")
if progress_callback:
await progress_callback("kb", 100, 100, "知识库导入完成")
# 4. 导入配置文件
if progress_callback:
await progress_callback("config", 0, 100, "正在导入配置文件...")
if "config/cmd_config.json" in zf.namelist():
try:
config_content = zf.read("config/cmd_config.json")
# 备份现有配置
if os.path.exists(self.config_path):
backup_path = f"{self.config_path}.bak"
shutil.copy2(self.config_path, backup_path)
with open(self.config_path, "wb") as f:
f.write(config_content)
result.imported_files["config"] = 1
except Exception as e:
result.add_warning(f"导入配置文件失败: {e}")
if progress_callback:
await progress_callback("config", 100, 100, "配置文件导入完成")
# 5. 导入附件文件
if progress_callback:
await progress_callback("attachments", 0, 100, "正在导入附件...")
attachment_count = await self._import_attachments(
zf, main_data.get("attachments", [])
)
result.imported_files["attachments"] = attachment_count
if progress_callback:
await progress_callback("attachments", 100, 100, "附件导入完成")
# 6. 导入插件和其他目录
if progress_callback:
await progress_callback(
"directories", 0, 100, "正在导入插件和数据目录..."
)
dir_stats = await self._import_directories(zf, manifest, result)
result.imported_directories = dir_stats
if progress_callback:
await progress_callback("directories", 100, 100, "目录导入完成")
logger.info(f"备份导入完成: {result.to_dict()}")
return result
except zipfile.BadZipFile:
result.add_error("无效的 ZIP 文件")
return result
except Exception as e:
result.add_error(f"导入失败: {e}")
return result
def _validate_version(self, manifest: dict) -> None:
"""验证版本兼容性 - 仅允许相同主版本导入
注意:此方法仅在 import_all 中调用,用于双重校验。
前端应先调用 pre_check 获取详细的版本信息并让用户确认。
"""
backup_version = manifest.get("astrbot_version")
if not backup_version:
raise ValueError("备份文件缺少版本信息")
# 使用新的版本兼容性检查
version_check = self._check_version_compatibility(backup_version)
if version_check["status"] == "major_diff":
raise ValueError(version_check["message"])
# minor_diff 和 match 都允许导入
if version_check["status"] == "minor_diff":
logger.warning(f"版本差异警告: {version_check['message']}")
async def _clear_main_db(self) -> None:
"""清空主数据库所有表"""
async with self.main_db.get_db() as session:
async with session.begin():
for table_name, model_class in MAIN_DB_MODELS.items():
try:
await session.execute(delete(model_class))
logger.debug(f"已清空表 {table_name}")
except Exception as e:
logger.warning(f"清空表 {table_name} 失败: {e}")
async def _clear_kb_data(self) -> None:
"""清空知识库数据"""
if not self.kb_manager:
return
# 清空知识库元数据表
async with self.kb_manager.kb_db.get_db() as session:
async with session.begin():
for table_name, model_class in KB_METADATA_MODELS.items():
try:
await session.execute(delete(model_class))
logger.debug(f"已清空知识库表 {table_name}")
except Exception as e:
logger.warning(f"清空知识库表 {table_name} 失败: {e}")
# 删除知识库文件目录
for kb_id in list(self.kb_manager.kb_insts.keys()):
try:
kb_helper = self.kb_manager.kb_insts[kb_id]
await kb_helper.terminate()
if kb_helper.kb_dir.exists():
shutil.rmtree(kb_helper.kb_dir)
except Exception as e:
logger.warning(f"清理知识库 {kb_id} 失败: {e}")
self.kb_manager.kb_insts.clear()
async def _import_main_database(
self, data: dict[str, list[dict]]
) -> dict[str, int]:
"""导入主数据库数据"""
imported: dict[str, int] = {}
async with self.main_db.get_db() as session:
async with session.begin():
for table_name, rows in data.items():
model_class = MAIN_DB_MODELS.get(table_name)
if not model_class:
logger.warning(f"未知的表: {table_name}")
continue
count = 0
for row in rows:
try:
# 转换 datetime 字符串为 datetime 对象
row = self._convert_datetime_fields(row, model_class)
obj = model_class(**row)
session.add(obj)
count += 1
except Exception as e:
logger.warning(f"导入记录到 {table_name} 失败: {e}")
imported[table_name] = count
logger.debug(f"导入表 {table_name}: {count} 条记录")
return imported
async def _import_knowledge_bases(
self,
zf: zipfile.ZipFile,
kb_meta_data: dict[str, list[dict]],
result: ImportResult,
) -> None:
"""导入知识库数据"""
if not self.kb_manager:
return
# 1. 导入知识库元数据
async with self.kb_manager.kb_db.get_db() as session:
async with session.begin():
for table_name, rows in kb_meta_data.items():
model_class = KB_METADATA_MODELS.get(table_name)
if not model_class:
continue
count = 0
for row in rows:
try:
row = self._convert_datetime_fields(row, model_class)
obj = model_class(**row)
session.add(obj)
count += 1
except Exception as e:
logger.warning(f"导入知识库记录到 {table_name} 失败: {e}")
result.imported_tables[f"kb_{table_name}"] = count
# 2. 导入每个知识库的文档和文件
for kb_data in kb_meta_data.get("knowledge_bases", []):
kb_id = kb_data.get("kb_id")
if not kb_id:
continue
# 创建知识库目录
kb_dir = Path(self.kb_root_dir) / kb_id
kb_dir.mkdir(parents=True, exist_ok=True)
# 导入文档数据
doc_path = f"databases/kb_{kb_id}/documents.json"
if doc_path in zf.namelist():
try:
doc_content = zf.read(doc_path)
doc_data = json.loads(doc_content)
# 导入到文档存储数据库
await self._import_kb_documents(kb_id, doc_data)
except Exception as e:
result.add_warning(f"导入知识库 {kb_id} 的文档失败: {e}")
# 导入 FAISS 索引
faiss_path = f"databases/kb_{kb_id}/index.faiss"
if faiss_path in zf.namelist():
try:
target_path = kb_dir / "index.faiss"
with zf.open(faiss_path) as src, open(target_path, "wb") as dst:
dst.write(src.read())
except Exception as e:
result.add_warning(f"导入知识库 {kb_id} 的 FAISS 索引失败: {e}")
# 导入媒体文件
media_prefix = f"files/kb_media/{kb_id}/"
for name in zf.namelist():
if name.startswith(media_prefix):
try:
rel_path = name[len(media_prefix) :]
target_path = kb_dir / rel_path
target_path.parent.mkdir(parents=True, exist_ok=True)
with zf.open(name) as src, open(target_path, "wb") as dst:
dst.write(src.read())
except Exception as e:
result.add_warning(f"导入媒体文件 {name} 失败: {e}")
# 3. 重新加载知识库实例
await self.kb_manager.load_kbs()
async def _import_kb_documents(self, kb_id: str, doc_data: dict) -> None:
"""导入知识库文档到向量数据库"""
from astrbot.core.db.vec_db.faiss_impl.document_storage import DocumentStorage
kb_dir = Path(self.kb_root_dir) / kb_id
doc_db_path = kb_dir / "doc.db"
# 初始化文档存储
doc_storage = DocumentStorage(str(doc_db_path))
await doc_storage.initialize()
try:
documents = doc_data.get("documents", [])
for doc in documents:
try:
await doc_storage.insert_document(
doc_id=doc.get("doc_id", ""),
text=doc.get("text", ""),
metadata=json.loads(doc.get("metadata", "{}")),
)
except Exception as e:
logger.warning(f"导入文档块失败: {e}")
finally:
await doc_storage.close()
async def _import_attachments(
self,
zf: zipfile.ZipFile,
attachments: list[dict],
) -> int:
"""导入附件文件"""
count = 0
attachments_dir = Path(self.config_path).parent / "attachments"
attachments_dir.mkdir(parents=True, exist_ok=True)
attachment_prefix = "files/attachments/"
for name in zf.namelist():
if name.startswith(attachment_prefix) and name != attachment_prefix:
try:
# 从附件记录中找到原始路径
attachment_id = os.path.splitext(os.path.basename(name))[0]
original_path = None
for att in attachments:
if att.get("attachment_id") == attachment_id:
original_path = att.get("path")
break
if original_path:
target_path = Path(original_path)
else:
target_path = attachments_dir / os.path.basename(name)
target_path.parent.mkdir(parents=True, exist_ok=True)
with zf.open(name) as src, open(target_path, "wb") as dst:
dst.write(src.read())
count += 1
except Exception as e:
logger.warning(f"导入附件 {name} 失败: {e}")
return count
async def _import_directories(
self,
zf: zipfile.ZipFile,
manifest: dict,
result: ImportResult,
) -> dict[str, int]:
"""导入插件和其他数据目录
Args:
zf: ZIP 文件对象
manifest: 备份清单
result: 导入结果对象
Returns:
dict: 每个目录导入的文件数量
"""
dir_stats: dict[str, int] = {}
# 检查备份版本是否支持目录备份(需要版本 >= 1.1)
backup_version = manifest.get("version", "1.0")
if VersionComparator.compare_version(backup_version, "1.1") < 0:
logger.info("备份版本不支持目录备份,跳过目录导入")
return dir_stats
backed_up_dirs = manifest.get("directories", [])
backup_directories = get_backup_directories()
for dir_name in backed_up_dirs:
if dir_name not in backup_directories:
result.add_warning(f"未知的目录类型: {dir_name}")
continue
target_dir = Path(backup_directories[dir_name])
archive_prefix = f"directories/{dir_name}/"
file_count = 0
try:
# 获取该目录下的所有文件
dir_files = [
name
for name in zf.namelist()
if name.startswith(archive_prefix) and name != archive_prefix
]
if not dir_files:
continue
# 备份现有目录(如果存在)
if target_dir.exists():
backup_path = Path(f"{target_dir}.bak")
if backup_path.exists():
shutil.rmtree(backup_path)
shutil.move(str(target_dir), str(backup_path))
logger.debug(f"已备份现有目录 {target_dir}{backup_path}")
# 创建目标目录
target_dir.mkdir(parents=True, exist_ok=True)
# 解压文件
for name in dir_files:
try:
# 计算相对路径
rel_path = name[len(archive_prefix) :]
if not rel_path: # 跳过目录条目
continue
target_path = target_dir / rel_path
target_path.parent.mkdir(parents=True, exist_ok=True)
with zf.open(name) as src, open(target_path, "wb") as dst:
dst.write(src.read())
file_count += 1
except Exception as e:
result.add_warning(f"导入文件 {name} 失败: {e}")
dir_stats[dir_name] = file_count
logger.debug(f"导入目录 {dir_name}: {file_count} 个文件")
except Exception as e:
result.add_warning(f"导入目录 {dir_name} 失败: {e}")
dir_stats[dir_name] = 0
return dir_stats
def _convert_datetime_fields(self, row: dict, model_class: type) -> dict:
"""转换 datetime 字符串字段为 datetime 对象"""
result = row.copy()
# 获取模型的 datetime 字段
from sqlalchemy import inspect as sa_inspect
try:
mapper = sa_inspect(model_class)
for column in mapper.columns:
if column.name in result and result[column.name] is not None:
# 检查是否是 datetime 类型的列
from sqlalchemy import DateTime
if isinstance(column.type, DateTime):
value = result[column.name]
if isinstance(value, str):
# 解析 ISO 格式的日期时间字符串
result[column.name] = datetime.fromisoformat(value)
except Exception:
pass
return result
+190 -146
View File
@@ -1,11 +1,10 @@
"""如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。"""
import os
from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.10.3"
VERSION = "4.9.0"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -62,8 +61,7 @@ DEFAULT_CONFIG = {
"ignore_bot_self_message": False,
"ignore_at_all": False,
},
"provider_sources": [], # provider sources
"provider": [], # models from provider_sources
"provider": [],
"provider_settings": {
"enable": True,
"default_provider_id": "",
@@ -110,7 +108,6 @@ DEFAULT_CONFIG = {
"provider_id": "",
"dual_output": False,
"use_file_service": False,
"trigger_probability": 1.0,
},
"provider_ltm_settings": {
"group_icl_enable": False,
@@ -173,22 +170,6 @@ 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 时代的配置元数据,目前仅承担以下功能:
@@ -227,7 +208,7 @@ CONFIG_METADATA_2 = {
"callback_server_host": "0.0.0.0",
"port": 6196,
},
"OneBot v11": {
"QQ 个人号(OneBot v11)": {
"id": "default",
"type": "aiocqhttp",
"enable": False,
@@ -862,7 +843,6 @@ CONFIG_METADATA_2 = {
"metadata": {
"provider": {
"type": "list",
# provider sources templates
"config_template": {
"OpenAI": {
"id": "openai",
@@ -873,49 +853,26 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://api.openai.com/v1",
"timeout": 120,
"model_config": {"model": "gpt-4o-mini", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
"hint": "也兼容所有与 OpenAI API 兼容的服务。",
},
"Google Gemini": {
"id": "google_gemini",
"provider": "google",
"type": "googlegenai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://generativelanguage.googleapis.com/",
"timeout": 120,
"gm_resp_image_modal": False,
"gm_native_search": False,
"gm_native_coderunner": False,
"gm_url_context": False,
"gm_safety_settings": {
"harassment": "BLOCK_MEDIUM_AND_ABOVE",
"hate_speech": "BLOCK_MEDIUM_AND_ABOVE",
"sexually_explicit": "BLOCK_MEDIUM_AND_ABOVE",
"dangerous_content": "BLOCK_MEDIUM_AND_ABOVE",
},
"gm_thinking_config": {"budget": 0, "level": "HIGH"},
},
"Anthropic": {
"id": "anthropic",
"provider": "anthropic",
"type": "anthropic_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.anthropic.com/v1",
"timeout": 120,
},
"Moonshot": {
"id": "moonshot",
"provider": "moonshot",
"Azure OpenAI": {
"id": "azure",
"provider": "azure",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"api_version": "2024-05-01-preview",
"key": [],
"api_base": "",
"timeout": 120,
"api_base": "https://api.moonshot.cn/v1",
"model_config": {"model": "gpt-4o-mini", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"xAI": {
"id": "xai",
@@ -926,52 +883,42 @@ CONFIG_METADATA_2 = {
"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"],
},
"DeepSeek": {
"id": "deepseek",
"provider": "deepseek",
"type": "openai_chat_completion",
"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.deepseek.com/v1",
"api_base": "https://api.anthropic.com/v1",
"timeout": 120,
"custom_headers": {},
},
"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": {},
"model_config": {
"model": "claude-3-5-sonnet-latest",
"max_tokens": 4096,
"temperature": 0.2,
},
"modalities": ["text", "image", "tool_use"],
},
"Ollama": {
"id": "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://127.0.0.1:11434/v1",
"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",
@@ -980,22 +927,16 @@ CONFIG_METADATA_2 = {
"provider_type": "chat_completion",
"enable": True,
"key": ["lmstudio"],
"api_base": "http://127.0.0.1:1234/v1",
"api_base": "http://localhost:1234/v1",
"model_config": {
"model": "llama-3.1-8b",
},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"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",
"Gemini(OpenAI兼容)": {
"id": "gemini_default",
"provider": "google",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
@@ -1003,10 +944,58 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://generativelanguage.googleapis.com/v1beta/openai/",
"timeout": 120,
"model_config": {
"model": "gemini-1.5-flash",
"temperature": 0.4,
},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"Gemini": {
"id": "gemini_default",
"provider": "google",
"type": "googlegenai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://generativelanguage.googleapis.com/",
"timeout": 120,
"model_config": {
"model": "gemini-2.0-flash-exp",
"temperature": 0.4,
},
"gm_resp_image_modal": False,
"gm_native_search": False,
"gm_native_coderunner": False,
"gm_url_context": False,
"gm_safety_settings": {
"harassment": "BLOCK_MEDIUM_AND_ABOVE",
"hate_speech": "BLOCK_MEDIUM_AND_ABOVE",
"sexually_explicit": "BLOCK_MEDIUM_AND_ABOVE",
"dangerous_content": "BLOCK_MEDIUM_AND_ABOVE",
},
"gm_thinking_config": {
"budget": 0,
},
"modalities": ["text", "image", "tool_use"],
},
"DeepSeek": {
"id": "deepseek_default",
"provider": "deepseek",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.deepseek.com/v1",
"timeout": 120,
"model_config": {"model": "deepseek-chat", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "tool_use"],
},
"Groq": {
"id": "groq",
"id": "groq_default",
"provider": "groq",
"type": "groq_chat_completion",
"provider_type": "chat_completion",
@@ -1014,7 +1003,13 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://api.groq.com/openai/v1",
"timeout": 120,
"model_config": {
"model": "openai/gpt-oss-20b",
"temperature": 0.4,
},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "tool_use"],
},
"302.AI": {
"id": "302ai",
@@ -1025,9 +1020,12 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://api.302.ai/v1",
"timeout": 120,
"model_config": {"model": "gpt-4.1-mini", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"SiliconFlow": {
"硅基流动": {
"id": "siliconflow",
"provider": "siliconflow",
"type": "openai_chat_completion",
@@ -1036,9 +1034,15 @@ CONFIG_METADATA_2 = {
"key": [],
"timeout": 120,
"api_base": "https://api.siliconflow.cn/v1",
"model_config": {
"model": "deepseek-ai/DeepSeek-V3",
"temperature": 0.4,
},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"PPIO": {
"PPIO派欧云": {
"id": "ppio",
"provider": "ppio",
"type": "openai_chat_completion",
@@ -1047,9 +1051,14 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://api.ppinfra.com/v3/openai",
"timeout": 120,
"model_config": {
"model": "deepseek/deepseek-r1",
"temperature": 0.4,
},
"custom_headers": {},
"custom_extra_body": {},
},
"TokenPony": {
"小马算力": {
"id": "tokenpony",
"provider": "tokenpony",
"type": "openai_chat_completion",
@@ -1058,9 +1067,14 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://api.tokenpony.cn/v1",
"timeout": 120,
"model_config": {
"model": "kimi-k2-instruct-0905",
"temperature": 0.7,
},
"custom_headers": {},
"custom_extra_body": {},
},
"Compshare": {
"优云智算": {
"id": "compshare",
"provider": "compshare",
"type": "openai_chat_completion",
@@ -1069,18 +1083,42 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://api.modelverse.cn/v1",
"timeout": 120,
"model_config": {
"model": "moonshotai/Kimi-K2-Instruct",
},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"ModelScope": {
"id": "modelscope",
"provider": "modelscope",
"Kimi": {
"id": "moonshot",
"provider": "moonshot",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"timeout": 120,
"api_base": "https://api-inference.modelscope.cn/v1",
"api_base": "https://api.moonshot.cn/v1",
"model_config": {"model": "moonshot-v1-8k", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"智谱 AI": {
"id": "zhipu_default",
"provider": "zhipu",
"type": "zhipu_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"timeout": 120,
"api_base": "https://open.bigmodel.cn/api/paas/v4/",
"model_config": {
"model": "glm-4-flash",
},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"Dify": {
"id": "dify_app_default",
@@ -1095,6 +1133,7 @@ CONFIG_METADATA_2 = {
"dify_query_input_key": "astrbot_text_query",
"variables": {},
"timeout": 60,
"hint": "请确保你在 AstrBot 里设置的 APP 类型和 Dify 里面创建的应用的类型一致!",
},
"Coze": {
"id": "coze",
@@ -1125,6 +1164,20 @@ CONFIG_METADATA_2 = {
"variables": {},
"timeout": 60,
},
"ModelScope": {
"id": "modelscope",
"provider": "modelscope",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"timeout": 120,
"api_base": "https://api-inference.modelscope.cn/v1",
"model_config": {"model": "Qwen/Qwen3-32B", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"FastGPT": {
"id": "fastgpt",
"provider": "fastgpt",
@@ -1148,6 +1201,7 @@ CONFIG_METADATA_2 = {
"model": "whisper-1",
},
"Whisper(Local)": {
"hint": "启用前请 pip 安装 openai-whisper 库(N卡用户大约下载 2GB,主要是 torch 和 cudaCPU 用户大约下载 1 GB),并且安装 ffmpeg。否则将无法正常转文字。",
"provider": "openai",
"type": "openai_whisper_selfhost",
"provider_type": "speech_to_text",
@@ -1156,6 +1210,7 @@ CONFIG_METADATA_2 = {
"model": "tiny",
},
"SenseVoice(Local)": {
"hint": "启用前请 pip 安装 funasr、funasr_onnx、torchaudio、torch、modelscope、jieba 库(默认使用CPU,大约下载 1 GB),并且安装 ffmpeg。否则将无法正常转文字。",
"type": "sensevoice_stt_selfhost",
"provider": "sensevoice",
"provider_type": "speech_to_text",
@@ -1177,6 +1232,7 @@ CONFIG_METADATA_2 = {
"timeout": "20",
},
"Edge TTS": {
"hint": "提示:使用这个服务前需要安装有 ffmpeg,并且可以直接在终端调用 ffmpeg 指令。",
"id": "edge_tts",
"provider": "microsoft",
"type": "edge_tts",
@@ -1392,10 +1448,6 @@ CONFIG_METADATA_2 = {
},
},
"items": {
"provider_source_id": {
"invisible": True,
"type": "string",
},
"xai_native_search": {
"description": "启用原生搜索功能",
"type": "bool",
@@ -1766,24 +1818,13 @@ CONFIG_METADATA_2 = {
},
},
"gm_thinking_config": {
"description": "Thinking Config",
"description": "Gemini思考设置",
"type": "object",
"items": {
"budget": {
"description": "Thinking Budget",
"description": "思考预算",
"type": "int",
"hint": "Guides the model on the specific number of thinking tokens to use for reasoning. See: https://ai.google.dev/gemini-api/docs/thinking#set-budget",
},
"level": {
"description": "Thinking Level",
"type": "string",
"hint": "Recommended for Gemini 3 models and onwards, lets you control reasoning behavior.See: https://ai.google.dev/gemini-api/docs/thinking#thinking-levels",
"options": [
"MINIMAL",
"LOW",
"MEDIUM",
"HIGH",
],
"hint": "模型应该生成的思考Token的数量,设为0关闭思考。除gemini-2.5-flash外的模型会静默忽略此参数。",
},
},
},
@@ -1964,6 +2005,7 @@ CONFIG_METADATA_2 = {
"id": {
"description": "ID",
"type": "string",
"hint": "模型提供商名字。",
},
"type": {
"description": "模型提供商种类",
@@ -1983,15 +2025,29 @@ CONFIG_METADATA_2 = {
"description": "API Key",
"type": "list",
"items": {"type": "string"},
"hint": "提供商 API Key。",
},
"api_base": {
"description": "API Base URL",
"type": "string",
"hint": "API Base URL 请在模型提供商处获得。如出现 404 报错,尝试在地址末尾加上 /v1",
},
"model": {
"description": "模型 ID",
"type": "string",
"hint": "模型名称,如 gpt-4o-mini, deepseek-chat。",
"model_config": {
"description": "模型配置",
"type": "object",
"items": {
"model": {
"description": "模型名称",
"type": "string",
"hint": "模型名称,如 gpt-4o-mini, deepseek-chat。",
},
"max_tokens": {
"description": "模型最大输出长度(tokens",
"type": "int",
},
"temperature": {"description": "温度", "type": "float"},
"top_p": {"description": "Top P值", "type": "float"},
},
},
"dify_api_key": {
"description": "API Key",
@@ -2153,9 +2209,6 @@ CONFIG_METADATA_2 = {
"use_file_service": {
"type": "bool",
},
"trigger_probability": {
"type": "float",
},
},
},
"provider_ltm_settings": {
@@ -2366,14 +2419,6 @@ CONFIG_METADATA_3 = {
"provider_tts_settings.enable": True,
},
},
"provider_tts_settings.trigger_probability": {
"description": "TTS 触发概率",
"type": "float",
"slider": {"min": 0, "max": 1, "step": 0.05},
"condition": {
"provider_tts_settings.enable": True,
},
},
"provider_settings.image_caption_prompt": {
"description": "图片转述提示词",
"type": "text",
@@ -2941,7 +2986,6 @@ CONFIG_METADATA_3 = {
"description": "回复概率",
"type": "float",
"hint": "0.0-1.0 之间的数值",
"slider": {"min": 0, "max": 1, "step": 0.05},
"condition": {
"provider_ltm_settings.active_reply.enable": True,
},
-1
View File
@@ -79,7 +79,6 @@ class ConfigMetadataI18n:
"_special",
"invisible",
"options",
"slider",
]:
if attr in field_data:
field_result[attr] = field_data[attr]
-3
View File
@@ -33,7 +33,6 @@ from astrbot.core.star.context import Context
from astrbot.core.star.star_handler import EventType, star_handlers_registry, star_map
from astrbot.core.umop_config_router import UmopConfigRouter
from astrbot.core.updator import AstrBotUpdator
from astrbot.core.utils.llm_metadata import update_llm_metadata
from astrbot.core.utils.migra_helper import migra
from . import astrbot_config, html_renderer
@@ -186,8 +185,6 @@ class AstrBotCoreLifecycle:
# 初始化关闭控制面板的事件
self.dashboard_shutdown_event = asyncio.Event()
asyncio.create_task(update_llm_metadata())
def _load(self) -> None:
"""加载事件总线和任务并初始化."""
# 创建一个异步任务来执行事件总线的 dispatch() 方法
+1 -1
View File
@@ -58,7 +58,7 @@ def is_plugin_path(pathname):
return False
norm_path = os.path.normpath(pathname)
return ("data/plugins" in norm_path) or ("astrbot/builtin_stars/" in norm_path)
return ("data/plugins" in norm_path) or ("packages/" in norm_path)
def get_short_level_name(level_name):
+5 -4
View File
@@ -629,11 +629,12 @@ class Nodes(BaseMessageComponent):
class Json(BaseMessageComponent):
type = ComponentType.Json
data: dict
data: str | dict
resid: int | None = 0
def __init__(self, data: str | dict, **_):
if isinstance(data, str):
data = json.loads(data)
def __init__(self, data, **_):
if isinstance(data, dict):
data = json.dumps(data)
super().__init__(data=data, **_)
@@ -321,12 +321,7 @@ class InternalAgentSubStage(Stage):
elif isinstance(req.tool_calls_result, list):
for tcr in req.tool_calls_result:
messages.extend(tcr.to_openai_messages())
messages.append(
{
"role": "assistant",
"content": llm_response.completion_text or "*No response*",
}
)
messages.append({"role": "assistant", "content": llm_response.completion_text})
messages = list(filter(lambda item: "_no_save" not in item, messages))
await self.conv_manager.update_conversation(
event.unified_msg_origin,
@@ -390,7 +385,7 @@ class InternalAgentSubStage(Stage):
return
req.prompt = event.message_str[len(provider_wake_prefix) :]
# func_tool selection 现在已经转移到 astrbot/builtin_stars/astrbot 插件中进行选择。
# func_tool selection 现在已经转移到 packages/astrbot 插件中进行选择。
# req.func_tool = self.ctx.plugin_manager.context.get_llm_tool_manager()
for comp in event.message_obj.message:
if isinstance(comp, Image):
+1 -5
View File
@@ -119,7 +119,7 @@ class RespondStage(Stage):
if (result := event.get_result()) is None:
return False
if self.only_llm_result and not result.is_llm_result():
if self.only_llm_result and result.is_llm_result():
return False
if event.get_platform_name() in [
@@ -158,11 +158,7 @@ class RespondStage(Stage):
result = event.get_result()
if result is None:
return
if event.get_extra("_streaming_finished", False):
# prevent some plugin make result content type to LLM_RESULT after streaming finished, lead to send again
return
if result.result_content_type == ResultContentType.STREAMING_FINISH:
event.set_extra("_streaming_finished", True)
return
logger.info(
+1 -21
View File
@@ -1,4 +1,3 @@
import random
import re
import time
import traceback
@@ -43,18 +42,6 @@ class ResultDecorateStage(Stage):
"forward_threshold"
]
trigger_probability = ctx.astrbot_config["provider_tts_settings"].get(
"trigger_probability",
1,
)
try:
self.tts_trigger_probability = max(
0.0,
min(float(trigger_probability), 1.0),
)
except (TypeError, ValueError):
self.tts_trigger_probability = 1.0
# 分段回复
self.words_count_threshold = int(
ctx.astrbot_config["platform_settings"]["segmented_reply"][
@@ -259,14 +246,7 @@ class ResultDecorateStage(Stage):
and result.is_llm_result()
and SessionServiceManager.should_process_tts_request(event)
):
should_tts = self.tts_trigger_probability >= 1.0 or (
self.tts_trigger_probability > 0.0
and random.random() <= self.tts_trigger_probability
)
if not should_tts:
logger.debug("跳过 TTS:触发概率未命中。")
elif not tts_provider:
if not tts_provider:
logger.warning(
f"会话 {event.unified_msg_origin} 未配置文本转语音模型。",
)
+1 -2
View File
@@ -136,8 +136,7 @@ class WakingCheckStage(Stage):
):
if (
self.disable_builtin_commands
and handler.handler_module_path
== "astrbot.builtin_stars.builtin_commands.main"
and handler.handler_module_path == "packages.builtin_commands.main"
):
logger.debug("skipping builtin command")
continue
@@ -385,25 +385,10 @@ class AiocqhttpAdapter(Platform):
logger.error(f"获取 @ 用户信息失败: {e},此消息段将被忽略。")
message_str += "".join(at_parts)
elif t == "markdown":
text = m["data"].get("markdown") or m["data"].get("content", "")
abm.message.append(Plain(text=text))
message_str += text
else:
for m in m_group:
try:
if t not in ComponentTypes:
logger.warning(
f"不支持的消息段类型,已忽略: {t}, data={m['data']}"
)
continue
a = ComponentTypes[t](**m["data"])
abm.message.append(a)
except Exception as e:
logger.exception(
f"消息段解析失败: type={t}, data={m['data']}. {e}"
)
continue
a = ComponentTypes[t](**m["data"])
abm.message.append(a)
abm.timestamp = int(time.time())
abm.message_str = message_str
@@ -200,15 +200,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
if isinstance(chain, MessageChain):
if chain.type == "break":
# 分割符
if message_id:
try:
await self.client.edit_message_text(
text=delta,
chat_id=payload["chat_id"],
message_id=message_id,
)
except Exception as e:
logger.warning(f"编辑消息失败(streaming-break): {e!s}")
message_id = None # 重置消息 ID
delta = "" # 重置 delta
continue
@@ -1,12 +1,11 @@
import base64
import json
import os
import shutil
import uuid
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import File, Image, Json, Plain, Record
from astrbot.api.message_components import File, Image, Plain, Record
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from .webchat_queue_mgr import webchat_queue_mgr
@@ -42,20 +41,12 @@ class WebChatMessageEvent(AstrMessageEvent):
await web_chat_back_queue.put(
{
"type": "plain",
"cid": cid,
"data": data,
"streaming": streaming,
"chain_type": message.type,
},
)
elif isinstance(comp, Json):
await web_chat_back_queue.put(
{
"type": "plain",
"data": json.dumps(comp.data, ensure_ascii=False),
"streaming": streaming,
"chain_type": message.type,
},
)
elif isinstance(comp, Image):
# save image to local
filename = f"{str(uuid.uuid4())}.jpg"
@@ -67,6 +58,7 @@ class WebChatMessageEvent(AstrMessageEvent):
await web_chat_back_queue.put(
{
"type": "image",
"cid": cid,
"data": data,
"streaming": streaming,
},
@@ -82,6 +74,7 @@ class WebChatMessageEvent(AstrMessageEvent):
await web_chat_back_queue.put(
{
"type": "record",
"cid": cid,
"data": data,
"streaming": streaming,
},
@@ -98,6 +91,7 @@ class WebChatMessageEvent(AstrMessageEvent):
await web_chat_back_queue.put(
{
"type": "file",
"cid": cid,
"data": data,
"streaming": streaming,
},
@@ -117,17 +111,18 @@ class WebChatMessageEvent(AstrMessageEvent):
cid = self.session_id.split("!")[-1]
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
async for chain in generator:
# if chain.type == "break" and final_data:
# # 分割符
# await web_chat_back_queue.put(
# {
# "type": "break", # break means a segment end
# "data": final_data,
# "streaming": True,
# },
# )
# final_data = ""
# continue
if chain.type == "break" and final_data:
# 分割符
await web_chat_back_queue.put(
{
"type": "break", # break means a segment end
"data": final_data,
"streaming": True,
"cid": cid,
},
)
final_data = ""
continue
r = await WebChatMessageEvent._send(
chain,
@@ -147,6 +142,7 @@ class WebChatMessageEvent(AstrMessageEvent):
"data": final_data,
"reasoning": reasoning_content,
"streaming": True,
"cid": cid,
},
)
await super().send_streaming(generator, use_fallback)
+9 -73
View File
@@ -1,5 +1,3 @@
from __future__ import annotations
import base64
import enum
import json
@@ -14,7 +12,6 @@ import astrbot.core.message.components as Comp
from astrbot import logger
from astrbot.core.agent.message import (
AssistantMessageSegment,
ContentPart,
ToolCall,
ToolCallMessageSegment,
)
@@ -93,8 +90,6 @@ class ProviderRequest:
"""会话 ID"""
image_urls: list[str] = field(default_factory=list)
"""图片 URL 列表"""
extra_user_content_parts: list[ContentPart] = field(default_factory=list)
"""额外的用户消息内容部分列表,用于在用户消息后添加额外的内容块(如系统提醒、指令等)。支持 dict 或 ContentPart 对象"""
func_tool: ToolSet | None = None
"""可用的函数工具"""
contexts: list[dict] = field(default_factory=list)
@@ -169,23 +164,13 @@ class ProviderRequest:
async def assemble_context(self) -> dict:
"""将请求(prompt 和 image_urls)包装成 OpenAI 的消息格式。"""
# 构建内容块列表
content_blocks = []
# 1. 用户原始发言(OpenAI 建议:用户发言在前)
if self.prompt and self.prompt.strip():
content_blocks.append({"type": "text", "text": self.prompt})
elif self.image_urls:
# 如果没有文本但有图片,添加占位文本
content_blocks.append({"type": "text", "text": "[图片]"})
# 2. 额外的内容块(系统提醒、指令等)
if self.extra_user_content_parts:
for part in self.extra_user_content_parts:
content_blocks.append(part.model_dump())
# 3. 图片内容
if self.image_urls:
user_content = {
"role": "user",
"content": [
{"type": "text", "text": self.prompt if self.prompt else "[图片]"},
],
}
for image_url in self.image_urls:
if image_url.startswith("http"):
image_path = await download_image_by_url(image_url)
@@ -198,21 +183,11 @@ class ProviderRequest:
if not image_data:
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
continue
content_blocks.append(
user_content["content"].append(
{"type": "image_url", "image_url": {"url": image_data}},
)
# 只有当只有一个来自 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}
return user_content
return {"role": "user", "content": self.prompt}
async def _encode_image_bs64(self, image_url: str) -> str:
"""将图片转换为 base64"""
@@ -224,38 +199,6 @@ class ProviderRequest:
return ""
@dataclass
class TokenUsage:
input_other: int = 0
"""The number of input tokens, excluding cached tokens."""
input_cached: int = 0
"""The number of input cached tokens."""
output: int = 0
"""The number of output tokens."""
@property
def total(self) -> int:
return self.input_other + self.input_cached + self.output
@property
def input(self) -> int:
return self.input_other + self.input_cached
def __add__(self, other: TokenUsage) -> TokenUsage:
return TokenUsage(
input_other=self.input_other + other.input_other,
input_cached=self.input_cached + other.input_cached,
output=self.output + other.output,
)
def __sub__(self, other: TokenUsage) -> TokenUsage:
return TokenUsage(
input_other=self.input_other - other.input_other,
input_cached=self.input_cached - other.input_cached,
output=self.output - other.output,
)
@dataclass
class LLMResponse:
role: str
@@ -284,11 +227,6 @@ class LLMResponse:
is_chunk: bool = False
"""Indicates if the response is a chunked response."""
id: str | None = None
"""The ID of the response. For chunked responses, it's the ID of the chunk; for non-chunked responses, it's the ID of the response."""
usage: TokenUsage | None = None
"""The usage of the response. For chunked responses, it's the usage of the chunk; for non-chunked responses, it's the usage of the response."""
def __init__(
self,
role: str,
@@ -303,8 +241,6 @@ class LLMResponse:
| AnthropicMessage
| None = None,
is_chunk: bool = False,
id: str | None = None,
usage: TokenUsage | None = None,
):
"""初始化 LLMResponse
+93 -203
View File
@@ -1,5 +1,4 @@
import asyncio
import copy
import traceback
from typing import Protocol, runtime_checkable
@@ -33,12 +32,10 @@ class ProviderManager:
persona_mgr: PersonaManager,
):
self.reload_lock = asyncio.Lock()
self.resource_lock = asyncio.Lock()
self.persona_mgr = persona_mgr
self.acm = acm
config = acm.confs["default"]
self.providers_config: list = config["provider"]
self.provider_sources_config: list = config.get("provider_sources", [])
self.provider_settings: dict = config["provider_settings"]
self.provider_stt_settings: dict = config.get("provider_stt_settings", {})
self.provider_tts_settings: dict = config.get("provider_tts_settings", {})
@@ -151,7 +148,6 @@ class ProviderManager:
"""
provider = None
provider_id = None
if umo:
provider_id = sp.get(
f"provider_perf_{provider_type.value}",
@@ -189,12 +185,6 @@ class ProviderManager:
)
else:
raise ValueError(f"Unknown provider type: {provider_type}")
if not provider and provider_id:
logger.warning(
f"没有找到 ID 为 {provider_id} 的提供商,这可能是由于您修改了提供商(模型)ID 导致的。"
)
return provider
async def initialize(self):
@@ -261,136 +251,7 @@ class ProviderManager:
# 初始化 MCP Client 连接
asyncio.create_task(self.llm_tools.init_mcp_clients(), name="init_mcp_clients")
def dynamic_import_provider(self, type: str):
"""动态导入提供商适配器模块
Args:
type (str): 提供商请求类型
Raises:
ImportError: 如果提供商类型未知或无法导入对应模块则抛出异常
"""
match type:
case "openai_chat_completion":
from .sources.openai_source import (
ProviderOpenAIOfficial as ProviderOpenAIOfficial,
)
case "zhipu_chat_completion":
from .sources.zhipu_source import ProviderZhipu as ProviderZhipu
case "groq_chat_completion":
from .sources.groq_source import ProviderGroq as ProviderGroq
case "anthropic_chat_completion":
from .sources.anthropic_source import (
ProviderAnthropic as ProviderAnthropic,
)
case "googlegenai_chat_completion":
from .sources.gemini_source import (
ProviderGoogleGenAI as ProviderGoogleGenAI,
)
case "sensevoice_stt_selfhost":
from .sources.sensevoice_selfhosted_source import (
ProviderSenseVoiceSTTSelfHost as ProviderSenseVoiceSTTSelfHost,
)
case "openai_whisper_api":
from .sources.whisper_api_source import (
ProviderOpenAIWhisperAPI as ProviderOpenAIWhisperAPI,
)
case "openai_whisper_selfhost":
from .sources.whisper_selfhosted_source import (
ProviderOpenAIWhisperSelfHost as ProviderOpenAIWhisperSelfHost,
)
case "xinference_stt":
from .sources.xinference_stt_provider import (
ProviderXinferenceSTT as ProviderXinferenceSTT,
)
case "openai_tts_api":
from .sources.openai_tts_api_source import (
ProviderOpenAITTSAPI as ProviderOpenAITTSAPI,
)
case "edge_tts":
from .sources.edge_tts_source import (
ProviderEdgeTTS as ProviderEdgeTTS,
)
case "gsv_tts_selfhost":
from .sources.gsv_selfhosted_source import (
ProviderGSVTTS as ProviderGSVTTS,
)
case "gsvi_tts_api":
from .sources.gsvi_tts_source import (
ProviderGSVITTS as ProviderGSVITTS,
)
case "fishaudio_tts_api":
from .sources.fishaudio_tts_api_source import (
ProviderFishAudioTTSAPI as ProviderFishAudioTTSAPI,
)
case "dashscope_tts":
from .sources.dashscope_tts import (
ProviderDashscopeTTSAPI as ProviderDashscopeTTSAPI,
)
case "azure_tts":
from .sources.azure_tts_source import (
AzureTTSProvider as AzureTTSProvider,
)
case "minimax_tts_api":
from .sources.minimax_tts_api_source import (
ProviderMiniMaxTTSAPI as ProviderMiniMaxTTSAPI,
)
case "volcengine_tts":
from .sources.volcengine_tts import (
ProviderVolcengineTTS as ProviderVolcengineTTS,
)
case "gemini_tts":
from .sources.gemini_tts_source import (
ProviderGeminiTTSAPI as ProviderGeminiTTSAPI,
)
case "openai_embedding":
from .sources.openai_embedding_source import (
OpenAIEmbeddingProvider as OpenAIEmbeddingProvider,
)
case "gemini_embedding":
from .sources.gemini_embedding_source import (
GeminiEmbeddingProvider as GeminiEmbeddingProvider,
)
case "vllm_rerank":
from .sources.vllm_rerank_source import (
VLLMRerankProvider as VLLMRerankProvider,
)
case "xinference_rerank":
from .sources.xinference_rerank_source import (
XinferenceRerankProvider as XinferenceRerankProvider,
)
case "bailian_rerank":
from .sources.bailian_rerank_source import (
BailianRerankProvider as BailianRerankProvider,
)
def get_merged_provider_config(self, provider_config: dict) -> dict:
"""获取 provider 配置和 provider_source 配置合并后的结果
Returns:
dict: 合并后的 provider 配置key provider idvalue 为合并后的配置字典
"""
pc = copy.deepcopy(provider_config)
provider_source_id = pc.get("provider_source_id", "")
if provider_source_id:
provider_source = None
for ps in self.provider_sources_config:
if ps.get("id") == provider_source_id:
provider_source = ps
break
if provider_source:
# 合并配置,provider 的配置优先级更高
merged_config = {**provider_source, **pc}
# 保持 id 为 provider 的 id,而不是 source 的 id
merged_config["id"] = pc["id"]
pc = merged_config
return pc
async def load_provider(self, provider_config: dict):
# 如果 provider_source_id 存在且不为空,则从 provider_sources 中找到对应的配置并合并
provider_config = self.get_merged_provider_config(provider_config)
if not provider_config["enable"]:
logger.info(f"Provider {provider_config['id']} is disabled, skipping")
return
@@ -403,7 +264,99 @@ class ProviderManager:
# 动态导入
try:
self.dynamic_import_provider(provider_config["type"])
match provider_config["type"]:
case "openai_chat_completion":
from .sources.openai_source import (
ProviderOpenAIOfficial as ProviderOpenAIOfficial,
)
case "zhipu_chat_completion":
from .sources.zhipu_source import ProviderZhipu as ProviderZhipu
case "groq_chat_completion":
from .sources.groq_source import ProviderGroq as ProviderGroq
case "anthropic_chat_completion":
from .sources.anthropic_source import (
ProviderAnthropic as ProviderAnthropic,
)
case "googlegenai_chat_completion":
from .sources.gemini_source import (
ProviderGoogleGenAI as ProviderGoogleGenAI,
)
case "sensevoice_stt_selfhost":
from .sources.sensevoice_selfhosted_source import (
ProviderSenseVoiceSTTSelfHost as ProviderSenseVoiceSTTSelfHost,
)
case "openai_whisper_api":
from .sources.whisper_api_source import (
ProviderOpenAIWhisperAPI as ProviderOpenAIWhisperAPI,
)
case "openai_whisper_selfhost":
from .sources.whisper_selfhosted_source import (
ProviderOpenAIWhisperSelfHost as ProviderOpenAIWhisperSelfHost,
)
case "xinference_stt":
from .sources.xinference_stt_provider import (
ProviderXinferenceSTT as ProviderXinferenceSTT,
)
case "openai_tts_api":
from .sources.openai_tts_api_source import (
ProviderOpenAITTSAPI as ProviderOpenAITTSAPI,
)
case "edge_tts":
from .sources.edge_tts_source import (
ProviderEdgeTTS as ProviderEdgeTTS,
)
case "gsv_tts_selfhost":
from .sources.gsv_selfhosted_source import (
ProviderGSVTTS as ProviderGSVTTS,
)
case "gsvi_tts_api":
from .sources.gsvi_tts_source import (
ProviderGSVITTS as ProviderGSVITTS,
)
case "fishaudio_tts_api":
from .sources.fishaudio_tts_api_source import (
ProviderFishAudioTTSAPI as ProviderFishAudioTTSAPI,
)
case "dashscope_tts":
from .sources.dashscope_tts import (
ProviderDashscopeTTSAPI as ProviderDashscopeTTSAPI,
)
case "azure_tts":
from .sources.azure_tts_source import (
AzureTTSProvider as AzureTTSProvider,
)
case "minimax_tts_api":
from .sources.minimax_tts_api_source import (
ProviderMiniMaxTTSAPI as ProviderMiniMaxTTSAPI,
)
case "volcengine_tts":
from .sources.volcengine_tts import (
ProviderVolcengineTTS as ProviderVolcengineTTS,
)
case "gemini_tts":
from .sources.gemini_tts_source import (
ProviderGeminiTTSAPI as ProviderGeminiTTSAPI,
)
case "openai_embedding":
from .sources.openai_embedding_source import (
OpenAIEmbeddingProvider as OpenAIEmbeddingProvider,
)
case "gemini_embedding":
from .sources.gemini_embedding_source import (
GeminiEmbeddingProvider as GeminiEmbeddingProvider,
)
case "vllm_rerank":
from .sources.vllm_rerank_source import (
VLLMRerankProvider as VLLMRerankProvider,
)
case "xinference_rerank":
from .sources.xinference_rerank_source import (
XinferenceRerankProvider as XinferenceRerankProvider,
)
case "bailian_rerank":
from .sources.bailian_rerank_source import (
BailianRerankProvider as BailianRerankProvider,
)
except (ImportError, ModuleNotFoundError) as e:
logger.critical(
f"加载 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}。可能是因为有未安装的依赖。",
@@ -546,7 +499,6 @@ class ProviderManager:
# 和配置文件保持同步
self.providers_config = astrbot_config["provider"]
self.provider_sources_config = astrbot_config.get("provider_sources", [])
config_ids = [provider["id"] for provider in self.providers_config]
logger.info(f"providers in user's config: {config_ids}")
for key in list(self.inst_map.keys()):
@@ -618,68 +570,6 @@ class ProviderManager:
)
del self.inst_map[provider_id]
async def delete_provider(
self, provider_id: str | None = None, provider_source_id: str | None = None
):
"""Delete provider and/or provider source from config and terminate the instances. Config will be saved after deletion."""
async with self.resource_lock:
# delete from config
target_prov_ids = []
if provider_id:
target_prov_ids.append(provider_id)
else:
for prov in self.providers_config:
if prov.get("provider_source_id") == provider_source_id:
target_prov_ids.append(prov.get("id"))
config = self.acm.default_conf
for tpid in target_prov_ids:
await self.terminate_provider(tpid)
config["provider"] = [
prov for prov in config["provider"] if prov.get("id") != tpid
]
config.save_config()
logger.info(f"Provider {target_prov_ids} 已从配置中删除。")
async def update_provider(self, origin_provider_id: str, new_config: dict):
"""Update provider config and reload the instance. Config will be saved after update."""
async with self.resource_lock:
npid = new_config.get("id", None)
if not npid:
raise ValueError("New provider config must have an 'id' field")
config = self.acm.default_conf
for provider in config["provider"]:
if (
provider.get("id", None) == npid
and provider.get("id", None) != origin_provider_id
):
raise ValueError(f"Provider ID {npid} already exists")
# update config
for idx, provider in enumerate(config["provider"]):
if provider.get("id", None) == origin_provider_id:
config["provider"][idx] = new_config
break
else:
raise ValueError(f"Provider ID {origin_provider_id} not found")
config.save_config()
# reload instance
await self.reload(new_config)
async def create_provider(self, new_config: dict):
"""Add new provider config and load the instance. Config will be saved after addition."""
async with self.resource_lock:
npid = new_config.get("id", None)
if not npid:
raise ValueError("New provider config must have an 'id' field")
config = self.acm.default_conf
for provider in config["provider"]:
if provider.get("id", None) == npid:
raise ValueError(f"Provider ID {npid} already exists")
# add to config
config["provider"].append(new_config)
config.save_config()
# load instance
await self.load_provider(new_config)
async def terminate(self):
for provider_inst in self.provider_insts:
if hasattr(provider_inst, "terminate"):
+1 -3
View File
@@ -4,7 +4,7 @@ import os
from collections.abc import AsyncGenerator
from typing import TypeAlias, Union
from astrbot.core.agent.message import ContentPart, Message
from astrbot.core.agent.message import Message
from astrbot.core.agent.tool import ToolSet
from astrbot.core.provider.entities import (
LLMResponse,
@@ -103,7 +103,6 @@ class Provider(AbstractProvider):
system_prompt: str | None = None,
tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None,
model: str | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
**kwargs,
) -> LLMResponse:
"""获得 LLM 的文本对话结果。会使用当前的模型进行对话。
@@ -115,7 +114,6 @@ class Provider(AbstractProvider):
tools: tool set
contexts: 上下文 prompt 二选一使用
tool_calls_result: 回传给 LLM 的工具调用结果参考: https://platform.openai.com/docs/guides/function-calling
extra_user_content_parts: 额外的内容块列表用于在用户消息后添加额外的文本块如系统提醒指令等
kwargs: 其他参数
Notes:
+38 -135
View File
@@ -6,13 +6,10 @@ from mimetypes import guess_type
import anthropic
from anthropic import AsyncAnthropic
from anthropic.types import Message
from anthropic.types.message_delta_usage import MessageDeltaUsage
from anthropic.types.usage import Usage
from astrbot import logger
from astrbot.api.provider import Provider
from astrbot.core.agent.message import ContentPart, ImageURLPart, TextPart
from astrbot.core.provider.entities import LLMResponse, TokenUsage
from astrbot.core.provider.entities import LLMResponse
from astrbot.core.provider.func_tool_manager import ToolSet
from astrbot.core.utils.io import download_image_by_url
@@ -48,7 +45,7 @@ class ProviderAnthropic(Provider):
base_url=self.base_url,
)
self.set_model(provider_config.get("model", "unknown"))
self.set_model(provider_config["model_config"]["model"])
def _prepare_payload(self, messages: list[dict]):
"""准备 Anthropic API 的请求 payload
@@ -69,7 +66,7 @@ class ProviderAnthropic(Provider):
blocks = []
if isinstance(message["content"], str):
blocks.append({"type": "text", "text": message["content"]})
if "tool_calls" in message and isinstance(message["tool_calls"], list):
if "tool_calls" in message:
for tool_call in message["tool_calls"]:
blocks.append( # noqa: PERF401
{
@@ -110,35 +107,12 @@ class ProviderAnthropic(Provider):
return system_prompt, new_messages
def _extract_usage(self, usage: Usage) -> TokenUsage:
# https://docs.claude.com/en/docs/build-with-claude/prompt-caching#tracking-cache-performance
return TokenUsage(
input_other=usage.input_tokens or 0,
input_cached=usage.cache_read_input_tokens or 0,
output=usage.output_tokens,
)
def _update_usage(self, token_usage: TokenUsage, usage: MessageDeltaUsage) -> None:
if usage.input_tokens is not None:
token_usage.input_other = usage.input_tokens
if usage.cache_read_input_tokens is not None:
token_usage.input_cached = usage.cache_read_input_tokens
if usage.output_tokens is not None:
token_usage.output = usage.output_tokens
async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse:
if tools:
if tool_list := tools.get_func_desc_anthropic_style():
payloads["tools"] = tool_list
extra_body = self.provider_config.get("custom_extra_body", {})
if "max_tokens" not in payloads:
payloads["max_tokens"] = 1024
completion = await self.client.messages.create(
**payloads, stream=False, extra_body=extra_body
)
completion = await self.client.messages.create(**payloads, stream=False)
assert isinstance(completion, Message)
logger.debug(f"completion: {completion}")
@@ -157,10 +131,6 @@ class ProviderAnthropic(Provider):
llm_response.tools_call_args.append(content_block.input)
llm_response.tools_call_name.append(content_block.name)
llm_response.tools_call_ids.append(content_block.id)
llm_response.id = completion.id
llm_response.usage = self._extract_usage(completion.usage)
# TODO(Soulter): 处理 end_turn 情况
if not llm_response.completion_text and not llm_response.tools_call_args:
raise Exception(f"Anthropic API 返回的 completion 无法解析:{completion}")
@@ -181,22 +151,10 @@ class ProviderAnthropic(Provider):
# 用于累积最终结果
final_text = ""
final_tool_calls = []
id = None
usage = TokenUsage()
extra_body = self.provider_config.get("custom_extra_body", {})
if "max_tokens" not in payloads:
payloads["max_tokens"] = 1024
async with self.client.messages.stream(
**payloads, extra_body=extra_body
) as stream:
async with self.client.messages.stream(**payloads) as stream:
assert isinstance(stream, anthropic.AsyncMessageStream)
async for event in stream:
if event.type == "message_start":
# the usage contains input token usage
id = event.message.id
usage = self._extract_usage(event.message.usage)
if event.type == "content_block_start":
if event.content_block.type == "text":
# 文本块开始
@@ -204,8 +162,6 @@ class ProviderAnthropic(Provider):
role="assistant",
completion_text="",
is_chunk=True,
usage=usage,
id=id,
)
elif event.content_block.type == "tool_use":
# 工具使用块开始,初始化缓冲区
@@ -223,8 +179,6 @@ class ProviderAnthropic(Provider):
role="assistant",
completion_text=event.delta.text,
is_chunk=True,
usage=usage,
id=id,
)
elif event.delta.type == "input_json_delta":
# 工具调用参数增量
@@ -261,8 +215,6 @@ class ProviderAnthropic(Provider):
tools_call_name=[tool_info["name"]],
tools_call_ids=[tool_info["id"]],
is_chunk=True,
usage=usage,
id=id,
)
except json.JSONDecodeError:
# JSON 解析失败,跳过这个工具调用
@@ -271,17 +223,11 @@ class ProviderAnthropic(Provider):
# 清理缓冲区
del tool_use_buffer[event.index]
elif event.type == "message_delta":
if event.usage:
self._update_usage(usage, event.usage)
# 返回最终的完整结果
final_response = LLMResponse(
role="assistant",
completion_text=final_text,
is_chunk=False,
usage=usage,
id=id,
)
if final_tool_calls:
@@ -303,16 +249,13 @@ class ProviderAnthropic(Provider):
system_prompt=None,
tool_calls_result=None,
model=None,
extra_user_content_parts=None,
**kwargs,
) -> LLMResponse:
if contexts is None:
contexts = []
new_record = None
if prompt is not None:
new_record = await self.assemble_context(
prompt, image_urls, extra_user_content_parts
)
new_record = await self.assemble_context(prompt, image_urls)
context_query = self._ensure_message_to_dicts(contexts)
if new_record:
context_query.append(new_record)
@@ -334,9 +277,10 @@ class ProviderAnthropic(Provider):
system_prompt, new_messages = self._prepare_payload(context_query)
model = model or self.get_model()
model_config = self.provider_config.get("model_config", {})
model_config["model"] = model or self.get_model()
payloads = {"messages": new_messages, "model": model}
payloads = {"messages": new_messages, **model_config}
# Anthropic has a different way of handling system prompts
if system_prompt:
@@ -346,30 +290,28 @@ class ProviderAnthropic(Provider):
try:
llm_response = await self._query(payloads, func_tool)
except Exception as e:
# logger.error(f"发生了错误。Provider 配置如下: {model_config}")
raise e
return llm_response
async def text_chat_stream(
self,
prompt=None,
prompt,
session_id=None,
image_urls=None,
image_urls=...,
func_tool=None,
contexts=None,
contexts=...,
system_prompt=None,
tool_calls_result=None,
model=None,
extra_user_content_parts=None,
**kwargs,
):
if contexts is None:
contexts = []
new_record = None
if prompt is not None:
new_record = await self.assemble_context(
prompt, image_urls, extra_user_content_parts
)
new_record = await self.assemble_context(prompt, image_urls)
context_query = self._ensure_message_to_dicts(contexts)
if new_record:
context_query.append(new_record)
@@ -390,9 +332,10 @@ class ProviderAnthropic(Provider):
system_prompt, new_messages = self._prepare_payload(context_query)
model = model or self.get_model()
model_config = self.provider_config.get("model_config", {})
model_config["model"] = model or self.get_model()
payloads = {"messages": new_messages, "model": model}
payloads = {"messages": new_messages, **model_config}
# Anthropic has a different way of handling system prompts
if system_prompt:
@@ -401,15 +344,15 @@ class ProviderAnthropic(Provider):
async for llm_response in self._query_stream(payloads, func_tool):
yield llm_response
async def assemble_context(
self,
text: str,
image_urls: list[str] | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
):
async def assemble_context(self, text: str, image_urls: list[str] | None = None):
"""组装上下文,支持文本和图片"""
if not image_urls:
return {"role": "user", "content": text}
async def resolve_image_url(image_url: str) -> dict | None:
content = []
content.append({"type": "text", "text": text})
for image_url in image_urls:
if image_url.startswith("http"):
image_path = await download_image_by_url(image_url)
image_data = await self.encode_image_bs64(image_path)
@@ -421,68 +364,28 @@ class ProviderAnthropic(Provider):
if not image_data:
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
return None
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
return {
"type": "image",
"source": {
"type": "base64",
"media_type": mime_type,
"data": (
image_data.split("base64,")[1]
if "base64," in image_data
else image_data
),
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
),
},
},
}
)
content = []
# 1. 用户原始发言(OpenAI 建议:用户发言在前)
if text:
content.append({"type": "text", "text": text})
elif image_urls:
# 如果没有文本但有图片,添加占位文本
content.append({"type": "text", "text": "[图片]"})
elif extra_user_content_parts:
# 如果只有额外内容块,也需要添加占位文本
content.append({"type": "text", "text": " "})
# 2. 额外的内容块(系统提醒、指令等)
if extra_user_content_parts:
for block in extra_user_content_parts:
if isinstance(block, TextPart):
content.append({"type": "text", "text": block.text})
elif isinstance(block, ImageURLPart):
image_dict = await resolve_image_url(block.image_url.url)
if image_dict:
content.append(image_dict)
else:
raise ValueError(f"不支持的额外内容块类型: {type(block)}")
# 3. 图片内容
if image_urls:
for image_url in image_urls:
image_dict = await resolve_image_url(image_url)
if image_dict:
content.append(image_dict)
# 如果只有主文本且没有额外内容块和图片,返回简单格式以保持向后兼容
if (
text
and not extra_user_content_parts
and not image_urls
and len(content) == 1
and content[0]["type"] == "text"
):
return {"role": "user", "content": content[0]["text"]}
# 否则返回多模态格式
return {"role": "user", "content": content}
async def encode_image_bs64(self, image_url: str) -> str:
@@ -56,14 +56,10 @@ class ProviderFishAudioTTSAPI(TTSProvider):
"api_base",
"https://api.fish-audio.cn/v1",
)
try:
self.timeout: int = int(provider_config.get("timeout", 20))
except ValueError:
self.timeout = 20
self.headers = {
"Authorization": f"Bearer {self.chosen_api_key}",
}
self.set_model(provider_config.get("model", None))
self.set_model(provider_config["model"])
async def _get_reference_id_by_character(self, character: str) -> str | None:
"""获取角色的reference_id
@@ -139,21 +135,17 @@ class ProviderFishAudioTTSAPI(TTSProvider):
path = os.path.join(temp_dir, f"fishaudio_tts_api_{uuid.uuid4()}.wav")
self.headers["content-type"] = "application/msgpack"
request = await self._generate_request(text)
async with AsyncClient(base_url=self.api_base, timeout=self.timeout).stream(
async with AsyncClient(base_url=self.api_base).stream(
"POST",
"/tts",
headers=self.headers,
content=ormsgpack.packb(request, option=ormsgpack.OPT_SERIALIZE_PYDANTIC),
) as response:
if response.status_code == 200 and response.headers.get(
"content-type", ""
).startswith("audio/"):
if response.headers["content-type"] == "audio/wav":
with open(path, "wb") as f:
async for chunk in response.aiter_bytes():
f.write(chunk)
return path
error_bytes = await response.aread()
error_text = error_bytes.decode("utf-8", errors="replace")[:1024]
raise Exception(
f"Fish Audio API请求失败: 状态码 {response.status_code}, 响应内容: {error_text}"
)
body = await response.aread()
text = body.decode("utf-8", errors="replace")
raise Exception(f"Fish Audio API请求失败: {text}")
+59 -159
View File
@@ -13,9 +13,8 @@ from google.genai.errors import APIError
import astrbot.core.message.components as Comp
from astrbot import logger
from astrbot.api.provider import Provider
from astrbot.core.agent.message import ContentPart, ImageURLPart, TextPart
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import LLMResponse, TokenUsage
from astrbot.core.provider.entities import LLMResponse
from astrbot.core.provider.func_tool_manager import ToolSet
from astrbot.core.utils.io import download_image_by_url
@@ -69,7 +68,7 @@ class ProviderGoogleGenAI(Provider):
self.api_base = self.api_base[:-1]
self._init_client()
self.set_model(provider_config.get("model", "unknown"))
self.set_model(provider_config["model_config"]["model"])
self._init_safety_settings()
def _init_client(self) -> None:
@@ -139,7 +138,7 @@ class ProviderGoogleGenAI(Provider):
modalities = ["TEXT"]
tool_list: list[types.Tool] | None = []
model_name = cast(str, payloads.get("model", self.get_model()))
model_name = self.get_model()
native_coderunner = self.provider_config.get("gm_native_coderunner", False)
native_search = self.provider_config.get("gm_native_search", False)
url_context = self.provider_config.get("gm_url_context", False)
@@ -198,53 +197,6 @@ class ProviderGoogleGenAI(Provider):
types.Tool(function_declarations=func_desc["function_declarations"]),
]
# oper thinking config
thinking_config = None
if model_name in [
"gemini-2.5-pro",
"gemini-2.5-pro-preview",
"gemini-2.5-flash",
"gemini-2.5-flash-preview",
"gemini-2.5-flash-lite",
"gemini-2.5-flash-lite-preview",
"gemini-robotics-er-1.5-preview",
"gemini-live-2.5-flash-preview-native-audio-09-2025",
]:
# The thinkingBudget parameter, introduced with the Gemini 2.5 series
thinking_budget = self.provider_config.get("gm_thinking_config", {}).get(
"budget", 0
)
if thinking_budget is not None:
thinking_config = types.ThinkingConfig(
thinking_budget=thinking_budget,
)
elif model_name in [
"gemini-3-pro",
"gemini-3-pro-preview",
"gemini-3-flash",
"gemini-3-flash-preview",
"gemini-3-flash-lite",
"gemini-3-flash-lite-preview",
]:
# The thinkingLevel parameter, recommended for Gemini 3 models and onwards
# Gemini 2.5 series models don't support thinkingLevel; use thinkingBudget instead.
thinking_level = self.provider_config.get("gm_thinking_config", {}).get(
"level", "HIGH"
)
if thinking_level and isinstance(thinking_level, str):
thinking_level = thinking_level.upper()
if thinking_level not in ["MINIMAL", "LOW", "MEDIUM", "HIGH"]:
logger.warning(
f"Invalid thinking level: {thinking_level}, using HIGH"
)
thinking_level = "HIGH"
level = types.ThinkingLevel(thinking_level)
thinking_config = types.ThinkingConfig()
if not hasattr(types.ThinkingConfig, "thinking_level"):
setattr(types.ThinkingConfig, "thinking_level", level)
else:
thinking_config.thinking_level = level
return types.GenerateContentConfig(
system_instruction=system_instruction,
temperature=temperature,
@@ -264,7 +216,22 @@ class ProviderGoogleGenAI(Provider):
response_modalities=modalities,
tools=cast(types.ToolListUnion | None, tool_list),
safety_settings=self.safety_settings if self.safety_settings else None,
thinking_config=thinking_config,
thinking_config=(
types.ThinkingConfig(
thinking_budget=min(
int(
self.provider_config.get("gm_thinking_config", {}).get(
"budget",
0,
),
),
24576,
),
)
if "gemini-2.5-flash" in self.get_model()
and hasattr(types.ThinkingConfig, "thinking_budget")
else None
),
automatic_function_calling=types.AutomaticFunctionCallingConfig(
disable=True,
),
@@ -380,16 +347,6 @@ class ProviderGoogleGenAI(Provider):
]
return "".join(thought_buf).strip()
def _extract_usage(
self, usage_metadata: types.GenerateContentResponseUsageMetadata
) -> TokenUsage:
"""Extract usage from candidate"""
return TokenUsage(
input_other=usage_metadata.prompt_token_count or 0,
input_cached=usage_metadata.cached_content_token_count or 0,
output=usage_metadata.candidates_token_count or 0,
)
def _process_content_parts(
self,
candidate: types.Candidate,
@@ -474,8 +431,6 @@ class ProviderGoogleGenAI(Provider):
None,
)
model = payloads.get("model", self.get_model())
modalities = ["TEXT"]
if self.provider_config.get("gm_resp_image_modal", False):
modalities.append("IMAGE")
@@ -494,7 +449,7 @@ class ProviderGoogleGenAI(Provider):
temperature,
)
result = await self.client.models.generate_content(
model=model,
model=self.get_model(),
contents=cast(types.ContentListUnion, conversation),
config=config,
)
@@ -520,11 +475,11 @@ class ProviderGoogleGenAI(Provider):
e.message = ""
if "Developer instruction is not enabled" in e.message:
logger.warning(
f"{model} 不支持 system prompt,已自动去除(影响人格设置)",
f"{self.get_model()} 不支持 system prompt,已自动去除(影响人格设置)",
)
system_instruction = None
elif "Function calling is not enabled" in e.message:
logger.warning(f"{model} 不支持函数调用,已自动去除")
logger.warning(f"{self.get_model()} 不支持函数调用,已自动去除")
tools = None
elif (
"Multi-modal output is not supported" in e.message
@@ -533,7 +488,7 @@ class ProviderGoogleGenAI(Provider):
or "only supports text output" in e.message
):
logger.warning(
f"{model} 不支持多模态输出,降级为文本模态",
f"{self.get_model()} 不支持多模态输出,降级为文本模态",
)
modalities = ["TEXT"]
else:
@@ -546,9 +501,6 @@ class ProviderGoogleGenAI(Provider):
result.candidates[0],
llm_response,
)
llm_response.id = result.response_id
if result.usage_metadata:
llm_response.usage = self._extract_usage(result.usage_metadata)
return llm_response
async def _query_stream(
@@ -561,7 +513,7 @@ class ProviderGoogleGenAI(Provider):
(msg["content"] for msg in payloads["messages"] if msg["role"] == "system"),
None,
)
model = payloads.get("model", self.get_model())
conversation = self._prepare_conversation(payloads)
result = None
@@ -573,7 +525,7 @@ class ProviderGoogleGenAI(Provider):
system_instruction,
)
result = await self.client.models.generate_content_stream(
model=model,
model=self.get_model(),
contents=cast(types.ContentListUnion, conversation),
config=config,
)
@@ -583,11 +535,11 @@ class ProviderGoogleGenAI(Provider):
e.message = ""
if "Developer instruction is not enabled" in e.message:
logger.warning(
f"{model} 不支持 system prompt,已自动去除(影响人格设置)",
f"{self.get_model()} 不支持 system prompt,已自动去除(影响人格设置)",
)
system_instruction = None
elif "Function calling is not enabled" in e.message:
logger.warning(f"{model} 不支持函数调用,已自动去除")
logger.warning(f"{self.get_model()} 不支持函数调用,已自动去除")
tools = None
else:
raise
@@ -617,9 +569,6 @@ class ProviderGoogleGenAI(Provider):
chunk.candidates[0],
llm_response,
)
llm_response.id = chunk.response_id
if chunk.usage_metadata:
llm_response.usage = self._extract_usage(chunk.usage_metadata)
yield llm_response
return
@@ -647,9 +596,6 @@ class ProviderGoogleGenAI(Provider):
chunk.candidates[0],
final_response,
)
final_response.id = chunk.response_id
if chunk.usage_metadata:
final_response.usage = self._extract_usage(chunk.usage_metadata)
break
# Yield final complete response with accumulated text
@@ -681,16 +627,13 @@ class ProviderGoogleGenAI(Provider):
system_prompt=None,
tool_calls_result=None,
model=None,
extra_user_content_parts=None,
**kwargs,
) -> LLMResponse:
if contexts is None:
contexts = []
new_record = None
if prompt is not None:
new_record = await self.assemble_context(
prompt, image_urls, extra_user_content_parts
)
new_record = await self.assemble_context(prompt, image_urls)
context_query = self._ensure_message_to_dicts(contexts)
if new_record:
context_query.append(new_record)
@@ -709,9 +652,10 @@ class ProviderGoogleGenAI(Provider):
for tcr in tool_calls_result:
context_query.extend(tcr.to_openai_messages())
model = model or self.get_model()
model_config = self.provider_config.get("model_config", {})
model_config["model"] = model or self.get_model()
payloads = {"messages": context_query, "model": model}
payloads = {"messages": context_query, **model_config}
retry = 10
keys = self.api_keys.copy()
@@ -736,16 +680,13 @@ class ProviderGoogleGenAI(Provider):
system_prompt=None,
tool_calls_result=None,
model=None,
extra_user_content_parts=None,
**kwargs,
) -> AsyncGenerator[LLMResponse, None]:
if contexts is None:
contexts = []
new_record = None
if prompt is not None:
new_record = await self.assemble_context(
prompt, image_urls, extra_user_content_parts
)
new_record = await self.assemble_context(prompt, image_urls)
context_query = self._ensure_message_to_dicts(contexts)
if new_record:
context_query.append(new_record)
@@ -764,9 +705,10 @@ class ProviderGoogleGenAI(Provider):
for tcr in tool_calls_result:
context_query.extend(tcr.to_openai_messages())
model = model or self.get_model()
model_config = self.provider_config.get("model_config", {})
model_config["model"] = model or self.get_model()
payloads = {"messages": context_query, "model": model}
payloads = {"messages": context_query, **model_config}
retry = 10
keys = self.api_keys.copy()
@@ -804,75 +746,33 @@ class ProviderGoogleGenAI(Provider):
self.chosen_api_key = key
self._init_client()
async def assemble_context(
self,
text: str,
image_urls: list[str] | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
):
async def assemble_context(self, text: str, image_urls: list[str] | None = None):
"""组装上下文。"""
async def resolve_image_part(image_url: str) -> dict | None:
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} 得到的结果为空,将忽略。")
return None
return {
"type": "image_url",
"image_url": {"url": image_data},
}
# 构建内容块列表
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:
if isinstance(part, TextPart):
content_blocks.append({"type": "text", "text": part.text})
elif isinstance(part, ImageURLPart):
image_part = await resolve_image_part(part.image_url.url)
if image_part:
content_blocks.append(image_part)
else:
raise ValueError(f"不支持的额外内容块类型: {type(part)}")
# 3. 图片内容
if image_urls:
user_content = {
"role": "user",
"content": [{"type": "text", "text": text if text else "[图片]"}],
}
for image_url in image_urls:
image_part = await resolve_image_part(image_url)
if image_part:
content_blocks.append(image_part)
# 如果只有主文本且没有额外内容块和图片,返回简单格式以保持向后兼容
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}
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
user_content["content"].append(
{
"type": "image_url",
"image_url": {"url": image_data},
},
)
return user_content
return {"role": "user", "content": text}
async def encode_image_bs64(self, image_url: str) -> str:
"""将图片转换为 base64"""
+31 -89
View File
@@ -12,15 +12,14 @@ from openai._exceptions import NotFoundError
from openai.lib.streaming.chat._completions import ChatCompletionStreamState
from openai.types.chat.chat_completion import ChatCompletion
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
from openai.types.completion_usage import CompletionUsage
import astrbot.core.message.components as Comp
from astrbot import logger
from astrbot.api.provider import Provider
from astrbot.core.agent.message import ContentPart, ImageURLPart, Message, TextPart
from astrbot.core.agent.message import Message
from astrbot.core.agent.tool import ToolSet
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, ToolCallsResult
from astrbot.core.utils.io import download_image_by_url
from ..register import register_provider_adapter
@@ -69,7 +68,8 @@ class ProviderOpenAIOfficial(Provider):
self.client.chat.completions.create,
).parameters.keys()
model = provider_config.get("model", "unknown")
model_config = provider_config.get("model_config", {})
model = model_config.get("model", "unknown")
self.set_model(model)
self.reasoning_key = "reasoning_content"
@@ -208,7 +208,6 @@ class ProviderOpenAIOfficial(Provider):
# handle the content delta
reasoning = self._extract_reasoning_content(chunk)
_y = False
llm_response.id = chunk.id
if reasoning:
llm_response.reasoning_content = reasoning
_y = True
@@ -218,8 +217,6 @@ class ProviderOpenAIOfficial(Provider):
chain=[Comp.Plain(completion_text)],
)
_y = True
if chunk.usage:
llm_response.usage = self._extract_usage(chunk.usage)
if _y:
yield llm_response
@@ -248,15 +245,6 @@ class ProviderOpenAIOfficial(Provider):
reasoning_text = str(reasoning_attr)
return reasoning_text
def _extract_usage(self, usage: CompletionUsage) -> TokenUsage:
ptd = usage.prompt_tokens_details
cached = ptd.cached_tokens if ptd and ptd.cached_tokens else 0
return TokenUsage(
input_other=usage.prompt_tokens - cached,
input_cached=ptd.cached_tokens if ptd and ptd.cached_tokens else 0,
output=usage.completion_tokens,
)
async def _parse_openai_completion(
self, completion: ChatCompletion, tools: ToolSet | None
) -> LLMResponse:
@@ -333,10 +321,6 @@ class ProviderOpenAIOfficial(Provider):
raise Exception(f"API 返回的 completion 无法解析:{completion}")
llm_response.raw_completion = completion
llm_response.id = completion.id
if completion.usage:
llm_response.usage = self._extract_usage(completion.usage)
return llm_response
@@ -348,7 +332,6 @@ class ProviderOpenAIOfficial(Provider):
system_prompt: str | None = None,
tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None,
model: str | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
**kwargs,
) -> tuple:
"""准备聊天所需的有效载荷和上下文"""
@@ -356,9 +339,7 @@ class ProviderOpenAIOfficial(Provider):
contexts = []
new_record = None
if prompt is not None:
new_record = await self.assemble_context(
prompt, image_urls, extra_user_content_parts
)
new_record = await self.assemble_context(prompt, image_urls)
context_query = self._ensure_message_to_dicts(contexts)
if new_record:
context_query.append(new_record)
@@ -377,9 +358,10 @@ class ProviderOpenAIOfficial(Provider):
for tcr in tool_calls_result:
context_query.extend(tcr.to_openai_messages())
model = model or self.get_model()
model_config = self.provider_config.get("model_config", {})
model_config["model"] = model or self.get_model()
payloads = {"messages": context_query, "model": model}
payloads = {"messages": context_query, **model_config}
# xAI origin search tool inject
self._maybe_inject_xai_search(payloads, **kwargs)
@@ -479,7 +461,6 @@ class ProviderOpenAIOfficial(Provider):
system_prompt=None,
tool_calls_result=None,
model=None,
extra_user_content_parts=None,
**kwargs,
) -> LLMResponse:
payloads, context_query = await self._prepare_chat_payload(
@@ -489,7 +470,6 @@ class ProviderOpenAIOfficial(Provider):
system_prompt,
tool_calls_result,
model=model,
extra_user_content_parts=extra_user_content_parts,
**kwargs,
)
@@ -629,71 +609,33 @@ class ProviderOpenAIOfficial(Provider):
self,
text: str,
image_urls: list[str] | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
) -> dict:
"""组装成符合 OpenAI 格式的 role 为 user 的消息段"""
async def resolve_image_part(image_url: str) -> dict | None:
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} 得到的结果为空,将忽略。")
return None
return {
"type": "image_url",
"image_url": {"url": image_data},
}
# 构建内容块列表
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:
if isinstance(part, TextPart):
content_blocks.append({"type": "text", "text": part.text})
elif isinstance(part, ImageURLPart):
image_part = await resolve_image_part(part.image_url.url)
if image_part:
content_blocks.append(image_part)
else:
raise ValueError(f"不支持的额外内容块类型: {type(part)}")
# 3. 图片内容
if image_urls:
user_content = {
"role": "user",
"content": [{"type": "text", "text": text if text else "[图片]"}],
}
for image_url in image_urls:
image_part = await resolve_image_part(image_url)
if image_part:
content_blocks.append(image_part)
# 如果只有主文本且没有额外内容块和图片,返回简单格式以保持向后兼容
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}
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
user_content["content"].append(
{
"type": "image_url",
"image_url": {"url": image_data},
},
)
return user_content
return {"role": "user", "content": text}
async def encode_image_bs64(self, image_url: str) -> str:
"""将图片转换为 base64"""
+1 -5
View File
@@ -2,19 +2,15 @@ from astrbot.core import html_renderer
from astrbot.core.provider import Provider
from astrbot.core.star.star_tools import StarTools
from astrbot.core.utils.command_parser import CommandParserMixin
from astrbot.core.utils.plugin_kv_store import PluginKVStoreMixin
from .context import Context
from .star import StarMetadata, star_map, star_registry
from .star_manager import PluginManager
class Star(CommandParserMixin, PluginKVStoreMixin):
class Star(CommandParserMixin):
"""所有插件(Star)的父类,所有插件都应该继承于这个类"""
author: str
name: str
def __init__(self, context: Context, config: dict | None = None):
StarTools.initialize(context)
self.context = context
+10 -57
View File
@@ -4,7 +4,7 @@ from collections import defaultdict
from dataclasses import dataclass, field
from typing import Any
from astrbot.core import db_helper, logger
from astrbot.core import db_helper
from astrbot.core.db.po import CommandConfig
from astrbot.core.star.filter.command import CommandFilter
from astrbot.core.star.filter.command_group import CommandGroupFilter
@@ -90,7 +90,6 @@ async def toggle_command(handler_full_name: str, enabled: bool) -> CommandDescri
async def rename_command(
handler_full_name: str,
new_fragment: str,
aliases: list[str] | None = None,
) -> CommandDescriptor:
descriptor = _build_descriptor_by_full_name(handler_full_name)
if not descriptor:
@@ -100,24 +99,9 @@ async def rename_command(
if not new_fragment:
raise ValueError("指令名不能为空。")
# 校验主指令名
candidate_full = _compose_command(descriptor.parent_signature, new_fragment)
if _is_command_in_use(handler_full_name, candidate_full):
raise ValueError(f"指令名 '{candidate_full}' 已被其他指令占用。")
# 校验别名
if aliases:
for alias in aliases:
alias = alias.strip()
if not alias:
continue
alias_full = _compose_command(descriptor.parent_signature, alias)
if _is_command_in_use(handler_full_name, alias_full):
raise ValueError(f"别名 '{alias_full}' 已被其他指令占用。")
existing_cfg = await db_helper.get_command_config(handler_full_name)
merged_extra = dict(existing_cfg.extra_data or {}) if existing_cfg else {}
merged_extra["resolved_aliases"] = aliases or []
raise ValueError("新的指令名已被其他指令占用,请换一个名称")
config = await db_helper.upsert_command_config(
handler_full_name=handler_full_name,
@@ -130,7 +114,7 @@ async def rename_command(
conflict_key=descriptor.original_command,
resolution_strategy="manual_rename",
note=None,
extra_data=merged_extra,
extra_data=None,
auto_managed=False,
)
_bind_descriptor_with_config(descriptor, config)
@@ -208,18 +192,12 @@ def _collect_descriptors(include_sub_commands: bool) -> list[CommandDescriptor]:
"""收集指令,按需包含子指令。"""
descriptors: list[CommandDescriptor] = []
for handler in star_handlers_registry:
try:
desc = _build_descriptor(handler)
if not desc:
continue
if not include_sub_commands and desc.is_sub_command:
continue
descriptors.append(desc)
except Exception as e:
logger.warning(
f"解析指令处理函数 {handler.handler_full_name} 失败,跳过该指令。原因: {e!s}"
)
desc = _build_descriptor(handler)
if not desc:
continue
if not include_sub_commands and desc.is_sub_command:
continue
descriptors.append(desc)
return descriptors
@@ -379,27 +357,14 @@ def _apply_config_to_descriptor(
new_fragment,
)
extra = config.extra_data or {}
resolved_aliases = extra.get("resolved_aliases")
if isinstance(resolved_aliases, list):
descriptor.aliases = [str(x) for x in resolved_aliases if str(x).strip()]
def _apply_config_to_runtime(
descriptor: CommandDescriptor,
config: CommandConfig,
) -> None:
descriptor.handler.enabled = config.enabled
if descriptor.filter_ref:
if descriptor.current_fragment:
_set_filter_fragment(descriptor.filter_ref, descriptor.current_fragment)
extra = config.extra_data or {}
resolved_aliases = extra.get("resolved_aliases")
if isinstance(resolved_aliases, list):
_set_filter_aliases(
descriptor.filter_ref,
[str(x) for x in resolved_aliases if str(x).strip()],
)
if descriptor.filter_ref and descriptor.current_fragment:
_set_filter_fragment(descriptor.filter_ref, descriptor.current_fragment)
def _bind_configs_to_descriptors(
@@ -438,18 +403,6 @@ def _set_filter_fragment(
filter_ref._cmpl_cmd_names = None
def _set_filter_aliases(
filter_ref: CommandFilter | CommandGroupFilter,
aliases: list[str],
) -> None:
current_aliases = getattr(filter_ref, "alias", set())
if set(aliases) == current_aliases:
return
setattr(filter_ref, "alias", set(aliases))
if hasattr(filter_ref, "_cmpl_cmd_names"):
filter_ref._cmpl_cmd_names = None
def _is_command_in_use(
target_handler_full_name: str,
candidate_full_command: str,
+1 -5
View File
@@ -267,10 +267,6 @@ class Context:
):
"""通过 ID 获取对应的 LLM Provider。"""
prov = self.provider_manager.inst_map.get(provider_id)
if provider_id and not prov:
logger.warning(
f"没有找到 ID 为 {provider_id} 的提供商,这可能是由于您修改了提供商(模型)ID 导致的。"
)
return prov
def get_all_providers(self) -> list[Provider]:
@@ -377,7 +373,7 @@ class Context:
if not module_path:
_parts = []
module_part = tool.__module__.split(".")
flags = ["builtin_stars", "plugins"]
flags = ["packages", "plugins"]
for i, part in enumerate(module_part):
_parts.append(part)
if part in flags and i + 1 < len(module_part):
+14 -28
View File
@@ -18,7 +18,6 @@ from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.provider.register import llm_tools
from astrbot.core.utils.astrbot_path import (
get_astrbot_config_path,
get_astrbot_path,
get_astrbot_plugin_path,
)
from astrbot.core.utils.io import remove_dir
@@ -50,10 +49,13 @@ class PluginManager:
"""存储插件的路径。即 data/plugins"""
self.plugin_config_path = get_astrbot_config_path()
"""存储插件配置的路径。data/config"""
self.reserved_plugin_path = os.path.join(
get_astrbot_path(), "astrbot", "builtin_stars"
self.reserved_plugin_path = os.path.abspath(
os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"../../../packages",
),
)
"""保留插件的路径。在 astrbot/builtin_stars 目录下"""
"""保留插件的路径。在 packages 目录下"""
self.conf_schema_fname = "_conf_schema.json"
self.logo_fname = "logo.png"
"""插件配置 Schema 文件名"""
@@ -250,7 +252,7 @@ class PluginManager:
list[str]: 与该插件相关的模块名列表
"""
prefix = "astrbot.builtin_stars." if is_reserved else "data.plugins."
prefix = "packages." if is_reserved else "data.plugins."
return [
key
for key in list(sys.modules.keys())
@@ -268,7 +270,7 @@ class PluginManager:
可以基于模块名模式或插件目录名移除模块用于清理插件相关的模块缓存
Args:
module_patterns: 要移除的模块名模式列表例如 ["data.plugins", "astrbot.builtin_stars"]
module_patterns: 要移除的模块名模式列表例如 ["data.plugins", "packages"]
root_dir_name: 插件根目录名用于移除与该插件相关的所有模块
is_reserved: 插件是否为保留插件影响模块路径前缀
@@ -380,9 +382,9 @@ class PluginManager:
reserved = plugin_module.get(
"reserved",
False,
) # 是否是保留插件。目前在 astrbot/builtin_stars 目录下的都是保留插件。保留插件不可以卸载。
) # 是否是保留插件。目前在 packages/ 目录下的都是保留插件。保留插件不可以卸载。
path = "data.plugins." if not reserved else "astrbot.builtin_stars."
path = "data.plugins." if not reserved else "packages."
path += root_dir_name + "." + module_str
# 检查是否需要载入指定的插件
@@ -466,18 +468,6 @@ class PluginManager:
metadata.star_cls = metadata.star_cls_type(
context=self.context,
)
p_name = (metadata.name or "unknown").lower().replace("/", "_")
p_author = (
(metadata.author or "unknown").lower().replace("/", "_")
)
setattr(metadata.star_cls, "name", p_name)
setattr(metadata.star_cls, "author", p_author)
setattr(
metadata.star_cls,
"plugin_id",
f"{p_author}/{p_name}",
)
else:
logger.info(f"插件 {metadata.name} 已被禁用。")
@@ -629,11 +619,7 @@ class PluginManager:
# 清除 pip.main 导致的多余的 logging handlers
for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
try:
await sync_command_configs()
except Exception as e:
logger.error(f"同步指令配置失败: {e!s}")
logger.error(traceback.format_exc())
await sync_command_configs()
if not fail_rec:
return True, None
@@ -827,7 +813,7 @@ class PluginManager:
if (
mp
and mp.startswith(plugin_module_path)
and not mp.endswith(("astrbot.builtin_stars", "data.plugins"))
and not mp.endswith(("packages", "data.plugins"))
):
to_remove.append(func_tool)
for func_tool in to_remove:
@@ -882,7 +868,7 @@ class PluginManager:
plugin.module_path
and mp
and plugin.module_path.startswith(mp)
and not mp.endswith(("astrbot.builtin_stars", "data.plugins"))
and not mp.endswith(("packages", "data.plugins"))
):
func_tool.active = False
if func_tool.name not in inactivated_llm_tools:
@@ -931,7 +917,7 @@ class PluginManager:
plugin.module_path
and mp
and plugin.module_path.startswith(mp)
and not mp.endswith(("astrbot.builtin_stars", "data.plugins"))
and not mp.endswith(("packages", "data.plugins"))
and func_tool.name in inactivated_llm_tools
):
inactivated_llm_tools.remove(func_tool.name)
-34
View File
@@ -5,10 +5,6 @@
数据目录路径固定为根目录下的 data 目录
配置文件路径固定为数据目录下的 config 目录
插件目录路径固定为数据目录下的 plugins 目录
插件数据目录路径固定为数据目录下的 plugin_data 目录
T2I 模板目录路径固定为数据目录下的 t2i_templates 目录
WebChat 数据目录路径固定为数据目录下的 webchat 目录
临时文件目录路径固定为数据目录下的 temp 目录
"""
import os
@@ -41,33 +37,3 @@ def get_astrbot_config_path() -> str:
def get_astrbot_plugin_path() -> str:
"""获取Astrbot插件目录路径"""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "plugins"))
def get_astrbot_plugin_data_path() -> str:
"""获取Astrbot插件数据目录路径"""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "plugin_data"))
def get_astrbot_t2i_templates_path() -> str:
"""获取Astrbot T2I 模板目录路径"""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "t2i_templates"))
def get_astrbot_webchat_path() -> str:
"""获取Astrbot WebChat 数据目录路径"""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "webchat"))
def get_astrbot_temp_path() -> str:
"""获取Astrbot临时文件目录路径"""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "temp"))
def get_astrbot_knowledge_base_path() -> str:
"""获取Astrbot知识库根目录路径"""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "knowledge_base"))
def get_astrbot_backups_path() -> str:
"""获取Astrbot备份目录路径"""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "backups"))
-63
View File
@@ -1,63 +0,0 @@
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,92 +32,6 @@ def _migra_agent_runner_configs(conf: AstrBotConfig, ids_map: dict) -> None:
logger.error(traceback.format_exc())
def _migra_provider_to_source_structure(conf: AstrBotConfig) -> None:
"""
Migrate old provider structure to new provider-source separation.
Provider only keeps: id, provider_source_id, model, modalities, custom_extra_body
All other fields move to provider_sources.
"""
providers = conf.get("provider", [])
provider_sources = conf.get("provider_sources", [])
# Track if any migration happened
migrated = False
# Provider-only fields that should stay in provider
provider_only_fields = {
"id",
"provider_source_id",
"model",
"modalities",
"custom_extra_body",
"enable",
}
# Fields that should not go to source
source_exclude_fields = provider_only_fields | {"model_config"}
for provider in providers:
# Skip if already has provider_source_id
if provider.get("provider_source_id"):
continue
# Skip non-chat-completion types (they don't need source separation)
provider_type = provider.get("provider_type", "")
if provider_type != "chat_completion":
# For old types without provider_type, check type field
old_type = provider.get("type", "")
if "chat_completion" not in old_type:
continue
migrated = True
logger.info(f"Migrating provider {provider.get('id')} to new structure")
# Extract source fields from provider
source_fields = {}
for key, value in list(provider.items()):
if key not in source_exclude_fields:
source_fields[key] = value
# Create new provider_source
source_id = provider.get("id", "") + "_source"
new_source = {"id": source_id, **source_fields}
# Update provider to only keep necessary fields
provider["provider_source_id"] = source_id
# Extract model from model_config if exists
if "model_config" in provider and isinstance(provider["model_config"], dict):
model_config = provider["model_config"]
provider["model"] = model_config.get("model", "")
# Put other model_config fields into custom_extra_body
extra_body_fields = {k: v for k, v in model_config.items() if k != "model"}
if extra_body_fields:
if "custom_extra_body" not in provider:
provider["custom_extra_body"] = {}
provider["custom_extra_body"].update(extra_body_fields)
# Initialize new fields if not present
if "modalities" not in provider:
provider["modalities"] = []
if "custom_extra_body" not in provider:
provider["custom_extra_body"] = {}
# Remove fields that should be in source
keys_to_remove = [k for k in provider.keys() if k not in provider_only_fields]
for key in keys_to_remove:
del provider[key]
# Add source to provider_sources
provider_sources.append(new_source)
if migrated:
conf["provider_sources"] = provider_sources
conf.save_config()
logger.info("Provider-source structure migration completed")
async def migra(
db, astrbot_config_mgr, umop_config_router, acm: AstrBotConfigManager
) -> None:
@@ -157,10 +71,3 @@ async def migra(
for conf in acm.confs.values():
_migra_agent_runner_configs(conf, ids_map)
# Migrate providers to new structure: extract source fields to provider_sources
try:
_migra_provider_to_source_structure(astrbot_config)
except Exception as e:
logger.error(f"Migration for provider-source structure failed: {e!s}")
logger.error(traceback.format_exc())
-28
View File
@@ -1,28 +0,0 @@
from typing import TypeVar
from astrbot.core import sp
SUPPORTED_VALUE_TYPES = int | float | str | bytes | bool | dict | list | None
_VT = TypeVar("_VT")
class PluginKVStoreMixin:
"""为插件提供键值存储功能的 Mixin 类"""
plugin_id: str
async def put_kv_data(
self,
key: str,
value: SUPPORTED_VALUE_TYPES,
) -> None:
"""为指定插件存储一个键值对"""
await sp.put_async("plugin", self.plugin_id, key, value)
async def get_kv_data(self, key: str, default: _VT) -> _VT | None:
"""获取指定插件存储的键值对"""
return await sp.get_async("plugin", self.plugin_id, key, default)
async def delete_kv_data(self, key: str) -> None:
"""删除指定插件存储的键值对"""
await sp.remove_async("plugin", self.plugin_id, key)
-2
View File
@@ -1,5 +1,4 @@
from .auth import AuthRoute
from .backup import BackupRoute
from .chat import ChatRoute
from .command import CommandRoute
from .config import ConfigRoute
@@ -18,7 +17,6 @@ from .update import UpdateRoute
__all__ = [
"AuthRoute",
"BackupRoute",
"ChatRoute",
"CommandRoute",
"ConfigRoute",
-589
View File
@@ -1,589 +0,0 @@
"""备份管理 API 路由"""
import asyncio
import os
import re
import traceback
import uuid
from datetime import datetime
from pathlib import Path
from quart import request, send_file
from astrbot.core import logger
from astrbot.core.backup.exporter import AstrBotExporter
from astrbot.core.backup.importer import AstrBotImporter
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db import BaseDatabase
from astrbot.core.utils.astrbot_path import (
get_astrbot_backups_path,
get_astrbot_data_path,
)
from .route import Response, Route, RouteContext
def secure_filename(filename: str) -> str:
"""清洗文件名,移除路径遍历字符和危险字符
Args:
filename: 原始文件名
Returns:
安全的文件名
"""
# 跨平台处理:先将反斜杠替换为正斜杠,再取文件名
filename = filename.replace("\\", "/")
# 仅保留文件名部分,移除路径
filename = os.path.basename(filename)
# 替换路径遍历字符
filename = filename.replace("..", "_")
# 仅保留字母、数字、下划线、连字符、点
filename = re.sub(r"[^\w\-.]", "_", filename)
# 移除前导点(隐藏文件)和尾部点
filename = filename.strip(".")
# 如果文件名为空或只包含下划线,生成一个默认名称
if not filename or filename.replace("_", "") == "":
filename = "backup"
return filename
def generate_unique_filename(original_filename: str) -> str:
"""生成唯一的文件名,添加时间戳前缀
Args:
original_filename: 原始文件名已清洗
Returns:
唯一的文件名
"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
name, ext = os.path.splitext(original_filename)
return f"uploaded_{timestamp}_{name}{ext}"
class BackupRoute(Route):
"""备份管理路由
提供备份导出导入列表等 API 接口
"""
def __init__(
self,
context: RouteContext,
db: BaseDatabase,
core_lifecycle: AstrBotCoreLifecycle,
) -> None:
super().__init__(context)
self.db = db
self.core_lifecycle = core_lifecycle
self.backup_dir = get_astrbot_backups_path()
self.data_dir = get_astrbot_data_path()
# 任务状态跟踪
self.backup_tasks: dict[str, dict] = {}
self.backup_progress: dict[str, dict] = {}
# 注册路由
self.routes = {
"/backup/list": ("GET", self.list_backups),
"/backup/export": ("POST", self.export_backup),
"/backup/upload": ("POST", self.upload_backup), # 上传文件
"/backup/check": ("POST", self.check_backup), # 预检查
"/backup/import": ("POST", self.import_backup), # 确认导入
"/backup/progress": ("GET", self.get_progress),
"/backup/download": ("GET", self.download_backup),
"/backup/delete": ("POST", self.delete_backup),
}
self.register_routes()
def _init_task(self, task_id: str, task_type: str, status: str = "pending") -> None:
"""初始化任务状态"""
self.backup_tasks[task_id] = {
"type": task_type,
"status": status,
"result": None,
"error": None,
}
self.backup_progress[task_id] = {
"status": status,
"stage": "waiting",
"current": 0,
"total": 100,
"message": "",
}
def _set_task_result(
self,
task_id: str,
status: str,
result: dict | None = None,
error: str | None = None,
) -> None:
"""设置任务结果"""
if task_id in self.backup_tasks:
self.backup_tasks[task_id]["status"] = status
self.backup_tasks[task_id]["result"] = result
self.backup_tasks[task_id]["error"] = error
if task_id in self.backup_progress:
self.backup_progress[task_id]["status"] = status
def _update_progress(
self,
task_id: str,
*,
status: str | None = None,
stage: str | None = None,
current: int | None = None,
total: int | None = None,
message: str | None = None,
) -> None:
"""更新任务进度"""
if task_id not in self.backup_progress:
return
p = self.backup_progress[task_id]
if status is not None:
p["status"] = status
if stage is not None:
p["stage"] = stage
if current is not None:
p["current"] = current
if total is not None:
p["total"] = total
if message is not None:
p["message"] = message
def _make_progress_callback(self, task_id: str):
"""创建进度回调函数"""
async def _callback(stage: str, current: int, total: int, message: str = ""):
self._update_progress(
task_id,
status="processing",
stage=stage,
current=current,
total=total,
message=message,
)
return _callback
async def list_backups(self):
"""获取备份列表
Query 参数:
- page: 页码 (默认 1)
- page_size: 每页数量 (默认 20)
"""
try:
page = request.args.get("page", 1, type=int)
page_size = request.args.get("page_size", 20, type=int)
# 确保备份目录存在
Path(self.backup_dir).mkdir(parents=True, exist_ok=True)
# 获取所有备份文件
backup_files = []
for filename in os.listdir(self.backup_dir):
if filename.endswith(".zip") and filename.startswith("astrbot_backup_"):
file_path = os.path.join(self.backup_dir, filename)
stat = os.stat(file_path)
backup_files.append(
{
"filename": filename,
"size": stat.st_size,
"created_at": stat.st_mtime,
}
)
# 按创建时间倒序排序
backup_files.sort(key=lambda x: x["created_at"], reverse=True)
# 分页
start = (page - 1) * page_size
end = start + page_size
items = backup_files[start:end]
return (
Response()
.ok(
{
"items": items,
"total": len(backup_files),
"page": page,
"page_size": page_size,
}
)
.__dict__
)
except Exception as e:
logger.error(f"获取备份列表失败: {e}")
logger.error(traceback.format_exc())
return Response().error(f"获取备份列表失败: {e!s}").__dict__
async def export_backup(self):
"""创建备份
返回:
- task_id: 任务ID用于查询导出进度
"""
try:
# 生成任务ID
task_id = str(uuid.uuid4())
# 初始化任务状态
self._init_task(task_id, "export", "pending")
# 启动后台导出任务
asyncio.create_task(self._background_export_task(task_id))
return (
Response()
.ok(
{
"task_id": task_id,
"message": "export task created, processing in background",
}
)
.__dict__
)
except Exception as e:
logger.error(f"创建备份失败: {e}")
logger.error(traceback.format_exc())
return Response().error(f"创建备份失败: {e!s}").__dict__
async def _background_export_task(self, task_id: str):
"""后台导出任务"""
try:
self._update_progress(task_id, status="processing", message="正在初始化...")
# 获取知识库管理器
kb_manager = getattr(self.core_lifecycle, "kb_manager", None)
exporter = AstrBotExporter(
main_db=self.db,
kb_manager=kb_manager,
config_path=os.path.join(self.data_dir, "cmd_config.json"),
)
# 创建进度回调
progress_callback = self._make_progress_callback(task_id)
# 执行导出
zip_path = await exporter.export_all(
output_dir=self.backup_dir,
progress_callback=progress_callback,
)
# 设置成功结果
self._set_task_result(
task_id,
"completed",
result={
"filename": os.path.basename(zip_path),
"path": zip_path,
"size": os.path.getsize(zip_path),
},
)
except Exception as e:
logger.error(f"后台导出任务 {task_id} 失败: {e}")
logger.error(traceback.format_exc())
self._set_task_result(task_id, "failed", error=str(e))
async def upload_backup(self):
"""上传备份文件
将备份文件上传到服务器返回保存的文件名
上传后应调用 check_backup 进行预检查
Form Data:
- file: 备份文件 (.zip)
返回:
- filename: 保存的文件名
"""
try:
files = await request.files
if "file" not in files:
return Response().error("缺少备份文件").__dict__
file = files["file"]
if not file.filename or not file.filename.endswith(".zip"):
return Response().error("请上传 ZIP 格式的备份文件").__dict__
# 清洗文件名并生成唯一名称,防止路径遍历和覆盖
safe_filename = secure_filename(file.filename)
unique_filename = generate_unique_filename(safe_filename)
# 保存上传的文件
Path(self.backup_dir).mkdir(parents=True, exist_ok=True)
zip_path = os.path.join(self.backup_dir, unique_filename)
await file.save(zip_path)
logger.info(
f"上传的备份文件已保存: {unique_filename} (原始名称: {file.filename})"
)
return (
Response()
.ok(
{
"filename": unique_filename,
"original_filename": file.filename,
"size": os.path.getsize(zip_path),
}
)
.__dict__
)
except Exception as e:
logger.error(f"上传备份文件失败: {e}")
logger.error(traceback.format_exc())
return Response().error(f"上传备份文件失败: {e!s}").__dict__
async def check_backup(self):
"""预检查备份文件
检查备份文件的版本兼容性返回确认信息
用户确认后调用 import_backup 执行导入
JSON Body:
- filename: 已上传的备份文件名
返回:
- ImportPreCheckResult: 预检查结果
"""
try:
data = await request.json
filename = data.get("filename")
if not filename:
return Response().error("缺少 filename 参数").__dict__
# 安全检查 - 防止路径遍历
if ".." in filename or "/" in filename or "\\" in filename:
return Response().error("无效的文件名").__dict__
zip_path = os.path.join(self.backup_dir, filename)
if not os.path.exists(zip_path):
return Response().error(f"备份文件不存在: {filename}").__dict__
# 获取知识库管理器(用于构造 importer)
kb_manager = getattr(self.core_lifecycle, "kb_manager", None)
importer = AstrBotImporter(
main_db=self.db,
kb_manager=kb_manager,
config_path=os.path.join(self.data_dir, "cmd_config.json"),
)
# 执行预检查
check_result = importer.pre_check(zip_path)
return Response().ok(check_result.to_dict()).__dict__
except Exception as e:
logger.error(f"预检查备份文件失败: {e}")
logger.error(traceback.format_exc())
return Response().error(f"预检查备份文件失败: {e!s}").__dict__
async def import_backup(self):
"""执行备份导入
在用户确认后执行实际的导入操作
需要先调用 upload_backup 上传文件再调用 check_backup 预检查
JSON Body:
- filename: 已上传的备份文件名必填
- confirmed: 用户已确认必填必须为 true
返回:
- task_id: 任务ID用于查询导入进度
"""
try:
data = await request.json
filename = data.get("filename")
confirmed = data.get("confirmed", False)
if not filename:
return Response().error("缺少 filename 参数").__dict__
if not confirmed:
return (
Response()
.error("请先确认导入。导入将会清空并覆盖现有数据,此操作不可撤销。")
.__dict__
)
# 安全检查 - 防止路径遍历
if ".." in filename or "/" in filename or "\\" in filename:
return Response().error("无效的文件名").__dict__
zip_path = os.path.join(self.backup_dir, filename)
if not os.path.exists(zip_path):
return Response().error(f"备份文件不存在: {filename}").__dict__
# 生成任务ID
task_id = str(uuid.uuid4())
# 初始化任务状态
self._init_task(task_id, "import", "pending")
# 启动后台导入任务
asyncio.create_task(self._background_import_task(task_id, zip_path))
return (
Response()
.ok(
{
"task_id": task_id,
"message": "import task created, processing in background",
}
)
.__dict__
)
except Exception as e:
logger.error(f"导入备份失败: {e}")
logger.error(traceback.format_exc())
return Response().error(f"导入备份失败: {e!s}").__dict__
async def _background_import_task(self, task_id: str, zip_path: str):
"""后台导入任务"""
try:
self._update_progress(task_id, status="processing", message="正在初始化...")
# 获取知识库管理器
kb_manager = getattr(self.core_lifecycle, "kb_manager", None)
importer = AstrBotImporter(
main_db=self.db,
kb_manager=kb_manager,
config_path=os.path.join(self.data_dir, "cmd_config.json"),
)
# 创建进度回调
progress_callback = self._make_progress_callback(task_id)
# 执行导入
result = await importer.import_all(
zip_path=zip_path,
mode="replace",
progress_callback=progress_callback,
)
# 设置结果
if result.success:
self._set_task_result(
task_id,
"completed",
result=result.to_dict(),
)
else:
self._set_task_result(
task_id,
"failed",
error="; ".join(result.errors),
)
except Exception as e:
logger.error(f"后台导入任务 {task_id} 失败: {e}")
logger.error(traceback.format_exc())
self._set_task_result(task_id, "failed", error=str(e))
async def get_progress(self):
"""获取任务进度
Query 参数:
- task_id: 任务 ID (必填)
"""
try:
task_id = request.args.get("task_id")
if not task_id:
return Response().error("缺少参数 task_id").__dict__
if task_id not in self.backup_tasks:
return Response().error("找不到该任务").__dict__
task_info = self.backup_tasks[task_id]
status = task_info["status"]
response_data = {
"task_id": task_id,
"type": task_info["type"],
"status": status,
}
# 如果任务正在处理,返回进度信息
if status == "processing" and task_id in self.backup_progress:
response_data["progress"] = self.backup_progress[task_id]
# 如果任务完成,返回结果
if status == "completed":
response_data["result"] = task_info["result"]
# 如果任务失败,返回错误信息
if status == "failed":
response_data["error"] = task_info["error"]
return Response().ok(response_data).__dict__
except Exception as e:
logger.error(f"获取任务进度失败: {e}")
logger.error(traceback.format_exc())
return Response().error(f"获取任务进度失败: {e!s}").__dict__
async def download_backup(self):
"""下载备份文件
Query 参数:
- filename: 备份文件名 (必填)
"""
try:
filename = request.args.get("filename")
if not filename:
return Response().error("缺少参数 filename").__dict__
# 安全检查 - 防止路径遍历
if ".." in filename or "/" in filename or "\\" in filename:
return Response().error("无效的文件名").__dict__
file_path = os.path.join(self.backup_dir, filename)
if not os.path.exists(file_path):
return Response().error("备份文件不存在").__dict__
return await send_file(
file_path,
as_attachment=True,
attachment_filename=filename,
)
except Exception as e:
logger.error(f"下载备份失败: {e}")
logger.error(traceback.format_exc())
return Response().error(f"下载备份失败: {e!s}").__dict__
async def delete_backup(self):
"""删除备份文件
Body:
- filename: 备份文件名 (必填)
"""
try:
data = await request.json
filename = data.get("filename")
if not filename:
return Response().error("缺少参数 filename").__dict__
# 安全检查 - 防止路径遍历
if ".." in filename or "/" in filename or "\\" in filename:
return Response().error("无效的文件名").__dict__
file_path = os.path.join(self.backup_dir, filename)
if not os.path.exists(file_path):
return Response().error("备份文件不存在").__dict__
os.remove(file_path)
return Response().ok(message="删除备份成功").__dict__
except Exception as e:
logger.error(f"删除备份失败: {e}")
logger.error(traceback.format_exc())
return Response().error(f"删除备份失败: {e!s}").__dict__
+13 -56
View File
@@ -227,19 +227,16 @@ class ChatRoute(Route):
text: str,
media_parts: list,
reasoning: str,
agent_stats: dict,
):
"""保存 bot 消息到历史记录,返回保存的记录"""
bot_message_parts = []
bot_message_parts.extend(media_parts)
if text:
bot_message_parts.append({"type": "plain", "text": text})
bot_message_parts.extend(media_parts)
new_his = {"type": "bot", "message": bot_message_parts}
if reasoning:
new_his["reasoning"] = reasoning
if agent_stats:
new_his["agent_stats"] = agent_stats
record = await self.platform_history_mgr.insert(
platform_id="webchat",
@@ -297,8 +294,7 @@ class ChatRoute(Route):
accumulated_parts = []
accumulated_text = ""
accumulated_reasoning = ""
tool_calls = {}
agent_stats = {}
try:
async with track_conversation(self.running_convs, webchat_conv_id):
while True:
@@ -318,16 +314,6 @@ class ChatRoute(Route):
result_text = result["data"]
msg_type = result.get("type")
streaming = result.get("streaming", False)
chain_type = result.get("chain_type")
if chain_type == "agent_stats":
stats_info = {
"type": "agent_stats",
"data": json.loads(result_text),
}
yield f"data: {json.dumps(stats_info, ensure_ascii=False)}\n\n"
agent_stats = stats_info["data"]
continue
# 发送 SSE 数据
try:
@@ -349,35 +335,11 @@ class ChatRoute(Route):
# 累积消息部分
if msg_type == "plain":
chain_type = result.get("chain_type")
if chain_type == "tool_call":
tool_call = json.loads(result_text)
tool_calls[tool_call.get("id")] = tool_call
if accumulated_text:
# 如果累积了文本,则先保存文本
accumulated_parts.append(
{"type": "plain", "text": accumulated_text}
)
accumulated_text = ""
elif chain_type == "tool_call_result":
tcr = json.loads(result_text)
tc_id = tcr.get("id")
if tc_id in tool_calls:
tool_calls[tc_id]["result"] = tcr.get("result")
tool_calls[tc_id]["finished_ts"] = tcr.get("ts")
accumulated_parts.append(
{
"type": "tool_call",
"tool_calls": [tool_calls[tc_id]],
}
)
tool_calls.pop(tc_id, None)
elif chain_type == "reasoning":
chain_type = result.get("chain_type", "normal")
if chain_type == "reasoning":
accumulated_reasoning += result_text
elif streaming:
accumulated_text += result_text
else:
accumulated_text = result_text
accumulated_text += result_text
elif msg_type == "image":
filename = result_text.replace("[IMAGE]", "")
part = await self._create_attachment_from_file(
@@ -405,20 +367,15 @@ class ChatRoute(Route):
if msg_type == "end":
break
elif (
(streaming and msg_type == "complete") or not streaming
# or msg_type == "break"
(streaming and msg_type == "complete")
or not streaming
or msg_type == "break"
):
if (
chain_type == "tool_call"
or chain_type == "tool_call_result"
):
continue
saved_record = await self._save_bot_message(
webchat_conv_id,
accumulated_text,
accumulated_parts,
accumulated_reasoning,
agent_stats,
)
# 发送保存的消息信息给前端
if saved_record and not client_disconnected:
@@ -433,11 +390,11 @@ class ChatRoute(Route):
yield f"data: {json.dumps(saved_info, ensure_ascii=False)}\n\n"
except Exception:
pass
accumulated_parts = []
accumulated_text = ""
accumulated_reasoning = ""
# tool_calls = {}
agent_stats = {}
# 重置累积变量 (对于 break 后的下一段消息)
if msg_type == "break":
accumulated_parts = []
accumulated_text = ""
accumulated_reasoning = ""
except BaseException as e:
logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True)
+1 -2
View File
@@ -61,13 +61,12 @@ class CommandRoute(Route):
data = await request.get_json()
handler_full_name = data.get("handler_full_name")
new_name = data.get("new_name")
aliases = data.get("aliases")
if not handler_full_name or not new_name:
return Response().error("handler_full_name 与 new_name 均为必填。").__dict__
try:
await rename_command_service(handler_full_name, new_name, aliases=aliases)
await rename_command_service(handler_full_name, new_name)
except ValueError as exc:
return Response().error(str(exc)).__dict__
+33 -303
View File
@@ -6,7 +6,7 @@ from typing import Any
from quart import request
from astrbot.core import astrbot_config, file_token_service, logger
from astrbot.core import file_token_service, logger
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.config.default import (
CONFIG_METADATA_2,
@@ -21,7 +21,6 @@ from astrbot.core.platform.register import platform_cls_map, platform_registry
from astrbot.core.provider import Provider
from astrbot.core.provider.register import provider_registry
from astrbot.core.star.star import star_registry
from astrbot.core.utils.llm_metadata import LLM_METADATAS
from astrbot.core.utils.webhook_utils import ensure_platform_webhook_config
from .route import Response, Route, RouteContext
@@ -180,157 +179,13 @@ class ConfigRoute(Route):
"/config/provider/new": ("POST", self.post_new_provider),
"/config/provider/update": ("POST", self.post_update_provider),
"/config/provider/delete": ("POST", self.post_delete_provider),
"/config/provider/template": ("GET", self.get_provider_template),
"/config/provider/check_one": ("GET", self.check_one_provider_status),
"/config/provider/list": ("GET", self.get_provider_config_list),
"/config/provider/model_list": ("GET", self.get_provider_model_list),
"/config/provider/get_embedding_dim": ("POST", self.get_embedding_dim),
"/config/provider_sources/models": (
"GET",
self.get_provider_source_models,
),
"/config/provider_sources/update": (
"POST",
self.update_provider_source,
),
"/config/provider_sources/delete": (
"POST",
self.delete_provider_source,
),
}
self.register_routes()
async def delete_provider_source(self):
"""删除 provider_source,并更新关联的 providers"""
post_data = await request.json
if not post_data:
return Response().error("缺少配置数据").__dict__
provider_source_id = post_data.get("id")
if not provider_source_id:
return Response().error("缺少 provider_source_id").__dict__
provider_sources = self.config.get("provider_sources", [])
target_idx = next(
(
i
for i, ps in enumerate(provider_sources)
if ps.get("id") == provider_source_id
),
-1,
)
if target_idx == -1:
return Response().error("未找到对应的 provider source").__dict__
# 删除 provider_source
del provider_sources[target_idx]
# 写回配置
self.config["provider_sources"] = provider_sources
# 删除引用了该 provider_source 的 providers
await self.core_lifecycle.provider_manager.delete_provider(
provider_source_id=provider_source_id
)
try:
save_config(self.config, self.config, is_core=True)
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
return Response().ok(message="删除 provider source 成功").__dict__
async def update_provider_source(self):
"""更新或新增 provider_source,并重载关联的 providers"""
post_data = await request.json
if not post_data:
return Response().error("缺少配置数据").__dict__
new_source_config = post_data.get("config") or post_data
original_id = post_data.get("original_id")
if not original_id:
return Response().error("缺少 original_id").__dict__
if not isinstance(new_source_config, dict):
return Response().error("缺少或错误的配置数据").__dict__
# 确保配置中有 id 字段
if not new_source_config.get("id"):
new_source_config["id"] = original_id
provider_sources = self.config.get("provider_sources", [])
for ps in provider_sources:
if ps.get("id") == new_source_config["id"] and ps.get("id") != original_id:
return (
Response()
.error(
f"Provider source ID '{new_source_config['id']}' exists already, please try another ID.",
)
.__dict__
)
# 查找旧的 provider_source,若不存在则追加为新配置
target_idx = next(
(i for i, ps in enumerate(provider_sources) if ps.get("id") == original_id),
-1,
)
old_id = original_id
if target_idx == -1:
provider_sources.append(new_source_config)
else:
old_id = provider_sources[target_idx].get("id")
provider_sources[target_idx] = new_source_config
# 更新引用了该 provider_source 的 providers
affected_providers = []
for provider in self.config.get("provider", []):
if provider.get("provider_source_id") == old_id:
provider["provider_source_id"] = new_source_config["id"]
affected_providers.append(provider)
# 写回配置
self.config["provider_sources"] = provider_sources
try:
save_config(self.config, self.config, is_core=True)
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
# 重载受影响的 providers,使新的 source 配置生效
reload_errors = []
prov_mgr = self.core_lifecycle.provider_manager
for provider in affected_providers:
try:
await prov_mgr.reload(provider)
except Exception as e:
logger.error(traceback.format_exc())
reload_errors.append(f"{provider.get('id')}: {e}")
if reload_errors:
return (
Response()
.error("更新成功,但部分提供商重载失败: " + ", ".join(reload_errors))
.__dict__
)
return Response().ok(message="更新 provider source 成功").__dict__
async def get_provider_template(self):
config_schema = {
"provider": CONFIG_METADATA_2["provider_group"]["metadata"]["provider"]
}
data = {
"config_schema": config_schema,
"providers": astrbot_config["provider"],
"provider_sources": astrbot_config["provider_sources"],
}
return Response().ok(data=data).__dict__
async def get_uc_table(self):
"""获取 UMOP 配置路由表"""
return Response().ok({"routing": self.ucr.umop_to_conf_id}).__dict__
@@ -578,25 +433,9 @@ class ConfigRoute(Route):
return Response().error("缺少参数 provider_type").__dict__
provider_type_ls = provider_type.split(",")
provider_list = []
ps = self.core_lifecycle.provider_manager.providers_config
p_source_pt = {
psrc["id"]: psrc["provider_type"]
for psrc in self.core_lifecycle.provider_manager.provider_sources_config
}
for provider in ps:
ps_id = provider.get("provider_source_id", None)
if (
ps_id
and ps_id in p_source_pt
and p_source_pt[ps_id] in provider_type_ls
):
# chat
prov = self.core_lifecycle.provider_manager.get_merged_provider_config(
provider
)
provider_list.append(prov)
elif not ps_id and provider.get("provider_type", None) in provider_type_ls:
# agent runner, embedding, etc
astrbot_config = self.core_lifecycle.astrbot_config
for provider in astrbot_config["provider"]:
if provider.get("provider_type", None) in provider_type_ls:
provider_list.append(provider)
return Response().ok(provider_list).__dict__
@@ -619,18 +458,9 @@ class ConfigRoute(Route):
try:
models = await provider.get_models()
models = models or []
metadata_map = {}
for model_id in models:
meta = LLM_METADATAS.get(model_id)
if meta:
metadata_map[model_id] = meta
ret = {
"models": models,
"provider_id": provider_id,
"model_metadata": metadata_map,
}
return Response().ok(ret).__dict__
except Exception as e:
@@ -692,104 +522,6 @@ class ConfigRoute(Route):
logger.error(traceback.format_exc())
return Response().error(f"获取嵌入维度失败: {e!s}").__dict__
async def get_provider_source_models(self):
"""获取指定 provider_source 支持的模型列表
本质上会临时初始化一个 Provider 实例调用 get_models() 获取模型列表然后销毁实例
"""
provider_source_id = request.args.get("source_id")
if not provider_source_id:
return Response().error("缺少参数 source_id").__dict__
try:
from astrbot.core.provider.register import provider_cls_map
# 从配置中查找对应的 provider_source
provider_sources = self.config.get("provider_sources", [])
provider_source = None
for ps in provider_sources:
if ps.get("id") == provider_source_id:
provider_source = ps
break
if not provider_source:
return (
Response()
.error(f"未找到 ID 为 {provider_source_id} 的 provider_source")
.__dict__
)
# 获取 provider 类型
provider_type = provider_source.get("type", None)
if not provider_type:
return Response().error("provider_source 缺少 type 字段").__dict__
try:
self.core_lifecycle.provider_manager.dynamic_import_provider(
provider_type
)
except ImportError as e:
logger.error(traceback.format_exc())
return Response().error(f"动态导入提供商适配器失败: {e!s}").__dict__
# 获取对应的 provider 类
if provider_type not in provider_cls_map:
return (
Response()
.error(f"未找到适用于 {provider_type} 的提供商适配器")
.__dict__
)
provider_metadata = provider_cls_map[provider_type]
cls_type = provider_metadata.cls_type
if not cls_type:
return Response().error(f"无法找到 {provider_type} 的类").__dict__
# 检查是否是 Provider 类型
if not issubclass(cls_type, Provider):
return (
Response()
.error(f"提供商 {provider_type} 不支持获取模型列表")
.__dict__
)
# 临时实例化 provider
inst = cls_type(provider_source, {})
# 如果有 initialize 方法,调用它
init_fn = getattr(inst, "initialize", None)
if inspect.iscoroutinefunction(init_fn):
await init_fn()
# 获取模型列表
models = await inst.get_models()
models = models or []
metadata_map = {}
for model_id in models:
meta = LLM_METADATAS.get(model_id)
if meta:
metadata_map[model_id] = meta
# 销毁实例(如果有 terminate 方法)
terminate_fn = getattr(inst, "terminate", None)
if inspect.iscoroutinefunction(terminate_fn):
await terminate_fn()
logger.info(
f"获取到 provider_source {provider_source_id} 的模型列表: {models}",
)
return (
Response()
.ok({"models": models, "model_metadata": metadata_map})
.__dict__
)
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"获取模型列表失败: {e!s}").__dict__
async def get_platform_list(self):
"""获取所有平台的列表"""
platform_list = []
@@ -801,15 +533,7 @@ class ConfigRoute(Route):
data = await request.json
config = data.get("config", None)
conf_id = data.get("conf_id", None)
try:
# 不更新 provider_sources, provider, platform
# 这些配置有单独的接口进行更新
if conf_id == "default":
no_update_keys = ["provider_sources", "provider", "platform"]
for key in no_update_keys:
config[key] = self.acm.default_conf[key]
await self._save_astrbot_configs(config, conf_id)
await self.core_lifecycle.reload_pipeline_scheduler(conf_id)
return Response().ok(None, "保存成功~").__dict__
@@ -849,30 +573,28 @@ class ConfigRoute(Route):
async def post_new_provider(self):
new_provider_config = await request.json
self.config["provider"].append(new_provider_config)
try:
await self.core_lifecycle.provider_manager.create_provider(
new_provider_config
save_config(self.config, self.config, is_core=True)
await self.core_lifecycle.provider_manager.load_provider(
new_provider_config,
)
except Exception as e:
return Response().error(str(e)).__dict__
return Response().ok(None, "新增服务提供商配置成功").__dict__
return Response().ok(None, "新增服务提供商配置成功~").__dict__
async def post_update_platform(self):
update_platform_config = await request.json
origin_platform_id = update_platform_config.get("id", None)
platform_id = update_platform_config.get("id", None)
new_config = update_platform_config.get("config", None)
if not origin_platform_id or not new_config:
if not platform_id or not new_config:
return Response().error("参数错误").__dict__
if origin_platform_id != new_config.get("id", None):
return Response().error("机器人名称不允许修改").__dict__
# 如果是支持统一 webhook 模式的平台,且启用了统一 webhook 模式,确保有 webhook_uuid
ensure_platform_webhook_config(new_config)
for i, platform in enumerate(self.config["platform"]):
if platform["id"] == origin_platform_id:
if platform["id"] == platform_id:
self.config["platform"][i] = new_config
break
else:
@@ -887,15 +609,21 @@ class ConfigRoute(Route):
async def post_update_provider(self):
update_provider_config = await request.json
origin_provider_id = update_provider_config.get("id", None)
provider_id = update_provider_config.get("id", None)
new_config = update_provider_config.get("config", None)
if not origin_provider_id or not new_config:
if not provider_id or not new_config:
return Response().error("参数错误").__dict__
for i, provider in enumerate(self.config["provider"]):
if provider["id"] == provider_id:
self.config["provider"][i] = new_config
break
else:
return Response().error("未找到对应服务提供商").__dict__
try:
await self.core_lifecycle.provider_manager.update_provider(
origin_provider_id, new_config
)
save_config(self.config, self.config, is_core=True)
await self.core_lifecycle.provider_manager.reload(new_config)
except Exception as e:
return Response().error(str(e)).__dict__
return Response().ok(None, "更新成功,已经实时生效~").__dict__
@@ -918,17 +646,19 @@ class ConfigRoute(Route):
async def post_delete_provider(self):
provider_id = await request.json
provider_id = provider_id.get("id", "")
if not provider_id:
return Response().error("缺少参数 id").__dict__
provider_id = provider_id.get("id")
for i, provider in enumerate(self.config["provider"]):
if provider["id"] == provider_id:
del self.config["provider"][i]
break
else:
return Response().error("未找到对应服务提供商").__dict__
try:
await self.core_lifecycle.provider_manager.delete_provider(
provider_id=provider_id
)
save_config(self.config, self.config, is_core=True)
await self.core_lifecycle.provider_manager.terminate_provider(provider_id)
except Exception as e:
return Response().error(str(e)).__dict__
return Response().ok(None, "删除成功,已经实时生效").__dict__
return Response().ok(None, "删除成功,已经实时生效~").__dict__
async def get_llm_tools(self):
"""获取函数调用工具。包含了本地加载的以及 MCP 服务的工具"""
+10 -44
View File
@@ -1,26 +1,15 @@
import asyncio
import json
import time
from collections.abc import AsyncGenerator
from typing import cast
from quart import Response as QuartResponse
from quart import make_response, request
from quart import make_response
from astrbot.core import LogBroker, logger
from .route import Response, Route, RouteContext
def _format_log_sse(log: dict, ts: float) -> str:
"""辅助函数:格式化 SSE 消息"""
payload = {
"type": "log",
**log,
}
return f"id: {ts}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
class LogRoute(Route):
def __init__(self, context: RouteContext, log_broker: LogBroker) -> None:
super().__init__(context)
@@ -32,44 +21,21 @@ class LogRoute(Route):
methods=["GET"],
)
async def _replay_cached_logs(
self, last_event_id: str
) -> AsyncGenerator[str, None]:
"""辅助生成器:重放缓存的日志"""
try:
last_ts = float(last_event_id)
cached_logs = list(self.log_broker.log_cache)
for log_item in cached_logs:
log_ts = float(log_item.get("time", 0))
if log_ts > last_ts:
yield _format_log_sse(log_item, log_ts)
except ValueError:
pass
except Exception as e:
logger.error(f"Log SSE 补发历史错误: {e}")
async def log(self) -> QuartResponse:
last_event_id = request.headers.get("Last-Event-ID")
async def log(self):
async def stream():
queue = None
try:
if last_event_id:
async for event in self._replay_cached_logs(last_event_id):
yield event
queue = self.log_broker.register()
while True:
message = await queue.get()
current_ts = message.get("time", time.time())
yield _format_log_sse(message, current_ts)
payload = {
"type": "log",
**message, # see astrbot/core/log.py
}
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
except asyncio.CancelledError:
pass
except Exception as e:
except BaseException as e:
logger.error(f"Log SSE 连接错误: {e}")
finally:
if queue:
@@ -87,7 +53,7 @@ class LogRoute(Route):
},
),
)
response.timeout = None # type: ignore
response.timeout = None
return response
async def log_history(self):
@@ -103,6 +69,6 @@ class LogRoute(Route):
)
.__dict__
)
except Exception as e:
except BaseException as e:
logger.error(f"获取日志历史失败: {e}")
return Response().error(f"获取日志历史失败: {e}").__dict__
-96
View File
@@ -1,9 +1,6 @@
import os
import re
import threading
import time
import traceback
from functools import cmp_to_key
import aiohttp
import psutil
@@ -14,9 +11,7 @@ from astrbot.core.config import VERSION
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db import BaseDatabase
from astrbot.core.db.migration.helper import check_migration_needed_v4
from astrbot.core.utils.astrbot_path import get_astrbot_path
from astrbot.core.utils.io import get_dashboard_version
from astrbot.core.utils.version_comparator import VersionComparator
from .route import Response, Route, RouteContext
@@ -35,8 +30,6 @@ class StatRoute(Route):
"/stat/start-time": ("GET", self.get_start_time),
"/stat/restart-core": ("POST", self.restart_core),
"/stat/test-ghproxy-connection": ("POST", self.test_ghproxy_connection),
"/stat/changelog": ("GET", self.get_changelog),
"/stat/changelog/list": ("GET", self.list_changelog_versions),
}
self.db_helper = db_helper
self.register_routes()
@@ -190,92 +183,3 @@ class StatRoute(Route):
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"Error: {e!s}").__dict__
async def get_changelog(self):
"""获取指定版本的更新日志"""
try:
version = request.args.get("version")
if not version:
return Response().error("version parameter is required").__dict__
version = version.lstrip("v")
# 防止路径遍历攻击
if not re.match(r"^[a-zA-Z0-9._-]+$", version):
return Response().error("Invalid version format").__dict__
if ".." in version or "/" in version or "\\" in version:
return Response().error("Invalid version format").__dict__
filename = f"v{version}.md"
project_path = get_astrbot_path()
changelogs_dir = os.path.join(project_path, "changelogs")
changelog_path = os.path.join(changelogs_dir, filename)
# 规范化路径,防止符号链接攻击
changelog_path = os.path.realpath(changelog_path)
changelogs_dir = os.path.realpath(changelogs_dir)
# 验证最终路径在预期的 changelogs 目录内(防止路径遍历)
# 确保规范化后的路径以 changelogs_dir 开头,且是目录内的文件
changelog_path_normalized = os.path.normpath(changelog_path)
changelogs_dir_normalized = os.path.normpath(changelogs_dir)
# 检查路径是否在预期目录内(必须是目录的子文件,不能是目录本身)
expected_prefix = changelogs_dir_normalized + os.sep
if not changelog_path_normalized.startswith(expected_prefix):
logger.warning(
f"Path traversal attempt detected: {version} -> {changelog_path}",
)
return Response().error("Invalid version format").__dict__
if not os.path.exists(changelog_path):
return (
Response()
.error(f"Changelog for version {version} not found")
.__dict__
)
if not os.path.isfile(changelog_path):
return (
Response()
.error(f"Changelog for version {version} not found")
.__dict__
)
with open(changelog_path, encoding="utf-8") as f:
content = f.read()
return Response().ok({"content": content, "version": version}).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"Error: {e!s}").__dict__
async def list_changelog_versions(self):
"""获取所有可用的更新日志版本列表"""
try:
project_path = get_astrbot_path()
changelogs_dir = os.path.join(project_path, "changelogs")
if not os.path.exists(changelogs_dir):
return Response().ok({"versions": []}).__dict__
versions = []
for filename in os.listdir(changelogs_dir):
if filename.endswith(".md") and filename.startswith("v"):
# 提取版本号(去除 v 前缀和 .md 后缀)
version = filename[1:-3] # 去掉 "v" 和 ".md"
# 验证版本号格式
if re.match(r"^[a-zA-Z0-9._-]+$", version):
versions.append(version)
# 按版本号排序(降序,最新的在前)
# 使用项目中的 VersionComparator 进行语义化版本号排序
versions.sort(
key=cmp_to_key(
lambda v1, v2: VersionComparator.compare_version(v2, v1),
),
)
return Response().ok({"versions": versions}).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"Error: {e!s}").__dict__
+1 -8
View File
@@ -19,7 +19,6 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.core.utils.io import get_local_ip_addresses
from .routes import *
from .routes.backup import BackupRoute
from .routes.platform import PlatformRoute
from .routes.route import Response, RouteContext
from .routes.session_management import SessionManagementRoute
@@ -86,7 +85,6 @@ class AstrBotDashboard:
self.t2i_route = T2iRoute(self.context, core_lifecycle)
self.kb_route = KnowledgeBaseRoute(self.context, core_lifecycle)
self.platform_route = PlatformRoute(self.context, core_lifecycle)
self.backup_route = BackupRoute(self.context, db, core_lifecycle)
self.app.add_url_rule(
"/api/plug/<path:subpath>",
@@ -110,12 +108,7 @@ class AstrBotDashboard:
async def auth_middleware(self):
if not request.path.startswith("/api"):
return None
allowed_endpoints = [
"/api/auth/login",
"/api/file",
"/api/platform/webhook",
"/api/stat/start-time",
]
allowed_endpoints = ["/api/auth/login", "/api/file", "/api/platform/webhook"]
if any(request.path.startswith(prefix) for prefix in allowed_endpoints):
return None
# 声明 JWT
-34
View File
@@ -1,34 +0,0 @@
## 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
@@ -1,44 +0,0 @@
## 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
@@ -1,40 +0,0 @@
## 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
@@ -1,46 +0,0 @@
## 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
@@ -1,9 +0,0 @@
## What's Changed
### 修复
1. ‼️‼️ 修复了由 `psutil` 新版本导致的启动时报错的问题。
### 新增
1. 插件指令管理支持管理别名。
-18
View File
@@ -1,18 +0,0 @@
## What's Changed
### 修复
1. 修复 FishAudio TTS 不可用的问题;
2. 修复 Anthropic API Chat Provider 部分情况下请求报错的问题;
3. 修复部分情况下 WebUI 日志重建连接之后丢失日志的问题;
4. 修复部分情况下 /provider 指令报错 index out of range 的问题;
5. 修复通过 `uv` 或者 cli 方式启动 AstrBot,缺少所有内置插件的问题。
### 优化
1. 丢弃值为 None 的 `tool_call_id``tool_calls` 字段,提高接口兼容性。
### 新增
1. 支持备份 AstrBot 数据和导入数据功能(Beta)。入口:WebUi -> 设置 -> 备份。
2. text_chat 和 text_chat_stream 接口支持额外用户内容块参数 `extra_user_content_parts`,用于在用户消息后添加额外的内容块(如系统提醒、指令等)。
-3
View File
@@ -1,3 +0,0 @@
## What's Changed
-
-17
View File
@@ -1,17 +0,0 @@
## What's Changed
### 修复
- 企业自部署飞书(自定义 domain)可以接收消息但无法发送消息的问题。
- 安装插件 Dialog 的深色样式问题。
### 优化
- 避免某些插件在流式响应结束后重d复发送消息的问题。
### 新增
- 支持在对话管理批量导出对话轨迹数据为 `jsonl` 格式文件。入口:WebUI -> 对话管理 -> 批量选中 -> 导出。
- 支持对 TTS(文本转语音)设置概率触发。
- (插件开发)支持在 schema 中对 float 和 int 类型设置 `slider` 滑块控件。例如 `slider: {min: 0, max: 1, step: 0.1}`
- (插件开发)支持 key-value 存储功能。例如使用 `await self.put_kv_data("key", value)`, `await self.get_kv_data("key", default_value)``await self.delete_kv_data("key")`
+1 -1
View File
@@ -8,7 +8,7 @@
<meta name="description" content="AstrBot Dashboard" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Outfit&family=Poppins:wght@400;500;600;700&family=Roboto:wght@400;500;700&display=swap"
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"
/>
<title>AstrBot - 仪表盘</title>
</head>
+5 -9
View File
@@ -14,27 +14,22 @@
},
"dependencies": {
"@guolao/vue-monaco-editor": "^1.5.4",
"@mdit/plugin-katex": "^0.24.1",
"@tiptap/starter-kit": "2.1.7",
"@tiptap/vue-3": "2.1.7",
"apexcharts": "3.42.0",
"axios": ">=1.6.2 <1.10.0 || >1.10.0 <2.0.0",
"axios-mock-adapter": "^1.22.0",
"chance": "1.1.11",
"d3": "^7.9.0",
"date-fns": "2.30.0",
"event-source-polyfill": "^1.0.31",
"highlight.js": "^11.11.1",
"js-md5": "^0.8.3",
"katex": "^0.16.27",
"lodash": "4.17.21",
"markstream-vue": "0.0.3-beta.7",
"mermaid": "^11.12.2",
"pinia": "2.1.6",
"marked": "^15.0.7",
"markdown-it": "^14.1.0",
"pinyin-pro": "^3.26.0",
"pinia": "2.1.6",
"remixicon": "3.5.0",
"shiki": "^3.20.0",
"stream-markdown": "^0.0.11",
"stream-monaco": "^0.0.8",
"vee-validate": "4.11.3",
"vite-plugin-vuetify": "1.0.2",
"vue": "3.3.4",
@@ -49,6 +44,7 @@
"@mdi/font": "7.2.96",
"@rushstack/eslint-patch": "1.3.3",
"@types/chance": "1.1.3",
"@types/markdown-it": "^14.1.2",
"@types/node": "^20.5.7",
"@vitejs/plugin-vue": "4.3.3",
"@vue/eslint-config-prettier": "8.0.0",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

+51 -77
View File
@@ -18,39 +18,63 @@
@editTitle="showEditTitleDialog"
@deleteConversation="handleDeleteConversation"
@closeMobileSidebar="closeMobileSidebar"
@toggleTheme="toggleTheme"
@toggleFullscreen="toggleFullscreen"
/>
<!-- 右侧聊天内容区域 -->
<div class="chat-content-panel">
<div class="conversation-header fade-in" v-if="isMobile">
<div class="conversation-header fade-in">
<!-- 手机端菜单按钮 -->
<v-btn icon class="mobile-menu-btn" @click="toggleMobileSidebar" variant="text">
<v-btn icon class="mobile-menu-btn" @click="toggleMobileSidebar" v-if="isMobile" variant="text">
<v-icon>mdi-menu</v-icon>
</v-btn>
<!-- <div v-if="currCid && getCurrentConversation">
<h3
style="max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
{{ getCurrentConversation.title || tm('conversation.newConversation') }}</h3>
<span style="font-size: 12px;">{{ formatDate(getCurrentConversation.updated_at) }}</span>
</div> -->
<div class="conversation-header-actions">
<!-- router 推送到 /chatbox -->
<v-tooltip :text="tm('actions.fullscreen')" v-if="!chatboxMode">
<template v-slot:activator="{ props }">
<v-icon v-bind="props"
@click="router.push(currSessionId ? `/chatbox/${currSessionId}` : '/chatbox')"
class="fullscreen-icon">mdi-fullscreen</v-icon>
</template>
</v-tooltip>
<!-- 语言切换按钮 -->
<v-tooltip :text="t('core.common.language')" v-if="chatboxMode">
<template v-slot:activator="{ props }">
<LanguageSwitcher variant="chatbox" />
</template>
</v-tooltip>
<!-- 主题切换按钮 -->
<v-tooltip :text="isDark ? tm('modes.lightMode') : tm('modes.darkMode')" v-if="chatboxMode">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon @click="toggleTheme" class="theme-toggle-icon"
size="small" rounded="sm" style="margin-right: 8px;" variant="text">
<v-icon>{{ isDark ? 'mdi-weather-night' : 'mdi-white-balance-sunny' }}</v-icon>
</v-btn>
</template>
</v-tooltip>
<!-- router 推送到 /chat -->
<v-tooltip :text="tm('actions.exitFullscreen')" v-if="chatboxMode">
<template v-slot:activator="{ props }">
<v-icon v-bind="props" @click="router.push(currSessionId ? `/chat/${currSessionId}` : '/chat')"
class="fullscreen-icon">mdi-fullscreen-exit</v-icon>
</template>
</v-tooltip>
</div>
</div>
<div class="message-list-wrapper" v-if="messages && messages.length > 0">
<MessageList :messages="messages" :isDark="isDark"
:isStreaming="isStreaming || isConvRunning"
:isLoadingMessages="isLoadingMessages"
@openImagePreview="openImagePreview"
@replyMessage="handleReplyMessage"
ref="messageList" />
<div class="message-list-fade" :class="{ 'fade-dark': isDark }"></div>
</div>
<MessageList v-if="messages && messages.length > 0" :messages="messages" :isDark="isDark"
:isStreaming="isStreaming || isConvRunning" @openImagePreview="openImagePreview"
@replyMessage="handleReplyMessage"
ref="messageList" />
<div class="welcome-container fade-in" v-else>
<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">
<div class="welcome-title">
<span>Hello, I'm</span>
<span class="bot-name">AstrBot </span>
</div>
@@ -149,7 +173,6 @@ const isMobile = ref(false);
const mobileMenuOpen = ref(false);
const imagePreviewDialog = ref(false);
const previewImageUrl = ref('');
const isLoadingMessages = ref(false);
// 使 composables
const {
@@ -237,14 +260,6 @@ function toggleTheme() {
theme.global.name.value = newTheme;
}
function toggleFullscreen() {
if (props.chatboxMode) {
router.push(currSessionId.value ? `/chat/${currSessionId.value}` : '/chat');
} else {
router.push(currSessionId.value ? `/chatbox/${currSessionId.value}` : '/chatbox');
}
}
function openImagePreview(imageUrl: string) {
previewImageUrl.value = imageUrl;
imagePreviewDialog.value = true;
@@ -288,14 +303,11 @@ function clearReply() {
async function handleSelectConversation(sessionIds: string[]) {
if (!sessionIds[0]) return;
//
currSessionId.value = sessionIds[0];
selectedSessions.value = [sessionIds[0]];
// URL
const basePath = props.chatboxMode ? '/chatbox' : '/chat';
if (route.path !== `${basePath}/${sessionIds[0]}`) {
router.push(`${basePath}/${sessionIds[0]}`);
return;
}
//
@@ -305,15 +317,11 @@ async function handleSelectConversation(sessionIds: string[]) {
//
clearReply();
currSessionId.value = sessionIds[0];
selectedSessions.value = [sessionIds[0]];
//
isLoadingMessages.value = true;
try {
await getSessionMsg(sessionIds[0]);
} finally {
isLoadingMessages.value = false;
}
await getSessionMsg(sessionIds[0], router);
nextTick(() => {
messageList.value?.scrollToBottom();
@@ -502,29 +510,6 @@ onBeforeUnmount(() => {
overflow: hidden;
}
.message-list-wrapper {
flex: 1;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
.message-list-fade {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 40px;
background: linear-gradient(to top, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%);
pointer-events: none;
z-index: 1;
}
.message-list-fade.fade-dark {
background: linear-gradient(to top, rgba(30, 30, 30, 1) 0%, rgba(30, 30, 30, 0) 100%);
}
.conversation-header {
display: flex;
justify-content: space-between;
@@ -558,7 +543,6 @@ onBeforeUnmount(() => {
justify-content: center;
align-items: center;
flex-direction: column;
position: relative;
}
.welcome-title {
@@ -566,12 +550,6 @@ onBeforeUnmount(() => {
margin-bottom: 16px;
}
.loading-overlay-welcome {
display: flex;
justify-content: center;
align-items: center;
}
.bot-name {
font-weight: 700;
margin-left: 8px;
@@ -597,9 +575,5 @@ onBeforeUnmount(() => {
.chat-page-container {
padding: 0 !important;
}
.conversation-header {
padding: 2px;
}
}
</style>
+8 -20
View File
@@ -1,15 +1,7 @@
<template>
<div class="input-area fade-in">
<div class="input-container"
: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'
}">
style="width: 85%; max-width: 900px; margin: 0 auto; border: 1px solid #e0e0e0; border-radius: 24px;">
<!-- 引用预览区 -->
<div class="reply-preview" v-if="props.replyTo">
<div class="reply-content">
@@ -24,8 +16,8 @@
@keydown="handleKeyDown"
:disabled="disabled"
placeholder="Ask AstrBot..."
style="width: 100%; resize: none; outline: none; border: 1px solid var(--v-theme-border); border-radius: 12px; padding: 12px 16px; min-height: 40px; font-family: inherit; font-size: 16px; background-color: var(--v-theme-surface);"></textarea>
<div style="display: flex; justify-content: space-between; align-items: center; padding: 6px 14px;">
style="width: 100%; resize: none; outline: none; border: 1px solid var(--v-theme-border); border-radius: 12px; padding: 8px 16px; min-height: 40px; font-family: inherit; font-size: 16px; background-color: var(--v-theme-surface);"></textarea>
<div style="display: flex; justify-content: space-between; align-items: center; padding: 0px 12px;">
<div style="display: flex; justify-content: flex-start; margin-top: 4px; align-items: center; gap: 8px;">
<ConfigSelector
:session-id="sessionId || null"
@@ -34,9 +26,7 @@
:initial-config-id="props.configId"
@config-changed="handleConfigChange"
/>
<!-- Provider/Model Selector Menu -->
<ProviderModelMenu v-if="showProviderSelector" ref="providerModelMenuRef" />
<ProviderModelSelector v-if="showProviderSelector" ref="providerModelSelectorRef" />
<v-tooltip :text="enableStreaming ? tm('streaming.enabled') : tm('streaming.disabled')" location="top">
<template v-slot:activator="{ props }">
@@ -94,9 +84,8 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import { useCustomizerStore } from '@/stores/customizer';
import ProviderModelSelector from './ProviderModelSelector.vue';
import ConfigSelector from './ConfigSelector.vue';
import ProviderModelMenu from './ProviderModelMenu.vue';
import type { Session } from '@/composables/useSessions';
interface StagedFileInfo {
@@ -149,11 +138,10 @@ const emit = defineEmits<{
}>();
const { tm } = useModuleI18n('features/chat');
const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark');
const inputField = ref<HTMLTextAreaElement | null>(null);
const imageInputRef = ref<HTMLInputElement | null>(null);
const providerModelMenuRef = ref<InstanceType<typeof ProviderModelMenu> | null>(null);
const providerModelSelectorRef = ref<InstanceType<typeof ProviderModelSelector> | null>(null);
const showProviderSelector = ref(true);
const localPrompt = computed({
@@ -246,7 +234,7 @@ function getCurrentSelection() {
if (!showProviderSelector.value) {
return null;
}
return providerModelMenuRef.value?.getCurrentSelection();
return providerModelSelectorRef.value?.getCurrentSelection();
}
onMounted(() => {
@@ -271,7 +259,7 @@ defineExpose({
<style scoped>
.input-area {
padding: 16px;
background-color: transparent;
background-color: var(--v-theme-surface);
position: relative;
border-top: 1px solid var(--v-theme-border);
flex-shrink: 0;
@@ -17,7 +17,7 @@
</template>
</v-tooltip>
<v-dialog v-model="dialog" max-width="480">
<v-dialog v-model="dialog" max-width="480" persistent>
<v-card>
<v-card-title class="d-flex align-center justify-space-between">
<span>选择配置文件</span>
@@ -5,11 +5,21 @@
'mobile-sidebar-open': isMobile && mobileMenuOpen,
'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">
<v-btn icon class="sidebar-collapse-btn" @click="toggleSidebar" variant="text" color="deep-purple">
<v-icon>{{ sidebarCollapsed ? 'mdi-chevron-right' : 'mdi-chevron-left' }}</v-icon>
<v-icon>{{ (sidebarCollapsed || (!sidebarCollapsed && sidebarHoverExpanded)) ?
'mdi-chevron-right' : 'mdi-chevron-left' }}</v-icon>
</v-btn>
</div>
@@ -20,14 +30,19 @@
</v-btn>
</div>
<div style="padding: 8px; opacity: 0.6;">
<div style="padding: 16px; padding-top: 8px;">
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId"
v-if="!sidebarCollapsed || isMobile" prepend-icon="mdi-square-edit-outline">{{ tm('actions.newChat') }}</v-btn>
<v-btn icon="mdi-square-edit-outline" rounded="xl" @click="$emit('newChat')" :disabled="!currSessionId"
v-if="!sidebarCollapsed || isMobile" prepend-icon="mdi-plus"
style="background-color: transparent !important; border-radius: 4px;">{{ tm('actions.newChat') }}</v-btn>
<v-btn icon="mdi-plus" rounded="lg" @click="$emit('newChat')" :disabled="!currSessionId"
v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn>
</div>
<div v-if="!sidebarCollapsed || isMobile">
<v-divider class="mx-4"></v-divider>
</div>
<div style="overflow-y: auto; flex-grow: 1;"
<div style="overflow-y: auto; flex-grow: 1;" :class="{ 'fade-in': sidebarHoverExpanded }"
v-if="!sidebarCollapsed || isMobile">
<v-card v-if="sessions.length > 0" flat style="background-color: transparent;">
<v-list density="compact" nav class="conversation-list"
@@ -35,19 +50,18 @@
@update:selected="$emit('selectConversation', $event)">
<v-list-item v-for="item in sessions" :key="item.session_id" :value="item.session_id"
rounded="lg" class="conversation-item" active-color="secondary">
<v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title"
:style="{ color: isDark ? '#ffffff' : '#000000' }">
<v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title">
{{ item.display_name || tm('conversation.newConversation') }}
</v-list-item-title>
<!-- <v-list-item-subtitle v-if="!sidebarCollapsed || isMobile" class="timestamp">
<v-list-item-subtitle v-if="!sidebarCollapsed || isMobile" class="timestamp">
{{ new Date(item.updated_at).toLocaleString() }}
</v-list-item-subtitle> -->
</v-list-item-subtitle>
<template v-if="!sidebarCollapsed || isMobile" v-slot:append>
<div class="conversation-actions">
<v-btn icon="mdi-pencil" size="x-small" variant="text"
class="edit-title-btn"
@click.stop="$emit('editTitle', item.session_id, item.display_name ?? '')" />
@click.stop="$emit('editTitle', item.session_id, item.display_name)" />
<v-btn icon="mdi-delete" size="x-small" variant="text"
class="delete-conversation-btn" color="error"
@click.stop="handleDeleteConversation(item)" />
@@ -60,83 +74,19 @@
<v-fade-transition>
<div class="no-conversations" v-if="sessions.length === 0">
<v-icon icon="mdi-message-text-outline" size="large" color="grey-lighten-1"></v-icon>
<div class="no-conversations-text" v-if="!sidebarCollapsed || isMobile">
<div class="no-conversations-text" v-if="!sidebarCollapsed || sidebarHoverExpanded || isMobile">
{{ tm('conversation.noHistory') }}
</div>
</div>
</v-fade-transition>
</div>
<!-- 收起时的占位元素 -->
<div class="sidebar-spacer" v-if="sidebarCollapsed && !isMobile"></div>
<!-- 底部设置按钮 -->
<div class="sidebar-footer">
<StyledMenu location="top" :close-on-content-click="false">
<template v-slot:activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
:icon="sidebarCollapsed && !isMobile"
:block="!sidebarCollapsed || isMobile"
variant="text"
class="settings-btn"
:class="{ 'settings-btn-collapsed': sidebarCollapsed && !isMobile }"
:prepend-icon="(!sidebarCollapsed || isMobile) ? 'mdi-cog-outline' : undefined"
>
<v-icon v-if="sidebarCollapsed && !isMobile">mdi-cog-outline</v-icon>
<template v-if="!sidebarCollapsed || isMobile">{{ t('core.common.settings') }}</template>
</v-btn>
</template>
<!-- 语言切换 -->
<v-list-item class="styled-menu-item">
<template v-slot:prepend>
<v-icon>mdi-translate</v-icon>
</template>
<v-list-item-title>{{ t('core.common.language') }}</v-list-item-title>
<template v-slot:append>
<LanguageSwitcher variant="chatbox" />
</template>
</v-list-item>
<!-- 主题切换 -->
<v-list-item class="styled-menu-item" @click="$emit('toggleTheme')">
<template v-slot:prepend>
<v-icon>{{ isDark ? 'mdi-weather-night' : 'mdi-white-balance-sunny' }}</v-icon>
</template>
<v-list-item-title>{{ isDark ? tm('modes.lightMode') : tm('modes.darkMode') }}</v-list-item-title>
</v-list-item>
<!-- 全屏/退出全屏 -->
<v-list-item class="styled-menu-item" @click="$emit('toggleFullscreen')">
<template v-slot:prepend>
<v-icon>{{ chatboxMode ? 'mdi-fullscreen-exit' : 'mdi-fullscreen' }}</v-icon>
</template>
<v-list-item-title>{{ chatboxMode ? tm('actions.exitFullscreen') : tm('actions.fullscreen') }}</v-list-item-title>
</v-list-item>
<!-- 提供商配置 -->
<v-list-item class="styled-menu-item" @click="showProviderConfigDialog = true">
<template v-slot:prepend>
<v-icon>mdi-creation</v-icon>
</template>
<v-list-item-title>{{ tm('actions.providerConfig') }}</v-list-item-title>
</v-list-item>
</StyledMenu>
</div>
<!-- 提供商配置对话框 -->
<ProviderConfigDialog v-model="showProviderConfigDialog" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { useModuleI18n } from '@/i18n/composables';
import type { Session } from '@/composables/useSessions';
import LanguageSwitcher from '@/components/shared/LanguageSwitcher.vue';
import StyledMenu from '@/components/shared/StyledMenu.vue';
import ProviderConfigDialog from '@/components/chat/ProviderConfigDialog.vue';
interface Props {
sessions: Session[];
@@ -156,15 +106,15 @@ const emit = defineEmits<{
editTitle: [sessionId: string, title: string];
deleteConversation: [sessionId: string];
closeMobileSidebar: [];
toggleTheme: [];
toggleFullscreen: [];
}>();
const { t } = useI18n();
const { tm } = useModuleI18n('features/chat');
const sidebarCollapsed = ref(true);
const showProviderConfigDialog = ref(false);
const sidebarHovered = ref(false);
const sidebarHoverTimer = ref<number | null>(null);
const sidebarHoverExpanded = ref(false);
const sidebarHoverDelay = 100;
// localStorage
const savedCollapsedState = localStorage.getItem('sidebarCollapsed');
@@ -175,10 +125,40 @@ if (savedCollapsedState !== null) {
}
function toggleSidebar() {
if (sidebarHoverExpanded.value) {
sidebarHoverExpanded.value = false;
return;
}
sidebarCollapsed.value = !sidebarCollapsed.value;
localStorage.setItem('sidebarCollapsed', JSON.stringify(sidebarCollapsed.value));
}
function handleSidebarMouseEnter() {
if (!sidebarCollapsed.value || props.isMobile) return;
sidebarHovered.value = true;
sidebarHoverTimer.value = window.setTimeout(() => {
if (sidebarHovered.value) {
sidebarHoverExpanded.value = true;
sidebarCollapsed.value = false;
}
}, sidebarHoverDelay);
}
function handleSidebarMouseLeave() {
sidebarHovered.value = false;
if (sidebarHoverTimer.value) {
clearTimeout(sidebarHoverTimer.value);
sidebarHoverTimer.value = null;
}
if (sidebarHoverExpanded.value) {
sidebarCollapsed.value = true;
}
sidebarHoverExpanded.value = false;
}
function handleDeleteConversation(session: Session) {
const sessionTitle = session.display_name || tm('conversation.newConversation');
const message = tm('conversation.confirmDelete', { name: sessionTitle });
@@ -204,8 +184,8 @@ function handleDeleteConversation(session: Session) {
}
.sidebar-collapsed {
max-width: 60px;
min-width: 60px;
max-width: 75px;
min-width: 75px;
transition: all 0.3s ease;
}
@@ -226,7 +206,7 @@ function handleDeleteConversation(session: Session) {
}
.sidebar-collapse-btn-container {
margin: 8px;
margin: 16px;
margin-bottom: 0px;
z-index: 10;
}
@@ -238,19 +218,13 @@ function handleDeleteConversation(session: Session) {
padding: 0;
}
.new-chat-btn {
justify-content: flex-start;
background-color: transparent !important;
border-radius: 20px;
padding: 8px 16px !important;
}
.conversation-item {
/* margin-bottom: 4px; */
border-radius: 20px !important;
margin-bottom: 4px;
border-radius: 8px !important;
transition: all 0.2s ease;
height: auto !important;
/* min-height: 56px; */
padding: 0px 16px !important;
min-height: 56px;
padding: 8px 16px !important;
position: relative;
}
@@ -313,31 +287,17 @@ function handleDeleteConversation(session: Session) {
transition: opacity 0.25s ease;
}
.sidebar-spacer {
flex-grow: 1;
.fade-in {
animation: fadeInContent 0.3s ease;
}
.sidebar-footer {
padding: 8px 8px;
padding-bottom: 16px;
flex-shrink: 0;
}
.settings-btn {
opacity: 0.6;
justify-content: flex-start;
padding: 8px 16px !important;
border-radius: 20px !important;
}
.settings-btn:hover {
opacity: 1;
}
.settings-btn-collapsed {
width: 100%;
display: flex;
justify-content: center;
@keyframes fadeInContent {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -1,375 +0,0 @@
<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>
@@ -1,217 +0,0 @@
<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>
@@ -0,0 +1,359 @@
<template>
<div>
<!-- 选择提供商和模型按钮 -->
<v-chip class="text-none" variant="tonal" size="x-small"
v-if="selectedProviderId && selectedModelName" @click="openDialog">
<v-icon start size="14">mdi-creation</v-icon>
{{ selectedProviderId }} / {{ selectedModelName }}
</v-chip>
<v-chip variant="tonal" rounded="xl" size="x-small" v-else @click="openDialog">
选择模型
</v-chip>
<!-- 选择提供商和模型对话框 -->
<v-dialog v-model="showDialog" max-width="800" persistent>
<v-card style="padding: 8px;">
<v-card-title class="dialog-title">
<span>选择提供商和模型</span>
</v-card-title>
<v-card-text class="pa-0">
<div class="provider-model-container">
<!-- 左侧提供商列表 -->
<div class="provider-list-panel">
<div class="panel-header">
<h4>提供商</h4>
</div>
<v-list density="compact" nav class="provider-list">
<v-list-item v-for="provider in providerConfigs" :key="provider.id" :value="provider.id"
@click="selectProvider(provider)" :active="tempSelectedProviderId === provider.id"
rounded="lg" class="provider-item">
<v-list-item-title>{{ provider.id }}</v-list-item-title>
<v-list-item-subtitle v-if="provider.api_base">{{ provider.api_base
}}</v-list-item-subtitle>
</v-list-item>
</v-list>
<div v-if="providerConfigs.length === 0" class="empty-state">
<v-icon icon="mdi-cloud-off-outline" size="large" color="grey-lighten-1"></v-icon>
<div class="empty-text">暂无可用提供商</div>
</div>
</div>
<!-- 右侧模型列表 -->
<div class="model-list-panel">
<div class="panel-header">
<h4>模型</h4>
<v-btn v-if="tempSelectedProviderId" icon="mdi-refresh" size="small" variant="text"
@click="refreshModels" :loading="loadingModels">
</v-btn>
</div>
<v-list density="compact" nav class="model-list" v-if="tempSelectedProviderId">
<v-text-field v-model="tempSelectedModelName" placeholder="自定义模型" hide-details solo variant="outlined" density="compact" class="mb-2 mx-2"></v-text-field>
<v-list-item v-for="model in modelList" :key="model" :value="model"
@click="selectModel(model)" :active="tempSelectedModelName === model" rounded="lg"
class="model-item">
<v-list-item-title>{{ model }}</v-list-item-title>
<v-list-item-subtitle v-if="model.description">{{ model.description
}}</v-list-item-subtitle>
</v-list-item>
</v-list>
<div v-else class="empty-state">
<v-icon icon="mdi-robot-outline" size="large" color="grey-lighten-1"></v-icon>
<div class="empty-text">请先选择提供商</div>
</div>
<div v-if="tempSelectedProviderId && modelList.length === 0 && !loadingModels"
class="empty-state">
<v-icon icon="mdi-robot-off-outline" size="large" color="grey-lighten-1"></v-icon>
<div class="empty-text">该提供商暂无可用模型</div>
</div>
</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn text @click="closeDialog" color="grey-darken-1">取消</v-btn>
<v-btn text @click="confirmSelection" color="primary"
:disabled="!tempSelectedProviderId || !tempSelectedModelName">
确认选择
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
import axios from 'axios';
export default {
name: 'ProviderModelSelector',
props: {
initialProvider: {
type: String,
default: ''
},
initialModel: {
type: String,
default: ''
}
},
emits: ['selection-changed'],
data() {
return {
showDialog: false,
providerConfigs: [],
modelList: [],
selectedProviderId: '',
selectedModelName: '',
//
tempSelectedProviderId: '',
tempSelectedModelName: '',
loadingModels: false
};
},
mounted() {
// localStorage
this.loadFromStorage();
//
this.resetTempSelection();
//
this.loadProviderConfigs();
//
if (this.selectedProviderId) {
this.getProviderModels(this.selectedProviderId);
}
},
methods: {
// localStorage
loadFromStorage() {
const savedProvider = localStorage.getItem('selectedProvider');
const savedModel = localStorage.getItem('selectedModel');
if (savedProvider) {
this.selectedProviderId = savedProvider;
} else if (this.initialProvider) {
this.selectedProviderId = this.initialProvider;
}
if (savedModel) {
this.selectedModelName = savedModel;
} else if (this.initialModel) {
this.selectedModelName = this.initialModel;
}
},
// localStorage
saveToStorage() {
if (this.selectedProviderId) {
localStorage.setItem('selectedProvider', this.selectedProviderId);
}
if (this.selectedModelName) {
localStorage.setItem('selectedModel', this.selectedModelName);
}
},
//
loadProviderConfigs() {
axios.get('/api/config/provider/list', {
params: {
provider_type: 'chat_completion'
}
})
.then(response => {
if (response.data.status === 'ok') {
this.providerConfigs = response.data.data || [];
} else {
console.error('获取聊天完成提供商列表失败:', response.data.message);
}
})
.catch(error => {
console.error('获取聊天完成提供商列表失败:', error);
});
},
//
getProviderModels(providerId) {
this.loadingModels = true;
axios.get('/api/config/provider/model_list', {
params: {
provider_id: providerId
}
})
.then(response => {
if (response.data.status === 'ok') {
this.modelList = response.data.data.models || [];
} else {
console.error('获取模型列表失败:', response.data.message);
this.modelList = [];
}
})
.catch(error => {
console.error('获取模型列表失败:', error);
this.modelList = [];
})
.finally(() => {
this.loadingModels = false;
});
},
//
selectProvider(provider) {
this.tempSelectedProviderId = provider.id;
this.tempSelectedModelName = ''; //
this.modelList = []; //
this.getProviderModels(provider.id); //
},
//
selectModel(model) {
this.tempSelectedModelName = model;
},
//
refreshModels() {
if (this.tempSelectedProviderId) {
this.getProviderModels(this.tempSelectedProviderId);
}
},
//
confirmSelection() {
if (this.tempSelectedProviderId && this.tempSelectedModelName) {
//
this.selectedProviderId = this.tempSelectedProviderId;
this.selectedModelName = this.tempSelectedModelName;
// localStorage
this.saveToStorage();
//
this.$emit('selection-changed', {
providerId: this.selectedProviderId,
modelName: this.selectedModelName
});
this.closeDialog();
}
},
//
closeDialog() {
this.showDialog = false;
//
this.resetTempSelection();
},
//
resetTempSelection() {
this.tempSelectedProviderId = this.selectedProviderId;
this.tempSelectedModelName = this.selectedModelName;
//
if (this.tempSelectedProviderId) {
this.getProviderModels(this.tempSelectedProviderId);
}
},
//
openDialog() {
this.resetTempSelection();
this.showDialog = true;
},
//
getCurrentSelection() {
return {
providerId: this.selectedProviderId,
modelName: this.selectedModelName
};
}
}
};
</script>
<style scoped>
/* 对话框标题样式 */
.dialog-title {
font-size: 18px;
font-weight: 500;
padding-bottom: 8px;
}
/* 提供商和模型选择对话框样式 */
.provider-model-container {
display: flex;
height: 500px;
border: 1px solid var(--v-theme-border);
border-radius: 8px;
overflow: hidden;
}
.provider-list-panel,
.model-list-panel {
flex: 1;
display: flex;
flex-direction: column;
background-color: var(--v-theme-surface);
}
.provider-list-panel {
border-right: 1px solid var(--v-theme-border);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid var(--v-theme-border);
background-color: var(--v-theme-containerBg);
}
.panel-header h4 {
margin: 0;
font-size: 16px;
font-weight: 500;
color: var(--v-theme-primaryText);
}
.provider-list,
.model-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.provider-item,
.model-item {
margin-bottom: 4px;
border-radius: 8px !important;
transition: all 0.2s ease;
cursor: pointer;
}
.provider-item:hover,
.model-item:hover {
background-color: rgba(103, 58, 183, 0.05);
}
.provider-item.v-list-item--active,
.model-item.v-list-item--active {
background-color: rgba(103, 58, 183, 0.1);
color: var(--v-theme-secondary);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
opacity: 0.6;
gap: 12px;
}
.empty-text {
font-size: 14px;
color: var(--v-theme-secondaryText);
}
</style>
@@ -1,16 +1,14 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import type { CommandItem } from '../types';
const { tm } = useModuleI18n('features/command');
// Props
const props = defineProps<{
defineProps<{
show: boolean;
command: CommandItem | null;
newName: string;
aliases: string[];
loading: boolean;
}>();
@@ -18,42 +16,8 @@ const props = defineProps<{
const emit = defineEmits<{
(e: 'update:show', value: boolean): void;
(e: 'update:newName', value: string): void;
(e: 'update:aliases', value: string[]): void;
(e: 'confirm'): void;
}>();
const addAlias = () => {
emit('update:aliases', [...props.aliases, '']);
};
const removeAlias = (index: number) => {
const newAliases = [...props.aliases];
newAliases.splice(index, 1);
emit('update:aliases', newAliases);
};
const updateAlias = (index: number, value: string) => {
const newAliases = [...props.aliases];
newAliases[index] = value;
emit('update:aliases', newAliases);
};
const hasAliases = computed(() => (props.aliases || []).some(a => (a ?? '').toString().trim()));
const showAliasEditor = ref(false);
const aliasEditorEverOpened = ref(false);
watch(
() => props.show,
(open) => {
if (!open) return;
//
showAliasEditor.value = hasAliases.value;
},
);
watch(showAliasEditor, (open) => {
if (open) aliasEditorEverOpened.value = true;
});
</script>
<template>
@@ -68,49 +32,7 @@ watch(showAliasEditor, (open) => {
variant="outlined"
density="compact"
autofocus
class="mb-2"
/>
<v-card variant="outlined" class="mt-2" elevation="0">
<div
class="d-flex align-center justify-space-between px-4 py-3"
role="button"
tabindex="0"
@click="showAliasEditor = !showAliasEditor"
@keydown.enter.prevent="showAliasEditor = !showAliasEditor"
@keydown.space.prevent="showAliasEditor = !showAliasEditor"
>
<div class="text-subtitle-1">{{ tm('dialogs.rename.aliases') }}</div>
<v-icon size="20">{{ showAliasEditor ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
</div>
<v-divider v-if="showAliasEditor" />
<v-slide-y-transition>
<div v-if="aliasEditorEverOpened" v-show="showAliasEditor" class="px-4 py-3">
<div v-for="(alias, index) in aliases" :key="index" class="d-flex align-center mb-2">
<v-text-field
:model-value="alias"
@update:model-value="updateAlias(index, $event)"
variant="outlined"
density="compact"
hide-details
class="flex-grow-1 mr-2"
/>
<v-btn icon="mdi-delete" variant="text" color="error" density="compact" @click="removeAlias(index)" />
</div>
<v-btn
prepend-icon="mdi-plus"
variant="outlined"
color="primary"
block
size="small"
class="mt-2"
@click="addAlias"
>
{{ tm('dialogs.rename.addAlias') }}
</v-btn>
</div>
</v-slide-y-transition>
</v-card>
</v-card-text>
<v-card-actions>
<v-spacer />
@@ -14,7 +14,6 @@ export function useCommandActions(
show: false,
command: null,
newName: '',
aliases: [],
loading: false
});
@@ -54,7 +53,6 @@ export function useCommandActions(
const openRenameDialog = (cmd: CommandItem) => {
renameDialog.command = cmd;
renameDialog.newName = cmd.current_fragment || '';
renameDialog.aliases = [...(cmd.aliases || [])];
renameDialog.show = true;
};
@@ -68,8 +66,7 @@ export function useCommandActions(
try {
const res = await axios.post('/api/commands/rename', {
handler_full_name: renameDialog.command.handler_full_name,
new_name: renameDialog.newName.trim(),
aliases: renameDialog.aliases.filter(a => a.trim())
new_name: renameDialog.newName.trim()
});
if (res.data.status === 'ok') {
toast(successMessage, 'success');
@@ -288,8 +288,6 @@ watch(viewMode, async (mode) => {
@update:show="renameDialog.show = $event"
:new-name="renameDialog.newName"
@update:new-name="renameDialog.newName = $event"
:aliases="renameDialog.aliases"
@update:aliases="renameDialog.aliases = $event"
:command="renameDialog.command"
:loading="renameDialog.loading"
@confirm="handleConfirmRename"
@@ -52,7 +52,6 @@ export interface RenameDialogState {
show: boolean;
command: CommandItem | null;
newName: string;
aliases: string[];
loading: boolean;
}
@@ -394,9 +394,6 @@ export default {
//
showConfigDrawer: false,
configDrawerTargetId: null,
// ID ID
originalUpdatingPlatformId: null,
};
},
setup() {
@@ -484,7 +481,6 @@ export default {
updatingPlatformConfig: {
handler(newConfig) {
if (this.updatingMode && newConfig && newConfig.id) {
this.originalUpdatingPlatformId = newConfig.id;
this.getPlatformConfigs(newConfig.id);
}
},
@@ -537,8 +533,6 @@ export default {
this.showConfigDrawer = false;
this.configDrawerTargetId = null;
this.originalUpdatingPlatformId = null;
},
closeDialog() {
this.resetForm();
@@ -630,7 +624,7 @@ export default {
}
},
async updatePlatform() {
const id = this.originalUpdatingPlatformId || this.updatingPlatformConfig.id;
let id = this.updatingPlatformConfig.id;
if (!id) {
this.loading = false;
this.showError('更新失败,缺少平台 ID。');
@@ -639,15 +633,11 @@ export default {
try {
//
let resp = await axios.post('/api/config/platform/update', {
await axios.post('/api/config/platform/update', {
id: id,
config: this.updatingPlatformConfig
})
});
if (resp.data.status === 'error') {
throw new Error(resp.data.message || '平台更新失败');
}
//
await this.saveRoutesInternal();
@@ -895,10 +885,7 @@ export default {
//
async saveRoutesInternal() {
const originalPlatformId = this.originalUpdatingPlatformId || this.updatingPlatformConfig?.id;
const newPlatformId = this.updatingPlatformConfig?.id || originalPlatformId;
if (!originalPlatformId && !newPlatformId) {
if (!this.updatingPlatformConfig || !this.updatingPlatformConfig.id) {
throw new Error('无法获取平台 ID');
}
@@ -908,11 +895,9 @@ export default {
const fullRoutingTable = routesRes.data.data.routing;
//
const platformId = this.updatingPlatformConfig.id;
for (const umop in fullRoutingTable) {
if (
(originalPlatformId && this.isUmopMatchPlatform(umop, originalPlatformId)) ||
(newPlatformId && this.isUmopMatchPlatform(umop, newPlatformId))
) {
if (this.isUmopMatchPlatform(umop, platformId)) {
delete fullRoutingTable[umop];
}
}
@@ -921,8 +906,7 @@ export default {
for (const route of this.platformRoutes) {
const messageType = route.messageType === '*' ? '*' : route.messageType;
const sessionId = route.sessionId === '*' ? '*' : route.sessionId;
const platformIdForRoute = newPlatformId || originalPlatformId;
const newUmop = `${platformIdForRoute}:${messageType}:${sessionId}`;
const newUmop = `${platformId}:${messageType}:${sessionId}`;
if (route.configId) {
fullRoutingTable[newUmop] = route.configId;
@@ -3,6 +3,10 @@
<v-card :title="tm('dialogs.addProvider.title')">
<v-card-text style="overflow-y: auto;">
<v-tabs v-model="activeProviderTab" grow>
<v-tab value="chat_completion" class="font-weight-medium px-3">
<v-icon start>mdi-message-text</v-icon>
{{ tm('dialogs.addProvider.tabs.basic') }}
</v-tab>
<v-tab value="agent_runner" class="font-weight-medium px-3">
<v-icon start>mdi-cogs</v-icon>
{{ tm('dialogs.addProvider.tabs.agentRunner') }}
@@ -112,7 +116,7 @@ export default {
//
getTemplatesByType(type) {
const templates = this.metadata.provider.config_template || {};
const templates = this.metadata['provider_group']?.metadata?.provider?.config_template || {};
const filtered = {};
for (const [name, template] of Object.entries(templates)) {
@@ -1,211 +0,0 @@
<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>
@@ -1,157 +0,0 @@
<template>
<v-card class="provider-sources-panel h-100" elevation="0">
<div class="d-flex align-center justify-space-between px-4 pt-4 pb-2">
<div class="d-flex align-center ga-2">
<h3 class="mb-0">{{ tm('providerSources.title') }}</h3>
</div>
<v-menu>
<template #activator="{ props }">
<v-btn
v-bind="props"
prepend-icon="mdi-plus"
color="primary"
variant="tonal"
rounded="xl"
size="small"
>
新增
</v-btn>
</template>
<v-list density="compact">
<v-list-item
v-for="sourceType in availableSourceTypes"
:key="sourceType.value"
@click="emitAddSource(sourceType.value)"
>
<v-list-item-title>{{ sourceType.label }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
<div v-if="displayedProviderSources.length > 0">
<v-list class="provider-source-list" nav density="compact" lines="two">
<v-list-item
v-for="source in displayedProviderSources"
:key="source.isPlaceholder ? `template-${source.templateKey}` : source.id"
:value="source.id"
:active="isActive(source)"
:class="['provider-source-list-item', { 'provider-source-list-item--active': isActive(source) }]"
rounded="lg"
@click="emitSelectSource(source)"
>
<template #prepend>
<v-avatar size="32" class="bg-grey-lighten-4" rounded="0">
<v-img v-if="source?.provider" :src="resolveSourceIcon(source)" alt="logo" cover></v-img>
<v-icon v-else size="32">mdi-creation</v-icon>
</v-avatar>
</template>
<v-list-item-title class="font-weight-bold">{{ getSourceDisplayName(source) }}</v-list-item-title>
<v-list-item-subtitle class="text-truncate">{{ source.api_base || 'N/A' }}</v-list-item-subtitle>
<template #append>
<div class="d-flex align-center ga-1">
<v-btn
v-if="!source.isPlaceholder"
icon="mdi-delete"
variant="text"
size="x-small"
color="error"
@click.stop="emitDeleteSource(source)"
></v-btn>
</div>
</template>
</v-list-item>
</v-list>
</div>
<div v-else class="text-center py-8 px-4">
<v-icon size="48" color="grey-lighten-1">mdi-api-off</v-icon>
<p class="text-grey mt-2">{{ tm('providerSources.empty') }}</p>
</div>
</v-card>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
displayedProviderSources: {
type: Array,
default: () => []
},
selectedProviderSource: {
type: Object,
default: null
},
availableSourceTypes: {
type: Array,
default: () => []
},
tm: {
type: Function,
required: true
},
resolveSourceIcon: {
type: Function,
required: true
},
getSourceDisplayName: {
type: Function,
required: true
}
})
const emit = defineEmits([
'add-provider-source',
'select-provider-source',
'delete-provider-source'
])
const selectedId = computed(() => props.selectedProviderSource?.id || null)
const isActive = (source) => {
if (source.isPlaceholder) return false
return selectedId.value !== null && selectedId.value === source.id
}
const emitAddSource = (type) => emit('add-provider-source', type)
const emitSelectSource = (source) => emit('select-provider-source', source)
const emitDeleteSource = (source) => emit('delete-provider-source', source)
</script>
<style scoped>
.provider-sources-panel {
min-height: 320px;
}
.provider-source-list {
max-height: calc(100vh - 335px);
overflow-y: auto;
padding: 6px 8px;
}
.provider-source-list-item {
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.provider-source-list-item--active {
background-color: #E8F0FE;
border: 1px solid rgba(var(--v-theme-primary), 0.25);
}
@media (max-width: 960px) {
.provider-source-list {
max-height: none;
}
.provider-sources-panel {
min-height: auto;
}
}
</style>
<style>
.v-theme--PurpleThemeDark .provider-source-list-item--active {
background-color: #2d2d2d;
border: none;
}
</style>
@@ -162,7 +162,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
<!-- Regular Property -->
<template v-else>
<v-row v-if="!metadata[metadataKey].items[key]?.invisible && shouldShowItem(metadata[metadataKey].items[key], key)" class="config-row">
<v-col cols="12" sm="6" class="property-info">
<v-col cols="12" sm="7" class="property-info">
<v-list-item density="compact">
<v-list-item-title class="property-name">
<span v-if="metadata[metadataKey].items[key]?.description">
@@ -180,7 +180,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
</v-list-item>
</v-col>
<v-col cols="12" sm="6" class="config-input">
<v-col cols="12" sm="5" class="config-input">
<div v-if="metadata[metadataKey].items[key]" class="w-100">
<!-- Special handling for specific metadata types -->
<div v-if="metadata[metadataKey].items[key]?._special === 'select_provider'">
@@ -304,32 +304,16 @@ function hasVisibleItemsAfter(items, currentIndex) {
hide-details
></v-text-field>
<!-- Numeric input with optional slider -->
<div
<!-- Numeric input -->
<v-text-field
v-else-if="(metadata[metadataKey].items[key]?.type === 'int' || metadata[metadataKey].items[key]?.type === 'float') && !metadata[metadataKey]?.invisible"
class="d-flex align-center gap-3"
>
<v-slider
v-if="metadata[metadataKey].items[key]?.slider"
v-model.number="iterable[key]"
:min="metadata[metadataKey].items[key]?.slider?.min ?? 0"
:max="metadata[metadataKey].items[key]?.slider?.max ?? 100"
:step="metadata[metadataKey].items[key]?.slider?.step ?? 1"
color="primary"
density="compact"
hide-details
class="flex-grow-1"
></v-slider>
<v-text-field
v-model.number="iterable[key]"
density="compact"
variant="outlined"
class="config-field"
type="number"
hide-details
style="max-width: 140px;"
></v-text-field>
</div>
v-model="iterable[key]"
density="compact"
variant="outlined"
class="config-field"
type="number"
hide-details
></v-text-field>
<!-- Text area -->
<v-textarea
@@ -429,32 +413,16 @@ function hasVisibleItemsAfter(items, currentIndex) {
hide-details
></v-text-field>
<!-- Numeric input with optional slider -->
<div
<!-- Numeric input -->
<v-text-field
v-else-if="(metadata[metadataKey]?.type === 'int' || metadata[metadataKey]?.type === 'float') && !metadata[metadataKey]?.invisible"
class="d-flex align-center gap-3"
>
<v-slider
v-if="metadata[metadataKey]?.slider"
v-model.number="iterable[metadataKey]"
:min="metadata[metadataKey]?.slider?.min ?? 0"
:max="metadata[metadataKey]?.slider?.max ?? 100"
:step="metadata[metadataKey]?.slider?.step ?? 1"
color="primary"
density="compact"
hide-details
class="flex-grow-1"
></v-slider>
<v-text-field
v-model.number="iterable[metadataKey]"
density="compact"
variant="outlined"
class="config-field"
type="number"
hide-details
style="max-width: 140px;"
></v-text-field>
</div>
v-model="iterable[metadataKey]"
density="compact"
variant="outlined"
class="config-field"
type="number"
hide-details
></v-text-field>
<!-- Text area -->
<v-textarea
@@ -540,7 +508,6 @@ function hasVisibleItemsAfter(items, currentIndex) {
font-size: 0.85em;
opacity: 0.7;
font-weight: normal;
display: none;
}
.important-hint {
@@ -574,6 +541,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
align-items: center;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.2s;
}
.config-row:hover {
@@ -245,29 +245,10 @@ function getSpecialSubtype(value) {
<v-text-field v-else-if="itemMeta?.type === 'string'" v-model="createSelectorModel(itemKey).value"
density="compact" variant="outlined" class="config-field" hide-details></v-text-field>
<!-- Numeric input with optional slider for JSON selector -->
<div v-else-if="itemMeta?.type === 'int' || itemMeta?.type === 'float'" class="d-flex align-center gap-3">
<v-slider
v-if="itemMeta?.slider"
v-model.number="createSelectorModel(itemKey).value"
:min="itemMeta?.slider?.min ?? 0"
:max="itemMeta?.slider?.max ?? 100"
:step="itemMeta?.slider?.step ?? 1"
color="primary"
density="compact"
hide-details
style="flex: 3"
></v-slider>
<v-text-field
v-model.number="createSelectorModel(itemKey).value"
density="compact"
variant="outlined"
class="config-field"
style="flex: 2"
type="number"
hide-details
></v-text-field>
</div>
<!-- Numeric input for JSON selector -->
<v-text-field v-else-if="itemMeta?.type === 'int' || itemMeta?.type === 'float'"
v-model="createSelectorModel(itemKey).value" density="compact" variant="outlined" class="config-field"
type="number" hide-details></v-text-field>
<!-- Text area for JSON selector -->
<v-textarea v-else-if="itemMeta?.type === 'text'" v-model="createSelectorModel(itemKey).value"
@@ -1,673 +0,0 @@
<template>
<v-dialog v-model="isOpen" persistent max-width="700" scrollable>
<v-card>
<v-card-title class="d-flex align-center">
<v-icon class="mr-2">mdi-backup-restore</v-icon>
{{ t('features.settings.backup.dialog.title') }}
</v-card-title>
<v-card-text class="pa-6">
<!-- 选项卡 -->
<v-tabs v-model="activeTab" color="primary" class="mb-4">
<v-tab value="export">
<v-icon class="mr-2">mdi-export</v-icon>
{{ t('features.settings.backup.tabs.export') }}
</v-tab>
<v-tab value="import">
<v-icon class="mr-2">mdi-import</v-icon>
{{ t('features.settings.backup.tabs.import') }}
</v-tab>
<v-tab value="list">
<v-icon class="mr-2">mdi-format-list-bulleted</v-icon>
{{ t('features.settings.backup.tabs.list') }}
</v-tab>
</v-tabs>
<v-window v-model="activeTab">
<!-- 导出标签页 -->
<v-window-item value="export">
<div v-if="exportStatus === 'idle'" class="text-center py-8">
<v-icon size="64" color="primary" class="mb-4">mdi-cloud-upload</v-icon>
<h3 class="mb-4">{{ t('features.settings.backup.export.title') }}</h3>
<p class="mb-4 text-grey">{{ t('features.settings.backup.export.description') }}</p>
<v-alert type="info" variant="tonal" class="mb-4 text-left">
<template v-slot:prepend>
<v-icon>mdi-information</v-icon>
</template>
{{ t('features.settings.backup.export.includes') }}
</v-alert>
<v-btn color="primary" size="large" @click="startExport" :loading="exportStatus === 'processing'">
<v-icon class="mr-2">mdi-export</v-icon>
{{ t('features.settings.backup.export.button') }}
</v-btn>
</div>
<div v-else-if="exportStatus === 'processing'" class="text-center py-8">
<v-progress-circular indeterminate color="primary" size="64" class="mb-4"></v-progress-circular>
<h3 class="mb-4">{{ t('features.settings.backup.export.processing') }}</h3>
<p class="text-grey">{{ exportProgress.message || t('features.settings.backup.export.wait') }}</p>
<v-progress-linear :model-value="exportProgress.current" :max="exportProgress.total" class="mt-4" color="primary"></v-progress-linear>
</div>
<div v-else-if="exportStatus === 'completed'" class="text-center py-8">
<v-icon size="64" color="success" class="mb-4">mdi-check-circle</v-icon>
<h3 class="mb-4">{{ t('features.settings.backup.export.completed') }}</h3>
<p class="mb-4">{{ exportResult?.filename }}</p>
<v-btn color="primary" @click="downloadBackup(exportResult?.filename)" class="mr-2">
<v-icon class="mr-2">mdi-download</v-icon>
{{ t('features.settings.backup.export.download') }}
</v-btn>
<v-btn color="grey" variant="text" @click="resetExport">
{{ t('features.settings.backup.export.another') }}
</v-btn>
</div>
<div v-else-if="exportStatus === 'failed'" class="text-center py-8">
<v-icon size="64" color="error" class="mb-4">mdi-alert-circle</v-icon>
<h3 class="mb-4">{{ t('features.settings.backup.export.failed') }}</h3>
<v-alert type="error" variant="tonal" class="mb-4">
{{ exportError }}
</v-alert>
<v-btn color="primary" @click="resetExport">
{{ t('features.settings.backup.export.retry') }}
</v-btn>
</div>
</v-window-item>
<!-- 导入标签页 -->
<v-window-item value="import">
<!-- 步骤1: 选择文件 -->
<div v-if="importStatus === 'idle'" class="py-4">
<v-alert type="warning" variant="tonal" class="mb-4">
<template v-slot:prepend>
<v-icon>mdi-alert</v-icon>
</template>
{{ t('features.settings.backup.import.warning') }}
</v-alert>
<v-file-input
v-model="importFile"
:label="t('features.settings.backup.import.selectFile')"
accept=".zip"
prepend-icon="mdi-file-upload"
show-size
class="mb-4"
></v-file-input>
<div class="d-flex justify-center">
<v-btn
color="primary"
size="large"
@click="uploadAndCheck"
:disabled="!importFile"
:loading="importStatus === 'uploading'"
>
<v-icon class="mr-2">mdi-upload</v-icon>
{{ t('features.settings.backup.import.uploadAndCheck') }}
</v-btn>
</div>
</div>
<!-- 步骤1.5: 上传中 -->
<div v-else-if="importStatus === 'uploading'" class="text-center py-8">
<v-progress-circular indeterminate color="primary" size="64" class="mb-4"></v-progress-circular>
<h3 class="mb-4">{{ t('features.settings.backup.import.uploading') }}</h3>
<p class="text-grey">{{ t('features.settings.backup.import.uploadWait') }}</p>
</div>
<!-- 步骤2: 确认导入 -->
<div v-else-if="importStatus === 'confirm'" class="py-4">
<v-alert
:type="versionAlertType"
variant="tonal"
class="mb-4"
>
<template v-slot:prepend>
<v-icon>{{ versionAlertIcon }}</v-icon>
</template>
<div class="confirm-message">
<div class="text-h6 mb-2">{{ versionAlertTitle }}</div>
<div class="mb-2">
<strong>{{ t('features.settings.backup.import.version.backupVersion') }}:</strong> {{ checkResult?.backup_version }}<br>
<strong>{{ t('features.settings.backup.import.version.currentVersion') }}:</strong> {{ checkResult?.current_version }}
</div>
<div v-if="checkResult?.backup_time && checkResult?.backup_time !== '未知'" class="mb-2">
<strong>{{ t('features.settings.backup.import.version.backupTime') }}:</strong> {{ formatISODate(checkResult?.backup_time) }}
</div>
<div class="mt-3" style="white-space: pre-line;">{{ versionAlertMessage }}</div>
</div>
</v-alert>
<!-- 备份摘要 -->
<v-card variant="outlined" class="mb-4" v-if="checkResult?.backup_summary">
<v-card-title class="text-subtitle-1">
<v-icon class="mr-2">mdi-package-variant</v-icon>
{{ t('features.settings.backup.import.backupContents') }}
</v-card-title>
<v-card-text>
<div class="d-flex flex-wrap ga-2">
<v-chip v-if="checkResult.backup_summary.tables?.length" size="small" color="primary" variant="tonal" :ripple="false" class="non-interactive-chip">
{{ checkResult.backup_summary.tables.length }} {{ t('features.settings.backup.import.tables') }}
</v-chip>
<v-chip v-if="checkResult.backup_summary.has_knowledge_bases" size="small" color="success" variant="tonal" :ripple="false" class="non-interactive-chip">
{{ t('features.settings.backup.import.knowledgeBases') }}
</v-chip>
<v-chip v-if="checkResult.backup_summary.has_config" size="small" color="info" variant="tonal" :ripple="false" class="non-interactive-chip">
{{ t('features.settings.backup.import.configFiles') }}
</v-chip>
<v-chip v-for="dir in (checkResult.backup_summary.directories || [])" :key="dir" size="small" color="warning" variant="tonal" :ripple="false" class="non-interactive-chip">
{{ dir }}
</v-chip>
</div>
</v-card-text>
</v-card>
<!-- 警告信息 -->
<v-alert v-if="checkResult?.warnings?.length" type="warning" variant="tonal" class="mb-4">
<div v-for="(warning, idx) in checkResult.warnings" :key="idx">{{ warning }}</div>
</v-alert>
<div class="d-flex justify-center align-center mt-4" style="gap: 16px;">
<v-btn
color="grey-darken-1"
variant="outlined"
size="large"
@click="resetImport"
>
<v-icon class="mr-2">mdi-close</v-icon>
{{ t('core.common.cancel') }}
</v-btn>
<v-btn
v-if="checkResult?.can_import"
color="error"
size="large"
variant="flat"
@click="confirmImport"
>
<v-icon class="mr-2">mdi-alert</v-icon>
{{ t('features.settings.backup.import.confirmImport') }}
</v-btn>
</div>
</div>
<!-- 步骤3: 导入进行中 -->
<div v-else-if="importStatus === 'processing'" class="text-center py-8">
<v-progress-circular indeterminate color="primary" size="64" class="mb-4"></v-progress-circular>
<h3 class="mb-4">{{ t('features.settings.backup.import.processing') }}</h3>
<p class="text-grey">{{ importProgress.message || t('features.settings.backup.import.wait') }}</p>
<v-progress-linear :model-value="importProgress.current" :max="importProgress.total" class="mt-4" color="primary"></v-progress-linear>
</div>
<div v-else-if="importStatus === 'completed'" class="text-center py-8">
<v-icon size="64" color="success" class="mb-4">mdi-check-circle</v-icon>
<h3 class="mb-4">{{ t('features.settings.backup.import.completed') }}</h3>
<v-alert type="info" variant="tonal" class="mb-4">
{{ t('features.settings.backup.import.restartRequired') }}
</v-alert>
<v-btn color="primary" @click="restartAstrBot" class="mr-2">
<v-icon class="mr-2">mdi-restart</v-icon>
{{ t('features.settings.backup.import.restartNow') }}
</v-btn>
<v-btn color="grey" variant="text" @click="resetImport">
{{ t('core.common.close') }}
</v-btn>
</div>
<div v-else-if="importStatus === 'failed'" class="text-center py-8">
<v-icon size="64" color="error" class="mb-4">mdi-alert-circle</v-icon>
<h3 class="mb-4">{{ t('features.settings.backup.import.failed') }}</h3>
<v-alert type="error" variant="tonal" class="mb-4">
{{ importError }}
</v-alert>
<v-btn color="primary" @click="resetImport">
{{ t('features.settings.backup.import.retry') }}
</v-btn>
</div>
</v-window-item>
<!-- 备份列表标签页 -->
<v-window-item value="list">
<div v-if="loadingList" class="text-center py-8">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
</div>
<div v-else-if="backupList.length === 0" class="text-center py-8">
<v-icon size="64" color="grey" class="mb-4">mdi-folder-open-outline</v-icon>
<p class="text-grey">{{ t('features.settings.backup.list.empty') }}</p>
</div>
<v-list v-else lines="two">
<v-list-item
v-for="backup in backupList"
:key="backup.filename"
>
<template v-slot:prepend>
<v-icon color="primary">mdi-zip-box</v-icon>
</template>
<v-list-item-title>{{ backup.filename }}</v-list-item-title>
<v-list-item-subtitle>
{{ formatFileSize(backup.size) }} · {{ formatDate(backup.created_at) }}
</v-list-item-subtitle>
<template v-slot:append>
<v-btn icon="mdi-download" variant="text" size="small" @click="downloadBackup(backup.filename)"></v-btn>
<v-btn icon="mdi-delete" variant="text" size="small" color="error" @click="deleteBackup(backup.filename)"></v-btn>
</template>
</v-list-item>
</v-list>
<div class="d-flex justify-center mt-4">
<v-btn color="primary" variant="text" @click="loadBackupList">
<v-icon class="mr-2">mdi-refresh</v-icon>
{{ t('features.settings.backup.list.refresh') }}
</v-btn>
</div>
</v-window-item>
</v-window>
</v-card-text>
<v-card-actions class="px-6 py-4">
<v-spacer></v-spacer>
<v-btn color="grey" variant="text" @click="handleClose" :disabled="isProcessing">
{{ t('core.common.close') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<WaitingForRestart ref="wfr"></WaitingForRestart>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import axios from 'axios'
import { useI18n } from '@/i18n/composables'
import WaitingForRestart from './WaitingForRestart.vue'
const { t } = useI18n()
const isOpen = ref(false)
const activeTab = ref('export')
const wfr = ref(null)
//
const exportStatus = ref('idle') // idle, processing, completed, failed
const exportTaskId = ref(null)
const exportProgress = ref({ current: 0, total: 100, message: '' })
const exportResult = ref(null)
const exportError = ref('')
//
const importStatus = ref('idle') // idle, uploading, confirm, processing, completed, failed
const importFile = ref(null)
const importTaskId = ref(null)
const importProgress = ref({ current: 0, total: 100, message: '' })
const importError = ref('')
const uploadedFilename = ref('') //
const checkResult = ref(null) //
//
const loadingList = ref(false)
const backupList = ref([])
//
const isProcessing = computed(() => {
return exportStatus.value === 'processing' || importStatus.value === 'processing'
})
//
const versionAlertType = computed(() => {
const status = checkResult.value?.version_status
if (status === 'major_diff') return 'error'
if (status === 'minor_diff') return 'warning'
return 'info'
})
const versionAlertIcon = computed(() => {
const status = checkResult.value?.version_status
if (status === 'major_diff') return 'mdi-close-circle'
if (status === 'minor_diff') return 'mdi-alert'
return 'mdi-check-circle'
})
const versionAlertTitle = computed(() => {
const status = checkResult.value?.version_status
if (status === 'major_diff') return t('features.settings.backup.import.version.majorDiffTitle')
if (status === 'minor_diff') return t('features.settings.backup.import.version.minorDiffTitle')
return t('features.settings.backup.import.version.matchTitle')
})
const versionAlertMessage = computed(() => {
const status = checkResult.value?.version_status
if (status === 'major_diff') return t('features.settings.backup.import.version.majorDiffMessage')
if (status === 'minor_diff') return t('features.settings.backup.import.version.minorDiffMessage')
return t('features.settings.backup.import.version.matchMessage')
})
//
watch(isOpen, (newVal) => {
if (newVal) {
loadBackupList()
} else {
resetAll()
}
})
//
watch(activeTab, (newVal) => {
if (newVal === 'list') {
loadBackupList()
}
})
//
const loadBackupList = async () => {
loadingList.value = true
try {
const response = await axios.get('/api/backup/list')
if (response.data.status === 'ok') {
backupList.value = response.data.data.items || []
}
} catch (error) {
console.error('Failed to load backup list:', error)
} finally {
loadingList.value = false
}
}
//
const startExport = async () => {
exportStatus.value = 'processing'
exportProgress.value = { current: 0, total: 100, message: '' }
try {
const response = await axios.post('/api/backup/export')
if (response.data.status === 'ok') {
exportTaskId.value = response.data.data.task_id
pollExportProgress()
} else {
throw new Error(response.data.message)
}
} catch (error) {
exportStatus.value = 'failed'
exportError.value = error.message || 'Export failed'
}
}
//
const pollExportProgress = async () => {
if (!exportTaskId.value) return
try {
const response = await axios.get('/api/backup/progress', {
params: { task_id: exportTaskId.value }
})
if (response.data.status === 'ok') {
const data = response.data.data
if (data.status === 'processing' && data.progress) {
exportProgress.value = {
current: data.progress.current || 0,
total: data.progress.total || 100,
message: data.progress.message || ''
}
setTimeout(pollExportProgress, 1000)
} else if (data.status === 'completed') {
exportStatus.value = 'completed'
exportResult.value = data.result
loadBackupList()
} else if (data.status === 'failed') {
exportStatus.value = 'failed'
exportError.value = data.error || 'Export failed'
} else {
setTimeout(pollExportProgress, 1000)
}
}
} catch (error) {
exportStatus.value = 'failed'
exportError.value = error.message || 'Failed to get export progress'
}
}
//
const resetExport = () => {
exportStatus.value = 'idle'
exportTaskId.value = null
exportProgress.value = { current: 0, total: 100, message: '' }
exportResult.value = null
exportError.value = ''
}
//
const uploadAndCheck = async () => {
if (!importFile.value) return
importStatus.value = 'uploading'
try {
// 1:
const formData = new FormData()
formData.append('file', importFile.value)
const uploadResponse = await axios.post('/api/backup/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
if (uploadResponse.data.status !== 'ok') {
throw new Error(uploadResponse.data.message)
}
uploadedFilename.value = uploadResponse.data.data.filename
// 2:
const checkResponse = await axios.post('/api/backup/check', {
filename: uploadedFilename.value
})
if (checkResponse.data.status !== 'ok') {
throw new Error(checkResponse.data.message)
}
checkResult.value = checkResponse.data.data
//
if (!checkResult.value.valid) {
importStatus.value = 'failed'
importError.value = checkResult.value.error || t('features.settings.backup.import.invalidBackup')
return
}
//
importStatus.value = 'confirm'
} catch (error) {
importStatus.value = 'failed'
importError.value = error.response?.data?.message || error.message || 'Upload failed'
}
}
//
const confirmImport = async () => {
if (!uploadedFilename.value) return
importStatus.value = 'processing'
importProgress.value = { current: 0, total: 100, message: '' }
try {
const response = await axios.post('/api/backup/import', {
filename: uploadedFilename.value,
confirmed: true
})
if (response.data.status === 'ok') {
importTaskId.value = response.data.data.task_id
pollImportProgress()
} else {
throw new Error(response.data.message)
}
} catch (error) {
importStatus.value = 'failed'
importError.value = error.response?.data?.message || error.message || 'Import failed'
}
}
//
const pollImportProgress = async () => {
if (!importTaskId.value) return
try {
const response = await axios.get('/api/backup/progress', {
params: { task_id: importTaskId.value }
})
if (response.data.status === 'ok') {
const data = response.data.data
if (data.status === 'processing' && data.progress) {
importProgress.value = {
current: data.progress.current || 0,
total: data.progress.total || 100,
message: data.progress.message || ''
}
setTimeout(pollImportProgress, 1000)
} else if (data.status === 'completed') {
importStatus.value = 'completed'
} else if (data.status === 'failed') {
importStatus.value = 'failed'
importError.value = data.error || 'Import failed'
} else {
setTimeout(pollImportProgress, 1000)
}
}
} catch (error) {
importStatus.value = 'failed'
importError.value = error.message || 'Failed to get import progress'
}
}
//
const resetImport = () => {
importStatus.value = 'idle'
importFile.value = null
importTaskId.value = null
importProgress.value = { current: 0, total: 100, message: '' }
importError.value = ''
uploadedFilename.value = ''
checkResult.value = null
}
//
const downloadBackup = async (filename) => {
try {
const response = await axios.get('/api/backup/download', {
params: { filename },
responseType: 'blob'
})
// Blob URL
const blob = new Blob([response.data], { type: 'application/zip' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
} catch (error) {
console.error('Download failed:', error)
alert(t('features.settings.backup.export.failed') + ': ' + (error.message || 'Unknown error'))
}
}
//
const deleteBackup = async (filename) => {
if (!confirm(t('features.settings.backup.list.confirmDelete'))) return
try {
const response = await axios.post('/api/backup/delete', { filename })
if (response.data.status === 'ok') {
loadBackupList()
} else {
alert(response.data.message || 'Delete failed')
}
} catch (error) {
alert(error.message || 'Delete failed')
}
}
//
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
//
const formatDate = (timestamp) => {
return new Date(timestamp * 1000).toLocaleString()
}
// ISO
const formatISODate = (isoString) => {
if (!isoString) return ''
try {
return new Date(isoString).toLocaleString()
} catch {
return isoString
}
}
// AstrBot
const restartAstrBot = () => {
axios.post('/api/stat/restart-core').then(() => {
if (wfr.value) {
wfr.value.check()
}
})
}
//
const resetAll = () => {
resetExport()
resetImport()
activeTab.value = 'export'
}
//
const handleClose = () => {
if (isProcessing.value) return
isOpen.value = false
}
//
const open = () => {
isOpen.value = true
}
defineExpose({ open })
</script>
<style scoped>
.v-list-item {
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.v-list-item:last-child {
border-bottom: none;
}
/* 禁用 Chip 的交互效果 */
.non-interactive-chip {
pointer-events: none;
cursor: default;
}
.non-interactive-chip:hover {
box-shadow: none !important;
}
</style>
@@ -1,209 +0,0 @@
<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,11 +1,12 @@
<script setup>
import { useCommonStore } from '@/stores/common';
import { storeToRefs } from 'pinia';
import axios from 'axios';
import { EventSourcePolyfill } from 'event-source-polyfill';
</script>
<template>
<div>
<!-- 添加筛选级别控件 -->
<div class="filter-controls mb-2" v-if="showLevelBtns">
<v-chip-group v-model="selectedLevels" column multiple>
<v-chip v-for="level in logLevels" :key="level" :color="getLevelColor(level)" filter variant="flat" size="small"
@@ -25,19 +26,20 @@ export default {
name: 'ConsoleDisplayer',
data() {
return {
autoScroll: true,
autoScroll: true, //
logColorAnsiMap: {
'\u001b[1;34m': 'color: #0000FF; font-weight: bold;',
'\u001b[1;36m': 'color: #00FFFF; font-weight: bold;',
'\u001b[1;33m': 'color: #FFFF00; font-weight: bold;',
'\u001b[31m': 'color: #FF0000;',
'\u001b[1;31m': 'color: #FF0000; font-weight: bold;',
'\u001b[0m': 'color: inherit; font-weight: normal;',
'\u001b[32m': 'color: #00FF00;',
'\u001b[1;34m': 'color: #0000FF; font-weight: bold;', // bold_blue
'\u001b[1;36m': 'color: #00FFFF; font-weight: bold;', // bold_cyan
'\u001b[1;33m': 'color: #FFFF00; font-weight: bold;', // bold_yellow
'\u001b[31m': 'color: #FF0000;', // red
'\u001b[1;31m': 'color: #FF0000; font-weight: bold;', // bold_red
'\u001b[0m': 'color: inherit; font-weight: normal;', // reset
'\u001b[32m': 'color: #00FF00;', // green
'default': 'color: #FFFFFF;'
},
historyNum_: -1,
logLevels: ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
selectedLevels: [0, 1, 2, 3, 4],
selectedLevels: [0, 1, 2, 3, 4], //
levelColors: {
'DEBUG': 'grey',
'INFO': 'blue-lighten-3',
@@ -45,19 +47,17 @@ export default {
'ERROR': 'red',
'CRITICAL': 'purple'
},
localLogCache: [],
eventSource: null,
retryTimer: null,
retryAttempts: 0,
maxRetryAttempts: 10,
baseRetryDelay: 1000,
lastEventId: null,
lastProcessedTime: 0, //
localLogCache: [], //
}
},
computed: {
commonStore() {
return useCommonStore();
},
logCache() {
return this.commonStore.log_cache;
}
},
props: {
historyNum: {
@@ -70,6 +70,41 @@ export default {
}
},
watch: {
logCache: {
handler(newVal) {
// timestamp
if (newVal && newVal.length > 0) {
// DOM
this.$nextTick(() => {
//
const newLogs = newVal.filter(log => log.time > this.lastProcessedTime);
if (newLogs.length > 0) {
this.localLogCache.push(...newLogs);
//
this.localLogCache.sort((a, b) => a.time - b.time);
// log_cache_max_len
if (this.localLogCache.length > this.commonStore.log_cache_max_len) {
this.localLogCache.splice(0, this.localLogCache.length - this.commonStore.log_cache_max_len);
}
//
newLogs.forEach(logItem => {
if (this.isLevelSelected(logItem.level)) {
this.printLog(logItem.data);
}
});
//
this.lastProcessedTime = Math.max(...newLogs.map(log => log.time));
}
});
}
},
deep: true,
immediate: false
},
selectedLevels: {
handler() {
this.refreshDisplay();
@@ -78,142 +113,30 @@ export default {
}
},
async mounted() {
//
await this.fetchLogHistory();
this.connectSSE();
},
beforeUnmount() {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
if (this.retryTimer) {
clearTimeout(this.retryTimer);
this.retryTimer = null;
}
this.retryAttempts = 0;
// DOM
this.$nextTick(() => {
if (this.localLogCache.length > 0) {
this.localLogCache.forEach(logItem => {
if (this.isLevelSelected(logItem.level)) {
this.printLog(logItem.data);
}
});
//
this.lastProcessedTime = Math.max(...this.localLogCache.map(log => log.time));
}
});
},
methods: {
connectSSE() {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
console.log(`正在连接日志流... (尝试次数: ${this.retryAttempts})`);
const token = localStorage.getItem('token');
this.eventSource = new EventSourcePolyfill('/api/live-log', {
headers: {
'Authorization': token ? `Bearer ${token}` : ''
},
heartbeatTimeout: 300000,
withCredentials: true
});
this.eventSource.onopen = () => {
console.log('日志流连接成功!');
this.retryAttempts = 0;
if (!this.lastEventId) {
this.fetchLogHistory();
}
};
this.eventSource.onmessage = (event) => {
try {
if (event.lastEventId) {
this.lastEventId = event.lastEventId;
}
const payload = JSON.parse(event.data);
this.processNewLogs([payload]);
} catch (e) {
console.error('解析日志失败:', e);
}
};
this.eventSource.onerror = (err) => {
if (err.status === 401) {
console.error('鉴权失败 (401),可能是 Token 过期了。');
} else {
console.warn('日志流连接错误:', err);
}
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
if (this.retryAttempts >= this.maxRetryAttempts) {
console.error('❌ 已达到最大重试次数,停止重连。请刷新页面重试。');
return;
}
const delay = Math.min(
this.baseRetryDelay * Math.pow(2, this.retryAttempts),
30000
);
console.log(`${delay}ms 后尝试第 ${this.retryAttempts + 1} 次重连...`);
if (this.retryTimer) {
clearTimeout(this.retryTimer);
this.retryTimer = null;
}
this.retryTimer = setTimeout(async () => {
this.retryAttempts++;
if (!this.lastEventId) {
await this.fetchLogHistory();
}
this.connectSSE();
}, delay);
};
},
processNewLogs(newLogs) {
if (!newLogs || newLogs.length === 0) return;
let hasUpdate = false;
newLogs.forEach(log => {
const exists = this.localLogCache.some(existing =>
existing.time === log.time &&
existing.data === log.data &&
existing.level === log.level
);
if (!exists) {
this.localLogCache.push(log);
hasUpdate = true;
if (this.isLevelSelected(log.level)) {
this.printLog(log.data);
}
}
});
if (hasUpdate) {
this.localLogCache.sort((a, b) => a.time - b.time);
const maxSize = this.commonStore.log_cache_max_len || 200;
if (this.localLogCache.length > maxSize) {
this.localLogCache.splice(0, this.localLogCache.length - maxSize);
}
}
},
async fetchLogHistory() {
try {
const res = await axios.get('/api/log-history');
if (res.data.data.logs && res.data.data.logs.length > 0) {
this.processNewLogs(res.data.data.logs);
this.localLogCache = [...res.data.data.logs];
//
this.localLogCache.sort((a, b) => a.time - b.time);
}
} catch (err) {
console.error('Failed to fetch log history:', err);
@@ -239,6 +162,7 @@ export default {
if (termElement) {
termElement.innerHTML = '';
//
if (this.localLogCache && this.localLogCache.length > 0) {
this.localLogCache.forEach(logItem => {
if (this.isLevelSelected(logItem.level)) {
@@ -249,13 +173,16 @@ export default {
}
},
toggleAutoScroll() {
this.autoScroll = !this.autoScroll;
},
printLog(log) {
// append span termblock
let ele = document.getElementById('term')
if (!ele) {
console.warn('term element not found, skipping log print');
return;
}
@@ -269,11 +196,11 @@ export default {
}
}
span.style = style + 'display: block; font-size: 12px; font-family: Consolas, monospace; white-space: pre-wrap; margin-bottom: 2px;'
span.style = style + 'display: block; font-size: 12px; font-family: Consolas, monospace; white-space: pre-wrap;'
span.classList.add('fade-in')
span.innerText = `${log}`;
ele.appendChild(span)
if (this.autoScroll) {
if (this.autoScroll ) {
ele.scrollTop = ele.scrollHeight
}
}
@@ -303,4 +230,4 @@ export default {
opacity: 1;
}
}
</style>
</style>
@@ -1,5 +1,5 @@
<template>
<StyledMenu offset="12" location="bottom center">
<v-menu offset="12" location="bottom center">
<template v-slot:activator="{ props: activatorProps }">
<v-btn
v-bind="activatorProps"
@@ -22,21 +22,25 @@
</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>
</StyledMenu>
<v-card class="language-dropdown" elevation="8" rounded="lg">
<v-list density="compact" class="pa-1">
<v-list-item
v-for="lang in languages"
:key="lang.code"
:value="lang.code"
@click="changeLanguage(lang.code)"
:class="{ 'v-list-item--active': currentLocale === lang.code, 'language-item-selected': currentLocale === lang.code }"
class="language-item"
rounded="md"
>
<template v-slot:prepend>
<span class="language-flag">{{ lang.flag }}</span>
</template>
<v-list-item-title>{{ lang.name }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
</template>
<script setup lang="ts">
@@ -44,7 +48,6 @@ import { computed } from 'vue'
import { useI18n, useLanguageSwitcher } from '@/i18n/composables'
import { useCustomizerStore } from '@/stores/customizer'
import type { Locale } from '@/i18n/types'
import StyledMenu from '@/components/shared/StyledMenu.vue'
// props
const props = withDefaults(defineProps<{
@@ -107,4 +110,49 @@ const changeLanguage = async (langCode: string) => {
:deep(.v-theme--PurpleThemeDark) .language-switcher--default:hover {
background: rgba(114, 46, 209, 0.12) !important;
}
.language-dropdown {
min-width: 100px;
width: fit-content;
border: 1px solid rgba(94, 53, 177, 0.15) !important;
background: #f8f6fc !important;
backdrop-filter: blur(10px);
}
/* 深色模式下的下拉框样式 */
:deep(.v-theme--PurpleThemeDark) .language-dropdown {
background: #2a2733 !important;
border: 1px solid rgba(110, 60, 180, 0.692) !important;
}
.language-item {
margin: 2px 0;
transition: all 0.2s ease;
}
.language-item:hover {
background: rgba(94, 53, 177, 0.08) !important;
}
.language-item-selected {
background: rgba(94, 53, 177, 0.15) !important;
font-weight: 500;
}
.language-item-selected:hover {
background: rgba(94, 53, 177, 0.2) !important;
}
/* 深色模式下的列表项悬停效果 */
:deep(.v-theme--PurpleThemeDark) .language-item:hover {
background: rgba(114, 46, 209, 0.12) !important;
}
:deep(.v-theme--PurpleThemeDark) .language-item-selected {
background: rgba(114, 46, 209, 0.2) !important;
}
:deep(.v-theme--PurpleThemeDark) .language-item-selected:hover {
background: rgba(114, 46, 209, 0.25) !important;
}
</style>
@@ -1,15 +1,6 @@
<template>
<div class="d-flex align-center justify-space-between ga-2">
<div v-if="isSingleItemMode" class="flex-grow-1 d-flex align-center ga-2">
<v-text-field
v-model="singleItemValue"
hide-details
variant="outlined"
density="compact"
class="flex-grow-1"
></v-text-field>
</div>
<div v-else>
<div class="d-flex align-center justify-space-between">
<div>
<span v-if="!modelValue || modelValue.length === 0" style="color: rgb(var(--v-theme-primaryText));">
{{ t('core.common.list.noItems') }}
</span>
@@ -23,7 +14,7 @@
</div>
</div>
<v-btn size="small" color="primary" variant="tonal" @click="openDialog">
{{ preferSingleItem ? '添加更多' : (buttonText || t('core.common.list.modifyButton')) }}
{{ buttonText || t('core.common.list.modifyButton') }}
</v-btn>
</div>
@@ -176,10 +167,6 @@ const props = defineProps({
maxDisplayItems: {
type: Number,
default: 1
},
preferSingleItem: {
type: Boolean,
default: true
}
})
@@ -193,21 +180,6 @@ const editIndex = ref(-1)
const editItem = ref('')
const showBatchImport = ref(false)
const batchImportText = ref('')
const isSingleItemMode = computed(() => (props.modelValue?.length ?? 0) <= 1 && props.preferSingleItem)
const singleItemValue = computed({
get: () => props.modelValue?.[0] ?? '',
set: (value) => {
const newItems = [...(props.modelValue || [])]
if (newItems.length === 0) {
newItems.push(value)
} else {
newItems[0] = value
}
emit('update:modelValue', newItems)
}
})
//
const displayItems = computed(() => {
@@ -14,20 +14,8 @@
<!-- Provider Selection Dialog -->
<v-dialog v-model="dialog" max-width="600px">
<v-card>
<v-card-title
class="text-h3 py-4 d-flex align-center justify-space-between gap-4 flex-wrap"
style="font-weight: normal;"
>
<span>{{ tm('providerSelector.dialogTitle') }}</span>
<v-btn
size="small"
color="primary"
variant="tonal"
prepend-icon="mdi-plus"
@click="openProviderDrawer"
>
{{ tm('providerSelector.createProvider') }}
</v-btn>
<v-card-title class="text-h3 py-4" style="font-weight: normal;">
{{ tm('providerSelector.dialogTitle') }}
</v-card-title>
<v-card-text class="pa-0" style="max-height: 400px; overflow-y: auto;">
@@ -63,7 +51,7 @@
<v-list-item-title>{{ provider.id }}</v-list-item-title>
<v-list-item-subtitle>
{{ provider.type || provider.provider_type || tm('providerSelector.unknownType') }}
<span v-if="provider.model">- {{ provider.model }}</span>
<span v-if="provider.model_config?.model">- {{ provider.model_config.model }}</span>
</v-list-item-subtitle>
<template v-slot:append>
@@ -91,33 +79,12 @@
</v-card-actions>
</v-card>
</v-dialog>
<v-overlay
v-model="providerDrawer"
class="provider-drawer-overlay"
location="right"
transition="slide-x-reverse-transition"
:scrim="true"
@click:outside="closeProviderDrawer"
>
<v-card class="provider-drawer-card" elevation="12">
<div class="provider-drawer-header">
<v-btn icon variant="text" @click="closeProviderDrawer">
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
<div class="provider-drawer-content">
<ProviderPage :default-tab="defaultTab" />
</div>
</v-card>
</v-overlay>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { ref, watch } from 'vue'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
import ProviderPage from '@/views/ProviderPage.vue'
const props = defineProps({
modelValue: {
@@ -145,26 +112,12 @@ const dialog = ref(false)
const providerList = ref([])
const loading = ref(false)
const selectedProvider = ref('')
const providerDrawer = ref(false)
const defaultTab = computed(() => {
if (props.providerType === 'agent_runner' && props.providerSubtype) {
return `select_agent_runner_provider:${props.providerSubtype}`
}
return props.providerType || 'chat_completion'
})
// modelValue selectedProvider
watch(() => props.modelValue, (newValue) => {
selectedProvider.value = newValue || ''
}, { immediate: true })
watch(providerDrawer, (isOpen, wasOpen) => {
if (!isOpen && wasOpen) {
loadProviders()
}
})
async function openDialog() {
selectedProvider.value = props.modelValue || ''
dialog.value = true
@@ -217,14 +170,6 @@ function cancelSelection() {
selectedProvider.value = props.modelValue || ''
dialog.value = false
}
function openProviderDrawer() {
providerDrawer.value = true
}
function closeProviderDrawer() {
providerDrawer.value = false
}
</script>
<style scoped>
@@ -239,35 +184,4 @@ function closeProviderDrawer() {
.v-list-item.v-list-item--active {
background-color: rgba(var(--v-theme-primary), 0.08);
}
.provider-drawer-overlay {
align-items: stretch;
justify-content: flex-end;
}
.provider-drawer-card {
width: clamp(360px, 70vw, 1200px);
height: calc(100vh - 32px);
margin: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.provider-drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 12px 20px;
}
.provider-drawer-content {
flex: 1;
overflow: hidden;
}
.provider-drawer-content > * {
height: 100%;
overflow: auto;
}
</style>
@@ -1,15 +1,11 @@
<script setup>
import { ref, watch, onMounted, computed } from 'vue';
import axios from 'axios';
import { MarkdownRender, enableKatex, enableMermaid } from 'markstream-vue';
import 'markstream-vue/index.css';
import 'katex/dist/katex.min.css';
import MarkdownIt from 'markdown-it';
import hljs from 'highlight.js';
import 'highlight.js/styles/github.css';
import { useI18n } from '@/i18n/composables';
enableKatex();
enableMermaid();
const props = defineProps({
show: {
type: Boolean,
@@ -78,6 +74,29 @@ function openRepoInNewTab() {
}
}
// markdown-it
const md = new MarkdownIt({
html: true, // HTML
breaks: true, // <br>
linkify: true, //
typographer: false, //
highlight: function(code, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(code, { language: lang }).value;
} catch (e) {
console.error(e);
}
}
return hljs.highlightAuto(code).value;
}
});
// Markdown
function renderMarkdown(content) {
if (!content) return '';
return md.render(content);
}
// README
function refreshReadme() {
@@ -96,7 +115,7 @@ const _show = computed({
</script>
<template>
<v-dialog v-model="_show" width="800">
<v-dialog v-model="_show" width="800" persistent>
<v-card>
<v-card-title class="d-flex justify-space-between align-center">
<span class="text-h5">{{ t('core.common.readme.title') }}</span>
@@ -131,9 +150,7 @@ const _show = computed({
</div>
<!-- 内容显示 -->
<div v-else-if="content" class="markdown-body">
<MarkdownRender :content="content" :typewriter="false" class="markdown-content" />
</div>
<div v-else-if="content" class="markdown-body" v-html="renderMarkdown(content)"></div>
<!-- 错误提示 -->
<div v-else-if="error" class="d-flex flex-column align-center justify-center" style="height: 100%;">
@@ -284,9 +301,6 @@ const _show = computed({
<script>
export default {
name: 'ReadmeDialog',
components: {
MarkdownRender
},
computed: {
_show: {
get() {
@@ -1,79 +0,0 @@
<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>
+163 -265
View File
@@ -2,29 +2,19 @@ import { ref, reactive, type Ref } from 'vue';
import axios from 'axios';
import { useToast } from '@/utils/toast';
// 工具调用信息
export interface ToolCall {
id: string;
name: string;
args: Record<string, any>;
ts: number; // 开始时间戳
result?: string; // 工具调用结果
finished_ts?: number; // 完成时间戳
// 新格式消息部分的类型定义
export interface MessagePart {
type: 'plain' | 'image' | 'record' | 'file' | 'video' | 'reply';
text?: string; // for plain
attachment_id?: string; // for image, record, file, video
filename?: string; // for file (filename from backend)
message_id?: number; // for reply (PlatformSessionHistoryMessage.id)
}
// Token 使用统计
export interface TokenUsage {
input_other: number;
input_cached: number;
output: number;
}
// Agent 统计信息
export interface AgentStats {
token_usage: TokenUsage;
start_time: number;
end_time: number;
time_to_first_token: number;
// 引用信息
export interface ReplyInfo {
messageId: number;
messageContent: string;
}
// 文件信息结构
@@ -34,33 +24,24 @@ export interface FileInfo {
attachment_id?: string; // 用于按需下载
}
// 消息部分的类型定义
export interface MessagePart {
type: 'plain' | 'image' | 'record' | 'file' | 'video' | 'reply' | 'tool_call';
text?: string; // for plain
attachment_id?: string; // for image, record, file, video
filename?: string; // for file (filename from backend)
message_id?: number; // for reply (PlatformSessionHistoryMessage.id)
tool_calls?: ToolCall[]; // for tool_call
// embedded fields - 加载后填充
embedded_url?: string; // blob URL for image, record
embedded_file?: FileInfo; // for file (保留 attachment_id 用于按需下载)
reply_content?: string; // for reply - 被引用消息的内容
// 引用消息信息
export interface ReplyTo {
message_id: number;
message_content?: string; // 被引用消息的内容(解析后填充)
}
// 引用信息 (用于发送消息时)
export interface ReplyInfo {
messageId: number;
messageContent: string;
}
// 简化的消息内容结构
export interface MessageContent {
type: string; // 'user' | 'bot'
message: MessagePart[]; // 消息部分列表 (保持顺序)
reasoning?: string; // reasoning content (for bot)
isLoading?: boolean; // loading state
agentStats?: AgentStats; // agent 统计信息 (for bot)
type: string;
message: string | MessagePart[]; // 支持旧格式(string)和新格式(MessagePart[])
reasoning?: string;
image_url?: string[];
audio_url?: string;
file_url?: FileInfo[];
embedded_images?: string[];
embedded_audio?: string;
embedded_files?: FileInfo[];
isLoading?: boolean;
reply_to?: ReplyTo; // 引用的消息
}
export interface Message {
@@ -112,67 +93,55 @@ export function useMessages(
}
}
// 解析消息内容,填充 embedded 字段 (保持原始顺序)
// 解析新格式消息为旧格式兼容的结构 (用于显示)
async function parseMessageContent(content: any): Promise<void> {
const message = content.message;
// 如果 message 是字符串 (格式),转换为数组格式
if (typeof message === 'string') {
const parts: MessagePart[] = [];
let text = message;
// 处理旧格式的特殊标记
if (text.startsWith('[IMAGE]')) {
const img = text.replace('[IMAGE]', '');
const imageUrl = await getMediaFile(img);
parts.push({
type: 'image',
embedded_url: imageUrl
});
} else if (text.startsWith('[RECORD]')) {
const audio = text.replace('[RECORD]', '');
const audioUrl = await getMediaFile(audio);
parts.push({
type: 'record',
embedded_url: audioUrl
});
} else if (text) {
parts.push({
type: 'plain',
text: text
});
}
content.message = parts;
return;
}
// 如果 message 是数组 (新格式),遍历并填充 embedded 字段
// 如果 message 是数组 (格式)
if (Array.isArray(message)) {
let textParts: string[] = [];
let imageUrls: string[] = [];
let audioUrl: string | undefined;
let fileInfos: FileInfo[] = [];
let replyTo: ReplyTo | undefined;
for (const part of message as MessagePart[]) {
if (part.type === 'image' && part.attachment_id) {
part.embedded_url = await getAttachment(part.attachment_id);
if (part.type === 'plain' && part.text) {
textParts.push(part.text);
} else if (part.type === 'image' && part.attachment_id) {
const url = await getAttachment(part.attachment_id);
if (url) imageUrls.push(url);
} else if (part.type === 'record' && part.attachment_id) {
part.embedded_url = await getAttachment(part.attachment_id);
audioUrl = await getAttachment(part.attachment_id);
} else if (part.type === 'file' && part.attachment_id) {
// file 类型不预加载,保留 attachment_id 以便点击时下载
part.embedded_file = {
fileInfos.push({
attachment_id: part.attachment_id,
filename: part.filename || 'file'
};
});
} else if (part.type === 'reply' && part.message_id) {
replyTo = { message_id: part.message_id };
}
// plain, reply, tool_call, video 保持原样
// video 类型可以后续扩展
}
// 转换为旧格式兼容的结构
content.message = textParts.join('\n');
content.reply_to = replyTo;
if (content.type === 'user') {
content.image_url = imageUrls.length > 0 ? imageUrls : undefined;
content.audio_url = audioUrl;
content.file_url = fileInfos.length > 0 ? fileInfos : undefined;
} else {
content.embedded_images = imageUrls.length > 0 ? imageUrls : undefined;
content.embedded_audio = audioUrl;
content.embedded_files = fileInfos.length > 0 ? fileInfos : undefined;
}
}
// 处理 agent_stats (snake_case -> camelCase)
if (content.agent_stats) {
content.agentStats = content.agent_stats;
delete content.agent_stats;
}
// 如果 message 是字符串 (旧格式),保持原有处理逻辑
}
async function getSessionMessages(sessionId: string) {
async function getSessionMessages(sessionId: string, router: any) {
if (!sessionId) return;
try {
@@ -188,14 +157,50 @@ export function useMessages(
// 如果会话还在运行,3秒后重新获取消息
setTimeout(() => {
getSessionMessages(currSessionId.value);
getSessionMessages(currSessionId.value, router);
}, 3000);
}
// 处理历史消息
// 处理历史消息中的媒体文件
for (let i = 0; i < history.length; i++) {
let content = history[i].content;
// 首先尝试解析新格式消息
await parseMessageContent(content);
// 以下是旧格式的兼容处理 (message 是字符串的情况)
if (typeof content.message === 'string') {
if (content.message?.startsWith('[IMAGE]')) {
let img = content.message.replace('[IMAGE]', '');
const imageUrl = await getMediaFile(img);
if (!content.embedded_images) {
content.embedded_images = [];
}
content.embedded_images.push(imageUrl);
content.message = '';
}
if (content.message?.startsWith('[RECORD]')) {
let audio = content.message.replace('[RECORD]', '');
const audioUrl = await getMediaFile(audio);
content.embedded_audio = audioUrl;
content.message = '';
}
}
// 旧格式中的 image_url 和 audio_url 字段处理
if (content.image_url && content.image_url.length > 0) {
for (let j = 0; j < content.image_url.length; j++) {
// 检查是否已经是 blob URL (新格式解析后的结果)
if (!content.image_url[j].startsWith('blob:')) {
content.image_url[j] = await getMediaFile(content.image_url[j]);
}
}
}
if (content.audio_url && !content.audio_url.startsWith('blob:')) {
content.audio_url = await getMediaFile(content.audio_url);
}
}
messages.value = history;
@@ -212,66 +217,47 @@ export function useMessages(
selectedModelName: string,
replyTo: ReplyInfo | null = null
) {
// 构建用户消息的 message 部分
const userMessageParts: MessagePart[] = [];
// 添加引用消息段
if (replyTo) {
userMessageParts.push({
type: 'reply',
message_id: replyTo.messageId,
reply_content: replyTo.messageContent
});
}
// 添加纯文本消息段
if (prompt) {
userMessageParts.push({
type: 'plain',
text: prompt
});
}
// 添加文件消息段
for (const f of stagedFiles) {
const partType = f.type === 'image' ? 'image' :
f.type === 'record' ? 'record' : 'file';
// 获取嵌入 URL
const embeddedUrl = await getAttachment(f.attachment_id);
userMessageParts.push({
type: partType as 'image' | 'record' | 'file',
attachment_id: f.attachment_id,
filename: f.original_name,
embedded_url: partType !== 'file' ? embeddedUrl : undefined,
embedded_file: partType === 'file' ? {
attachment_id: f.attachment_id,
filename: f.original_name
} : undefined
});
}
// 添加录音(如果有)
if (audioName) {
userMessageParts.push({
type: 'record',
embedded_url: audioName // 录音使用本地 URL
});
}
// 创建用户消息
// Create user message
const userMessage: MessageContent = {
type: 'user',
message: userMessageParts
message: prompt,
image_url: [],
audio_url: undefined,
file_url: [],
reply_to: replyTo ? { message_id: replyTo.messageId } : undefined
};
// 分离图片和文件
const imageFiles = stagedFiles.filter(f => f.type === 'image');
const nonImageFiles = stagedFiles.filter(f => f.type !== 'image');
// 使用 attachment_id 获取图片内容(避免 blob URL 被 revoke 后 404
if (imageFiles.length > 0) {
const imageUrls = await Promise.all(
imageFiles.map(f => getAttachment(f.attachment_id))
);
userMessage.image_url = imageUrls.filter(url => url !== '');
}
// 使用 blob URL 作为音频预览(录音不走 attachment
if (audioName) {
userMessage.audio_url = audioName;
}
// 文件不预加载,只显示文件名和 attachment_id
if (nonImageFiles.length > 0) {
userMessage.file_url = nonImageFiles.map(f => ({
filename: f.original_name,
attachment_id: f.attachment_id
}));
}
messages.value.push({ content: userMessage });
// 添加一个加载中的机器人消息占位符
const loadingMessage = reactive<MessageContent>({
const loadingMessage = reactive({
type: 'bot',
message: [],
message: '',
reasoning: '',
isLoading: true
});
@@ -286,11 +272,12 @@ export function useMessages(
// 收集所有 attachment_id
const files = stagedFiles.map(f => f.attachment_id);
// 构建发送给后端的 message 参数
// 构建 message 参数
// 当 files 或 reply 存在时,message 是 list,否则是 str
let messageToSend: string | MessagePart[];
if (files.length > 0 || replyTo) {
const parts: MessagePart[] = [];
// 添加引用消息段
if (replyTo) {
parts.push({
@@ -298,7 +285,7 @@ export function useMessages(
message_id: replyTo.messageId
});
}
// 添加纯文本消息段
if (prompt) {
parts.push({
@@ -306,17 +293,17 @@ export function useMessages(
text: prompt
});
}
// 添加文件消息段
for (const f of stagedFiles) {
const partType = f.type === 'image' ? 'image' :
f.type === 'record' ? 'record' : 'file';
const partType = f.type === 'image' ? 'image' :
f.type === 'record' ? 'record' : 'file';
parts.push({
type: partType as 'image' | 'record' | 'file',
attachment_id: f.attachment_id
});
}
messageToSend = parts;
} else {
messageToSend = prompt;
@@ -344,7 +331,7 @@ export function useMessages(
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let in_streaming = false;
let message_obj: MessageContent | null = null;
let message_obj: any = null;
isStreaming.value = true;
@@ -353,10 +340,6 @@ export function useMessages(
const { done, value } = await reader.read();
if (done) {
console.log('SSE stream completed');
// 流式传输结束后,获取最终消息并重新渲染
if (currSessionId.value) {
await getSessionMessages(currSessionId.value);
}
break;
}
@@ -395,10 +378,8 @@ export function useMessages(
const imageUrl = await getMediaFile(img);
let bot_resp: MessageContent = {
type: 'bot',
message: [{
type: 'image',
embedded_url: imageUrl
}]
message: '',
embedded_images: [imageUrl]
};
messages.value.push({ content: bot_resp });
} else if (chunk_json.type === 'record') {
@@ -406,122 +387,43 @@ export function useMessages(
const audioUrl = await getMediaFile(audio);
let bot_resp: MessageContent = {
type: 'bot',
message: [{
type: 'record',
embedded_url: audioUrl
}]
message: '',
embedded_audio: audioUrl
};
messages.value.push({ content: bot_resp });
} else if (chunk_json.type === 'file') {
// 格式: [FILE]filename|original_name
let fileData = chunk_json.data.replace('[FILE]', '');
let [filename, originalName] = fileData.includes('|')
? fileData.split('|', 2)
let [filename, originalName] = fileData.includes('|')
? fileData.split('|', 2)
: [fileData, fileData];
const fileUrl = await getMediaFile(filename);
let bot_resp: MessageContent = {
type: 'bot',
message: [{
type: 'file',
embedded_file: {
url: fileUrl,
filename: originalName
}
message: '',
embedded_files: [{
url: fileUrl,
filename: originalName
}]
};
messages.value.push({ content: bot_resp });
} else if (chunk_json.type === 'plain') {
const chain_type = chunk_json.chain_type || 'normal';
if (chain_type === 'tool_call') {
// 解析工具调用数据
const toolCallData = JSON.parse(chunk_json.data);
const toolCall: ToolCall = {
id: toolCallData.id,
name: toolCallData.name,
args: toolCallData.args,
ts: toolCallData.ts
};
if (!in_streaming) {
message_obj = reactive<MessageContent>({
type: 'bot',
message: [{
type: 'tool_call',
tool_calls: [toolCall]
}]
});
messages.value.push({ content: message_obj });
in_streaming = true;
} else {
// 找到最后一个 tool_call part 或创建新的
const lastPart = message_obj!.message[message_obj!.message.length - 1];
if (lastPart?.type === 'tool_call') {
// 检查是否已存在相同id的tool_call
const existingIndex = lastPart.tool_calls!.findIndex((tc: ToolCall) => tc.id === toolCall.id);
if (existingIndex === -1) {
lastPart.tool_calls!.push(toolCall);
}
} else {
// 添加新的 tool_call part
message_obj!.message.push({
type: 'tool_call',
tool_calls: [toolCall]
});
}
}
} else if (chain_type === 'tool_call_result') {
// 解析工具调用结果数据
const resultData = JSON.parse(chunk_json.data);
if (message_obj) {
// 遍历所有 tool_call parts 找到对应的 tool_call
for (const part of message_obj.message) {
if (part.type === 'tool_call' && part.tool_calls) {
const toolCall = part.tool_calls.find((tc: ToolCall) => tc.id === resultData.id);
if (toolCall) {
toolCall.result = resultData.result;
toolCall.finished_ts = resultData.ts;
break;
}
}
}
}
} else if (chain_type === 'reasoning') {
if (!in_streaming) {
message_obj = reactive<MessageContent>({
type: 'bot',
message: [],
reasoning: chunk_json.data
});
messages.value.push({ content: message_obj });
in_streaming = true;
} else {
message_obj!.reasoning = (message_obj!.reasoning || '') + chunk_json.data;
}
if (!in_streaming) {
message_obj = reactive({
type: 'bot',
message: chain_type === 'reasoning' ? '' : chunk_json.data,
reasoning: chain_type === 'reasoning' ? chunk_json.data : '',
});
messages.value.push({ content: message_obj });
in_streaming = true;
} else {
// normal text
if (!in_streaming) {
message_obj = reactive<MessageContent>({
type: 'bot',
message: [{
type: 'plain',
text: chunk_json.data
}]
});
messages.value.push({ content: message_obj });
in_streaming = true;
if (chain_type === 'reasoning') {
// 使用 reactive 对象,直接修改属性会触发响应式更新
message_obj.reasoning = (message_obj.reasoning || '') + chunk_json.data;
} else {
// 找到最后一个 plain part 或创建新的
const lastPart = message_obj!.message[message_obj!.message.length - 1];
if (lastPart?.type === 'plain') {
lastPart.text = (lastPart.text || '') + chunk_json.data;
} else {
message_obj!.message.push({
type: 'plain',
text: chunk_json.data
});
}
message_obj.message = (message_obj.message || '') + chunk_json.data;
}
}
} else if (chunk_json.type === 'update_title') {
@@ -533,11 +435,6 @@ export function useMessages(
lastBotMsg.id = chunk_json.data.id;
lastBotMsg.created_at = chunk_json.data.created_at;
}
} else if (chunk_json.type === 'agent_stats') {
// 更新当前 bot 消息的 agent 统计信息
if (message_obj) {
message_obj.agentStats = chunk_json.data;
}
}
if ((chunk_json.type === 'break' && chunk_json.streaming) || !chunk_json.streaming) {
@@ -583,3 +480,4 @@ export function useMessages(
getAttachment
};
}
@@ -1,661 +0,0 @@
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
}
}
+1 -11
View File
@@ -41,13 +41,7 @@ export function useSessions(chatboxMode: boolean = false) {
selectedSessions.value = [pendingSessionId.value];
pendingSessionId.value = null;
}
} 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) {
} else if (!currSessionId.value && sessions.value.length > 0) {
// 默认选择第一个会话
const firstSession = sessions.value[0];
selectedSessions.value = [firstSession.session_id];
@@ -71,10 +65,6 @@ export function useSessions(chatboxMode: boolean = false) {
router.push(`${basePath}/${sessionId}`);
await getSessions();
// 确保新创建的会话被选中高亮
selectedSessions.value = [sessionId];
return sessionId;
} catch (err) {
console.error(err);
@@ -27,7 +27,6 @@
"uninstall": "Uninstall",
"update": "Update",
"language": "Language",
"settings": "Settings",
"locale": "en-US",
"type": "Type",
"press": "Press",
@@ -15,19 +15,10 @@
"knowledgeBase": "Knowledge Base",
"about": "About",
"settings": "Settings",
"changelog": "Changelog",
"documentation": "Documentation",
"github": "GitHub",
"drag": "Drag",
"groups": {
"more": "More Features"
},
"changelogDialog": {
"title": "Changelog",
"loading": "Loading...",
"error": "Failed to load",
"notFound": "Changelog for this version not found",
"selectVersion": "Select Version",
"current": "Current"
}
}
@@ -40,8 +40,6 @@
"cancelSelection": "Cancel",
"clearSelection": "None",
"clearSelectionSubtitle": "Clear current selection",
"unknownType": "Unknown type",
"createProvider": "Create Provider",
"manageProviders": "Provider Management"
"unknownType": "Unknown type"
}
}
@@ -41,8 +41,7 @@
"editTitle": "Edit Title",
"fullscreen": "Fullscreen Mode",
"exitFullscreen": "Exit Fullscreen",
"reply": "Reply",
"providerConfig": "AI Configuration"
"reply": "Reply"
},
"conversation": {
"newConversation": "New Conversation",
@@ -81,14 +80,6 @@
"today": "Today",
"yesterday": "Yesterday"
},
"stats": {
"tokens": "Tokens",
"inputTokens": "Input Tokens",
"outputTokens": "Output Tokens",
"cachedTokens": "Cached Tokens",
"duration": "Duration",
"ttft": "Time to First Token"
},
"connection": {
"title": "Connection Status Notice",
"message": "The system detected that the chat connection needs to be re-established.",
@@ -45,8 +45,6 @@
"rename": {
"title": "Rename Command",
"newName": "New command name",
"aliases": "Manage aliases",
"addAlias": "Add alias",
"cancel": "Cancel",
"confirm": "Confirm"
},
@@ -57,9 +57,6 @@
},
"provider_id": {
"description": "Default Text-to-Speech Model"
},
"trigger_probability": {
"description": "TTS Trigger Probability"
}
}
},

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