Compare commits
78 Commits
v4.9.2
...
multimessage
| Author | SHA1 | Date | |
|---|---|---|---|
| 7cedf0d587 | |||
| aeb21f719e | |||
| 7c1dbecea5 | |||
| 05012af627 | |||
| 17b52ab5dd | |||
| 9449ff668b | |||
| c5a2827def | |||
| 701399c00c | |||
| eaee98d4b8 | |||
| 76c66000a7 | |||
| 4b365143c0 | |||
| 6e4e5011e2 | |||
| d853bfde84 | |||
| a0e856f80f | |||
| 8c94a0010c | |||
| a44fdaaec0 | |||
| 60105c76f5 | |||
| bcf87d3ce4 | |||
| 4d7c8c8453 | |||
| a064a9115f | |||
| 6ef99e1553 | |||
| c0dbe5cf65 | |||
| 3598c51eff | |||
| b5cdb8f650 | |||
| fc5b520f9b | |||
| 904f56b32f | |||
| 2f15fd019c | |||
| 82330b8d10 | |||
| 3ee6af7027 | |||
| 6e20ebe901 | |||
| 4d6150fd6d | |||
| 544e52191b | |||
| f2c2a6da4a | |||
| dd3df425ee | |||
| 40b4a27a3d | |||
| 9d991c7468 | |||
| ad6a8b5c94 | |||
| 1b4bfcbd72 | |||
| 9d3cc593a1 | |||
| f0dee35ba9 | |||
| 4135bd84d5 | |||
| f6da614e5d | |||
| 5f531c9be5 | |||
| 94591d965b | |||
| 8a0f865af1 | |||
| 4aced976a8 | |||
| 0299aa6e4c | |||
| e8b54a019e | |||
| 98ce796275 | |||
| b87dcf2275 | |||
| 591a228431 | |||
| f52f375154 | |||
| 975c685a17 | |||
| 6db80d36a8 | |||
| 4651bd2807 | |||
| 94ada3793e | |||
| fd05b0bf09 | |||
| 4d046f8490 | |||
| 58e32b7b70 | |||
| 903dd0f9f7 | |||
| 1acac0cac2 | |||
| 80b89fd2ea | |||
| 26f863ba81 | |||
| f78a90218e | |||
| a3ecebd2aa | |||
| 67c33b842d | |||
| 5431c9f46e | |||
| 764b91a5f7 | |||
| c20c1b84bf | |||
| fd66a0ac00 | |||
| aaee283367 | |||
| 4a5b7d1976 | |||
| 08244548ab | |||
| b486de6a98 | |||
| b2e9dab233 | |||
| 45110200ea | |||
| a70088b799 | |||
| bb45d9cb54 |
@@ -36,7 +36,7 @@ jobs:
|
||||
zip -r dist.zip dist
|
||||
|
||||
- name: Archive production artifacts
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: dist-without-markdown
|
||||
path: |
|
||||
|
||||
+26
-1
@@ -33,6 +33,20 @@
|
||||
- 请使用英文描述您的 PR。
|
||||
- 标题请使用 `fix: `, `feat: `, `docs: `, `style: `, `refactor: `, `test: `, `chore: ` 等语义化前缀,并简要描述更改内容。如:`fix: correct login page typo`。
|
||||
|
||||
#### 代码规范
|
||||
|
||||
##### Core
|
||||
|
||||
我们使用 Ruff 作为代码格式化和静态分析工具。在提交代码之前,请运行以下命令以确保代码符合规范:
|
||||
|
||||
```bash
|
||||
ruff format .
|
||||
ruff check .
|
||||
```
|
||||
|
||||
如果您使用 VSCode,可以安装 `Ruff` 插件。
|
||||
|
||||
|
||||
## Contributing Guide
|
||||
|
||||
First off, thanks for taking the time to contribute! ❤️
|
||||
@@ -62,4 +76,15 @@ We use the `fix/` prefix for bug fixes and the `feat/` prefix for new features.
|
||||
|
||||
#### PR Description
|
||||
- Please use English to describe your PR.
|
||||
- Use semantic prefixes like `fix: `, `feat: `, `docs: `, `style: `, `refactor: `, `test: `, `chore: ` in the title, followed by a brief description of the changes, e.g., `fix: correct login page typo`.
|
||||
- Use semantic prefixes like `fix: `, `feat: `, `docs: `, `style: `, `refactor: `, `test: `, `chore: ` in the title, followed by a brief description of the changes, e.g., `fix: correct login page typo`.
|
||||
|
||||
#### Code Style
|
||||
|
||||
##### Core
|
||||
|
||||
We use Ruff as our code formatter and static analysis tool. Before submitting your code, please run the following commands to ensure your code adheres to the style guidelines:
|
||||
|
||||
```bash
|
||||
ruff format .
|
||||
ruff check .
|
||||
```
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||

|
||||

|
||||
|
||||
<div align="center">
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.9.2"
|
||||
__version__ = "4.10.2"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
from typing import Any, ClassVar, Literal, cast
|
||||
|
||||
from pydantic import BaseModel, GetCoreSchemaHandler, model_validator
|
||||
from pydantic import BaseModel, GetCoreSchemaHandler, model_serializer, model_validator
|
||||
from pydantic_core import core_schema
|
||||
|
||||
|
||||
@@ -122,10 +122,12 @@ class ToolCall(BaseModel):
|
||||
extra_content: dict[str, Any] | None = None
|
||||
"""Extra metadata for the tool call."""
|
||||
|
||||
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
|
||||
@model_serializer(mode="wrap")
|
||||
def serialize(self, handler):
|
||||
data = handler(self)
|
||||
if self.extra_content is None:
|
||||
kwargs.setdefault("exclude", set()).add("extra_content")
|
||||
return super().model_dump(**kwargs)
|
||||
data.pop("extra_content", None)
|
||||
return data
|
||||
|
||||
|
||||
class ToolCallPart(BaseModel):
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import typing as T
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.provider.entities import TokenUsage
|
||||
|
||||
|
||||
class AgentResponseData(T.TypedDict):
|
||||
@@ -12,3 +13,23 @@ class AgentResponseData(T.TypedDict):
|
||||
class AgentResponse:
|
||||
type: str
|
||||
data: AgentResponseData
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentStats:
|
||||
token_usage: TokenUsage = field(default_factory=TokenUsage)
|
||||
start_time: float = 0.0
|
||||
end_time: float = 0.0
|
||||
time_to_first_token: float = 0.0
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
return self.end_time - self.start_time
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"token_usage": self.token_usage.__dict__,
|
||||
"start_time": self.start_time,
|
||||
"end_time": self.end_time,
|
||||
"time_to_first_token": self.time_to_first_token,
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ from .message import Message
|
||||
TContext = TypeVar("TContext", default=Any)
|
||||
|
||||
|
||||
@dataclass(config={"arbitrary_types_allowed": True})
|
||||
@dataclass
|
||||
class ContextWrapper(Generic[TContext]):
|
||||
"""A context for running an agent, which can be used to pass additional data or state."""
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
import typing as T
|
||||
|
||||
@@ -12,6 +13,7 @@ from mcp.types import (
|
||||
)
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.message.components import Json
|
||||
from astrbot.core.message.message_event_result import (
|
||||
MessageChain,
|
||||
)
|
||||
@@ -24,7 +26,7 @@ from astrbot.core.provider.provider import Provider
|
||||
|
||||
from ..hooks import BaseAgentRunHooks
|
||||
from ..message import AssistantMessageSegment, Message, ToolCallMessageSegment
|
||||
from ..response import AgentResponseData
|
||||
from ..response import AgentResponseData, AgentStats
|
||||
from ..run_context import ContextWrapper, TContext
|
||||
from ..tool_executor import BaseFunctionToolExecutor
|
||||
from .base import AgentResponse, AgentState, BaseAgentRunner
|
||||
@@ -69,14 +71,25 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
)
|
||||
self.run_context.messages = messages
|
||||
|
||||
self.stats = AgentStats()
|
||||
self.stats.start_time = time.time()
|
||||
|
||||
async def _iter_llm_responses(self) -> T.AsyncGenerator[LLMResponse, None]:
|
||||
"""Yields chunks *and* a final LLMResponse."""
|
||||
payload = {
|
||||
"contexts": self.run_context.messages, # list[Message]
|
||||
"func_tool": self.req.func_tool,
|
||||
"model": self.req.model, # NOTE: in fact, this arg is None in most cases
|
||||
"session_id": self.req.session_id,
|
||||
"extra_user_content_parts": self.req.extra_user_content_parts, # list[ContentPart]
|
||||
}
|
||||
|
||||
if self.streaming:
|
||||
stream = self.provider.text_chat_stream(**self.req.__dict__)
|
||||
stream = self.provider.text_chat_stream(**payload)
|
||||
async for resp in stream: # type: ignore
|
||||
yield resp
|
||||
else:
|
||||
yield await self.provider.text_chat(**self.req.__dict__)
|
||||
yield await self.provider.text_chat(**payload)
|
||||
|
||||
@override
|
||||
async def step(self):
|
||||
@@ -98,6 +111,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
|
||||
async for llm_response in self._iter_llm_responses():
|
||||
if llm_response.is_chunk:
|
||||
# update ttft
|
||||
if self.stats.time_to_first_token == 0:
|
||||
self.stats.time_to_first_token = time.time() - self.stats.start_time
|
||||
|
||||
if llm_response.result_chain:
|
||||
yield AgentResponse(
|
||||
type="streaming_delta",
|
||||
@@ -121,6 +138,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
)
|
||||
continue
|
||||
llm_resp_result = llm_response
|
||||
|
||||
if not llm_response.is_chunk and llm_response.usage:
|
||||
# only count the token usage of the final response for computation purpose
|
||||
self.stats.token_usage += llm_response.usage
|
||||
break # got final response
|
||||
|
||||
if not llm_resp_result:
|
||||
@@ -132,6 +153,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
if llm_resp.role == "err":
|
||||
# 如果 LLM 响应错误,转换到错误状态
|
||||
self.final_llm_resp = llm_resp
|
||||
self.stats.end_time = time.time()
|
||||
self._transition_state(AgentState.ERROR)
|
||||
yield AgentResponse(
|
||||
type="err",
|
||||
@@ -146,11 +168,12 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
# 如果没有工具调用,转换到完成状态
|
||||
self.final_llm_resp = llm_resp
|
||||
self._transition_state(AgentState.DONE)
|
||||
self.stats.end_time = time.time()
|
||||
# record the final assistant message
|
||||
self.run_context.messages.append(
|
||||
Message(
|
||||
role="assistant",
|
||||
content=llm_resp.completion_text or "",
|
||||
content=llm_resp.completion_text or "*No response*",
|
||||
),
|
||||
)
|
||||
try:
|
||||
@@ -175,22 +198,19 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
# 如果有工具调用,还需处理工具调用
|
||||
if llm_resp.tools_call_name:
|
||||
tool_call_result_blocks = []
|
||||
for tool_call_name in llm_resp.tools_call_name:
|
||||
yield AgentResponse(
|
||||
type="tool_call",
|
||||
data=AgentResponseData(
|
||||
chain=MessageChain(type="tool_call").message(
|
||||
f"🔨 调用工具: {tool_call_name}"
|
||||
),
|
||||
),
|
||||
)
|
||||
async for result in self._handle_function_tools(self.req, llm_resp):
|
||||
if isinstance(result, list):
|
||||
tool_call_result_blocks = result
|
||||
elif isinstance(result, MessageChain):
|
||||
result.type = "tool_call_result"
|
||||
if result.type is None:
|
||||
# should not happen
|
||||
continue
|
||||
if result.type == "tool_direct_result":
|
||||
ar_type = "tool_call_result"
|
||||
else:
|
||||
ar_type = result.type
|
||||
yield AgentResponse(
|
||||
type="tool_call_result",
|
||||
type=ar_type,
|
||||
data=AgentResponseData(chain=result),
|
||||
)
|
||||
# 将结果添加到上下文中
|
||||
@@ -218,6 +238,25 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
async for resp in self.step():
|
||||
yield resp
|
||||
|
||||
# 如果循环结束了但是 agent 还没有完成,说明是达到了 max_step
|
||||
if not self.done():
|
||||
logger.warning(
|
||||
f"Agent reached max steps ({max_step}), forcing a final response."
|
||||
)
|
||||
# 拔掉所有工具
|
||||
if self.req:
|
||||
self.req.func_tool = None
|
||||
# 注入提示词
|
||||
self.run_context.messages.append(
|
||||
Message(
|
||||
role="user",
|
||||
content="工具调用次数已达到上限,请停止使用工具,并根据已经收集到的信息,对你的任务和发现进行总结,然后直接回复用户。",
|
||||
)
|
||||
)
|
||||
# 再执行最后一步
|
||||
async for resp in self.step():
|
||||
yield resp
|
||||
|
||||
async def _handle_function_tools(
|
||||
self,
|
||||
req: ProviderRequest,
|
||||
@@ -233,6 +272,19 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
llm_response.tools_call_args,
|
||||
llm_response.tools_call_ids,
|
||||
):
|
||||
yield MessageChain(
|
||||
type="tool_call",
|
||||
chain=[
|
||||
Json(
|
||||
data={
|
||||
"id": func_tool_id,
|
||||
"name": func_tool_name,
|
||||
"args": func_tool_args,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
],
|
||||
)
|
||||
try:
|
||||
if not req.func_tool:
|
||||
return
|
||||
@@ -306,7 +358,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
content=res.content[0].text,
|
||||
),
|
||||
)
|
||||
yield MessageChain().message(res.content[0].text)
|
||||
elif isinstance(res.content[0], ImageContent):
|
||||
tool_call_result_blocks.append(
|
||||
ToolCallMessageSegment(
|
||||
@@ -328,7 +379,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
content=resource.text,
|
||||
),
|
||||
)
|
||||
yield MessageChain().message(resource.text)
|
||||
elif (
|
||||
isinstance(resource, BlobResourceContents)
|
||||
and resource.mimeType
|
||||
@@ -352,20 +402,34 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
content="返回的数据类型不受支持",
|
||||
),
|
||||
)
|
||||
yield MessageChain().message("返回的数据类型不受支持。")
|
||||
|
||||
elif resp is None:
|
||||
# Tool 直接请求发送消息给用户
|
||||
# 这里我们将直接结束 Agent Loop。
|
||||
# 发送消息逻辑在 ToolExecutor 中处理了。
|
||||
logger.warning(
|
||||
f"{func_tool_name} 没有没有返回值或者将结果直接发送给用户,此工具调用不会被记录到历史中。"
|
||||
f"{func_tool_name} 没有没有返回值或者将结果直接发送给用户。"
|
||||
)
|
||||
self._transition_state(AgentState.DONE)
|
||||
self.stats.end_time = time.time()
|
||||
tool_call_result_blocks.append(
|
||||
ToolCallMessageSegment(
|
||||
role="tool",
|
||||
tool_call_id=func_tool_id,
|
||||
content="*工具没有返回值或者将结果直接发送给了用户*",
|
||||
),
|
||||
)
|
||||
else:
|
||||
# 不应该出现其他类型
|
||||
logger.warning(
|
||||
f"Tool 返回了不支持的类型: {type(resp)},将忽略。",
|
||||
f"Tool 返回了不支持的类型: {type(resp)}。",
|
||||
)
|
||||
tool_call_result_blocks.append(
|
||||
ToolCallMessageSegment(
|
||||
role="tool",
|
||||
tool_call_id=func_tool_id,
|
||||
content="*工具返回了不支持的类型,请告诉用户检查这个工具的定义和实现。*",
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -387,6 +451,22 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
),
|
||||
)
|
||||
|
||||
# yield the last tool call result
|
||||
if tool_call_result_blocks:
|
||||
last_tcr_content = str(tool_call_result_blocks[-1].content)
|
||||
yield MessageChain(
|
||||
type="tool_call_result",
|
||||
chain=[
|
||||
Json(
|
||||
data={
|
||||
"id": func_tool_id,
|
||||
"ts": time.time(),
|
||||
"result": last_tcr_content,
|
||||
}
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
# 处理函数调用响应
|
||||
if tool_call_result_blocks:
|
||||
yield tool_call_result_blocks
|
||||
|
||||
@@ -6,8 +6,10 @@ from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.star.context import Context
|
||||
|
||||
|
||||
@dataclass(config={"arbitrary_types_allowed": True})
|
||||
@dataclass
|
||||
class AstrAgentContext:
|
||||
__pydantic_config__ = {"arbitrary_types_allowed": True}
|
||||
|
||||
context: Context
|
||||
"""The star context instance"""
|
||||
event: AstrMessageEvent
|
||||
|
||||
@@ -2,8 +2,10 @@ import traceback
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.agent.message import Message
|
||||
from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
from astrbot.core.message.components import Json
|
||||
from astrbot.core.message.message_event_result import (
|
||||
MessageChain,
|
||||
MessageEventResult,
|
||||
@@ -23,8 +25,25 @@ async def run_agent(
|
||||
) -> AsyncGenerator[MessageChain | None, None]:
|
||||
step_idx = 0
|
||||
astr_event = agent_runner.run_context.context.event
|
||||
while step_idx < max_step:
|
||||
while step_idx < max_step + 1:
|
||||
step_idx += 1
|
||||
|
||||
if step_idx == max_step + 1:
|
||||
logger.warning(
|
||||
f"Agent reached max steps ({max_step}), forcing a final response."
|
||||
)
|
||||
if not agent_runner.done():
|
||||
# 拔掉所有工具
|
||||
if agent_runner.req:
|
||||
agent_runner.req.func_tool = None
|
||||
# 注入提示词
|
||||
agent_runner.run_context.messages.append(
|
||||
Message(
|
||||
role="user",
|
||||
content="工具调用次数已达到上限,请停止使用工具,并根据已经收集到的信息,对你的任务和发现进行总结,然后直接回复用户。",
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
async for resp in agent_runner.step():
|
||||
if astr_event.is_stopped():
|
||||
@@ -33,16 +52,27 @@ async def run_agent(
|
||||
msg_chain = resp.data["chain"]
|
||||
if msg_chain.type == "tool_direct_result":
|
||||
# tool_direct_result 用于标记 llm tool 需要直接发送给用户的内容
|
||||
await astr_event.send(resp.data["chain"])
|
||||
await astr_event.send(msg_chain)
|
||||
continue
|
||||
if astr_event.get_platform_id() == "webchat":
|
||||
await astr_event.send(msg_chain)
|
||||
# 对于其他情况,暂时先不处理
|
||||
continue
|
||||
elif resp.type == "tool_call":
|
||||
if agent_runner.streaming:
|
||||
# 用来标记流式响应需要分节
|
||||
yield MessageChain(chain=[], type="break")
|
||||
if show_tool_use:
|
||||
|
||||
if astr_event.get_platform_name() == "webchat":
|
||||
await astr_event.send(resp.data["chain"])
|
||||
elif show_tool_use:
|
||||
json_comp = resp.data["chain"].chain[0]
|
||||
if isinstance(json_comp, Json):
|
||||
m = f"🔨 调用工具: {json_comp.data.get('name')}"
|
||||
else:
|
||||
m = "🔨 调用工具..."
|
||||
chain = MessageChain(type="tool_call").message(m)
|
||||
await astr_event.send(chain)
|
||||
continue
|
||||
|
||||
if stream_to_general and resp.type == "streaming_delta":
|
||||
@@ -69,6 +99,15 @@ async def run_agent(
|
||||
continue
|
||||
yield resp.data["chain"] # MessageChain
|
||||
if agent_runner.done():
|
||||
# send agent stats to webchat
|
||||
if astr_event.get_platform_name() == "webchat":
|
||||
await astr_event.send(
|
||||
MessageChain(
|
||||
type="agent_stats",
|
||||
chain=[Json(data=agent_runner.stats.to_dict())],
|
||||
)
|
||||
)
|
||||
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -209,12 +209,42 @@ async def call_local_llm_tool(
|
||||
else:
|
||||
raise ValueError(f"未知的方法名: {method_name}")
|
||||
except ValueError as e:
|
||||
logger.error(f"调用本地 LLM 工具时出错: {e}", exc_info=True)
|
||||
except TypeError:
|
||||
logger.error("处理函数参数不匹配,请检查 handler 的定义。", exc_info=True)
|
||||
raise Exception(f"Tool execution ValueError: {e}") from e
|
||||
except TypeError as e:
|
||||
# 获取函数的签名(包括类型),除了第一个 event/context 参数。
|
||||
try:
|
||||
sig = inspect.signature(handler)
|
||||
params = list(sig.parameters.values())
|
||||
# 跳过第一个参数(event 或 context)
|
||||
if params:
|
||||
params = params[1:]
|
||||
|
||||
param_strs = []
|
||||
for param in params:
|
||||
param_str = param.name
|
||||
if param.annotation != inspect.Parameter.empty:
|
||||
# 获取类型注解的字符串表示
|
||||
if isinstance(param.annotation, type):
|
||||
type_str = param.annotation.__name__
|
||||
else:
|
||||
type_str = str(param.annotation)
|
||||
param_str += f": {type_str}"
|
||||
if param.default != inspect.Parameter.empty:
|
||||
param_str += f" = {param.default!r}"
|
||||
param_strs.append(param_str)
|
||||
|
||||
handler_param_str = (
|
||||
", ".join(param_strs) if param_strs else "(no additional parameters)"
|
||||
)
|
||||
except Exception:
|
||||
handler_param_str = "(unable to inspect signature)"
|
||||
|
||||
raise Exception(
|
||||
f"Tool handler parameter mismatch, please check the handler definition. Handler parameters: {handler_param_str}"
|
||||
) from e
|
||||
except Exception as e:
|
||||
trace_ = traceback.format_exc()
|
||||
logger.error(f"调用本地 LLM 工具时出错: {e}\n{trace_}")
|
||||
raise Exception(f"Tool execution error: {e}. Traceback: {trace_}") from e
|
||||
|
||||
if not ready_to_call:
|
||||
return
|
||||
|
||||
+155
-212
@@ -1,10 +1,11 @@
|
||||
"""如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。"""
|
||||
|
||||
import os
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
VERSION = "4.9.2"
|
||||
VERSION = "4.10.2"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||
|
||||
WEBHOOK_SUPPORTED_PLATFORMS = [
|
||||
@@ -61,7 +62,8 @@ DEFAULT_CONFIG = {
|
||||
"ignore_bot_self_message": False,
|
||||
"ignore_at_all": False,
|
||||
},
|
||||
"provider": [],
|
||||
"provider_sources": [], # provider sources
|
||||
"provider": [], # models from provider_sources
|
||||
"provider_settings": {
|
||||
"enable": True,
|
||||
"default_provider_id": "",
|
||||
@@ -171,6 +173,22 @@ DEFAULT_CONFIG = {
|
||||
}
|
||||
|
||||
|
||||
class ChatProviderTemplate(TypedDict):
|
||||
id: str
|
||||
provider_source_id: str
|
||||
model: str
|
||||
modalities: list
|
||||
custom_extra_body: dict[str, Any]
|
||||
|
||||
|
||||
CHAT_PROVIDER_TEMPLATE = {
|
||||
"id": "",
|
||||
"provide_source_id": "",
|
||||
"model": "",
|
||||
"modalities": [],
|
||||
"custom_extra_body": {},
|
||||
}
|
||||
|
||||
"""
|
||||
AstrBot v3 时代的配置元数据,目前仅承担以下功能:
|
||||
|
||||
@@ -209,7 +227,7 @@ CONFIG_METADATA_2 = {
|
||||
"callback_server_host": "0.0.0.0",
|
||||
"port": 6196,
|
||||
},
|
||||
"QQ 个人号(OneBot v11)": {
|
||||
"OneBot v11": {
|
||||
"id": "default",
|
||||
"type": "aiocqhttp",
|
||||
"enable": False,
|
||||
@@ -844,6 +862,7 @@ CONFIG_METADATA_2 = {
|
||||
"metadata": {
|
||||
"provider": {
|
||||
"type": "list",
|
||||
# provider sources templates
|
||||
"config_template": {
|
||||
"OpenAI": {
|
||||
"id": "openai",
|
||||
@@ -854,107 +873,10 @@ CONFIG_METADATA_2 = {
|
||||
"key": [],
|
||||
"api_base": "https://api.openai.com/v1",
|
||||
"timeout": 120,
|
||||
"model_config": {"model": "gpt-4o-mini", "temperature": 0.4},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
"hint": "也兼容所有与 OpenAI API 兼容的服务。",
|
||||
},
|
||||
"Azure OpenAI": {
|
||||
"id": "azure",
|
||||
"provider": "azure",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"api_version": "2024-05-01-preview",
|
||||
"key": [],
|
||||
"api_base": "",
|
||||
"timeout": 120,
|
||||
"model_config": {"model": "gpt-4o-mini", "temperature": 0.4},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
"xAI": {
|
||||
"id": "xai",
|
||||
"provider": "xai",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"api_base": "https://api.x.ai/v1",
|
||||
"timeout": 120,
|
||||
"model_config": {"model": "grok-2-latest", "temperature": 0.4},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"xai_native_search": False,
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
"Anthropic": {
|
||||
"hint": "注意Claude系列模型的温度调节范围为0到1.0,超出可能导致报错",
|
||||
"id": "claude",
|
||||
"provider": "anthropic",
|
||||
"type": "anthropic_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"api_base": "https://api.anthropic.com/v1",
|
||||
"timeout": 120,
|
||||
"model_config": {
|
||||
"model": "claude-3-5-sonnet-latest",
|
||||
"max_tokens": 4096,
|
||||
"temperature": 0.2,
|
||||
},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
"Ollama": {
|
||||
"hint": "启用前请确保已正确安装并运行 Ollama 服务端,Ollama默认不带鉴权,无需修改key",
|
||||
"id": "ollama_default",
|
||||
"provider": "ollama",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": ["ollama"], # ollama 的 key 默认是 ollama
|
||||
"api_base": "http://localhost:11434/v1",
|
||||
"model_config": {"model": "llama3.1-8b", "temperature": 0.4},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
"LM Studio": {
|
||||
"id": "lm_studio",
|
||||
"provider": "lm_studio",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": ["lmstudio"],
|
||||
"api_base": "http://localhost:1234/v1",
|
||||
"model_config": {
|
||||
"model": "llama-3.1-8b",
|
||||
},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
"Gemini(OpenAI兼容)": {
|
||||
"id": "gemini_default",
|
||||
"provider": "google",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"api_base": "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
"timeout": 120,
|
||||
"model_config": {
|
||||
"model": "gemini-1.5-flash",
|
||||
"temperature": 0.4,
|
||||
},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
"Gemini": {
|
||||
"id": "gemini_default",
|
||||
"Google Gemini": {
|
||||
"id": "google_gemini",
|
||||
"provider": "google",
|
||||
"type": "googlegenai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
@@ -962,10 +884,6 @@ CONFIG_METADATA_2 = {
|
||||
"key": [],
|
||||
"api_base": "https://generativelanguage.googleapis.com/",
|
||||
"timeout": 120,
|
||||
"model_config": {
|
||||
"model": "gemini-2.0-flash-exp",
|
||||
"temperature": 0.4,
|
||||
},
|
||||
"gm_resp_image_modal": False,
|
||||
"gm_native_search": False,
|
||||
"gm_native_coderunner": False,
|
||||
@@ -976,13 +894,43 @@ CONFIG_METADATA_2 = {
|
||||
"sexually_explicit": "BLOCK_MEDIUM_AND_ABOVE",
|
||||
"dangerous_content": "BLOCK_MEDIUM_AND_ABOVE",
|
||||
},
|
||||
"gm_thinking_config": {
|
||||
"budget": 0,
|
||||
},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
"gm_thinking_config": {"budget": 0, "level": "HIGH"},
|
||||
},
|
||||
"Anthropic": {
|
||||
"id": "anthropic",
|
||||
"provider": "anthropic",
|
||||
"type": "anthropic_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"api_base": "https://api.anthropic.com/v1",
|
||||
"timeout": 120,
|
||||
},
|
||||
"Moonshot": {
|
||||
"id": "moonshot",
|
||||
"provider": "moonshot",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"timeout": 120,
|
||||
"api_base": "https://api.moonshot.cn/v1",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"xAI": {
|
||||
"id": "xai",
|
||||
"provider": "xai",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"api_base": "https://api.x.ai/v1",
|
||||
"timeout": 120,
|
||||
"custom_headers": {},
|
||||
"xai_native_search": False,
|
||||
},
|
||||
"DeepSeek": {
|
||||
"id": "deepseek_default",
|
||||
"id": "deepseek",
|
||||
"provider": "deepseek",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
@@ -990,13 +938,75 @@ CONFIG_METADATA_2 = {
|
||||
"key": [],
|
||||
"api_base": "https://api.deepseek.com/v1",
|
||||
"timeout": 120,
|
||||
"model_config": {"model": "deepseek-chat", "temperature": 0.4},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "tool_use"],
|
||||
},
|
||||
"Zhipu": {
|
||||
"id": "zhipu",
|
||||
"provider": "zhipu",
|
||||
"type": "zhipu_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"timeout": 120,
|
||||
"api_base": "https://open.bigmodel.cn/api/paas/v4/",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"Azure OpenAI": {
|
||||
"id": "azure_openai",
|
||||
"provider": "azure",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"api_version": "2024-05-01-preview",
|
||||
"key": [],
|
||||
"api_base": "",
|
||||
"timeout": 120,
|
||||
"custom_headers": {},
|
||||
},
|
||||
"Ollama": {
|
||||
"id": "ollama",
|
||||
"provider": "ollama",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": ["ollama"], # ollama 的 key 默认是 ollama
|
||||
"api_base": "http://127.0.0.1:11434/v1",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"LM Studio": {
|
||||
"id": "lm_studio",
|
||||
"provider": "lm_studio",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": ["lmstudio"],
|
||||
"api_base": "http://127.0.0.1:1234/v1",
|
||||
"custom_headers": {},
|
||||
},
|
||||
"ModelStack": {
|
||||
"id": "modelstack",
|
||||
"provider": "modelstack",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"api_base": "https://modelstack.app/v1",
|
||||
"timeout": 120,
|
||||
"custom_headers": {},
|
||||
},
|
||||
"Gemini_OpenAI_API": {
|
||||
"id": "google_gemini_openai",
|
||||
"provider": "google",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"api_base": "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
"timeout": 120,
|
||||
"custom_headers": {},
|
||||
},
|
||||
"Groq": {
|
||||
"id": "groq_default",
|
||||
"id": "groq",
|
||||
"provider": "groq",
|
||||
"type": "groq_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
@@ -1004,13 +1014,7 @@ CONFIG_METADATA_2 = {
|
||||
"key": [],
|
||||
"api_base": "https://api.groq.com/openai/v1",
|
||||
"timeout": 120,
|
||||
"model_config": {
|
||||
"model": "openai/gpt-oss-20b",
|
||||
"temperature": 0.4,
|
||||
},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "tool_use"],
|
||||
},
|
||||
"302.AI": {
|
||||
"id": "302ai",
|
||||
@@ -1021,12 +1025,9 @@ CONFIG_METADATA_2 = {
|
||||
"key": [],
|
||||
"api_base": "https://api.302.ai/v1",
|
||||
"timeout": 120,
|
||||
"model_config": {"model": "gpt-4.1-mini", "temperature": 0.4},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
"硅基流动": {
|
||||
"SiliconFlow": {
|
||||
"id": "siliconflow",
|
||||
"provider": "siliconflow",
|
||||
"type": "openai_chat_completion",
|
||||
@@ -1035,15 +1036,9 @@ CONFIG_METADATA_2 = {
|
||||
"key": [],
|
||||
"timeout": 120,
|
||||
"api_base": "https://api.siliconflow.cn/v1",
|
||||
"model_config": {
|
||||
"model": "deepseek-ai/DeepSeek-V3",
|
||||
"temperature": 0.4,
|
||||
},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
"PPIO派欧云": {
|
||||
"PPIO": {
|
||||
"id": "ppio",
|
||||
"provider": "ppio",
|
||||
"type": "openai_chat_completion",
|
||||
@@ -1052,14 +1047,9 @@ CONFIG_METADATA_2 = {
|
||||
"key": [],
|
||||
"api_base": "https://api.ppinfra.com/v3/openai",
|
||||
"timeout": 120,
|
||||
"model_config": {
|
||||
"model": "deepseek/deepseek-r1",
|
||||
"temperature": 0.4,
|
||||
},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
},
|
||||
"小马算力": {
|
||||
"TokenPony": {
|
||||
"id": "tokenpony",
|
||||
"provider": "tokenpony",
|
||||
"type": "openai_chat_completion",
|
||||
@@ -1068,14 +1058,9 @@ CONFIG_METADATA_2 = {
|
||||
"key": [],
|
||||
"api_base": "https://api.tokenpony.cn/v1",
|
||||
"timeout": 120,
|
||||
"model_config": {
|
||||
"model": "kimi-k2-instruct-0905",
|
||||
"temperature": 0.7,
|
||||
},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
},
|
||||
"优云智算": {
|
||||
"Compshare": {
|
||||
"id": "compshare",
|
||||
"provider": "compshare",
|
||||
"type": "openai_chat_completion",
|
||||
@@ -1084,42 +1069,18 @@ CONFIG_METADATA_2 = {
|
||||
"key": [],
|
||||
"api_base": "https://api.modelverse.cn/v1",
|
||||
"timeout": 120,
|
||||
"model_config": {
|
||||
"model": "moonshotai/Kimi-K2-Instruct",
|
||||
},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
"Kimi": {
|
||||
"id": "moonshot",
|
||||
"provider": "moonshot",
|
||||
"ModelScope": {
|
||||
"id": "modelscope",
|
||||
"provider": "modelscope",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"timeout": 120,
|
||||
"api_base": "https://api.moonshot.cn/v1",
|
||||
"model_config": {"model": "moonshot-v1-8k", "temperature": 0.4},
|
||||
"api_base": "https://api-inference.modelscope.cn/v1",
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
"智谱 AI": {
|
||||
"id": "zhipu_default",
|
||||
"provider": "zhipu",
|
||||
"type": "zhipu_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"timeout": 120,
|
||||
"api_base": "https://open.bigmodel.cn/api/paas/v4/",
|
||||
"model_config": {
|
||||
"model": "glm-4-flash",
|
||||
},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
"Dify": {
|
||||
"id": "dify_app_default",
|
||||
@@ -1134,7 +1095,6 @@ CONFIG_METADATA_2 = {
|
||||
"dify_query_input_key": "astrbot_text_query",
|
||||
"variables": {},
|
||||
"timeout": 60,
|
||||
"hint": "请确保你在 AstrBot 里设置的 APP 类型和 Dify 里面创建的应用的类型一致!",
|
||||
},
|
||||
"Coze": {
|
||||
"id": "coze",
|
||||
@@ -1165,20 +1125,6 @@ CONFIG_METADATA_2 = {
|
||||
"variables": {},
|
||||
"timeout": 60,
|
||||
},
|
||||
"ModelScope": {
|
||||
"id": "modelscope",
|
||||
"provider": "modelscope",
|
||||
"type": "openai_chat_completion",
|
||||
"provider_type": "chat_completion",
|
||||
"enable": True,
|
||||
"key": [],
|
||||
"timeout": 120,
|
||||
"api_base": "https://api-inference.modelscope.cn/v1",
|
||||
"model_config": {"model": "Qwen/Qwen3-32B", "temperature": 0.4},
|
||||
"custom_headers": {},
|
||||
"custom_extra_body": {},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
"FastGPT": {
|
||||
"id": "fastgpt",
|
||||
"provider": "fastgpt",
|
||||
@@ -1202,7 +1148,6 @@ CONFIG_METADATA_2 = {
|
||||
"model": "whisper-1",
|
||||
},
|
||||
"Whisper(Local)": {
|
||||
"hint": "启用前请 pip 安装 openai-whisper 库(N卡用户大约下载 2GB,主要是 torch 和 cuda,CPU 用户大约下载 1 GB),并且安装 ffmpeg。否则将无法正常转文字。",
|
||||
"provider": "openai",
|
||||
"type": "openai_whisper_selfhost",
|
||||
"provider_type": "speech_to_text",
|
||||
@@ -1211,7 +1156,6 @@ CONFIG_METADATA_2 = {
|
||||
"model": "tiny",
|
||||
},
|
||||
"SenseVoice(Local)": {
|
||||
"hint": "启用前请 pip 安装 funasr、funasr_onnx、torchaudio、torch、modelscope、jieba 库(默认使用CPU,大约下载 1 GB),并且安装 ffmpeg。否则将无法正常转文字。",
|
||||
"type": "sensevoice_stt_selfhost",
|
||||
"provider": "sensevoice",
|
||||
"provider_type": "speech_to_text",
|
||||
@@ -1233,7 +1177,6 @@ CONFIG_METADATA_2 = {
|
||||
"timeout": "20",
|
||||
},
|
||||
"Edge TTS": {
|
||||
"hint": "提示:使用这个服务前需要安装有 ffmpeg,并且可以直接在终端调用 ffmpeg 指令。",
|
||||
"id": "edge_tts",
|
||||
"provider": "microsoft",
|
||||
"type": "edge_tts",
|
||||
@@ -1449,6 +1392,10 @@ CONFIG_METADATA_2 = {
|
||||
},
|
||||
},
|
||||
"items": {
|
||||
"provider_source_id": {
|
||||
"invisible": True,
|
||||
"type": "string",
|
||||
},
|
||||
"xai_native_search": {
|
||||
"description": "启用原生搜索功能",
|
||||
"type": "bool",
|
||||
@@ -1819,13 +1766,24 @@ CONFIG_METADATA_2 = {
|
||||
},
|
||||
},
|
||||
"gm_thinking_config": {
|
||||
"description": "Gemini思考设置",
|
||||
"description": "Thinking Config",
|
||||
"type": "object",
|
||||
"items": {
|
||||
"budget": {
|
||||
"description": "思考预算",
|
||||
"description": "Thinking Budget",
|
||||
"type": "int",
|
||||
"hint": "模型应该生成的思考Token的数量,设为0关闭思考。除gemini-2.5-flash外的模型会静默忽略此参数。",
|
||||
"hint": "Guides the model on the specific number of thinking tokens to use for reasoning. See: https://ai.google.dev/gemini-api/docs/thinking#set-budget",
|
||||
},
|
||||
"level": {
|
||||
"description": "Thinking Level",
|
||||
"type": "string",
|
||||
"hint": "Recommended for Gemini 3 models and onwards, lets you control reasoning behavior.See: https://ai.google.dev/gemini-api/docs/thinking#thinking-levels",
|
||||
"options": [
|
||||
"MINIMAL",
|
||||
"LOW",
|
||||
"MEDIUM",
|
||||
"HIGH",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2006,7 +1964,6 @@ CONFIG_METADATA_2 = {
|
||||
"id": {
|
||||
"description": "ID",
|
||||
"type": "string",
|
||||
"hint": "模型提供商名字。",
|
||||
},
|
||||
"type": {
|
||||
"description": "模型提供商种类",
|
||||
@@ -2026,29 +1983,15 @@ CONFIG_METADATA_2 = {
|
||||
"description": "API Key",
|
||||
"type": "list",
|
||||
"items": {"type": "string"},
|
||||
"hint": "提供商 API Key。",
|
||||
},
|
||||
"api_base": {
|
||||
"description": "API Base URL",
|
||||
"type": "string",
|
||||
"hint": "API Base URL 请在模型提供商处获得。如出现 404 报错,尝试在地址末尾加上 /v1",
|
||||
},
|
||||
"model_config": {
|
||||
"description": "模型配置",
|
||||
"type": "object",
|
||||
"items": {
|
||||
"model": {
|
||||
"description": "模型名称",
|
||||
"type": "string",
|
||||
"hint": "模型名称,如 gpt-4o-mini, deepseek-chat。",
|
||||
},
|
||||
"max_tokens": {
|
||||
"description": "模型最大输出长度(tokens)",
|
||||
"type": "int",
|
||||
},
|
||||
"temperature": {"description": "温度", "type": "float"},
|
||||
"top_p": {"description": "Top P值", "type": "float"},
|
||||
},
|
||||
"model": {
|
||||
"description": "模型 ID",
|
||||
"type": "string",
|
||||
"hint": "模型名称,如 gpt-4o-mini, deepseek-chat。",
|
||||
},
|
||||
"dify_api_key": {
|
||||
"description": "API Key",
|
||||
|
||||
@@ -33,6 +33,7 @@ from astrbot.core.star.context import Context
|
||||
from astrbot.core.star.star_handler import EventType, star_handlers_registry, star_map
|
||||
from astrbot.core.umop_config_router import UmopConfigRouter
|
||||
from astrbot.core.updator import AstrBotUpdator
|
||||
from astrbot.core.utils.llm_metadata import update_llm_metadata
|
||||
from astrbot.core.utils.migra_helper import migra
|
||||
|
||||
from . import astrbot_config, html_renderer
|
||||
@@ -185,6 +186,8 @@ class AstrBotCoreLifecycle:
|
||||
# 初始化关闭控制面板的事件
|
||||
self.dashboard_shutdown_event = asyncio.Event()
|
||||
|
||||
asyncio.create_task(update_llm_metadata())
|
||||
|
||||
def _load(self) -> None:
|
||||
"""加载事件总线和任务并初始化."""
|
||||
# 创建一个异步任务来执行事件总线的 dispatch() 方法
|
||||
|
||||
@@ -9,6 +9,8 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
|
||||
|
||||
from astrbot.core.db.po import (
|
||||
Attachment,
|
||||
CommandConfig,
|
||||
CommandConflict,
|
||||
ConversationV2,
|
||||
Persona,
|
||||
PlatformMessageHistory,
|
||||
@@ -314,6 +316,76 @@ class BaseDatabase(abc.ABC):
|
||||
"""Clear all preferences for a specific scope ID."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_command_configs(self) -> list[CommandConfig]:
|
||||
"""Get all stored command configurations."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def get_command_config(self, handler_full_name: str) -> CommandConfig | None:
|
||||
"""Fetch a single command configuration by handler."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def upsert_command_config(
|
||||
self,
|
||||
handler_full_name: str,
|
||||
plugin_name: str,
|
||||
module_path: str,
|
||||
original_command: str,
|
||||
*,
|
||||
resolved_command: str | None = None,
|
||||
enabled: bool | None = None,
|
||||
keep_original_alias: bool | None = None,
|
||||
conflict_key: str | None = None,
|
||||
resolution_strategy: str | None = None,
|
||||
note: str | None = None,
|
||||
extra_data: dict | None = None,
|
||||
auto_managed: bool | None = None,
|
||||
) -> CommandConfig:
|
||||
"""Create or update a command configuration."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def delete_command_config(self, handler_full_name: str) -> None:
|
||||
"""Delete a single command configuration."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def delete_command_configs(self, handler_full_names: list[str]) -> None:
|
||||
"""Bulk delete command configurations."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def list_command_conflicts(
|
||||
self,
|
||||
status: str | None = None,
|
||||
) -> list[CommandConflict]:
|
||||
"""List recorded command conflict entries."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def upsert_command_conflict(
|
||||
self,
|
||||
conflict_key: str,
|
||||
handler_full_name: str,
|
||||
plugin_name: str,
|
||||
*,
|
||||
status: str | None = None,
|
||||
resolution: str | None = None,
|
||||
resolved_command: str | None = None,
|
||||
note: str | None = None,
|
||||
extra_data: dict | None = None,
|
||||
auto_generated: bool | None = None,
|
||||
) -> CommandConflict:
|
||||
"""Create or update a conflict record."""
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def delete_command_conflicts(self, ids: list[int]) -> None:
|
||||
"""Delete conflict records."""
|
||||
...
|
||||
|
||||
# @abc.abstractmethod
|
||||
# async def insert_llm_message(
|
||||
# self,
|
||||
|
||||
@@ -234,6 +234,65 @@ class Attachment(SQLModel, table=True):
|
||||
)
|
||||
|
||||
|
||||
class CommandConfig(SQLModel, table=True):
|
||||
"""Per-command configuration overrides for dashboard management."""
|
||||
|
||||
__tablename__ = "command_configs" # type: ignore
|
||||
|
||||
handler_full_name: str = Field(
|
||||
primary_key=True,
|
||||
max_length=512,
|
||||
)
|
||||
plugin_name: str = Field(nullable=False, max_length=255)
|
||||
module_path: str = Field(nullable=False, max_length=255)
|
||||
original_command: str = Field(nullable=False, max_length=255)
|
||||
resolved_command: str | None = Field(default=None, max_length=255)
|
||||
enabled: bool = Field(default=True, nullable=False)
|
||||
keep_original_alias: bool = Field(default=False, nullable=False)
|
||||
conflict_key: str | None = Field(default=None, max_length=255)
|
||||
resolution_strategy: str | None = Field(default=None, max_length=64)
|
||||
note: str | None = Field(default=None, sa_type=Text)
|
||||
extra_data: dict | None = Field(default=None, sa_type=JSON)
|
||||
auto_managed: bool = Field(default=False, nullable=False)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(timezone.utc),
|
||||
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
|
||||
)
|
||||
|
||||
|
||||
class CommandConflict(SQLModel, table=True):
|
||||
"""Conflict tracking for duplicated command names."""
|
||||
|
||||
__tablename__ = "command_conflicts" # type: ignore
|
||||
|
||||
id: int | None = Field(
|
||||
default=None, primary_key=True, sa_column_kwargs={"autoincrement": True}
|
||||
)
|
||||
conflict_key: str = Field(nullable=False, max_length=255)
|
||||
handler_full_name: str = Field(nullable=False, max_length=512)
|
||||
plugin_name: str = Field(nullable=False, max_length=255)
|
||||
status: str = Field(default="pending", max_length=32)
|
||||
resolution: str | None = Field(default=None, max_length=64)
|
||||
resolved_command: str | None = Field(default=None, max_length=255)
|
||||
note: str | None = Field(default=None, sa_type=Text)
|
||||
extra_data: dict | None = Field(default=None, sa_type=JSON)
|
||||
auto_generated: bool = Field(default=False, nullable=False)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(timezone.utc),
|
||||
sa_column_kwargs={"onupdate": datetime.now(timezone.utc)},
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"conflict_key",
|
||||
"handler_full_name",
|
||||
name="uix_conflict_handler",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Conversation:
|
||||
"""LLM 对话类
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import threading
|
||||
import typing as T
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import CursorResult
|
||||
@@ -10,6 +11,8 @@ from sqlmodel import col, delete, desc, func, or_, select, text, update
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.db.po import (
|
||||
Attachment,
|
||||
CommandConfig,
|
||||
CommandConflict,
|
||||
ConversationV2,
|
||||
Persona,
|
||||
PlatformMessageHistory,
|
||||
@@ -26,6 +29,7 @@ from astrbot.core.db.po import (
|
||||
)
|
||||
|
||||
NOT_GIVEN = T.TypeVar("NOT_GIVEN")
|
||||
TxResult = T.TypeVar("TxResult")
|
||||
|
||||
|
||||
class SQLiteDatabase(BaseDatabase):
|
||||
@@ -670,6 +674,242 @@ class SQLiteDatabase(BaseDatabase):
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
# ====
|
||||
# Command Configuration & Conflict Tracking
|
||||
# ====
|
||||
|
||||
async def _run_in_tx(
|
||||
self,
|
||||
fn: Callable[[AsyncSession], Awaitable[TxResult]],
|
||||
) -> TxResult:
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
async with session.begin():
|
||||
return await fn(session)
|
||||
|
||||
@staticmethod
|
||||
def _apply_updates(model, **updates) -> None:
|
||||
for field, value in updates.items():
|
||||
if value is not None:
|
||||
setattr(model, field, value)
|
||||
|
||||
@staticmethod
|
||||
def _new_command_config(
|
||||
handler_full_name: str,
|
||||
plugin_name: str,
|
||||
module_path: str,
|
||||
original_command: str,
|
||||
*,
|
||||
resolved_command: str | None = None,
|
||||
enabled: bool | None = None,
|
||||
keep_original_alias: bool | None = None,
|
||||
conflict_key: str | None = None,
|
||||
resolution_strategy: str | None = None,
|
||||
note: str | None = None,
|
||||
extra_data: dict | None = None,
|
||||
auto_managed: bool | None = None,
|
||||
) -> CommandConfig:
|
||||
return CommandConfig(
|
||||
handler_full_name=handler_full_name,
|
||||
plugin_name=plugin_name,
|
||||
module_path=module_path,
|
||||
original_command=original_command,
|
||||
resolved_command=resolved_command,
|
||||
enabled=True if enabled is None else enabled,
|
||||
keep_original_alias=False
|
||||
if keep_original_alias is None
|
||||
else keep_original_alias,
|
||||
conflict_key=conflict_key or original_command,
|
||||
resolution_strategy=resolution_strategy,
|
||||
note=note,
|
||||
extra_data=extra_data,
|
||||
auto_managed=bool(auto_managed),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _new_command_conflict(
|
||||
conflict_key: str,
|
||||
handler_full_name: str,
|
||||
plugin_name: str,
|
||||
*,
|
||||
status: str | None = None,
|
||||
resolution: str | None = None,
|
||||
resolved_command: str | None = None,
|
||||
note: str | None = None,
|
||||
extra_data: dict | None = None,
|
||||
auto_generated: bool | None = None,
|
||||
) -> CommandConflict:
|
||||
return CommandConflict(
|
||||
conflict_key=conflict_key,
|
||||
handler_full_name=handler_full_name,
|
||||
plugin_name=plugin_name,
|
||||
status=status or "pending",
|
||||
resolution=resolution,
|
||||
resolved_command=resolved_command,
|
||||
note=note,
|
||||
extra_data=extra_data,
|
||||
auto_generated=bool(auto_generated),
|
||||
)
|
||||
|
||||
async def get_command_configs(self) -> list[CommandConfig]:
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
result = await session.execute(select(CommandConfig))
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_command_config(
|
||||
self,
|
||||
handler_full_name: str,
|
||||
) -> CommandConfig | None:
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
return await session.get(CommandConfig, handler_full_name)
|
||||
|
||||
async def upsert_command_config(
|
||||
self,
|
||||
handler_full_name: str,
|
||||
plugin_name: str,
|
||||
module_path: str,
|
||||
original_command: str,
|
||||
*,
|
||||
resolved_command: str | None = None,
|
||||
enabled: bool | None = None,
|
||||
keep_original_alias: bool | None = None,
|
||||
conflict_key: str | None = None,
|
||||
resolution_strategy: str | None = None,
|
||||
note: str | None = None,
|
||||
extra_data: dict | None = None,
|
||||
auto_managed: bool | None = None,
|
||||
) -> CommandConfig:
|
||||
async def _op(session: AsyncSession) -> CommandConfig:
|
||||
config = await session.get(CommandConfig, handler_full_name)
|
||||
if not config:
|
||||
config = self._new_command_config(
|
||||
handler_full_name,
|
||||
plugin_name,
|
||||
module_path,
|
||||
original_command,
|
||||
resolved_command=resolved_command,
|
||||
enabled=enabled,
|
||||
keep_original_alias=keep_original_alias,
|
||||
conflict_key=conflict_key,
|
||||
resolution_strategy=resolution_strategy,
|
||||
note=note,
|
||||
extra_data=extra_data,
|
||||
auto_managed=auto_managed,
|
||||
)
|
||||
session.add(config)
|
||||
else:
|
||||
self._apply_updates(
|
||||
config,
|
||||
plugin_name=plugin_name,
|
||||
module_path=module_path,
|
||||
original_command=original_command,
|
||||
resolved_command=resolved_command,
|
||||
enabled=enabled,
|
||||
keep_original_alias=keep_original_alias,
|
||||
conflict_key=conflict_key,
|
||||
resolution_strategy=resolution_strategy,
|
||||
note=note,
|
||||
extra_data=extra_data,
|
||||
auto_managed=auto_managed,
|
||||
)
|
||||
await session.flush()
|
||||
await session.refresh(config)
|
||||
return config
|
||||
|
||||
return await self._run_in_tx(_op)
|
||||
|
||||
async def delete_command_config(self, handler_full_name: str) -> None:
|
||||
await self.delete_command_configs([handler_full_name])
|
||||
|
||||
async def delete_command_configs(self, handler_full_names: list[str]) -> None:
|
||||
if not handler_full_names:
|
||||
return
|
||||
|
||||
async def _op(session: AsyncSession) -> None:
|
||||
await session.execute(
|
||||
delete(CommandConfig).where(
|
||||
col(CommandConfig.handler_full_name).in_(handler_full_names),
|
||||
),
|
||||
)
|
||||
|
||||
await self._run_in_tx(_op)
|
||||
|
||||
async def list_command_conflicts(
|
||||
self,
|
||||
status: str | None = None,
|
||||
) -> list[CommandConflict]:
|
||||
async with self.get_db() as session:
|
||||
session: AsyncSession
|
||||
query = select(CommandConflict)
|
||||
if status:
|
||||
query = query.where(CommandConflict.status == status)
|
||||
result = await session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def upsert_command_conflict(
|
||||
self,
|
||||
conflict_key: str,
|
||||
handler_full_name: str,
|
||||
plugin_name: str,
|
||||
*,
|
||||
status: str | None = None,
|
||||
resolution: str | None = None,
|
||||
resolved_command: str | None = None,
|
||||
note: str | None = None,
|
||||
extra_data: dict | None = None,
|
||||
auto_generated: bool | None = None,
|
||||
) -> CommandConflict:
|
||||
async def _op(session: AsyncSession) -> CommandConflict:
|
||||
result = await session.execute(
|
||||
select(CommandConflict).where(
|
||||
CommandConflict.conflict_key == conflict_key,
|
||||
CommandConflict.handler_full_name == handler_full_name,
|
||||
),
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if not record:
|
||||
record = self._new_command_conflict(
|
||||
conflict_key,
|
||||
handler_full_name,
|
||||
plugin_name,
|
||||
status=status,
|
||||
resolution=resolution,
|
||||
resolved_command=resolved_command,
|
||||
note=note,
|
||||
extra_data=extra_data,
|
||||
auto_generated=auto_generated,
|
||||
)
|
||||
session.add(record)
|
||||
else:
|
||||
self._apply_updates(
|
||||
record,
|
||||
plugin_name=plugin_name,
|
||||
status=status,
|
||||
resolution=resolution,
|
||||
resolved_command=resolved_command,
|
||||
note=note,
|
||||
extra_data=extra_data,
|
||||
auto_generated=auto_generated,
|
||||
)
|
||||
await session.flush()
|
||||
await session.refresh(record)
|
||||
return record
|
||||
|
||||
return await self._run_in_tx(_op)
|
||||
|
||||
async def delete_command_conflicts(self, ids: list[int]) -> None:
|
||||
if not ids:
|
||||
return
|
||||
|
||||
async def _op(session: AsyncSession) -> None:
|
||||
await session.execute(
|
||||
delete(CommandConflict).where(col(CommandConflict.id).in_(ids)),
|
||||
)
|
||||
|
||||
await self._run_in_tx(_op)
|
||||
|
||||
# ====
|
||||
# Deprecated Methods
|
||||
# ====
|
||||
|
||||
@@ -629,12 +629,11 @@ class Nodes(BaseMessageComponent):
|
||||
|
||||
class Json(BaseMessageComponent):
|
||||
type = ComponentType.Json
|
||||
data: str | dict
|
||||
resid: int | None = 0
|
||||
data: dict
|
||||
|
||||
def __init__(self, data, **_):
|
||||
if isinstance(data, dict):
|
||||
data = json.dumps(data)
|
||||
def __init__(self, data: str | dict, **_):
|
||||
if isinstance(data, str):
|
||||
data = json.loads(data)
|
||||
super().__init__(data=data, **_)
|
||||
|
||||
|
||||
|
||||
@@ -321,7 +321,12 @@ class InternalAgentSubStage(Stage):
|
||||
elif isinstance(req.tool_calls_result, list):
|
||||
for tcr in req.tool_calls_result:
|
||||
messages.extend(tcr.to_openai_messages())
|
||||
messages.append({"role": "assistant", "content": llm_response.completion_text})
|
||||
messages.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": llm_response.completion_text or "*No response*",
|
||||
}
|
||||
)
|
||||
messages = list(filter(lambda item: "_no_save" not in item, messages))
|
||||
await self.conv_manager.update_conversation(
|
||||
event.unified_msg_origin,
|
||||
|
||||
@@ -119,7 +119,7 @@ class RespondStage(Stage):
|
||||
|
||||
if (result := event.get_result()) is None:
|
||||
return False
|
||||
if self.only_llm_result and result.is_llm_result():
|
||||
if self.only_llm_result and not result.is_llm_result():
|
||||
return False
|
||||
|
||||
if event.get_platform_name() in [
|
||||
|
||||
@@ -385,10 +385,25 @@ class AiocqhttpAdapter(Platform):
|
||||
logger.error(f"获取 @ 用户信息失败: {e},此消息段将被忽略。")
|
||||
|
||||
message_str += "".join(at_parts)
|
||||
elif t == "markdown":
|
||||
text = m["data"].get("markdown") or m["data"].get("content", "")
|
||||
abm.message.append(Plain(text=text))
|
||||
message_str += text
|
||||
else:
|
||||
for m in m_group:
|
||||
a = ComponentTypes[t](**m["data"])
|
||||
abm.message.append(a)
|
||||
try:
|
||||
if t not in ComponentTypes:
|
||||
logger.warning(
|
||||
f"不支持的消息段类型,已忽略: {t}, data={m['data']}"
|
||||
)
|
||||
continue
|
||||
a = ComponentTypes[t](**m["data"])
|
||||
abm.message.append(a)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"消息段解析失败: type={t}, data={m['data']}. {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
abm.timestamp = int(time.time())
|
||||
abm.message_str = message_str
|
||||
|
||||
@@ -200,6 +200,15 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
if isinstance(chain, MessageChain):
|
||||
if chain.type == "break":
|
||||
# 分割符
|
||||
if message_id:
|
||||
try:
|
||||
await self.client.edit_message_text(
|
||||
text=delta,
|
||||
chat_id=payload["chat_id"],
|
||||
message_id=message_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"编辑消息失败(streaming-break): {e!s}")
|
||||
message_id = None # 重置消息 ID
|
||||
delta = "" # 重置 delta
|
||||
continue
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.message_components import File, Image, Plain, Record
|
||||
from astrbot.api.message_components import File, Image, Json, Plain, Record
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
from .webchat_queue_mgr import webchat_queue_mgr
|
||||
@@ -41,12 +42,20 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
await web_chat_back_queue.put(
|
||||
{
|
||||
"type": "plain",
|
||||
"cid": cid,
|
||||
"data": data,
|
||||
"streaming": streaming,
|
||||
"chain_type": message.type,
|
||||
},
|
||||
)
|
||||
elif isinstance(comp, Json):
|
||||
await web_chat_back_queue.put(
|
||||
{
|
||||
"type": "plain",
|
||||
"data": json.dumps(comp.data, ensure_ascii=False),
|
||||
"streaming": streaming,
|
||||
"chain_type": message.type,
|
||||
},
|
||||
)
|
||||
elif isinstance(comp, Image):
|
||||
# save image to local
|
||||
filename = f"{str(uuid.uuid4())}.jpg"
|
||||
@@ -58,7 +67,6 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
await web_chat_back_queue.put(
|
||||
{
|
||||
"type": "image",
|
||||
"cid": cid,
|
||||
"data": data,
|
||||
"streaming": streaming,
|
||||
},
|
||||
@@ -74,7 +82,6 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
await web_chat_back_queue.put(
|
||||
{
|
||||
"type": "record",
|
||||
"cid": cid,
|
||||
"data": data,
|
||||
"streaming": streaming,
|
||||
},
|
||||
@@ -91,7 +98,6 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
await web_chat_back_queue.put(
|
||||
{
|
||||
"type": "file",
|
||||
"cid": cid,
|
||||
"data": data,
|
||||
"streaming": streaming,
|
||||
},
|
||||
@@ -111,18 +117,17 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
cid = self.session_id.split("!")[-1]
|
||||
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
|
||||
async for chain in generator:
|
||||
if chain.type == "break" and final_data:
|
||||
# 分割符
|
||||
await web_chat_back_queue.put(
|
||||
{
|
||||
"type": "break", # break means a segment end
|
||||
"data": final_data,
|
||||
"streaming": True,
|
||||
"cid": cid,
|
||||
},
|
||||
)
|
||||
final_data = ""
|
||||
continue
|
||||
# if chain.type == "break" and final_data:
|
||||
# # 分割符
|
||||
# await web_chat_back_queue.put(
|
||||
# {
|
||||
# "type": "break", # break means a segment end
|
||||
# "data": final_data,
|
||||
# "streaming": True,
|
||||
# },
|
||||
# )
|
||||
# final_data = ""
|
||||
# continue
|
||||
|
||||
r = await WebChatMessageEvent._send(
|
||||
chain,
|
||||
@@ -142,7 +147,6 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
"data": final_data,
|
||||
"reasoning": reasoning_content,
|
||||
"streaming": True,
|
||||
"cid": cid,
|
||||
},
|
||||
)
|
||||
await super().send_streaming(generator, use_fallback)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import enum
|
||||
import json
|
||||
@@ -12,6 +14,7 @@ import astrbot.core.message.components as Comp
|
||||
from astrbot import logger
|
||||
from astrbot.core.agent.message import (
|
||||
AssistantMessageSegment,
|
||||
ContentPart,
|
||||
ToolCall,
|
||||
ToolCallMessageSegment,
|
||||
)
|
||||
@@ -90,6 +93,8 @@ class ProviderRequest:
|
||||
"""会话 ID"""
|
||||
image_urls: list[str] = field(default_factory=list)
|
||||
"""图片 URL 列表"""
|
||||
extra_user_content_parts: list[ContentPart] = field(default_factory=list)
|
||||
"""额外的用户消息内容部分列表,用于在用户消息后添加额外的内容块(如系统提醒、指令等)。"""
|
||||
func_tool: ToolSet | None = None
|
||||
"""可用的函数工具"""
|
||||
contexts: list[dict] = field(default_factory=list)
|
||||
@@ -164,13 +169,23 @@ class ProviderRequest:
|
||||
|
||||
async def assemble_context(self) -> dict:
|
||||
"""将请求(prompt 和 image_urls)包装成 OpenAI 的消息格式。"""
|
||||
# 构建内容块列表
|
||||
content_blocks = []
|
||||
|
||||
# 1. 用户原始发言(OpenAI 建议:用户发言在前)
|
||||
if self.prompt and self.prompt.strip():
|
||||
content_blocks.append({"type": "text", "text": self.prompt})
|
||||
elif self.image_urls:
|
||||
# 如果没有文本但有图片,添加占位文本
|
||||
content_blocks.append({"type": "text", "text": "[图片]"})
|
||||
|
||||
# 2. 额外的内容块(系统提醒、指令等)
|
||||
if self.extra_user_content_parts:
|
||||
for part in self.extra_user_content_parts:
|
||||
content_blocks.append(part.model_dump())
|
||||
|
||||
# 3. 图片内容
|
||||
if self.image_urls:
|
||||
user_content = {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": self.prompt if self.prompt else "[图片]"},
|
||||
],
|
||||
}
|
||||
for image_url in self.image_urls:
|
||||
if image_url.startswith("http"):
|
||||
image_path = await download_image_by_url(image_url)
|
||||
@@ -183,11 +198,21 @@ class ProviderRequest:
|
||||
if not image_data:
|
||||
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
|
||||
continue
|
||||
user_content["content"].append(
|
||||
content_blocks.append(
|
||||
{"type": "image_url", "image_url": {"url": image_data}},
|
||||
)
|
||||
return user_content
|
||||
return {"role": "user", "content": self.prompt}
|
||||
|
||||
# 只有当只有一个来自 prompt 的文本块且没有额外内容块时,才降级为简单格式以保持向后兼容
|
||||
if (
|
||||
len(content_blocks) == 1
|
||||
and content_blocks[0]["type"] == "text"
|
||||
and not self.extra_user_content_parts
|
||||
and not self.image_urls
|
||||
):
|
||||
return {"role": "user", "content": content_blocks[0]["text"]}
|
||||
|
||||
# 否则返回多模态格式
|
||||
return {"role": "user", "content": content_blocks}
|
||||
|
||||
async def _encode_image_bs64(self, image_url: str) -> str:
|
||||
"""将图片转换为 base64"""
|
||||
@@ -199,6 +224,38 @@ class ProviderRequest:
|
||||
return ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class TokenUsage:
|
||||
input_other: int = 0
|
||||
"""The number of input tokens, excluding cached tokens."""
|
||||
input_cached: int = 0
|
||||
"""The number of input cached tokens."""
|
||||
output: int = 0
|
||||
"""The number of output tokens."""
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return self.input_other + self.input_cached + self.output
|
||||
|
||||
@property
|
||||
def input(self) -> int:
|
||||
return self.input_other + self.input_cached
|
||||
|
||||
def __add__(self, other: TokenUsage) -> TokenUsage:
|
||||
return TokenUsage(
|
||||
input_other=self.input_other + other.input_other,
|
||||
input_cached=self.input_cached + other.input_cached,
|
||||
output=self.output + other.output,
|
||||
)
|
||||
|
||||
def __sub__(self, other: TokenUsage) -> TokenUsage:
|
||||
return TokenUsage(
|
||||
input_other=self.input_other - other.input_other,
|
||||
input_cached=self.input_cached - other.input_cached,
|
||||
output=self.output - other.output,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMResponse:
|
||||
role: str
|
||||
@@ -227,6 +284,11 @@ class LLMResponse:
|
||||
is_chunk: bool = False
|
||||
"""Indicates if the response is a chunked response."""
|
||||
|
||||
id: str | None = None
|
||||
"""The ID of the response. For chunked responses, it's the ID of the chunk; for non-chunked responses, it's the ID of the response."""
|
||||
usage: TokenUsage | None = None
|
||||
"""The usage of the response. For chunked responses, it's the usage of the chunk; for non-chunked responses, it's the usage of the response."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
role: str,
|
||||
@@ -241,6 +303,8 @@ class LLMResponse:
|
||||
| AnthropicMessage
|
||||
| None = None,
|
||||
is_chunk: bool = False,
|
||||
id: str | None = None,
|
||||
usage: TokenUsage | None = None,
|
||||
):
|
||||
"""初始化 LLMResponse
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import copy
|
||||
import traceback
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
@@ -32,10 +33,12 @@ class ProviderManager:
|
||||
persona_mgr: PersonaManager,
|
||||
):
|
||||
self.reload_lock = asyncio.Lock()
|
||||
self.resource_lock = asyncio.Lock()
|
||||
self.persona_mgr = persona_mgr
|
||||
self.acm = acm
|
||||
config = acm.confs["default"]
|
||||
self.providers_config: list = config["provider"]
|
||||
self.provider_sources_config: list = config.get("provider_sources", [])
|
||||
self.provider_settings: dict = config["provider_settings"]
|
||||
self.provider_stt_settings: dict = config.get("provider_stt_settings", {})
|
||||
self.provider_tts_settings: dict = config.get("provider_tts_settings", {})
|
||||
@@ -148,6 +151,7 @@ class ProviderManager:
|
||||
|
||||
"""
|
||||
provider = None
|
||||
provider_id = None
|
||||
if umo:
|
||||
provider_id = sp.get(
|
||||
f"provider_perf_{provider_type.value}",
|
||||
@@ -185,6 +189,12 @@ class ProviderManager:
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown provider type: {provider_type}")
|
||||
|
||||
if not provider and provider_id:
|
||||
logger.warning(
|
||||
f"没有找到 ID 为 {provider_id} 的提供商,这可能是由于您修改了提供商(模型)ID 导致的。"
|
||||
)
|
||||
|
||||
return provider
|
||||
|
||||
async def initialize(self):
|
||||
@@ -251,7 +261,136 @@ class ProviderManager:
|
||||
# 初始化 MCP Client 连接
|
||||
asyncio.create_task(self.llm_tools.init_mcp_clients(), name="init_mcp_clients")
|
||||
|
||||
def dynamic_import_provider(self, type: str):
|
||||
"""动态导入提供商适配器模块
|
||||
|
||||
Args:
|
||||
type (str): 提供商请求类型。
|
||||
|
||||
Raises:
|
||||
ImportError: 如果提供商类型未知或无法导入对应模块,则抛出异常。
|
||||
"""
|
||||
match type:
|
||||
case "openai_chat_completion":
|
||||
from .sources.openai_source import (
|
||||
ProviderOpenAIOfficial as ProviderOpenAIOfficial,
|
||||
)
|
||||
case "zhipu_chat_completion":
|
||||
from .sources.zhipu_source import ProviderZhipu as ProviderZhipu
|
||||
case "groq_chat_completion":
|
||||
from .sources.groq_source import ProviderGroq as ProviderGroq
|
||||
case "anthropic_chat_completion":
|
||||
from .sources.anthropic_source import (
|
||||
ProviderAnthropic as ProviderAnthropic,
|
||||
)
|
||||
case "googlegenai_chat_completion":
|
||||
from .sources.gemini_source import (
|
||||
ProviderGoogleGenAI as ProviderGoogleGenAI,
|
||||
)
|
||||
case "sensevoice_stt_selfhost":
|
||||
from .sources.sensevoice_selfhosted_source import (
|
||||
ProviderSenseVoiceSTTSelfHost as ProviderSenseVoiceSTTSelfHost,
|
||||
)
|
||||
case "openai_whisper_api":
|
||||
from .sources.whisper_api_source import (
|
||||
ProviderOpenAIWhisperAPI as ProviderOpenAIWhisperAPI,
|
||||
)
|
||||
case "openai_whisper_selfhost":
|
||||
from .sources.whisper_selfhosted_source import (
|
||||
ProviderOpenAIWhisperSelfHost as ProviderOpenAIWhisperSelfHost,
|
||||
)
|
||||
case "xinference_stt":
|
||||
from .sources.xinference_stt_provider import (
|
||||
ProviderXinferenceSTT as ProviderXinferenceSTT,
|
||||
)
|
||||
case "openai_tts_api":
|
||||
from .sources.openai_tts_api_source import (
|
||||
ProviderOpenAITTSAPI as ProviderOpenAITTSAPI,
|
||||
)
|
||||
case "edge_tts":
|
||||
from .sources.edge_tts_source import (
|
||||
ProviderEdgeTTS as ProviderEdgeTTS,
|
||||
)
|
||||
case "gsv_tts_selfhost":
|
||||
from .sources.gsv_selfhosted_source import (
|
||||
ProviderGSVTTS as ProviderGSVTTS,
|
||||
)
|
||||
case "gsvi_tts_api":
|
||||
from .sources.gsvi_tts_source import (
|
||||
ProviderGSVITTS as ProviderGSVITTS,
|
||||
)
|
||||
case "fishaudio_tts_api":
|
||||
from .sources.fishaudio_tts_api_source import (
|
||||
ProviderFishAudioTTSAPI as ProviderFishAudioTTSAPI,
|
||||
)
|
||||
case "dashscope_tts":
|
||||
from .sources.dashscope_tts import (
|
||||
ProviderDashscopeTTSAPI as ProviderDashscopeTTSAPI,
|
||||
)
|
||||
case "azure_tts":
|
||||
from .sources.azure_tts_source import (
|
||||
AzureTTSProvider as AzureTTSProvider,
|
||||
)
|
||||
case "minimax_tts_api":
|
||||
from .sources.minimax_tts_api_source import (
|
||||
ProviderMiniMaxTTSAPI as ProviderMiniMaxTTSAPI,
|
||||
)
|
||||
case "volcengine_tts":
|
||||
from .sources.volcengine_tts import (
|
||||
ProviderVolcengineTTS as ProviderVolcengineTTS,
|
||||
)
|
||||
case "gemini_tts":
|
||||
from .sources.gemini_tts_source import (
|
||||
ProviderGeminiTTSAPI as ProviderGeminiTTSAPI,
|
||||
)
|
||||
case "openai_embedding":
|
||||
from .sources.openai_embedding_source import (
|
||||
OpenAIEmbeddingProvider as OpenAIEmbeddingProvider,
|
||||
)
|
||||
case "gemini_embedding":
|
||||
from .sources.gemini_embedding_source import (
|
||||
GeminiEmbeddingProvider as GeminiEmbeddingProvider,
|
||||
)
|
||||
case "vllm_rerank":
|
||||
from .sources.vllm_rerank_source import (
|
||||
VLLMRerankProvider as VLLMRerankProvider,
|
||||
)
|
||||
case "xinference_rerank":
|
||||
from .sources.xinference_rerank_source import (
|
||||
XinferenceRerankProvider as XinferenceRerankProvider,
|
||||
)
|
||||
case "bailian_rerank":
|
||||
from .sources.bailian_rerank_source import (
|
||||
BailianRerankProvider as BailianRerankProvider,
|
||||
)
|
||||
|
||||
def get_merged_provider_config(self, provider_config: dict) -> dict:
|
||||
"""获取 provider 配置和 provider_source 配置合并后的结果
|
||||
|
||||
Returns:
|
||||
dict: 合并后的 provider 配置,key 为 provider id,value 为合并后的配置字典
|
||||
"""
|
||||
pc = copy.deepcopy(provider_config)
|
||||
provider_source_id = pc.get("provider_source_id", "")
|
||||
if provider_source_id:
|
||||
provider_source = None
|
||||
for ps in self.provider_sources_config:
|
||||
if ps.get("id") == provider_source_id:
|
||||
provider_source = ps
|
||||
break
|
||||
|
||||
if provider_source:
|
||||
# 合并配置,provider 的配置优先级更高
|
||||
merged_config = {**provider_source, **pc}
|
||||
# 保持 id 为 provider 的 id,而不是 source 的 id
|
||||
merged_config["id"] = pc["id"]
|
||||
pc = merged_config
|
||||
return pc
|
||||
|
||||
async def load_provider(self, provider_config: dict):
|
||||
# 如果 provider_source_id 存在且不为空,则从 provider_sources 中找到对应的配置并合并
|
||||
provider_config = self.get_merged_provider_config(provider_config)
|
||||
|
||||
if not provider_config["enable"]:
|
||||
logger.info(f"Provider {provider_config['id']} is disabled, skipping")
|
||||
return
|
||||
@@ -264,99 +403,7 @@ class ProviderManager:
|
||||
|
||||
# 动态导入
|
||||
try:
|
||||
match provider_config["type"]:
|
||||
case "openai_chat_completion":
|
||||
from .sources.openai_source import (
|
||||
ProviderOpenAIOfficial as ProviderOpenAIOfficial,
|
||||
)
|
||||
case "zhipu_chat_completion":
|
||||
from .sources.zhipu_source import ProviderZhipu as ProviderZhipu
|
||||
case "groq_chat_completion":
|
||||
from .sources.groq_source import ProviderGroq as ProviderGroq
|
||||
case "anthropic_chat_completion":
|
||||
from .sources.anthropic_source import (
|
||||
ProviderAnthropic as ProviderAnthropic,
|
||||
)
|
||||
case "googlegenai_chat_completion":
|
||||
from .sources.gemini_source import (
|
||||
ProviderGoogleGenAI as ProviderGoogleGenAI,
|
||||
)
|
||||
case "sensevoice_stt_selfhost":
|
||||
from .sources.sensevoice_selfhosted_source import (
|
||||
ProviderSenseVoiceSTTSelfHost as ProviderSenseVoiceSTTSelfHost,
|
||||
)
|
||||
case "openai_whisper_api":
|
||||
from .sources.whisper_api_source import (
|
||||
ProviderOpenAIWhisperAPI as ProviderOpenAIWhisperAPI,
|
||||
)
|
||||
case "openai_whisper_selfhost":
|
||||
from .sources.whisper_selfhosted_source import (
|
||||
ProviderOpenAIWhisperSelfHost as ProviderOpenAIWhisperSelfHost,
|
||||
)
|
||||
case "xinference_stt":
|
||||
from .sources.xinference_stt_provider import (
|
||||
ProviderXinferenceSTT as ProviderXinferenceSTT,
|
||||
)
|
||||
case "openai_tts_api":
|
||||
from .sources.openai_tts_api_source import (
|
||||
ProviderOpenAITTSAPI as ProviderOpenAITTSAPI,
|
||||
)
|
||||
case "edge_tts":
|
||||
from .sources.edge_tts_source import (
|
||||
ProviderEdgeTTS as ProviderEdgeTTS,
|
||||
)
|
||||
case "gsv_tts_selfhost":
|
||||
from .sources.gsv_selfhosted_source import (
|
||||
ProviderGSVTTS as ProviderGSVTTS,
|
||||
)
|
||||
case "gsvi_tts_api":
|
||||
from .sources.gsvi_tts_source import (
|
||||
ProviderGSVITTS as ProviderGSVITTS,
|
||||
)
|
||||
case "fishaudio_tts_api":
|
||||
from .sources.fishaudio_tts_api_source import (
|
||||
ProviderFishAudioTTSAPI as ProviderFishAudioTTSAPI,
|
||||
)
|
||||
case "dashscope_tts":
|
||||
from .sources.dashscope_tts import (
|
||||
ProviderDashscopeTTSAPI as ProviderDashscopeTTSAPI,
|
||||
)
|
||||
case "azure_tts":
|
||||
from .sources.azure_tts_source import (
|
||||
AzureTTSProvider as AzureTTSProvider,
|
||||
)
|
||||
case "minimax_tts_api":
|
||||
from .sources.minimax_tts_api_source import (
|
||||
ProviderMiniMaxTTSAPI as ProviderMiniMaxTTSAPI,
|
||||
)
|
||||
case "volcengine_tts":
|
||||
from .sources.volcengine_tts import (
|
||||
ProviderVolcengineTTS as ProviderVolcengineTTS,
|
||||
)
|
||||
case "gemini_tts":
|
||||
from .sources.gemini_tts_source import (
|
||||
ProviderGeminiTTSAPI as ProviderGeminiTTSAPI,
|
||||
)
|
||||
case "openai_embedding":
|
||||
from .sources.openai_embedding_source import (
|
||||
OpenAIEmbeddingProvider as OpenAIEmbeddingProvider,
|
||||
)
|
||||
case "gemini_embedding":
|
||||
from .sources.gemini_embedding_source import (
|
||||
GeminiEmbeddingProvider as GeminiEmbeddingProvider,
|
||||
)
|
||||
case "vllm_rerank":
|
||||
from .sources.vllm_rerank_source import (
|
||||
VLLMRerankProvider as VLLMRerankProvider,
|
||||
)
|
||||
case "xinference_rerank":
|
||||
from .sources.xinference_rerank_source import (
|
||||
XinferenceRerankProvider as XinferenceRerankProvider,
|
||||
)
|
||||
case "bailian_rerank":
|
||||
from .sources.bailian_rerank_source import (
|
||||
BailianRerankProvider as BailianRerankProvider,
|
||||
)
|
||||
self.dynamic_import_provider(provider_config["type"])
|
||||
except (ImportError, ModuleNotFoundError) as e:
|
||||
logger.critical(
|
||||
f"加载 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}。可能是因为有未安装的依赖。",
|
||||
@@ -499,6 +546,7 @@ class ProviderManager:
|
||||
|
||||
# 和配置文件保持同步
|
||||
self.providers_config = astrbot_config["provider"]
|
||||
self.provider_sources_config = astrbot_config.get("provider_sources", [])
|
||||
config_ids = [provider["id"] for provider in self.providers_config]
|
||||
logger.info(f"providers in user's config: {config_ids}")
|
||||
for key in list(self.inst_map.keys()):
|
||||
@@ -570,6 +618,68 @@ class ProviderManager:
|
||||
)
|
||||
del self.inst_map[provider_id]
|
||||
|
||||
async def delete_provider(
|
||||
self, provider_id: str | None = None, provider_source_id: str | None = None
|
||||
):
|
||||
"""Delete provider and/or provider source from config and terminate the instances. Config will be saved after deletion."""
|
||||
async with self.resource_lock:
|
||||
# delete from config
|
||||
target_prov_ids = []
|
||||
if provider_id:
|
||||
target_prov_ids.append(provider_id)
|
||||
else:
|
||||
for prov in self.providers_config:
|
||||
if prov.get("provider_source_id") == provider_source_id:
|
||||
target_prov_ids.append(prov.get("id"))
|
||||
config = self.acm.default_conf
|
||||
for tpid in target_prov_ids:
|
||||
await self.terminate_provider(tpid)
|
||||
config["provider"] = [
|
||||
prov for prov in config["provider"] if prov.get("id") != tpid
|
||||
]
|
||||
config.save_config()
|
||||
logger.info(f"Provider {target_prov_ids} 已从配置中删除。")
|
||||
|
||||
async def update_provider(self, origin_provider_id: str, new_config: dict):
|
||||
"""Update provider config and reload the instance. Config will be saved after update."""
|
||||
async with self.resource_lock:
|
||||
npid = new_config.get("id", None)
|
||||
if not npid:
|
||||
raise ValueError("New provider config must have an 'id' field")
|
||||
config = self.acm.default_conf
|
||||
for provider in config["provider"]:
|
||||
if (
|
||||
provider.get("id", None) == npid
|
||||
and provider.get("id", None) != origin_provider_id
|
||||
):
|
||||
raise ValueError(f"Provider ID {npid} already exists")
|
||||
# update config
|
||||
for idx, provider in enumerate(config["provider"]):
|
||||
if provider.get("id", None) == origin_provider_id:
|
||||
config["provider"][idx] = new_config
|
||||
break
|
||||
else:
|
||||
raise ValueError(f"Provider ID {origin_provider_id} not found")
|
||||
config.save_config()
|
||||
# reload instance
|
||||
await self.reload(new_config)
|
||||
|
||||
async def create_provider(self, new_config: dict):
|
||||
"""Add new provider config and load the instance. Config will be saved after addition."""
|
||||
async with self.resource_lock:
|
||||
npid = new_config.get("id", None)
|
||||
if not npid:
|
||||
raise ValueError("New provider config must have an 'id' field")
|
||||
config = self.acm.default_conf
|
||||
for provider in config["provider"]:
|
||||
if provider.get("id", None) == npid:
|
||||
raise ValueError(f"Provider ID {npid} already exists")
|
||||
# add to config
|
||||
config["provider"].append(new_config)
|
||||
config.save_config()
|
||||
# load instance
|
||||
await self.load_provider(new_config)
|
||||
|
||||
async def terminate(self):
|
||||
for provider_inst in self.provider_insts:
|
||||
if hasattr(provider_inst, "terminate"):
|
||||
|
||||
@@ -4,7 +4,7 @@ import os
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import TypeAlias, Union
|
||||
|
||||
from astrbot.core.agent.message import Message
|
||||
from astrbot.core.agent.message import ContentPart, Message
|
||||
from astrbot.core.agent.tool import ToolSet
|
||||
from astrbot.core.provider.entities import (
|
||||
LLMResponse,
|
||||
@@ -103,6 +103,7 @@ class Provider(AbstractProvider):
|
||||
system_prompt: str | None = None,
|
||||
tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None,
|
||||
model: str | None = None,
|
||||
extra_user_content_parts: list[ContentPart] | None = None,
|
||||
**kwargs,
|
||||
) -> LLMResponse:
|
||||
"""获得 LLM 的文本对话结果。会使用当前的模型进行对话。
|
||||
@@ -114,6 +115,7 @@ class Provider(AbstractProvider):
|
||||
tools: tool set
|
||||
contexts: 上下文,和 prompt 二选一使用
|
||||
tool_calls_result: 回传给 LLM 的工具调用结果。参考: https://platform.openai.com/docs/guides/function-calling
|
||||
extra_user_content_parts: 额外的用户内容块列表,用于在用户消息后添加额外的文本块(如系统提醒、指令等)
|
||||
kwargs: 其他参数
|
||||
|
||||
Notes:
|
||||
@@ -133,6 +135,7 @@ class Provider(AbstractProvider):
|
||||
system_prompt: str | None = None,
|
||||
tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None,
|
||||
model: str | None = None,
|
||||
extra_user_content_parts: list[ContentPart] | None = None,
|
||||
**kwargs,
|
||||
) -> AsyncGenerator[LLMResponse, None]:
|
||||
"""获得 LLM 的流式文本对话结果。会使用当前的模型进行对话。在生成的最后会返回一次完整的结果。
|
||||
@@ -144,6 +147,7 @@ class Provider(AbstractProvider):
|
||||
tools: tool set
|
||||
contexts: 上下文,和 prompt 二选一使用
|
||||
tool_calls_result: 回传给 LLM 的工具调用结果。参考: https://platform.openai.com/docs/guides/function-calling
|
||||
extra_user_content_parts: 额外的用户内容块列表,用于在用户消息后添加额外的文本块(如系统提醒、指令等)
|
||||
kwargs: 其他参数
|
||||
|
||||
Notes:
|
||||
|
||||
@@ -6,10 +6,13 @@ from mimetypes import guess_type
|
||||
import anthropic
|
||||
from anthropic import AsyncAnthropic
|
||||
from anthropic.types import Message
|
||||
from anthropic.types.message_delta_usage import MessageDeltaUsage
|
||||
from anthropic.types.usage import Usage
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.api.provider import Provider
|
||||
from astrbot.core.provider.entities import LLMResponse
|
||||
from astrbot.core.agent.message import ContentPart
|
||||
from astrbot.core.provider.entities import LLMResponse, TokenUsage
|
||||
from astrbot.core.provider.func_tool_manager import ToolSet
|
||||
from astrbot.core.utils.io import download_image_by_url
|
||||
|
||||
@@ -45,7 +48,7 @@ class ProviderAnthropic(Provider):
|
||||
base_url=self.base_url,
|
||||
)
|
||||
|
||||
self.set_model(provider_config["model_config"]["model"])
|
||||
self.set_model(provider_config.get("model", "unknown"))
|
||||
|
||||
def _prepare_payload(self, messages: list[dict]):
|
||||
"""准备 Anthropic API 的请求 payload
|
||||
@@ -107,12 +110,32 @@ class ProviderAnthropic(Provider):
|
||||
|
||||
return system_prompt, new_messages
|
||||
|
||||
def _extract_usage(self, usage: Usage) -> TokenUsage:
|
||||
# https://docs.claude.com/en/docs/build-with-claude/prompt-caching#tracking-cache-performance
|
||||
return TokenUsage(
|
||||
input_other=usage.input_tokens or 0,
|
||||
input_cached=usage.cache_read_input_tokens or 0,
|
||||
output=usage.output_tokens,
|
||||
)
|
||||
|
||||
def _update_usage(self, token_usage: TokenUsage, usage: MessageDeltaUsage) -> None:
|
||||
if usage.input_tokens is not None:
|
||||
token_usage.input_other = usage.input_tokens
|
||||
if usage.cache_read_input_tokens is not None:
|
||||
token_usage.input_cached = usage.cache_read_input_tokens
|
||||
if usage.output_tokens is not None:
|
||||
token_usage.output = usage.output_tokens
|
||||
|
||||
async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse:
|
||||
if tools:
|
||||
if tool_list := tools.get_func_desc_anthropic_style():
|
||||
payloads["tools"] = tool_list
|
||||
|
||||
completion = await self.client.messages.create(**payloads, stream=False)
|
||||
extra_body = self.provider_config.get("custom_extra_body", {})
|
||||
|
||||
completion = await self.client.messages.create(
|
||||
**payloads, stream=False, extra_body=extra_body
|
||||
)
|
||||
|
||||
assert isinstance(completion, Message)
|
||||
logger.debug(f"completion: {completion}")
|
||||
@@ -131,6 +154,10 @@ class ProviderAnthropic(Provider):
|
||||
llm_response.tools_call_args.append(content_block.input)
|
||||
llm_response.tools_call_name.append(content_block.name)
|
||||
llm_response.tools_call_ids.append(content_block.id)
|
||||
|
||||
llm_response.id = completion.id
|
||||
llm_response.usage = self._extract_usage(completion.usage)
|
||||
|
||||
# TODO(Soulter): 处理 end_turn 情况
|
||||
if not llm_response.completion_text and not llm_response.tools_call_args:
|
||||
raise Exception(f"Anthropic API 返回的 completion 无法解析:{completion}。")
|
||||
@@ -151,10 +178,19 @@ class ProviderAnthropic(Provider):
|
||||
# 用于累积最终结果
|
||||
final_text = ""
|
||||
final_tool_calls = []
|
||||
id = None
|
||||
usage = TokenUsage()
|
||||
extra_body = self.provider_config.get("custom_extra_body", {})
|
||||
|
||||
async with self.client.messages.stream(**payloads) as stream:
|
||||
async with self.client.messages.stream(
|
||||
**payloads, extra_body=extra_body
|
||||
) as stream:
|
||||
assert isinstance(stream, anthropic.AsyncMessageStream)
|
||||
async for event in stream:
|
||||
if event.type == "message_start":
|
||||
# the usage contains input token usage
|
||||
id = event.message.id
|
||||
usage = self._extract_usage(event.message.usage)
|
||||
if event.type == "content_block_start":
|
||||
if event.content_block.type == "text":
|
||||
# 文本块开始
|
||||
@@ -162,6 +198,8 @@ class ProviderAnthropic(Provider):
|
||||
role="assistant",
|
||||
completion_text="",
|
||||
is_chunk=True,
|
||||
usage=usage,
|
||||
id=id,
|
||||
)
|
||||
elif event.content_block.type == "tool_use":
|
||||
# 工具使用块开始,初始化缓冲区
|
||||
@@ -179,6 +217,8 @@ class ProviderAnthropic(Provider):
|
||||
role="assistant",
|
||||
completion_text=event.delta.text,
|
||||
is_chunk=True,
|
||||
usage=usage,
|
||||
id=id,
|
||||
)
|
||||
elif event.delta.type == "input_json_delta":
|
||||
# 工具调用参数增量
|
||||
@@ -215,6 +255,8 @@ class ProviderAnthropic(Provider):
|
||||
tools_call_name=[tool_info["name"]],
|
||||
tools_call_ids=[tool_info["id"]],
|
||||
is_chunk=True,
|
||||
usage=usage,
|
||||
id=id,
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
# JSON 解析失败,跳过这个工具调用
|
||||
@@ -223,11 +265,17 @@ class ProviderAnthropic(Provider):
|
||||
# 清理缓冲区
|
||||
del tool_use_buffer[event.index]
|
||||
|
||||
elif event.type == "message_delta":
|
||||
if event.usage:
|
||||
self._update_usage(usage, event.usage)
|
||||
|
||||
# 返回最终的完整结果
|
||||
final_response = LLMResponse(
|
||||
role="assistant",
|
||||
completion_text=final_text,
|
||||
is_chunk=False,
|
||||
usage=usage,
|
||||
id=id,
|
||||
)
|
||||
|
||||
if final_tool_calls:
|
||||
@@ -249,13 +297,16 @@ class ProviderAnthropic(Provider):
|
||||
system_prompt=None,
|
||||
tool_calls_result=None,
|
||||
model=None,
|
||||
extra_user_content_parts=None,
|
||||
**kwargs,
|
||||
) -> LLMResponse:
|
||||
if contexts is None:
|
||||
contexts = []
|
||||
new_record = None
|
||||
if prompt is not None:
|
||||
new_record = await self.assemble_context(prompt, image_urls)
|
||||
new_record = await self.assemble_context(
|
||||
prompt, image_urls, extra_user_content_parts
|
||||
)
|
||||
context_query = self._ensure_message_to_dicts(contexts)
|
||||
if new_record:
|
||||
context_query.append(new_record)
|
||||
@@ -277,10 +328,9 @@ class ProviderAnthropic(Provider):
|
||||
|
||||
system_prompt, new_messages = self._prepare_payload(context_query)
|
||||
|
||||
model_config = self.provider_config.get("model_config", {})
|
||||
model_config["model"] = model or self.get_model()
|
||||
model = model or self.get_model()
|
||||
|
||||
payloads = {"messages": new_messages, **model_config}
|
||||
payloads = {"messages": new_messages, "model": model}
|
||||
|
||||
# Anthropic has a different way of handling system prompts
|
||||
if system_prompt:
|
||||
@@ -290,7 +340,6 @@ class ProviderAnthropic(Provider):
|
||||
try:
|
||||
llm_response = await self._query(payloads, func_tool)
|
||||
except Exception as e:
|
||||
# logger.error(f"发生了错误。Provider 配置如下: {model_config}")
|
||||
raise e
|
||||
|
||||
return llm_response
|
||||
@@ -305,13 +354,16 @@ class ProviderAnthropic(Provider):
|
||||
system_prompt=None,
|
||||
tool_calls_result=None,
|
||||
model=None,
|
||||
extra_user_content_parts=None,
|
||||
**kwargs,
|
||||
):
|
||||
if contexts is None:
|
||||
contexts = []
|
||||
new_record = None
|
||||
if prompt is not None:
|
||||
new_record = await self.assemble_context(prompt, image_urls)
|
||||
new_record = await self.assemble_context(
|
||||
prompt, image_urls, extra_user_content_parts
|
||||
)
|
||||
context_query = self._ensure_message_to_dicts(contexts)
|
||||
if new_record:
|
||||
context_query.append(new_record)
|
||||
@@ -332,10 +384,9 @@ class ProviderAnthropic(Provider):
|
||||
|
||||
system_prompt, new_messages = self._prepare_payload(context_query)
|
||||
|
||||
model_config = self.provider_config.get("model_config", {})
|
||||
model_config["model"] = model or self.get_model()
|
||||
model = model or self.get_model()
|
||||
|
||||
payloads = {"messages": new_messages, **model_config}
|
||||
payloads = {"messages": new_messages, "model": model}
|
||||
|
||||
# Anthropic has a different way of handling system prompts
|
||||
if system_prompt:
|
||||
@@ -344,48 +395,116 @@ class ProviderAnthropic(Provider):
|
||||
async for llm_response in self._query_stream(payloads, func_tool):
|
||||
yield llm_response
|
||||
|
||||
async def assemble_context(self, text: str, image_urls: list[str] | None = None):
|
||||
async def assemble_context(
|
||||
self,
|
||||
text: str,
|
||||
image_urls: list[str] | None = None,
|
||||
extra_user_content_parts: list[ContentPart] | None = None,
|
||||
):
|
||||
"""组装上下文,支持文本和图片"""
|
||||
if not image_urls:
|
||||
return {"role": "user", "content": text}
|
||||
|
||||
content = []
|
||||
content.append({"type": "text", "text": text})
|
||||
|
||||
for image_url in image_urls:
|
||||
if image_url.startswith("http"):
|
||||
image_path = await download_image_by_url(image_url)
|
||||
image_data = await self.encode_image_bs64(image_path)
|
||||
elif image_url.startswith("file:///"):
|
||||
image_path = image_url.replace("file:///", "")
|
||||
image_data = await self.encode_image_bs64(image_path)
|
||||
else:
|
||||
image_data = await self.encode_image_bs64(image_url)
|
||||
# 1. 用户原始发言(OpenAI 建议:用户发言在前)
|
||||
if text:
|
||||
content.append({"type": "text", "text": text})
|
||||
elif image_urls:
|
||||
# 如果没有文本但有图片,添加占位文本
|
||||
content.append({"type": "text", "text": "[图片]"})
|
||||
elif extra_user_content_parts:
|
||||
# 如果只有额外内容块,也需要添加占位文本
|
||||
content.append({"type": "text", "text": " "})
|
||||
|
||||
if not image_data:
|
||||
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
|
||||
continue
|
||||
# 2. 额外的内容块(系统提醒、指令等)
|
||||
if extra_user_content_parts:
|
||||
for block in extra_user_content_parts:
|
||||
block_type = block.get("type")
|
||||
|
||||
# Get mime type for the image
|
||||
mime_type, _ = guess_type(image_url)
|
||||
if not mime_type:
|
||||
mime_type = "image/jpeg" # Default to JPEG if can't determine
|
||||
if block_type == "text":
|
||||
# 文本直接添加
|
||||
content.append(block)
|
||||
|
||||
content.append(
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": mime_type,
|
||||
"data": (
|
||||
image_data.split("base64,")[1]
|
||||
if "base64," in image_data
|
||||
else image_data
|
||||
),
|
||||
elif block_type == "image_url":
|
||||
# 转换 OpenAI 格式的图片为 Anthropic 格式
|
||||
image_url_data = block.get("image_url", {})
|
||||
if isinstance(image_url_data, dict):
|
||||
url = image_url_data.get("url", "")
|
||||
else:
|
||||
# 兼容直接传 URL 字符串的情况
|
||||
url = str(image_url_data)
|
||||
|
||||
if url and url.startswith("data:"):
|
||||
try:
|
||||
# 提取 MIME 类型和 base64 数据
|
||||
mime_type = url.split(":")[1].split(";")[0]
|
||||
base64_data = (
|
||||
url.split("base64,")[1] if "base64," in url else url
|
||||
)
|
||||
content.append(
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": mime_type,
|
||||
"data": base64_data,
|
||||
},
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"转换 image_url 到 Anthropic 格式失败: {e}")
|
||||
else:
|
||||
logger.warning(f"image_url 不是有效的 data URI: {url[:50]}...")
|
||||
|
||||
else:
|
||||
# 其他类型(如 audio_url)Anthropic 不支持,记录警告
|
||||
logger.debug(f"Anthropic 不支持的内容类型 '{block_type}',已忽略")
|
||||
|
||||
# 3. 图片内容
|
||||
if image_urls:
|
||||
for image_url in image_urls:
|
||||
if image_url.startswith("http"):
|
||||
image_path = await download_image_by_url(image_url)
|
||||
image_data = await self.encode_image_bs64(image_path)
|
||||
elif image_url.startswith("file:///"):
|
||||
image_path = image_url.replace("file:///", "")
|
||||
image_data = await self.encode_image_bs64(image_path)
|
||||
else:
|
||||
image_data = await self.encode_image_bs64(image_url)
|
||||
|
||||
if not image_data:
|
||||
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
|
||||
continue
|
||||
|
||||
# Get mime type for the image
|
||||
mime_type, _ = guess_type(image_url)
|
||||
if not mime_type:
|
||||
mime_type = "image/jpeg" # Default to JPEG if can't determine
|
||||
|
||||
content.append(
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": mime_type,
|
||||
"data": (
|
||||
image_data.split("base64,")[1]
|
||||
if "base64," in image_data
|
||||
else image_data
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
# 如果只有主文本且没有额外内容块和图片,返回简单格式以保持向后兼容
|
||||
if (
|
||||
text
|
||||
and not extra_user_content_parts
|
||||
and not image_urls
|
||||
and len(content) == 1
|
||||
and content[0]["type"] == "text"
|
||||
):
|
||||
return {"role": "user", "content": content[0]["text"]}
|
||||
|
||||
# 否则返回多模态格式
|
||||
return {"role": "user", "content": content}
|
||||
|
||||
async def encode_image_bs64(self, image_url: str) -> str:
|
||||
|
||||
@@ -13,8 +13,9 @@ from google.genai.errors import APIError
|
||||
import astrbot.core.message.components as Comp
|
||||
from astrbot import logger
|
||||
from astrbot.api.provider import Provider
|
||||
from astrbot.core.agent.message import ContentPart
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.provider.entities import LLMResponse
|
||||
from astrbot.core.provider.entities import LLMResponse, TokenUsage
|
||||
from astrbot.core.provider.func_tool_manager import ToolSet
|
||||
from astrbot.core.utils.io import download_image_by_url
|
||||
|
||||
@@ -68,7 +69,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
self.api_base = self.api_base[:-1]
|
||||
|
||||
self._init_client()
|
||||
self.set_model(provider_config["model_config"]["model"])
|
||||
self.set_model(provider_config.get("model", "unknown"))
|
||||
self._init_safety_settings()
|
||||
|
||||
def _init_client(self) -> None:
|
||||
@@ -138,7 +139,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
modalities = ["TEXT"]
|
||||
|
||||
tool_list: list[types.Tool] | None = []
|
||||
model_name = self.get_model()
|
||||
model_name = cast(str, payloads.get("model", self.get_model()))
|
||||
native_coderunner = self.provider_config.get("gm_native_coderunner", False)
|
||||
native_search = self.provider_config.get("gm_native_search", False)
|
||||
url_context = self.provider_config.get("gm_url_context", False)
|
||||
@@ -197,6 +198,53 @@ class ProviderGoogleGenAI(Provider):
|
||||
types.Tool(function_declarations=func_desc["function_declarations"]),
|
||||
]
|
||||
|
||||
# oper thinking config
|
||||
thinking_config = None
|
||||
if model_name in [
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.5-pro-preview",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-flash-preview",
|
||||
"gemini-2.5-flash-lite",
|
||||
"gemini-2.5-flash-lite-preview",
|
||||
"gemini-robotics-er-1.5-preview",
|
||||
"gemini-live-2.5-flash-preview-native-audio-09-2025",
|
||||
]:
|
||||
# The thinkingBudget parameter, introduced with the Gemini 2.5 series
|
||||
thinking_budget = self.provider_config.get("gm_thinking_config", {}).get(
|
||||
"budget", 0
|
||||
)
|
||||
if thinking_budget is not None:
|
||||
thinking_config = types.ThinkingConfig(
|
||||
thinking_budget=thinking_budget,
|
||||
)
|
||||
elif model_name in [
|
||||
"gemini-3-pro",
|
||||
"gemini-3-pro-preview",
|
||||
"gemini-3-flash",
|
||||
"gemini-3-flash-preview",
|
||||
"gemini-3-flash-lite",
|
||||
"gemini-3-flash-lite-preview",
|
||||
]:
|
||||
# The thinkingLevel parameter, recommended for Gemini 3 models and onwards
|
||||
# Gemini 2.5 series models don't support thinkingLevel; use thinkingBudget instead.
|
||||
thinking_level = self.provider_config.get("gm_thinking_config", {}).get(
|
||||
"level", "HIGH"
|
||||
)
|
||||
if thinking_level and isinstance(thinking_level, str):
|
||||
thinking_level = thinking_level.upper()
|
||||
if thinking_level not in ["MINIMAL", "LOW", "MEDIUM", "HIGH"]:
|
||||
logger.warning(
|
||||
f"Invalid thinking level: {thinking_level}, using HIGH"
|
||||
)
|
||||
thinking_level = "HIGH"
|
||||
level = types.ThinkingLevel(thinking_level)
|
||||
thinking_config = types.ThinkingConfig()
|
||||
if not hasattr(types.ThinkingConfig, "thinking_level"):
|
||||
setattr(types.ThinkingConfig, "thinking_level", level)
|
||||
else:
|
||||
thinking_config.thinking_level = level
|
||||
|
||||
return types.GenerateContentConfig(
|
||||
system_instruction=system_instruction,
|
||||
temperature=temperature,
|
||||
@@ -216,22 +264,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
response_modalities=modalities,
|
||||
tools=cast(types.ToolListUnion | None, tool_list),
|
||||
safety_settings=self.safety_settings if self.safety_settings else None,
|
||||
thinking_config=(
|
||||
types.ThinkingConfig(
|
||||
thinking_budget=min(
|
||||
int(
|
||||
self.provider_config.get("gm_thinking_config", {}).get(
|
||||
"budget",
|
||||
0,
|
||||
),
|
||||
),
|
||||
24576,
|
||||
),
|
||||
)
|
||||
if "gemini-2.5-flash" in self.get_model()
|
||||
and hasattr(types.ThinkingConfig, "thinking_budget")
|
||||
else None
|
||||
),
|
||||
thinking_config=thinking_config,
|
||||
automatic_function_calling=types.AutomaticFunctionCallingConfig(
|
||||
disable=True,
|
||||
),
|
||||
@@ -347,6 +380,16 @@ class ProviderGoogleGenAI(Provider):
|
||||
]
|
||||
return "".join(thought_buf).strip()
|
||||
|
||||
def _extract_usage(
|
||||
self, usage_metadata: types.GenerateContentResponseUsageMetadata
|
||||
) -> TokenUsage:
|
||||
"""Extract usage from candidate"""
|
||||
return TokenUsage(
|
||||
input_other=usage_metadata.prompt_token_count or 0,
|
||||
input_cached=usage_metadata.cached_content_token_count or 0,
|
||||
output=usage_metadata.candidates_token_count or 0,
|
||||
)
|
||||
|
||||
def _process_content_parts(
|
||||
self,
|
||||
candidate: types.Candidate,
|
||||
@@ -431,6 +474,8 @@ class ProviderGoogleGenAI(Provider):
|
||||
None,
|
||||
)
|
||||
|
||||
model = payloads.get("model", self.get_model())
|
||||
|
||||
modalities = ["TEXT"]
|
||||
if self.provider_config.get("gm_resp_image_modal", False):
|
||||
modalities.append("IMAGE")
|
||||
@@ -449,7 +494,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
temperature,
|
||||
)
|
||||
result = await self.client.models.generate_content(
|
||||
model=self.get_model(),
|
||||
model=model,
|
||||
contents=cast(types.ContentListUnion, conversation),
|
||||
config=config,
|
||||
)
|
||||
@@ -475,11 +520,11 @@ class ProviderGoogleGenAI(Provider):
|
||||
e.message = ""
|
||||
if "Developer instruction is not enabled" in e.message:
|
||||
logger.warning(
|
||||
f"{self.get_model()} 不支持 system prompt,已自动去除(影响人格设置)",
|
||||
f"{model} 不支持 system prompt,已自动去除(影响人格设置)",
|
||||
)
|
||||
system_instruction = None
|
||||
elif "Function calling is not enabled" in e.message:
|
||||
logger.warning(f"{self.get_model()} 不支持函数调用,已自动去除")
|
||||
logger.warning(f"{model} 不支持函数调用,已自动去除")
|
||||
tools = None
|
||||
elif (
|
||||
"Multi-modal output is not supported" in e.message
|
||||
@@ -488,7 +533,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
or "only supports text output" in e.message
|
||||
):
|
||||
logger.warning(
|
||||
f"{self.get_model()} 不支持多模态输出,降级为文本模态",
|
||||
f"{model} 不支持多模态输出,降级为文本模态",
|
||||
)
|
||||
modalities = ["TEXT"]
|
||||
else:
|
||||
@@ -501,6 +546,9 @@ class ProviderGoogleGenAI(Provider):
|
||||
result.candidates[0],
|
||||
llm_response,
|
||||
)
|
||||
llm_response.id = result.response_id
|
||||
if result.usage_metadata:
|
||||
llm_response.usage = self._extract_usage(result.usage_metadata)
|
||||
return llm_response
|
||||
|
||||
async def _query_stream(
|
||||
@@ -513,7 +561,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
(msg["content"] for msg in payloads["messages"] if msg["role"] == "system"),
|
||||
None,
|
||||
)
|
||||
|
||||
model = payloads.get("model", self.get_model())
|
||||
conversation = self._prepare_conversation(payloads)
|
||||
|
||||
result = None
|
||||
@@ -525,7 +573,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
system_instruction,
|
||||
)
|
||||
result = await self.client.models.generate_content_stream(
|
||||
model=self.get_model(),
|
||||
model=model,
|
||||
contents=cast(types.ContentListUnion, conversation),
|
||||
config=config,
|
||||
)
|
||||
@@ -535,11 +583,11 @@ class ProviderGoogleGenAI(Provider):
|
||||
e.message = ""
|
||||
if "Developer instruction is not enabled" in e.message:
|
||||
logger.warning(
|
||||
f"{self.get_model()} 不支持 system prompt,已自动去除(影响人格设置)",
|
||||
f"{model} 不支持 system prompt,已自动去除(影响人格设置)",
|
||||
)
|
||||
system_instruction = None
|
||||
elif "Function calling is not enabled" in e.message:
|
||||
logger.warning(f"{self.get_model()} 不支持函数调用,已自动去除")
|
||||
logger.warning(f"{model} 不支持函数调用,已自动去除")
|
||||
tools = None
|
||||
else:
|
||||
raise
|
||||
@@ -569,6 +617,9 @@ class ProviderGoogleGenAI(Provider):
|
||||
chunk.candidates[0],
|
||||
llm_response,
|
||||
)
|
||||
llm_response.id = chunk.response_id
|
||||
if chunk.usage_metadata:
|
||||
llm_response.usage = self._extract_usage(chunk.usage_metadata)
|
||||
yield llm_response
|
||||
return
|
||||
|
||||
@@ -596,6 +647,9 @@ class ProviderGoogleGenAI(Provider):
|
||||
chunk.candidates[0],
|
||||
final_response,
|
||||
)
|
||||
final_response.id = chunk.response_id
|
||||
if chunk.usage_metadata:
|
||||
final_response.usage = self._extract_usage(chunk.usage_metadata)
|
||||
break
|
||||
|
||||
# Yield final complete response with accumulated text
|
||||
@@ -627,13 +681,16 @@ class ProviderGoogleGenAI(Provider):
|
||||
system_prompt=None,
|
||||
tool_calls_result=None,
|
||||
model=None,
|
||||
extra_user_content_parts=None,
|
||||
**kwargs,
|
||||
) -> LLMResponse:
|
||||
if contexts is None:
|
||||
contexts = []
|
||||
new_record = None
|
||||
if prompt is not None:
|
||||
new_record = await self.assemble_context(prompt, image_urls)
|
||||
new_record = await self.assemble_context(
|
||||
prompt, image_urls, extra_user_content_parts
|
||||
)
|
||||
context_query = self._ensure_message_to_dicts(contexts)
|
||||
if new_record:
|
||||
context_query.append(new_record)
|
||||
@@ -652,10 +709,9 @@ class ProviderGoogleGenAI(Provider):
|
||||
for tcr in tool_calls_result:
|
||||
context_query.extend(tcr.to_openai_messages())
|
||||
|
||||
model_config = self.provider_config.get("model_config", {})
|
||||
model_config["model"] = model or self.get_model()
|
||||
model = model or self.get_model()
|
||||
|
||||
payloads = {"messages": context_query, **model_config}
|
||||
payloads = {"messages": context_query, "model": model}
|
||||
|
||||
retry = 10
|
||||
keys = self.api_keys.copy()
|
||||
@@ -680,13 +736,16 @@ class ProviderGoogleGenAI(Provider):
|
||||
system_prompt=None,
|
||||
tool_calls_result=None,
|
||||
model=None,
|
||||
extra_user_content_parts=None,
|
||||
**kwargs,
|
||||
) -> AsyncGenerator[LLMResponse, None]:
|
||||
if contexts is None:
|
||||
contexts = []
|
||||
new_record = None
|
||||
if prompt is not None:
|
||||
new_record = await self.assemble_context(prompt, image_urls)
|
||||
new_record = await self.assemble_context(
|
||||
prompt, image_urls, extra_user_content_parts
|
||||
)
|
||||
context_query = self._ensure_message_to_dicts(contexts)
|
||||
if new_record:
|
||||
context_query.append(new_record)
|
||||
@@ -705,10 +764,9 @@ class ProviderGoogleGenAI(Provider):
|
||||
for tcr in tool_calls_result:
|
||||
context_query.extend(tcr.to_openai_messages())
|
||||
|
||||
model_config = self.provider_config.get("model_config", {})
|
||||
model_config["model"] = model or self.get_model()
|
||||
model = model or self.get_model()
|
||||
|
||||
payloads = {"messages": context_query, **model_config}
|
||||
payloads = {"messages": context_query, "model": model}
|
||||
|
||||
retry = 10
|
||||
keys = self.api_keys.copy()
|
||||
@@ -746,13 +804,33 @@ class ProviderGoogleGenAI(Provider):
|
||||
self.chosen_api_key = key
|
||||
self._init_client()
|
||||
|
||||
async def assemble_context(self, text: str, image_urls: list[str] | None = None):
|
||||
async def assemble_context(
|
||||
self,
|
||||
text: str,
|
||||
image_urls: list[str] | None = None,
|
||||
extra_user_content_parts: list[ContentPart] | None = None,
|
||||
):
|
||||
"""组装上下文。"""
|
||||
# 构建内容块列表
|
||||
content_blocks = []
|
||||
|
||||
# 1. 用户原始发言(OpenAI 建议:用户发言在前)
|
||||
if text:
|
||||
content_blocks.append({"type": "text", "text": text})
|
||||
elif image_urls:
|
||||
# 如果没有文本但有图片,添加占位文本
|
||||
content_blocks.append({"type": "text", "text": "[图片]"})
|
||||
elif extra_user_content_parts:
|
||||
# 如果只有额外内容块,也需要添加占位文本
|
||||
content_blocks.append({"type": "text", "text": " "})
|
||||
|
||||
# 2. 额外的内容块(系统提醒、指令等)
|
||||
if extra_user_content_parts:
|
||||
for part in extra_user_content_parts:
|
||||
content_blocks.append(part.model_dump())
|
||||
|
||||
# 3. 图片内容
|
||||
if image_urls:
|
||||
user_content = {
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": text if text else "[图片]"}],
|
||||
}
|
||||
for image_url in image_urls:
|
||||
if image_url.startswith("http"):
|
||||
image_path = await download_image_by_url(image_url)
|
||||
@@ -765,14 +843,25 @@ class ProviderGoogleGenAI(Provider):
|
||||
if not image_data:
|
||||
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
|
||||
continue
|
||||
user_content["content"].append(
|
||||
content_blocks.append(
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": image_data},
|
||||
},
|
||||
)
|
||||
return user_content
|
||||
return {"role": "user", "content": text}
|
||||
|
||||
# 如果只有主文本且没有额外内容块和图片,返回简单格式以保持向后兼容
|
||||
if (
|
||||
text
|
||||
and not extra_user_content_parts
|
||||
and not image_urls
|
||||
and len(content_blocks) == 1
|
||||
and content_blocks[0]["type"] == "text"
|
||||
):
|
||||
return {"role": "user", "content": content_blocks[0]["text"]}
|
||||
|
||||
# 否则返回多模态格式
|
||||
return {"role": "user", "content": content_blocks}
|
||||
|
||||
async def encode_image_bs64(self, image_url: str) -> str:
|
||||
"""将图片转换为 base64"""
|
||||
|
||||
@@ -12,14 +12,15 @@ from openai._exceptions import NotFoundError
|
||||
from openai.lib.streaming.chat._completions import ChatCompletionStreamState
|
||||
from openai.types.chat.chat_completion import ChatCompletion
|
||||
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
|
||||
from openai.types.completion_usage import CompletionUsage
|
||||
|
||||
import astrbot.core.message.components as Comp
|
||||
from astrbot import logger
|
||||
from astrbot.api.provider import Provider
|
||||
from astrbot.core.agent.message import Message
|
||||
from astrbot.core.agent.message import ContentPart, Message
|
||||
from astrbot.core.agent.tool import ToolSet
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.provider.entities import LLMResponse, ToolCallsResult
|
||||
from astrbot.core.provider.entities import LLMResponse, TokenUsage, ToolCallsResult
|
||||
from astrbot.core.utils.io import download_image_by_url
|
||||
|
||||
from ..register import register_provider_adapter
|
||||
@@ -68,8 +69,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
self.client.chat.completions.create,
|
||||
).parameters.keys()
|
||||
|
||||
model_config = provider_config.get("model_config", {})
|
||||
model = model_config.get("model", "unknown")
|
||||
model = provider_config.get("model", "unknown")
|
||||
self.set_model(model)
|
||||
|
||||
self.reasoning_key = "reasoning_content"
|
||||
@@ -208,6 +208,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
# handle the content delta
|
||||
reasoning = self._extract_reasoning_content(chunk)
|
||||
_y = False
|
||||
llm_response.id = chunk.id
|
||||
if reasoning:
|
||||
llm_response.reasoning_content = reasoning
|
||||
_y = True
|
||||
@@ -217,6 +218,8 @@ class ProviderOpenAIOfficial(Provider):
|
||||
chain=[Comp.Plain(completion_text)],
|
||||
)
|
||||
_y = True
|
||||
if chunk.usage:
|
||||
llm_response.usage = self._extract_usage(chunk.usage)
|
||||
if _y:
|
||||
yield llm_response
|
||||
|
||||
@@ -245,6 +248,15 @@ class ProviderOpenAIOfficial(Provider):
|
||||
reasoning_text = str(reasoning_attr)
|
||||
return reasoning_text
|
||||
|
||||
def _extract_usage(self, usage: CompletionUsage) -> TokenUsage:
|
||||
ptd = usage.prompt_tokens_details
|
||||
cached = ptd.cached_tokens if ptd and ptd.cached_tokens else 0
|
||||
return TokenUsage(
|
||||
input_other=usage.prompt_tokens - cached,
|
||||
input_cached=ptd.cached_tokens if ptd and ptd.cached_tokens else 0,
|
||||
output=usage.completion_tokens,
|
||||
)
|
||||
|
||||
async def _parse_openai_completion(
|
||||
self, completion: ChatCompletion, tools: ToolSet | None
|
||||
) -> LLMResponse:
|
||||
@@ -321,6 +333,10 @@ class ProviderOpenAIOfficial(Provider):
|
||||
raise Exception(f"API 返回的 completion 无法解析:{completion}。")
|
||||
|
||||
llm_response.raw_completion = completion
|
||||
llm_response.id = completion.id
|
||||
|
||||
if completion.usage:
|
||||
llm_response.usage = self._extract_usage(completion.usage)
|
||||
|
||||
return llm_response
|
||||
|
||||
@@ -332,6 +348,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
system_prompt: str | None = None,
|
||||
tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None,
|
||||
model: str | None = None,
|
||||
extra_user_content_parts: list[ContentPart] | None = None,
|
||||
**kwargs,
|
||||
) -> tuple:
|
||||
"""准备聊天所需的有效载荷和上下文"""
|
||||
@@ -339,7 +356,9 @@ class ProviderOpenAIOfficial(Provider):
|
||||
contexts = []
|
||||
new_record = None
|
||||
if prompt is not None:
|
||||
new_record = await self.assemble_context(prompt, image_urls)
|
||||
new_record = await self.assemble_context(
|
||||
prompt, image_urls, extra_user_content_parts
|
||||
)
|
||||
context_query = self._ensure_message_to_dicts(contexts)
|
||||
if new_record:
|
||||
context_query.append(new_record)
|
||||
@@ -358,10 +377,9 @@ class ProviderOpenAIOfficial(Provider):
|
||||
for tcr in tool_calls_result:
|
||||
context_query.extend(tcr.to_openai_messages())
|
||||
|
||||
model_config = self.provider_config.get("model_config", {})
|
||||
model_config["model"] = model or self.get_model()
|
||||
model = model or self.get_model()
|
||||
|
||||
payloads = {"messages": context_query, **model_config}
|
||||
payloads = {"messages": context_query, "model": model}
|
||||
|
||||
# xAI origin search tool inject
|
||||
self._maybe_inject_xai_search(payloads, **kwargs)
|
||||
@@ -461,6 +479,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
system_prompt=None,
|
||||
tool_calls_result=None,
|
||||
model=None,
|
||||
extra_user_content_parts=None,
|
||||
**kwargs,
|
||||
) -> LLMResponse:
|
||||
payloads, context_query = await self._prepare_chat_payload(
|
||||
@@ -470,6 +489,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
system_prompt,
|
||||
tool_calls_result,
|
||||
model=model,
|
||||
extra_user_content_parts=extra_user_content_parts,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -524,6 +544,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
system_prompt=None,
|
||||
tool_calls_result=None,
|
||||
model=None,
|
||||
extra_user_content_parts=None,
|
||||
**kwargs,
|
||||
) -> AsyncGenerator[LLMResponse, None]:
|
||||
"""流式对话,与服务商交互并逐步返回结果"""
|
||||
@@ -534,6 +555,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
system_prompt,
|
||||
tool_calls_result,
|
||||
model=model,
|
||||
extra_user_content_parts=extra_user_content_parts,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -609,13 +631,29 @@ class ProviderOpenAIOfficial(Provider):
|
||||
self,
|
||||
text: str,
|
||||
image_urls: list[str] | None = None,
|
||||
extra_user_content_parts: list[ContentPart] | None = None,
|
||||
) -> dict:
|
||||
"""组装成符合 OpenAI 格式的 role 为 user 的消息段"""
|
||||
# 构建内容块列表
|
||||
content_blocks = []
|
||||
|
||||
# 1. 用户原始发言(OpenAI 建议:用户发言在前)
|
||||
if text:
|
||||
content_blocks.append({"type": "text", "text": text})
|
||||
elif image_urls:
|
||||
# 如果没有文本但有图片,添加占位文本
|
||||
content_blocks.append({"type": "text", "text": "[图片]"})
|
||||
elif extra_user_content_parts:
|
||||
# 如果只有额外内容块,也需要添加占位文本
|
||||
content_blocks.append({"type": "text", "text": " "})
|
||||
|
||||
# 2. 额外的内容块(系统提醒、指令等)
|
||||
if extra_user_content_parts:
|
||||
for part in extra_user_content_parts:
|
||||
content_blocks.append(part.model_dump())
|
||||
|
||||
# 3. 图片内容
|
||||
if image_urls:
|
||||
user_content = {
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": text if text else "[图片]"}],
|
||||
}
|
||||
for image_url in image_urls:
|
||||
if image_url.startswith("http"):
|
||||
image_path = await download_image_by_url(image_url)
|
||||
@@ -628,14 +666,25 @@ class ProviderOpenAIOfficial(Provider):
|
||||
if not image_data:
|
||||
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
|
||||
continue
|
||||
user_content["content"].append(
|
||||
content_blocks.append(
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": image_data},
|
||||
},
|
||||
)
|
||||
return user_content
|
||||
return {"role": "user", "content": text}
|
||||
|
||||
# 如果只有主文本且没有额外内容块和图片,返回简单格式以保持向后兼容
|
||||
if (
|
||||
text
|
||||
and not extra_user_content_parts
|
||||
and not image_urls
|
||||
and len(content_blocks) == 1
|
||||
and content_blocks[0]["type"] == "text"
|
||||
):
|
||||
return {"role": "user", "content": content_blocks[0]["text"]}
|
||||
|
||||
# 否则返回多模态格式
|
||||
return {"role": "user", "content": content_blocks}
|
||||
|
||||
async def encode_image_bs64(self, image_url: str) -> str:
|
||||
"""将图片转换为 base64"""
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from astrbot.core import db_helper, logger
|
||||
from astrbot.core.db.po import CommandConfig
|
||||
from astrbot.core.star.filter.command import CommandFilter
|
||||
from astrbot.core.star.filter.command_group import CommandGroupFilter
|
||||
from astrbot.core.star.filter.permission import PermissionType, PermissionTypeFilter
|
||||
from astrbot.core.star.star import star_map
|
||||
from astrbot.core.star.star_handler import StarHandlerMetadata, star_handlers_registry
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandDescriptor:
|
||||
handler: StarHandlerMetadata = field(repr=False)
|
||||
filter_ref: CommandFilter | CommandGroupFilter | None = field(
|
||||
default=None,
|
||||
repr=False,
|
||||
)
|
||||
handler_full_name: str = ""
|
||||
handler_name: str = ""
|
||||
plugin_name: str = ""
|
||||
plugin_display_name: str | None = None
|
||||
module_path: str = ""
|
||||
description: str = ""
|
||||
command_type: str = "command" # "command" | "group" | "sub_command"
|
||||
raw_command_name: str | None = None
|
||||
current_fragment: str | None = None
|
||||
parent_signature: str = ""
|
||||
parent_group_handler: str = ""
|
||||
original_command: str | None = None
|
||||
effective_command: str | None = None
|
||||
aliases: list[str] = field(default_factory=list)
|
||||
permission: str = "everyone"
|
||||
enabled: bool = True
|
||||
is_group: bool = False
|
||||
is_sub_command: bool = False
|
||||
reserved: bool = False
|
||||
config: CommandConfig | None = None
|
||||
has_conflict: bool = False
|
||||
sub_commands: list[CommandDescriptor] = field(default_factory=list)
|
||||
|
||||
|
||||
async def sync_command_configs() -> None:
|
||||
"""同步指令配置,清理过期配置。"""
|
||||
descriptors = _collect_descriptors(include_sub_commands=False)
|
||||
config_records = await db_helper.get_command_configs()
|
||||
config_map = _bind_configs_to_descriptors(descriptors, config_records)
|
||||
live_handlers = {desc.handler_full_name for desc in descriptors}
|
||||
|
||||
stale_configs = [key for key in config_map if key not in live_handlers]
|
||||
if stale_configs:
|
||||
await db_helper.delete_command_configs(stale_configs)
|
||||
|
||||
|
||||
async def toggle_command(handler_full_name: str, enabled: bool) -> CommandDescriptor:
|
||||
descriptor = _build_descriptor_by_full_name(handler_full_name)
|
||||
if not descriptor:
|
||||
raise ValueError("指定的处理函数不存在或不是指令。")
|
||||
|
||||
existing_cfg = await db_helper.get_command_config(handler_full_name)
|
||||
config = await db_helper.upsert_command_config(
|
||||
handler_full_name=handler_full_name,
|
||||
plugin_name=descriptor.plugin_name or "",
|
||||
module_path=descriptor.module_path,
|
||||
original_command=descriptor.original_command or descriptor.handler_name,
|
||||
resolved_command=(
|
||||
existing_cfg.resolved_command
|
||||
if existing_cfg
|
||||
else descriptor.current_fragment
|
||||
),
|
||||
enabled=enabled,
|
||||
keep_original_alias=False,
|
||||
conflict_key=existing_cfg.conflict_key
|
||||
if existing_cfg and existing_cfg.conflict_key
|
||||
else descriptor.original_command,
|
||||
resolution_strategy=existing_cfg.resolution_strategy if existing_cfg else None,
|
||||
note=existing_cfg.note if existing_cfg else None,
|
||||
extra_data=existing_cfg.extra_data if existing_cfg else None,
|
||||
auto_managed=False,
|
||||
)
|
||||
_bind_descriptor_with_config(descriptor, config)
|
||||
await sync_command_configs()
|
||||
return descriptor
|
||||
|
||||
|
||||
async def rename_command(
|
||||
handler_full_name: str,
|
||||
new_fragment: str,
|
||||
aliases: list[str] | None = None,
|
||||
) -> CommandDescriptor:
|
||||
descriptor = _build_descriptor_by_full_name(handler_full_name)
|
||||
if not descriptor:
|
||||
raise ValueError("指定的处理函数不存在或不是指令。")
|
||||
|
||||
new_fragment = new_fragment.strip()
|
||||
if not new_fragment:
|
||||
raise ValueError("指令名不能为空。")
|
||||
|
||||
# 校验主指令名
|
||||
candidate_full = _compose_command(descriptor.parent_signature, new_fragment)
|
||||
if _is_command_in_use(handler_full_name, candidate_full):
|
||||
raise ValueError(f"指令名 '{candidate_full}' 已被其他指令占用。")
|
||||
|
||||
# 校验别名
|
||||
if aliases:
|
||||
for alias in aliases:
|
||||
alias = alias.strip()
|
||||
if not alias:
|
||||
continue
|
||||
alias_full = _compose_command(descriptor.parent_signature, alias)
|
||||
if _is_command_in_use(handler_full_name, alias_full):
|
||||
raise ValueError(f"别名 '{alias_full}' 已被其他指令占用。")
|
||||
|
||||
existing_cfg = await db_helper.get_command_config(handler_full_name)
|
||||
merged_extra = dict(existing_cfg.extra_data or {}) if existing_cfg else {}
|
||||
merged_extra["resolved_aliases"] = aliases or []
|
||||
|
||||
config = await db_helper.upsert_command_config(
|
||||
handler_full_name=handler_full_name,
|
||||
plugin_name=descriptor.plugin_name or "",
|
||||
module_path=descriptor.module_path,
|
||||
original_command=descriptor.original_command or descriptor.handler_name,
|
||||
resolved_command=new_fragment,
|
||||
enabled=True if descriptor.enabled else False,
|
||||
keep_original_alias=False,
|
||||
conflict_key=descriptor.original_command,
|
||||
resolution_strategy="manual_rename",
|
||||
note=None,
|
||||
extra_data=merged_extra,
|
||||
auto_managed=False,
|
||||
)
|
||||
_bind_descriptor_with_config(descriptor, config)
|
||||
|
||||
await sync_command_configs()
|
||||
return descriptor
|
||||
|
||||
|
||||
async def list_commands() -> list[dict[str, Any]]:
|
||||
descriptors = _collect_descriptors(include_sub_commands=True)
|
||||
config_records = await db_helper.get_command_configs()
|
||||
_bind_configs_to_descriptors(descriptors, config_records)
|
||||
|
||||
conflict_groups = _group_conflicts(descriptors)
|
||||
conflict_handler_names: set[str] = {
|
||||
d.handler_full_name for group in conflict_groups.values() for d in group
|
||||
}
|
||||
|
||||
# 分类,设置冲突标志,将子指令挂载到父指令组
|
||||
group_map: dict[str, CommandDescriptor] = {}
|
||||
sub_commands: list[CommandDescriptor] = []
|
||||
root_commands: list[CommandDescriptor] = []
|
||||
|
||||
for desc in descriptors:
|
||||
desc.has_conflict = desc.handler_full_name in conflict_handler_names
|
||||
if desc.is_group:
|
||||
group_map[desc.handler_full_name] = desc
|
||||
elif desc.is_sub_command:
|
||||
sub_commands.append(desc)
|
||||
else:
|
||||
root_commands.append(desc)
|
||||
|
||||
for sub in sub_commands:
|
||||
if sub.parent_group_handler and sub.parent_group_handler in group_map:
|
||||
group_map[sub.parent_group_handler].sub_commands.append(sub)
|
||||
else:
|
||||
root_commands.append(sub)
|
||||
|
||||
# 指令组 + 普通指令,按 effective_command 字母排序
|
||||
all_commands = list(group_map.values()) + root_commands
|
||||
all_commands.sort(key=lambda d: (d.effective_command or "").lower())
|
||||
|
||||
result = [_descriptor_to_dict(desc) for desc in all_commands]
|
||||
return result
|
||||
|
||||
|
||||
async def list_command_conflicts() -> list[dict[str, Any]]:
|
||||
"""列出所有冲突的指令组。"""
|
||||
descriptors = _collect_descriptors(include_sub_commands=False)
|
||||
config_records = await db_helper.get_command_configs()
|
||||
_bind_configs_to_descriptors(descriptors, config_records)
|
||||
|
||||
conflict_groups = _group_conflicts(descriptors)
|
||||
details = [
|
||||
{
|
||||
"conflict_key": key,
|
||||
"handlers": [
|
||||
{
|
||||
"handler_full_name": item.handler_full_name,
|
||||
"plugin": item.plugin_name,
|
||||
"current_name": item.effective_command,
|
||||
}
|
||||
for item in group
|
||||
],
|
||||
}
|
||||
for key, group in conflict_groups.items()
|
||||
]
|
||||
return details
|
||||
|
||||
|
||||
# Internal helpers ----------------------------------------------------------
|
||||
|
||||
|
||||
def _collect_descriptors(include_sub_commands: bool) -> list[CommandDescriptor]:
|
||||
"""收集指令,按需包含子指令。"""
|
||||
descriptors: list[CommandDescriptor] = []
|
||||
for handler in star_handlers_registry:
|
||||
try:
|
||||
desc = _build_descriptor(handler)
|
||||
if not desc:
|
||||
continue
|
||||
if not include_sub_commands and desc.is_sub_command:
|
||||
continue
|
||||
descriptors.append(desc)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"解析指令处理函数 {handler.handler_full_name} 失败,跳过该指令。原因: {e!s}"
|
||||
)
|
||||
continue
|
||||
return descriptors
|
||||
|
||||
|
||||
def _build_descriptor(handler: StarHandlerMetadata) -> CommandDescriptor | None:
|
||||
filter_ref = _locate_primary_filter(handler)
|
||||
if filter_ref is None:
|
||||
return None
|
||||
|
||||
plugin_meta = star_map.get(handler.handler_module_path)
|
||||
plugin_name = (
|
||||
plugin_meta.name if plugin_meta else None
|
||||
) or handler.handler_module_path
|
||||
plugin_display = plugin_meta.display_name if plugin_meta else None
|
||||
|
||||
is_sub_command = bool(handler.extras_configs.get("sub_command"))
|
||||
parent_group_handler = ""
|
||||
|
||||
if isinstance(filter_ref, CommandFilter):
|
||||
raw_fragment = getattr(
|
||||
filter_ref, "_original_command_name", filter_ref.command_name
|
||||
)
|
||||
current_fragment = filter_ref.command_name
|
||||
parent_signature = (filter_ref.parent_command_names or [""])[0].strip()
|
||||
# 如果是子指令,尝试找到父指令组的 handler_full_name
|
||||
if is_sub_command and parent_signature:
|
||||
parent_group_handler = _find_parent_group_handler(
|
||||
handler.handler_module_path, parent_signature
|
||||
)
|
||||
else:
|
||||
raw_fragment = getattr(
|
||||
filter_ref, "_original_group_name", filter_ref.group_name
|
||||
)
|
||||
current_fragment = filter_ref.group_name
|
||||
parent_signature = _resolve_group_parent_signature(filter_ref)
|
||||
|
||||
original_command = _compose_command(parent_signature, raw_fragment)
|
||||
effective_command = _compose_command(parent_signature, current_fragment)
|
||||
|
||||
# 确定 command_type
|
||||
if isinstance(filter_ref, CommandGroupFilter):
|
||||
command_type = "group"
|
||||
elif is_sub_command:
|
||||
command_type = "sub_command"
|
||||
else:
|
||||
command_type = "command"
|
||||
|
||||
descriptor = CommandDescriptor(
|
||||
handler=handler,
|
||||
filter_ref=filter_ref,
|
||||
handler_full_name=handler.handler_full_name,
|
||||
handler_name=handler.handler_name,
|
||||
plugin_name=plugin_name,
|
||||
plugin_display_name=plugin_display,
|
||||
module_path=handler.handler_module_path,
|
||||
description=handler.desc or "",
|
||||
command_type=command_type,
|
||||
raw_command_name=raw_fragment,
|
||||
current_fragment=current_fragment,
|
||||
parent_signature=parent_signature,
|
||||
parent_group_handler=parent_group_handler,
|
||||
original_command=original_command,
|
||||
effective_command=effective_command,
|
||||
aliases=sorted(getattr(filter_ref, "alias", set())),
|
||||
permission=_determine_permission(handler),
|
||||
enabled=handler.enabled,
|
||||
is_group=isinstance(filter_ref, CommandGroupFilter),
|
||||
is_sub_command=is_sub_command,
|
||||
reserved=plugin_meta.reserved if plugin_meta else False,
|
||||
)
|
||||
return descriptor
|
||||
|
||||
|
||||
def _build_descriptor_by_full_name(full_name: str) -> CommandDescriptor | None:
|
||||
handler = star_handlers_registry.get_handler_by_full_name(full_name)
|
||||
if not handler:
|
||||
return None
|
||||
return _build_descriptor(handler)
|
||||
|
||||
|
||||
def _locate_primary_filter(
|
||||
handler: StarHandlerMetadata,
|
||||
) -> CommandFilter | CommandGroupFilter | None:
|
||||
for filter_ref in handler.event_filters:
|
||||
if isinstance(filter_ref, (CommandFilter, CommandGroupFilter)):
|
||||
return filter_ref
|
||||
return None
|
||||
|
||||
|
||||
def _determine_permission(handler: StarHandlerMetadata) -> str:
|
||||
for filter_ref in handler.event_filters:
|
||||
if isinstance(filter_ref, PermissionTypeFilter):
|
||||
return (
|
||||
"admin"
|
||||
if filter_ref.permission_type == PermissionType.ADMIN
|
||||
else "member"
|
||||
)
|
||||
return "everyone"
|
||||
|
||||
|
||||
def _resolve_group_parent_signature(group_filter: CommandGroupFilter) -> str:
|
||||
signatures: list[str] = []
|
||||
parent = group_filter.parent_group
|
||||
while parent:
|
||||
signatures.append(getattr(parent, "_original_group_name", parent.group_name))
|
||||
parent = parent.parent_group
|
||||
return " ".join(reversed(signatures)).strip()
|
||||
|
||||
|
||||
def _find_parent_group_handler(module_path: str, parent_signature: str) -> str:
|
||||
"""根据模块路径和父级签名,找到对应的指令组 handler_full_name。"""
|
||||
parent_sig_normalized = parent_signature.strip()
|
||||
for handler in star_handlers_registry:
|
||||
if handler.handler_module_path != module_path:
|
||||
continue
|
||||
filter_ref = _locate_primary_filter(handler)
|
||||
if not isinstance(filter_ref, CommandGroupFilter):
|
||||
continue
|
||||
# 检查该指令组的完整指令名是否匹配 parent_signature
|
||||
group_names = filter_ref.get_complete_command_names()
|
||||
if parent_sig_normalized in group_names:
|
||||
return handler.handler_full_name
|
||||
return ""
|
||||
|
||||
|
||||
def _compose_command(parent_signature: str, fragment: str | None) -> str:
|
||||
fragment = (fragment or "").strip()
|
||||
parent_signature = parent_signature.strip()
|
||||
if not parent_signature:
|
||||
return fragment
|
||||
if not fragment:
|
||||
return parent_signature
|
||||
return f"{parent_signature} {fragment}"
|
||||
|
||||
|
||||
def _bind_descriptor_with_config(
|
||||
descriptor: CommandDescriptor,
|
||||
config: CommandConfig,
|
||||
) -> None:
|
||||
_apply_config_to_descriptor(descriptor, config)
|
||||
_apply_config_to_runtime(descriptor, config)
|
||||
|
||||
|
||||
def _apply_config_to_descriptor(
|
||||
descriptor: CommandDescriptor,
|
||||
config: CommandConfig,
|
||||
) -> None:
|
||||
descriptor.config = config
|
||||
descriptor.enabled = config.enabled
|
||||
|
||||
if config.original_command:
|
||||
descriptor.original_command = config.original_command
|
||||
|
||||
new_fragment = config.resolved_command or descriptor.current_fragment
|
||||
descriptor.current_fragment = new_fragment
|
||||
descriptor.effective_command = _compose_command(
|
||||
descriptor.parent_signature,
|
||||
new_fragment,
|
||||
)
|
||||
|
||||
extra = config.extra_data or {}
|
||||
resolved_aliases = extra.get("resolved_aliases")
|
||||
if isinstance(resolved_aliases, list):
|
||||
descriptor.aliases = [str(x) for x in resolved_aliases if str(x).strip()]
|
||||
|
||||
|
||||
def _apply_config_to_runtime(
|
||||
descriptor: CommandDescriptor,
|
||||
config: CommandConfig,
|
||||
) -> None:
|
||||
descriptor.handler.enabled = config.enabled
|
||||
if descriptor.filter_ref:
|
||||
if descriptor.current_fragment:
|
||||
_set_filter_fragment(descriptor.filter_ref, descriptor.current_fragment)
|
||||
extra = config.extra_data or {}
|
||||
resolved_aliases = extra.get("resolved_aliases")
|
||||
if isinstance(resolved_aliases, list):
|
||||
_set_filter_aliases(
|
||||
descriptor.filter_ref,
|
||||
[str(x) for x in resolved_aliases if str(x).strip()],
|
||||
)
|
||||
|
||||
|
||||
def _bind_configs_to_descriptors(
|
||||
descriptors: list[CommandDescriptor],
|
||||
config_records: list[CommandConfig],
|
||||
) -> dict[str, CommandConfig]:
|
||||
config_map = {cfg.handler_full_name: cfg for cfg in config_records}
|
||||
for desc in descriptors:
|
||||
if cfg := config_map.get(desc.handler_full_name):
|
||||
_bind_descriptor_with_config(desc, cfg)
|
||||
return config_map
|
||||
|
||||
|
||||
def _group_conflicts(
|
||||
descriptors: list[CommandDescriptor],
|
||||
) -> dict[str, list[CommandDescriptor]]:
|
||||
conflicts: dict[str, list[CommandDescriptor]] = defaultdict(list)
|
||||
for desc in descriptors:
|
||||
if desc.effective_command and desc.enabled:
|
||||
conflicts[desc.effective_command].append(desc)
|
||||
return {k: v for k, v in conflicts.items() if len(v) > 1}
|
||||
|
||||
|
||||
def _set_filter_fragment(
|
||||
filter_ref: CommandFilter | CommandGroupFilter,
|
||||
fragment: str,
|
||||
) -> None:
|
||||
attr = (
|
||||
"group_name" if isinstance(filter_ref, CommandGroupFilter) else "command_name"
|
||||
)
|
||||
current_value = getattr(filter_ref, attr)
|
||||
if fragment == current_value:
|
||||
return
|
||||
setattr(filter_ref, attr, fragment)
|
||||
if hasattr(filter_ref, "_cmpl_cmd_names"):
|
||||
filter_ref._cmpl_cmd_names = None
|
||||
|
||||
|
||||
def _set_filter_aliases(
|
||||
filter_ref: CommandFilter | CommandGroupFilter,
|
||||
aliases: list[str],
|
||||
) -> None:
|
||||
current_aliases = getattr(filter_ref, "alias", set())
|
||||
if set(aliases) == current_aliases:
|
||||
return
|
||||
setattr(filter_ref, "alias", set(aliases))
|
||||
if hasattr(filter_ref, "_cmpl_cmd_names"):
|
||||
filter_ref._cmpl_cmd_names = None
|
||||
|
||||
|
||||
def _is_command_in_use(
|
||||
target_handler_full_name: str,
|
||||
candidate_full_command: str,
|
||||
) -> bool:
|
||||
candidate = candidate_full_command.strip()
|
||||
for handler in star_handlers_registry:
|
||||
if handler.handler_full_name == target_handler_full_name:
|
||||
continue
|
||||
filter_ref = _locate_primary_filter(handler)
|
||||
if not filter_ref:
|
||||
continue
|
||||
names = {name.strip() for name in filter_ref.get_complete_command_names()}
|
||||
if candidate in names:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _descriptor_to_dict(desc: CommandDescriptor) -> dict[str, Any]:
|
||||
result = {
|
||||
"handler_full_name": desc.handler_full_name,
|
||||
"handler_name": desc.handler_name,
|
||||
"plugin": desc.plugin_name,
|
||||
"plugin_display_name": desc.plugin_display_name,
|
||||
"module_path": desc.module_path,
|
||||
"description": desc.description,
|
||||
"type": desc.command_type,
|
||||
"parent_signature": desc.parent_signature,
|
||||
"parent_group_handler": desc.parent_group_handler,
|
||||
"original_command": desc.original_command,
|
||||
"current_fragment": desc.current_fragment,
|
||||
"effective_command": desc.effective_command,
|
||||
"aliases": desc.aliases,
|
||||
"permission": desc.permission,
|
||||
"enabled": desc.enabled,
|
||||
"is_group": desc.is_group,
|
||||
"has_conflict": desc.has_conflict,
|
||||
"reserved": desc.reserved,
|
||||
}
|
||||
# 如果是指令组,包含子指令列表
|
||||
if desc.is_group and desc.sub_commands:
|
||||
result["sub_commands"] = [_descriptor_to_dict(sub) for sub in desc.sub_commands]
|
||||
else:
|
||||
result["sub_commands"] = []
|
||||
return result
|
||||
@@ -267,6 +267,10 @@ class Context:
|
||||
):
|
||||
"""通过 ID 获取对应的 LLM Provider。"""
|
||||
prov = self.provider_manager.inst_map.get(provider_id)
|
||||
if provider_id and not prov:
|
||||
logger.warning(
|
||||
f"没有找到 ID 为 {provider_id} 的提供商,这可能是由于您修改了提供商(模型)ID 导致的。"
|
||||
)
|
||||
return prov
|
||||
|
||||
def get_all_providers(self) -> list[Provider]:
|
||||
|
||||
@@ -40,6 +40,7 @@ class CommandFilter(HandlerFilter):
|
||||
):
|
||||
self.command_name = command_name
|
||||
self.alias = alias if alias else set()
|
||||
self._original_command_name = command_name
|
||||
self.parent_command_names = (
|
||||
parent_command_names if parent_command_names is not None else [""]
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ class CommandGroupFilter(HandlerFilter):
|
||||
):
|
||||
self.group_name = group_name
|
||||
self.alias = alias if alias else set()
|
||||
self._original_group_name = group_name
|
||||
self.sub_command_filters: list[CommandFilter | CommandGroupFilter] = []
|
||||
self.custom_filter_list: list[CustomFilter] = []
|
||||
self.parent_group = parent_group
|
||||
|
||||
@@ -118,6 +118,8 @@ class StarHandlerRegistry(Generic[T]):
|
||||
# 过滤事件类型
|
||||
if handler.event_type != event_type:
|
||||
continue
|
||||
if not handler.enabled:
|
||||
continue
|
||||
# 过滤启用状态
|
||||
if only_activated:
|
||||
plugin = star_map.get(handler.handler_module_path)
|
||||
@@ -220,6 +222,8 @@ class StarHandlerMetadata(Generic[H]):
|
||||
extras_configs: dict = field(default_factory=dict)
|
||||
"""插件注册的一些其他的信息, 如 priority 等"""
|
||||
|
||||
enabled: bool = True
|
||||
|
||||
def __lt__(self, other: StarHandlerMetadata):
|
||||
"""定义小于运算符以支持优先队列"""
|
||||
return self.extras_configs.get("priority", 0) < other.extras_configs.get(
|
||||
|
||||
@@ -23,6 +23,7 @@ from astrbot.core.utils.astrbot_path import (
|
||||
from astrbot.core.utils.io import remove_dir
|
||||
|
||||
from . import StarMetadata
|
||||
from .command_management import sync_command_configs
|
||||
from .context import Context
|
||||
from .filter.permission import PermissionType, PermissionTypeFilter
|
||||
from .star import star_map, star_registry
|
||||
@@ -630,6 +631,11 @@ class PluginManager:
|
||||
# 清除 pip.main 导致的多余的 logging handlers
|
||||
for handler in logging.root.handlers[:]:
|
||||
logging.root.removeHandler(handler)
|
||||
try:
|
||||
await sync_command_configs()
|
||||
except Exception as e:
|
||||
logger.error(f"同步指令配置失败: {e!s}")
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
if not fail_rec:
|
||||
return True, None
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
import aiohttp
|
||||
|
||||
from astrbot.core import logger
|
||||
|
||||
|
||||
class LLMModalities(TypedDict):
|
||||
input: list[Literal["text", "image", "audio", "video"]]
|
||||
output: list[Literal["text", "image", "audio", "video"]]
|
||||
|
||||
|
||||
class LLMLimit(TypedDict):
|
||||
context: int
|
||||
output: int
|
||||
|
||||
|
||||
class LLMMetadata(TypedDict):
|
||||
id: str
|
||||
reasoning: bool
|
||||
tool_call: bool
|
||||
knowledge: str
|
||||
release_date: str
|
||||
modalities: LLMModalities
|
||||
open_weights: bool
|
||||
limit: LLMLimit
|
||||
|
||||
|
||||
LLM_METADATAS: dict[str, LLMMetadata] = {}
|
||||
|
||||
|
||||
async def update_llm_metadata():
|
||||
url = "https://models.dev/api.json"
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url) as response:
|
||||
data = await response.json()
|
||||
global LLM_METADATAS
|
||||
models = {}
|
||||
for info in data.values():
|
||||
for model in info.get("models", {}).values():
|
||||
model_id = model.get("id")
|
||||
if not model_id:
|
||||
continue
|
||||
models[model_id] = LLMMetadata(
|
||||
id=model_id,
|
||||
reasoning=model.get("reasoning", False),
|
||||
tool_call=model.get("tool_call", False),
|
||||
knowledge=model.get("knowledge", "none"),
|
||||
release_date=model.get("release_date", ""),
|
||||
modalities=model.get(
|
||||
"modalities", {"input": [], "output": []}
|
||||
),
|
||||
open_weights=model.get("open_weights", False),
|
||||
limit=model.get("limit", {"context": 0, "output": 0}),
|
||||
)
|
||||
# Replace the global cache in-place so references remain valid
|
||||
LLM_METADATAS.clear()
|
||||
LLM_METADATAS.update(models)
|
||||
logger.info(f"Successfully fetched metadata for {len(models)} LLMs.")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch LLM metadata: {e}")
|
||||
return
|
||||
@@ -32,6 +32,92 @@ def _migra_agent_runner_configs(conf: AstrBotConfig, ids_map: dict) -> None:
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
|
||||
def _migra_provider_to_source_structure(conf: AstrBotConfig) -> None:
|
||||
"""
|
||||
Migrate old provider structure to new provider-source separation.
|
||||
Provider only keeps: id, provider_source_id, model, modalities, custom_extra_body
|
||||
All other fields move to provider_sources.
|
||||
"""
|
||||
providers = conf.get("provider", [])
|
||||
provider_sources = conf.get("provider_sources", [])
|
||||
|
||||
# Track if any migration happened
|
||||
migrated = False
|
||||
|
||||
# Provider-only fields that should stay in provider
|
||||
provider_only_fields = {
|
||||
"id",
|
||||
"provider_source_id",
|
||||
"model",
|
||||
"modalities",
|
||||
"custom_extra_body",
|
||||
"enable",
|
||||
}
|
||||
|
||||
# Fields that should not go to source
|
||||
source_exclude_fields = provider_only_fields | {"model_config"}
|
||||
|
||||
for provider in providers:
|
||||
# Skip if already has provider_source_id
|
||||
if provider.get("provider_source_id"):
|
||||
continue
|
||||
|
||||
# Skip non-chat-completion types (they don't need source separation)
|
||||
provider_type = provider.get("provider_type", "")
|
||||
if provider_type != "chat_completion":
|
||||
# For old types without provider_type, check type field
|
||||
old_type = provider.get("type", "")
|
||||
if "chat_completion" not in old_type:
|
||||
continue
|
||||
|
||||
migrated = True
|
||||
logger.info(f"Migrating provider {provider.get('id')} to new structure")
|
||||
|
||||
# Extract source fields from provider
|
||||
source_fields = {}
|
||||
for key, value in list(provider.items()):
|
||||
if key not in source_exclude_fields:
|
||||
source_fields[key] = value
|
||||
|
||||
# Create new provider_source
|
||||
source_id = provider.get("id", "") + "_source"
|
||||
new_source = {"id": source_id, **source_fields}
|
||||
|
||||
# Update provider to only keep necessary fields
|
||||
provider["provider_source_id"] = source_id
|
||||
|
||||
# Extract model from model_config if exists
|
||||
if "model_config" in provider and isinstance(provider["model_config"], dict):
|
||||
model_config = provider["model_config"]
|
||||
provider["model"] = model_config.get("model", "")
|
||||
|
||||
# Put other model_config fields into custom_extra_body
|
||||
extra_body_fields = {k: v for k, v in model_config.items() if k != "model"}
|
||||
if extra_body_fields:
|
||||
if "custom_extra_body" not in provider:
|
||||
provider["custom_extra_body"] = {}
|
||||
provider["custom_extra_body"].update(extra_body_fields)
|
||||
|
||||
# Initialize new fields if not present
|
||||
if "modalities" not in provider:
|
||||
provider["modalities"] = []
|
||||
if "custom_extra_body" not in provider:
|
||||
provider["custom_extra_body"] = {}
|
||||
|
||||
# Remove fields that should be in source
|
||||
keys_to_remove = [k for k in provider.keys() if k not in provider_only_fields]
|
||||
for key in keys_to_remove:
|
||||
del provider[key]
|
||||
|
||||
# Add source to provider_sources
|
||||
provider_sources.append(new_source)
|
||||
|
||||
if migrated:
|
||||
conf["provider_sources"] = provider_sources
|
||||
conf.save_config()
|
||||
logger.info("Provider-source structure migration completed")
|
||||
|
||||
|
||||
async def migra(
|
||||
db, astrbot_config_mgr, umop_config_router, acm: AstrBotConfigManager
|
||||
) -> None:
|
||||
@@ -71,3 +157,10 @@ async def migra(
|
||||
|
||||
for conf in acm.confs.values():
|
||||
_migra_agent_runner_configs(conf, ids_map)
|
||||
|
||||
# Migrate providers to new structure: extract source fields to provider_sources
|
||||
try:
|
||||
_migra_provider_to_source_structure(astrbot_config)
|
||||
except Exception as e:
|
||||
logger.error(f"Migration for provider-source structure failed: {e!s}")
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from .auth import AuthRoute
|
||||
from .chat import ChatRoute
|
||||
from .command import CommandRoute
|
||||
from .config import ConfigRoute
|
||||
from .conversation import ConversationRoute
|
||||
from .file import FileRoute
|
||||
@@ -17,6 +18,7 @@ from .update import UpdateRoute
|
||||
__all__ = [
|
||||
"AuthRoute",
|
||||
"ChatRoute",
|
||||
"CommandRoute",
|
||||
"ConfigRoute",
|
||||
"ConversationRoute",
|
||||
"FileRoute",
|
||||
|
||||
@@ -227,16 +227,19 @@ class ChatRoute(Route):
|
||||
text: str,
|
||||
media_parts: list,
|
||||
reasoning: str,
|
||||
agent_stats: dict,
|
||||
):
|
||||
"""保存 bot 消息到历史记录,返回保存的记录"""
|
||||
bot_message_parts = []
|
||||
bot_message_parts.extend(media_parts)
|
||||
if text:
|
||||
bot_message_parts.append({"type": "plain", "text": text})
|
||||
bot_message_parts.extend(media_parts)
|
||||
|
||||
new_his = {"type": "bot", "message": bot_message_parts}
|
||||
if reasoning:
|
||||
new_his["reasoning"] = reasoning
|
||||
if agent_stats:
|
||||
new_his["agent_stats"] = agent_stats
|
||||
|
||||
record = await self.platform_history_mgr.insert(
|
||||
platform_id="webchat",
|
||||
@@ -294,7 +297,8 @@ class ChatRoute(Route):
|
||||
accumulated_parts = []
|
||||
accumulated_text = ""
|
||||
accumulated_reasoning = ""
|
||||
|
||||
tool_calls = {}
|
||||
agent_stats = {}
|
||||
try:
|
||||
async with track_conversation(self.running_convs, webchat_conv_id):
|
||||
while True:
|
||||
@@ -314,6 +318,16 @@ class ChatRoute(Route):
|
||||
result_text = result["data"]
|
||||
msg_type = result.get("type")
|
||||
streaming = result.get("streaming", False)
|
||||
chain_type = result.get("chain_type")
|
||||
|
||||
if chain_type == "agent_stats":
|
||||
stats_info = {
|
||||
"type": "agent_stats",
|
||||
"data": json.loads(result_text),
|
||||
}
|
||||
yield f"data: {json.dumps(stats_info, ensure_ascii=False)}\n\n"
|
||||
agent_stats = stats_info["data"]
|
||||
continue
|
||||
|
||||
# 发送 SSE 数据
|
||||
try:
|
||||
@@ -335,11 +349,35 @@ class ChatRoute(Route):
|
||||
|
||||
# 累积消息部分
|
||||
if msg_type == "plain":
|
||||
chain_type = result.get("chain_type", "normal")
|
||||
if chain_type == "reasoning":
|
||||
chain_type = result.get("chain_type")
|
||||
if chain_type == "tool_call":
|
||||
tool_call = json.loads(result_text)
|
||||
tool_calls[tool_call.get("id")] = tool_call
|
||||
if accumulated_text:
|
||||
# 如果累积了文本,则先保存文本
|
||||
accumulated_parts.append(
|
||||
{"type": "plain", "text": accumulated_text}
|
||||
)
|
||||
accumulated_text = ""
|
||||
elif chain_type == "tool_call_result":
|
||||
tcr = json.loads(result_text)
|
||||
tc_id = tcr.get("id")
|
||||
if tc_id in tool_calls:
|
||||
tool_calls[tc_id]["result"] = tcr.get("result")
|
||||
tool_calls[tc_id]["finished_ts"] = tcr.get("ts")
|
||||
accumulated_parts.append(
|
||||
{
|
||||
"type": "tool_call",
|
||||
"tool_calls": [tool_calls[tc_id]],
|
||||
}
|
||||
)
|
||||
tool_calls.pop(tc_id, None)
|
||||
elif chain_type == "reasoning":
|
||||
accumulated_reasoning += result_text
|
||||
else:
|
||||
elif streaming:
|
||||
accumulated_text += result_text
|
||||
else:
|
||||
accumulated_text = result_text
|
||||
elif msg_type == "image":
|
||||
filename = result_text.replace("[IMAGE]", "")
|
||||
part = await self._create_attachment_from_file(
|
||||
@@ -367,15 +405,20 @@ class ChatRoute(Route):
|
||||
if msg_type == "end":
|
||||
break
|
||||
elif (
|
||||
(streaming and msg_type == "complete")
|
||||
or not streaming
|
||||
or msg_type == "break"
|
||||
(streaming and msg_type == "complete") or not streaming
|
||||
# or msg_type == "break"
|
||||
):
|
||||
if (
|
||||
chain_type == "tool_call"
|
||||
or chain_type == "tool_call_result"
|
||||
):
|
||||
continue
|
||||
saved_record = await self._save_bot_message(
|
||||
webchat_conv_id,
|
||||
accumulated_text,
|
||||
accumulated_parts,
|
||||
accumulated_reasoning,
|
||||
agent_stats,
|
||||
)
|
||||
# 发送保存的消息信息给前端
|
||||
if saved_record and not client_disconnected:
|
||||
@@ -390,11 +433,11 @@ class ChatRoute(Route):
|
||||
yield f"data: {json.dumps(saved_info, ensure_ascii=False)}\n\n"
|
||||
except Exception:
|
||||
pass
|
||||
# 重置累积变量 (对于 break 后的下一段消息)
|
||||
if msg_type == "break":
|
||||
accumulated_parts = []
|
||||
accumulated_text = ""
|
||||
accumulated_reasoning = ""
|
||||
accumulated_parts = []
|
||||
accumulated_text = ""
|
||||
accumulated_reasoning = ""
|
||||
# tool_calls = {}
|
||||
agent_stats = {}
|
||||
except BaseException as e:
|
||||
logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True)
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
from quart import request
|
||||
|
||||
from astrbot.core.star.command_management import (
|
||||
list_command_conflicts,
|
||||
list_commands,
|
||||
)
|
||||
from astrbot.core.star.command_management import (
|
||||
rename_command as rename_command_service,
|
||||
)
|
||||
from astrbot.core.star.command_management import (
|
||||
toggle_command as toggle_command_service,
|
||||
)
|
||||
|
||||
from .route import Response, Route, RouteContext
|
||||
|
||||
|
||||
class CommandRoute(Route):
|
||||
def __init__(self, context: RouteContext) -> None:
|
||||
super().__init__(context)
|
||||
self.routes = {
|
||||
"/commands": ("GET", self.get_commands),
|
||||
"/commands/conflicts": ("GET", self.get_conflicts),
|
||||
"/commands/toggle": ("POST", self.toggle_command),
|
||||
"/commands/rename": ("POST", self.rename_command),
|
||||
}
|
||||
self.register_routes()
|
||||
|
||||
async def get_commands(self):
|
||||
commands = await list_commands()
|
||||
summary = {
|
||||
"total": len(commands),
|
||||
"disabled": len([cmd for cmd in commands if not cmd["enabled"]]),
|
||||
"conflicts": len([cmd for cmd in commands if cmd.get("has_conflict")]),
|
||||
}
|
||||
return Response().ok({"items": commands, "summary": summary}).__dict__
|
||||
|
||||
async def get_conflicts(self):
|
||||
conflicts = await list_command_conflicts()
|
||||
return Response().ok(conflicts).__dict__
|
||||
|
||||
async def toggle_command(self):
|
||||
data = await request.get_json()
|
||||
handler_full_name = data.get("handler_full_name")
|
||||
enabled = data.get("enabled")
|
||||
|
||||
if handler_full_name is None or enabled is None:
|
||||
return Response().error("handler_full_name 与 enabled 均为必填。").__dict__
|
||||
|
||||
if isinstance(enabled, str):
|
||||
enabled = enabled.lower() in ("1", "true", "yes", "on")
|
||||
|
||||
try:
|
||||
await toggle_command_service(handler_full_name, bool(enabled))
|
||||
except ValueError as exc:
|
||||
return Response().error(str(exc)).__dict__
|
||||
|
||||
payload = await _get_command_payload(handler_full_name)
|
||||
return Response().ok(payload).__dict__
|
||||
|
||||
async def rename_command(self):
|
||||
data = await request.get_json()
|
||||
handler_full_name = data.get("handler_full_name")
|
||||
new_name = data.get("new_name")
|
||||
aliases = data.get("aliases")
|
||||
|
||||
if not handler_full_name or not new_name:
|
||||
return Response().error("handler_full_name 与 new_name 均为必填。").__dict__
|
||||
|
||||
try:
|
||||
await rename_command_service(handler_full_name, new_name, aliases=aliases)
|
||||
except ValueError as exc:
|
||||
return Response().error(str(exc)).__dict__
|
||||
|
||||
payload = await _get_command_payload(handler_full_name)
|
||||
return Response().ok(payload).__dict__
|
||||
|
||||
|
||||
async def _get_command_payload(handler_full_name: str):
|
||||
commands = await list_commands()
|
||||
for cmd in commands:
|
||||
if cmd["handler_full_name"] == handler_full_name:
|
||||
return cmd
|
||||
return {}
|
||||
@@ -6,7 +6,7 @@ from typing import Any
|
||||
|
||||
from quart import request
|
||||
|
||||
from astrbot.core import file_token_service, logger
|
||||
from astrbot.core import astrbot_config, file_token_service, logger
|
||||
from astrbot.core.config.astrbot_config import AstrBotConfig
|
||||
from astrbot.core.config.default import (
|
||||
CONFIG_METADATA_2,
|
||||
@@ -21,6 +21,7 @@ from astrbot.core.platform.register import platform_cls_map, platform_registry
|
||||
from astrbot.core.provider import Provider
|
||||
from astrbot.core.provider.register import provider_registry
|
||||
from astrbot.core.star.star import star_registry
|
||||
from astrbot.core.utils.llm_metadata import LLM_METADATAS
|
||||
from astrbot.core.utils.webhook_utils import ensure_platform_webhook_config
|
||||
|
||||
from .route import Response, Route, RouteContext
|
||||
@@ -179,13 +180,157 @@ class ConfigRoute(Route):
|
||||
"/config/provider/new": ("POST", self.post_new_provider),
|
||||
"/config/provider/update": ("POST", self.post_update_provider),
|
||||
"/config/provider/delete": ("POST", self.post_delete_provider),
|
||||
"/config/provider/template": ("GET", self.get_provider_template),
|
||||
"/config/provider/check_one": ("GET", self.check_one_provider_status),
|
||||
"/config/provider/list": ("GET", self.get_provider_config_list),
|
||||
"/config/provider/model_list": ("GET", self.get_provider_model_list),
|
||||
"/config/provider/get_embedding_dim": ("POST", self.get_embedding_dim),
|
||||
"/config/provider_sources/models": (
|
||||
"GET",
|
||||
self.get_provider_source_models,
|
||||
),
|
||||
"/config/provider_sources/update": (
|
||||
"POST",
|
||||
self.update_provider_source,
|
||||
),
|
||||
"/config/provider_sources/delete": (
|
||||
"POST",
|
||||
self.delete_provider_source,
|
||||
),
|
||||
}
|
||||
self.register_routes()
|
||||
|
||||
async def delete_provider_source(self):
|
||||
"""删除 provider_source,并更新关联的 providers"""
|
||||
post_data = await request.json
|
||||
if not post_data:
|
||||
return Response().error("缺少配置数据").__dict__
|
||||
|
||||
provider_source_id = post_data.get("id")
|
||||
if not provider_source_id:
|
||||
return Response().error("缺少 provider_source_id").__dict__
|
||||
|
||||
provider_sources = self.config.get("provider_sources", [])
|
||||
target_idx = next(
|
||||
(
|
||||
i
|
||||
for i, ps in enumerate(provider_sources)
|
||||
if ps.get("id") == provider_source_id
|
||||
),
|
||||
-1,
|
||||
)
|
||||
|
||||
if target_idx == -1:
|
||||
return Response().error("未找到对应的 provider source").__dict__
|
||||
|
||||
# 删除 provider_source
|
||||
del provider_sources[target_idx]
|
||||
|
||||
# 写回配置
|
||||
self.config["provider_sources"] = provider_sources
|
||||
|
||||
# 删除引用了该 provider_source 的 providers
|
||||
await self.core_lifecycle.provider_manager.delete_provider(
|
||||
provider_source_id=provider_source_id
|
||||
)
|
||||
|
||||
try:
|
||||
save_config(self.config, self.config, is_core=True)
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(str(e)).__dict__
|
||||
|
||||
return Response().ok(message="删除 provider source 成功").__dict__
|
||||
|
||||
async def update_provider_source(self):
|
||||
"""更新或新增 provider_source,并重载关联的 providers"""
|
||||
post_data = await request.json
|
||||
if not post_data:
|
||||
return Response().error("缺少配置数据").__dict__
|
||||
|
||||
new_source_config = post_data.get("config") or post_data
|
||||
original_id = post_data.get("original_id")
|
||||
if not original_id:
|
||||
return Response().error("缺少 original_id").__dict__
|
||||
|
||||
if not isinstance(new_source_config, dict):
|
||||
return Response().error("缺少或错误的配置数据").__dict__
|
||||
|
||||
# 确保配置中有 id 字段
|
||||
if not new_source_config.get("id"):
|
||||
new_source_config["id"] = original_id
|
||||
|
||||
provider_sources = self.config.get("provider_sources", [])
|
||||
|
||||
for ps in provider_sources:
|
||||
if ps.get("id") == new_source_config["id"] and ps.get("id") != original_id:
|
||||
return (
|
||||
Response()
|
||||
.error(
|
||||
f"Provider source ID '{new_source_config['id']}' exists already, please try another ID.",
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
|
||||
# 查找旧的 provider_source,若不存在则追加为新配置
|
||||
target_idx = next(
|
||||
(i for i, ps in enumerate(provider_sources) if ps.get("id") == original_id),
|
||||
-1,
|
||||
)
|
||||
|
||||
old_id = original_id
|
||||
if target_idx == -1:
|
||||
provider_sources.append(new_source_config)
|
||||
else:
|
||||
old_id = provider_sources[target_idx].get("id")
|
||||
provider_sources[target_idx] = new_source_config
|
||||
|
||||
# 更新引用了该 provider_source 的 providers
|
||||
affected_providers = []
|
||||
for provider in self.config.get("provider", []):
|
||||
if provider.get("provider_source_id") == old_id:
|
||||
provider["provider_source_id"] = new_source_config["id"]
|
||||
affected_providers.append(provider)
|
||||
|
||||
# 写回配置
|
||||
self.config["provider_sources"] = provider_sources
|
||||
|
||||
try:
|
||||
save_config(self.config, self.config, is_core=True)
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(str(e)).__dict__
|
||||
|
||||
# 重载受影响的 providers,使新的 source 配置生效
|
||||
reload_errors = []
|
||||
prov_mgr = self.core_lifecycle.provider_manager
|
||||
for provider in affected_providers:
|
||||
try:
|
||||
await prov_mgr.reload(provider)
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
reload_errors.append(f"{provider.get('id')}: {e}")
|
||||
|
||||
if reload_errors:
|
||||
return (
|
||||
Response()
|
||||
.error("更新成功,但部分提供商重载失败: " + ", ".join(reload_errors))
|
||||
.__dict__
|
||||
)
|
||||
|
||||
return Response().ok(message="更新 provider source 成功").__dict__
|
||||
|
||||
async def get_provider_template(self):
|
||||
config_schema = {
|
||||
"provider": CONFIG_METADATA_2["provider_group"]["metadata"]["provider"]
|
||||
}
|
||||
data = {
|
||||
"config_schema": config_schema,
|
||||
"providers": astrbot_config["provider"],
|
||||
"provider_sources": astrbot_config["provider_sources"],
|
||||
}
|
||||
return Response().ok(data=data).__dict__
|
||||
|
||||
async def get_uc_table(self):
|
||||
"""获取 UMOP 配置路由表"""
|
||||
return Response().ok({"routing": self.ucr.umop_to_conf_id}).__dict__
|
||||
@@ -433,9 +578,25 @@ class ConfigRoute(Route):
|
||||
return Response().error("缺少参数 provider_type").__dict__
|
||||
provider_type_ls = provider_type.split(",")
|
||||
provider_list = []
|
||||
astrbot_config = self.core_lifecycle.astrbot_config
|
||||
for provider in astrbot_config["provider"]:
|
||||
if provider.get("provider_type", None) in provider_type_ls:
|
||||
ps = self.core_lifecycle.provider_manager.providers_config
|
||||
p_source_pt = {
|
||||
psrc["id"]: psrc["provider_type"]
|
||||
for psrc in self.core_lifecycle.provider_manager.provider_sources_config
|
||||
}
|
||||
for provider in ps:
|
||||
ps_id = provider.get("provider_source_id", None)
|
||||
if (
|
||||
ps_id
|
||||
and ps_id in p_source_pt
|
||||
and p_source_pt[ps_id] in provider_type_ls
|
||||
):
|
||||
# chat
|
||||
prov = self.core_lifecycle.provider_manager.get_merged_provider_config(
|
||||
provider
|
||||
)
|
||||
provider_list.append(prov)
|
||||
elif not ps_id and provider.get("provider_type", None) in provider_type_ls:
|
||||
# agent runner, embedding, etc
|
||||
provider_list.append(provider)
|
||||
return Response().ok(provider_list).__dict__
|
||||
|
||||
@@ -458,9 +619,18 @@ class ConfigRoute(Route):
|
||||
|
||||
try:
|
||||
models = await provider.get_models()
|
||||
models = models or []
|
||||
|
||||
metadata_map = {}
|
||||
for model_id in models:
|
||||
meta = LLM_METADATAS.get(model_id)
|
||||
if meta:
|
||||
metadata_map[model_id] = meta
|
||||
|
||||
ret = {
|
||||
"models": models,
|
||||
"provider_id": provider_id,
|
||||
"model_metadata": metadata_map,
|
||||
}
|
||||
return Response().ok(ret).__dict__
|
||||
except Exception as e:
|
||||
@@ -522,6 +692,104 @@ class ConfigRoute(Route):
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"获取嵌入维度失败: {e!s}").__dict__
|
||||
|
||||
async def get_provider_source_models(self):
|
||||
"""获取指定 provider_source 支持的模型列表
|
||||
|
||||
本质上会临时初始化一个 Provider 实例,调用 get_models() 获取模型列表,然后销毁实例
|
||||
"""
|
||||
provider_source_id = request.args.get("source_id")
|
||||
if not provider_source_id:
|
||||
return Response().error("缺少参数 source_id").__dict__
|
||||
|
||||
try:
|
||||
from astrbot.core.provider.register import provider_cls_map
|
||||
|
||||
# 从配置中查找对应的 provider_source
|
||||
provider_sources = self.config.get("provider_sources", [])
|
||||
provider_source = None
|
||||
for ps in provider_sources:
|
||||
if ps.get("id") == provider_source_id:
|
||||
provider_source = ps
|
||||
break
|
||||
|
||||
if not provider_source:
|
||||
return (
|
||||
Response()
|
||||
.error(f"未找到 ID 为 {provider_source_id} 的 provider_source")
|
||||
.__dict__
|
||||
)
|
||||
|
||||
# 获取 provider 类型
|
||||
provider_type = provider_source.get("type", None)
|
||||
if not provider_type:
|
||||
return Response().error("provider_source 缺少 type 字段").__dict__
|
||||
|
||||
try:
|
||||
self.core_lifecycle.provider_manager.dynamic_import_provider(
|
||||
provider_type
|
||||
)
|
||||
except ImportError as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"动态导入提供商适配器失败: {e!s}").__dict__
|
||||
|
||||
# 获取对应的 provider 类
|
||||
if provider_type not in provider_cls_map:
|
||||
return (
|
||||
Response()
|
||||
.error(f"未找到适用于 {provider_type} 的提供商适配器")
|
||||
.__dict__
|
||||
)
|
||||
|
||||
provider_metadata = provider_cls_map[provider_type]
|
||||
cls_type = provider_metadata.cls_type
|
||||
|
||||
if not cls_type:
|
||||
return Response().error(f"无法找到 {provider_type} 的类").__dict__
|
||||
|
||||
# 检查是否是 Provider 类型
|
||||
if not issubclass(cls_type, Provider):
|
||||
return (
|
||||
Response()
|
||||
.error(f"提供商 {provider_type} 不支持获取模型列表")
|
||||
.__dict__
|
||||
)
|
||||
|
||||
# 临时实例化 provider
|
||||
inst = cls_type(provider_source, {})
|
||||
|
||||
# 如果有 initialize 方法,调用它
|
||||
init_fn = getattr(inst, "initialize", None)
|
||||
if inspect.iscoroutinefunction(init_fn):
|
||||
await init_fn()
|
||||
|
||||
# 获取模型列表
|
||||
models = await inst.get_models()
|
||||
models = models or []
|
||||
|
||||
metadata_map = {}
|
||||
for model_id in models:
|
||||
meta = LLM_METADATAS.get(model_id)
|
||||
if meta:
|
||||
metadata_map[model_id] = meta
|
||||
|
||||
# 销毁实例(如果有 terminate 方法)
|
||||
terminate_fn = getattr(inst, "terminate", None)
|
||||
if inspect.iscoroutinefunction(terminate_fn):
|
||||
await terminate_fn()
|
||||
|
||||
logger.info(
|
||||
f"获取到 provider_source {provider_source_id} 的模型列表: {models}",
|
||||
)
|
||||
|
||||
return (
|
||||
Response()
|
||||
.ok({"models": models, "model_metadata": metadata_map})
|
||||
.__dict__
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"获取模型列表失败: {e!s}").__dict__
|
||||
|
||||
async def get_platform_list(self):
|
||||
"""获取所有平台的列表"""
|
||||
platform_list = []
|
||||
@@ -533,7 +801,15 @@ class ConfigRoute(Route):
|
||||
data = await request.json
|
||||
config = data.get("config", None)
|
||||
conf_id = data.get("conf_id", None)
|
||||
|
||||
try:
|
||||
# 不更新 provider_sources, provider, platform
|
||||
# 这些配置有单独的接口进行更新
|
||||
if conf_id == "default":
|
||||
no_update_keys = ["provider_sources", "provider", "platform"]
|
||||
for key in no_update_keys:
|
||||
config[key] = self.acm.default_conf[key]
|
||||
|
||||
await self._save_astrbot_configs(config, conf_id)
|
||||
await self.core_lifecycle.reload_pipeline_scheduler(conf_id)
|
||||
return Response().ok(None, "保存成功~").__dict__
|
||||
@@ -573,28 +849,30 @@ class ConfigRoute(Route):
|
||||
|
||||
async def post_new_provider(self):
|
||||
new_provider_config = await request.json
|
||||
self.config["provider"].append(new_provider_config)
|
||||
|
||||
try:
|
||||
save_config(self.config, self.config, is_core=True)
|
||||
await self.core_lifecycle.provider_manager.load_provider(
|
||||
new_provider_config,
|
||||
await self.core_lifecycle.provider_manager.create_provider(
|
||||
new_provider_config
|
||||
)
|
||||
except Exception as e:
|
||||
return Response().error(str(e)).__dict__
|
||||
return Response().ok(None, "新增服务提供商配置成功~").__dict__
|
||||
return Response().ok(None, "新增服务提供商配置成功").__dict__
|
||||
|
||||
async def post_update_platform(self):
|
||||
update_platform_config = await request.json
|
||||
platform_id = update_platform_config.get("id", None)
|
||||
origin_platform_id = update_platform_config.get("id", None)
|
||||
new_config = update_platform_config.get("config", None)
|
||||
if not platform_id or not new_config:
|
||||
if not origin_platform_id or not new_config:
|
||||
return Response().error("参数错误").__dict__
|
||||
|
||||
if origin_platform_id != new_config.get("id", None):
|
||||
return Response().error("机器人名称不允许修改").__dict__
|
||||
|
||||
# 如果是支持统一 webhook 模式的平台,且启用了统一 webhook 模式,确保有 webhook_uuid
|
||||
ensure_platform_webhook_config(new_config)
|
||||
|
||||
for i, platform in enumerate(self.config["platform"]):
|
||||
if platform["id"] == platform_id:
|
||||
if platform["id"] == origin_platform_id:
|
||||
self.config["platform"][i] = new_config
|
||||
break
|
||||
else:
|
||||
@@ -609,21 +887,15 @@ class ConfigRoute(Route):
|
||||
|
||||
async def post_update_provider(self):
|
||||
update_provider_config = await request.json
|
||||
provider_id = update_provider_config.get("id", None)
|
||||
origin_provider_id = update_provider_config.get("id", None)
|
||||
new_config = update_provider_config.get("config", None)
|
||||
if not provider_id or not new_config:
|
||||
if not origin_provider_id or not new_config:
|
||||
return Response().error("参数错误").__dict__
|
||||
|
||||
for i, provider in enumerate(self.config["provider"]):
|
||||
if provider["id"] == provider_id:
|
||||
self.config["provider"][i] = new_config
|
||||
break
|
||||
else:
|
||||
return Response().error("未找到对应服务提供商").__dict__
|
||||
|
||||
try:
|
||||
save_config(self.config, self.config, is_core=True)
|
||||
await self.core_lifecycle.provider_manager.reload(new_config)
|
||||
await self.core_lifecycle.provider_manager.update_provider(
|
||||
origin_provider_id, new_config
|
||||
)
|
||||
except Exception as e:
|
||||
return Response().error(str(e)).__dict__
|
||||
return Response().ok(None, "更新成功,已经实时生效~").__dict__
|
||||
@@ -646,19 +918,17 @@ class ConfigRoute(Route):
|
||||
|
||||
async def post_delete_provider(self):
|
||||
provider_id = await request.json
|
||||
provider_id = provider_id.get("id")
|
||||
for i, provider in enumerate(self.config["provider"]):
|
||||
if provider["id"] == provider_id:
|
||||
del self.config["provider"][i]
|
||||
break
|
||||
else:
|
||||
return Response().error("未找到对应服务提供商").__dict__
|
||||
provider_id = provider_id.get("id", "")
|
||||
if not provider_id:
|
||||
return Response().error("缺少参数 id").__dict__
|
||||
|
||||
try:
|
||||
save_config(self.config, self.config, is_core=True)
|
||||
await self.core_lifecycle.provider_manager.terminate_provider(provider_id)
|
||||
await self.core_lifecycle.provider_manager.delete_provider(
|
||||
provider_id=provider_id
|
||||
)
|
||||
except Exception as e:
|
||||
return Response().error(str(e)).__dict__
|
||||
return Response().ok(None, "删除成功,已经实时生效~").__dict__
|
||||
return Response().ok(None, "删除成功,已经实时生效。").__dict__
|
||||
|
||||
async def get_llm_tools(self):
|
||||
"""获取函数调用工具。包含了本地加载的以及 MCP 服务的工具"""
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from functools import cmp_to_key
|
||||
|
||||
import aiohttp
|
||||
import psutil
|
||||
@@ -11,7 +14,9 @@ from astrbot.core.config import VERSION
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.db.migration.helper import check_migration_needed_v4
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_path
|
||||
from astrbot.core.utils.io import get_dashboard_version
|
||||
from astrbot.core.utils.version_comparator import VersionComparator
|
||||
|
||||
from .route import Response, Route, RouteContext
|
||||
|
||||
@@ -30,6 +35,8 @@ class StatRoute(Route):
|
||||
"/stat/start-time": ("GET", self.get_start_time),
|
||||
"/stat/restart-core": ("POST", self.restart_core),
|
||||
"/stat/test-ghproxy-connection": ("POST", self.test_ghproxy_connection),
|
||||
"/stat/changelog": ("GET", self.get_changelog),
|
||||
"/stat/changelog/list": ("GET", self.list_changelog_versions),
|
||||
}
|
||||
self.db_helper = db_helper
|
||||
self.register_routes()
|
||||
@@ -183,3 +190,92 @@ class StatRoute(Route):
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"Error: {e!s}").__dict__
|
||||
|
||||
async def get_changelog(self):
|
||||
"""获取指定版本的更新日志"""
|
||||
try:
|
||||
version = request.args.get("version")
|
||||
if not version:
|
||||
return Response().error("version parameter is required").__dict__
|
||||
|
||||
version = version.lstrip("v")
|
||||
|
||||
# 防止路径遍历攻击
|
||||
if not re.match(r"^[a-zA-Z0-9._-]+$", version):
|
||||
return Response().error("Invalid version format").__dict__
|
||||
if ".." in version or "/" in version or "\\" in version:
|
||||
return Response().error("Invalid version format").__dict__
|
||||
|
||||
filename = f"v{version}.md"
|
||||
project_path = get_astrbot_path()
|
||||
changelogs_dir = os.path.join(project_path, "changelogs")
|
||||
changelog_path = os.path.join(changelogs_dir, filename)
|
||||
|
||||
# 规范化路径,防止符号链接攻击
|
||||
changelog_path = os.path.realpath(changelog_path)
|
||||
changelogs_dir = os.path.realpath(changelogs_dir)
|
||||
|
||||
# 验证最终路径在预期的 changelogs 目录内(防止路径遍历)
|
||||
# 确保规范化后的路径以 changelogs_dir 开头,且是目录内的文件
|
||||
changelog_path_normalized = os.path.normpath(changelog_path)
|
||||
changelogs_dir_normalized = os.path.normpath(changelogs_dir)
|
||||
|
||||
# 检查路径是否在预期目录内(必须是目录的子文件,不能是目录本身)
|
||||
expected_prefix = changelogs_dir_normalized + os.sep
|
||||
if not changelog_path_normalized.startswith(expected_prefix):
|
||||
logger.warning(
|
||||
f"Path traversal attempt detected: {version} -> {changelog_path}",
|
||||
)
|
||||
return Response().error("Invalid version format").__dict__
|
||||
|
||||
if not os.path.exists(changelog_path):
|
||||
return (
|
||||
Response()
|
||||
.error(f"Changelog for version {version} not found")
|
||||
.__dict__
|
||||
)
|
||||
if not os.path.isfile(changelog_path):
|
||||
return (
|
||||
Response()
|
||||
.error(f"Changelog for version {version} not found")
|
||||
.__dict__
|
||||
)
|
||||
|
||||
with open(changelog_path, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
return Response().ok({"content": content, "version": version}).__dict__
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"Error: {e!s}").__dict__
|
||||
|
||||
async def list_changelog_versions(self):
|
||||
"""获取所有可用的更新日志版本列表"""
|
||||
try:
|
||||
project_path = get_astrbot_path()
|
||||
changelogs_dir = os.path.join(project_path, "changelogs")
|
||||
|
||||
if not os.path.exists(changelogs_dir):
|
||||
return Response().ok({"versions": []}).__dict__
|
||||
|
||||
versions = []
|
||||
for filename in os.listdir(changelogs_dir):
|
||||
if filename.endswith(".md") and filename.startswith("v"):
|
||||
# 提取版本号(去除 v 前缀和 .md 后缀)
|
||||
version = filename[1:-3] # 去掉 "v" 和 ".md"
|
||||
# 验证版本号格式
|
||||
if re.match(r"^[a-zA-Z0-9._-]+$", version):
|
||||
versions.append(version)
|
||||
|
||||
# 按版本号排序(降序,最新的在前)
|
||||
# 使用项目中的 VersionComparator 进行语义化版本号排序
|
||||
versions.sort(
|
||||
key=cmp_to_key(
|
||||
lambda v1, v2: VersionComparator.compare_version(v2, v1),
|
||||
),
|
||||
)
|
||||
|
||||
return Response().ok({"versions": versions}).__dict__
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(f"Error: {e!s}").__dict__
|
||||
|
||||
@@ -3,6 +3,7 @@ import traceback
|
||||
from quart import request
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.agent.mcp_client import MCPTool
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from astrbot.core.star import star_map
|
||||
|
||||
@@ -296,15 +297,30 @@ class ToolsRoute(Route):
|
||||
"""获取所有注册的工具列表"""
|
||||
try:
|
||||
tools = self.tool_mgr.func_list
|
||||
tools_dict = [
|
||||
{
|
||||
tools_dict = []
|
||||
for tool in tools:
|
||||
if isinstance(tool, MCPTool):
|
||||
origin = "mcp"
|
||||
origin_name = tool.mcp_server_name
|
||||
elif tool.handler_module_path and star_map.get(
|
||||
tool.handler_module_path
|
||||
):
|
||||
star = star_map[tool.handler_module_path]
|
||||
origin = "plugin"
|
||||
origin_name = star.name
|
||||
else:
|
||||
origin = "unknown"
|
||||
origin_name = "unknown"
|
||||
|
||||
tool_info = {
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"parameters": tool.parameters,
|
||||
"active": tool.active,
|
||||
"origin": origin,
|
||||
"origin_name": origin_name,
|
||||
}
|
||||
for tool in tools
|
||||
]
|
||||
tools_dict.append(tool_info)
|
||||
return Response().ok(data=tools_dict).__dict__
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
@@ -67,6 +67,7 @@ class AstrBotDashboard:
|
||||
core_lifecycle,
|
||||
core_lifecycle.plugin_manager,
|
||||
)
|
||||
self.command_route = CommandRoute(self.context)
|
||||
self.cr = ConfigRoute(self.context, core_lifecycle)
|
||||
self.lr = LogRoute(self.context, core_lifecycle.log_broker)
|
||||
self.sfr = StaticFileRoute(self.context)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
## What's Changed
|
||||
|
||||
> 📢 在升级前,请**完整阅读**本次更新日志。
|
||||
>
|
||||
> **特别提醒:**
|
||||
> 1. 该版本为 alpha.1 预览版本。
|
||||
> 2. 本次升级**如果再降级**,会由于提供商配置的变更,导致提供商配置错乱,需要手动删除后重新添加。
|
||||
> 3. 此版本 WebUI 包体相较上一个版本增加约 **193%**,共约 **9.8 MB**,升级可能会需要一些时间。
|
||||
|
||||
### 重构与优化
|
||||
|
||||
- 重构 Provider 页面和提供商的配置结构,将 Chat Provider 配置拆分为 Provider Source(提供商源)和 Provider(代表提供商源的各个模型),引入了提供商模型自动发现、模型元数据自动发现的功能,**提供更加便捷的模型添加体验**。
|
||||
- ⚠️ 将 “MCP” 页面移动到了 “插件” 页面中
|
||||
- ⚠️ 将 “MCP” 页面中的工具管理移动到了 “插件” -> “管理行为” 中。
|
||||
- ⚠️ 将 “QQ 个人号(OneBot v11)” 机器人适配器类型更名为 “OneBot v11”,并将其 Logo 更改为 OneBot 的 Logo。
|
||||
- ⚠️ AstrBot WebChat 升级为 **AstrBot ChatUI**,入口从边栏修改为顶部(右上角)切换按钮。
|
||||
- 优化引用消息的逻辑,减少对模型输入缓存的破坏。
|
||||
|
||||
### 修复
|
||||
|
||||
- ‼️ 修复部分情况下,分段回复无法正常分段的问题。
|
||||
- 修复处理工具返回结果的过程中,导致一些直接发送图片的工具(如生图工具)无法正确发送到用户的问题。
|
||||
- 修复 WebChat 部分情况下,上一条消息文字内容增量到下一条消息的问题。
|
||||
|
||||
### 新增
|
||||
|
||||
- 支持**指令管理**,设置指令别名、解决指令冲突、查看指令详情等。入口:“插件” -> “管理行为”。
|
||||
- 支持 Google Gemini 3 系列引入的 [Thinking Level](https://ai.google.dev/gemini-api/docs/thinking#thinking-levels) 配置。
|
||||
- 支持记录每条 LLM 消息的耗时、Token 使用量、TTFT 数据,以及每次 Agent Loop 的各种统计数据。
|
||||
- AstrBot ChatUI 支持查看每条消息的 TTFT、Token 使用量数据。
|
||||
- AstrBot ChatUI 支持显示每次工具调用的耗时、参数和响应。
|
||||
- AstrBot ChatUI 支持渲染 Mermaid、LateX 内容,优化了 Code Block 的显示效果(使用 Monaco Editor),并减少 DOM 更新于内存占用。(Powered by [Simon-He95/markstream-vue](https://github.com/Simon-He95/markstream-vue))
|
||||
- 支持查看 Changelog 历史版本更新日志。
|
||||
- 🎄
|
||||
@@ -0,0 +1,44 @@
|
||||
## What's Changed
|
||||
|
||||
> 📢 在升级前,请**完整阅读**本次更新日志。
|
||||
>
|
||||
> **特别提醒:**
|
||||
> 1. 该版本为 alpha.2 预览版本。
|
||||
> 2. 本次升级**如果再降级**,会由于提供商配置的变更,导致提供商配置错乱,需要手动删除后重新添加。
|
||||
> 3. 此版本 WebUI 包体相较上一个版本增加约 **193%**,共约 **9.8 MB**,升级可能会需要一些时间。
|
||||
|
||||
## alpha.1 -> alpha.2
|
||||
|
||||
- 修复:“对话数据”页对话轨迹详情显示异常的问题
|
||||
- 优化:当 Agent 达到最大步数时的处理。在达到最大步数后,会移除所有请求中的 tools 并告知模型根据上下文进行最终总结。
|
||||
- 优化:LLM tools 执行的错误处理,减少工具调用无限循环的问题。
|
||||
- 优化:ChatUI 打开模型选择菜单时,会重新获取提供商配置。
|
||||
- 优化:ChatUI 新建对话并发送消息后,对话列表页自动选中该对话。
|
||||
|
||||
## 4.10.0 变化
|
||||
|
||||
### 重构与优化
|
||||
|
||||
- 重构 Provider 页面和提供商的配置结构,将 Chat Provider 配置拆分为 Provider Source(提供商源)和 Provider(代表提供商源的各个模型),引入了提供商模型自动发现、模型元数据自动发现的功能,**提供更加便捷的模型添加体验**。
|
||||
- ⚠️ 将 “MCP” 页面移动到了 “插件” 页面中
|
||||
- ⚠️ 将 “MCP” 页面中的工具管理移动到了 “插件” -> “管理行为” 中。
|
||||
- ⚠️ 将 “QQ 个人号(OneBot v11)” 机器人适配器类型更名为 “OneBot v11”,并将其 Logo 更改为 OneBot 的 Logo。
|
||||
- ⚠️ AstrBot WebChat 升级为 **AstrBot ChatUI**,入口从边栏修改为顶部(右上角)切换按钮。
|
||||
- 优化引用消息的逻辑,减少对模型输入缓存的破坏。
|
||||
|
||||
### 修复
|
||||
|
||||
- ‼️ 修复部分情况下,分段回复无法正常分段的问题。
|
||||
- 修复处理工具返回结果的过程中,导致一些直接发送图片的工具(如生图工具)无法正确发送到用户的问题。
|
||||
- 修复 WebChat 部分情况下,上一条消息文字内容增量到下一条消息的问题。
|
||||
|
||||
### 新增
|
||||
|
||||
- 支持**指令管理**,设置指令别名、解决指令冲突、查看指令详情等。入口:“插件” -> “管理行为”。
|
||||
- 支持 Google Gemini 3 系列引入的 [Thinking Level](https://ai.google.dev/gemini-api/docs/thinking#thinking-levels) 配置。
|
||||
- 支持记录每条 LLM 消息的耗时、Token 使用量、TTFT 数据,以及每次 Agent Loop 的各种统计数据。
|
||||
- AstrBot ChatUI 支持查看每条消息的 TTFT、Token 使用量数据。
|
||||
- AstrBot ChatUI 支持显示每次工具调用的耗时、参数和响应。
|
||||
- AstrBot ChatUI 支持渲染 Mermaid、LateX 内容,优化了 Code Block 的显示效果(使用 Monaco Editor),并减少 DOM 更新于内存占用。(Powered by [Simon-He95/markstream-vue](https://github.com/Simon-He95/markstream-vue))
|
||||
- 支持查看 Changelog 历史版本更新日志。
|
||||
- 🎄
|
||||
@@ -0,0 +1,40 @@
|
||||
## What's Changed
|
||||
|
||||
> 📢 在升级前,请**完整阅读**本次更新日志。
|
||||
>
|
||||
> **特别提醒:**
|
||||
> 1. 本次升级**如果再降级**,会由于提供商配置的变更,导致提供商配置错乱,需要手动删除后重新添加。
|
||||
> 2. 此版本 WebUI 包体相较上一个版本增加约 **193%**,共约 **9.8 MB**,升级可能会需要一些时间。
|
||||
> 3. **升级后请务必确保 WebUI 和 AstrBot Core 版本一致**,否则会产生预期之外的情况。(判断方法:日志中出现 `WebUI 版本已是最新。` 即为一致的版本,`检测到 WebUI 版本 (xxx) 与当前 AstrBot 版本 (xxx) 不符。` 即为不一致的版本。此版本的判断方法也可通查看 WebUI 右上角是否出现 Bot / Chat 的切换按钮控件来判断是否是新版本的 WebUI)。
|
||||
> 4. 如果有任何问题请提交 [Issue](https://github.com/AstrBotDevs/AstrBot/issues) 并附带 `v4.10.0` tag。
|
||||
|
||||
### 重构与优化
|
||||
|
||||
- 重构 Provider 页面和提供商的配置结构,将 Chat Provider 配置拆分为 Provider Source(提供商源)和 Provider(代表提供商源的各个模型),引入了提供商模型自动发现、模型元数据自动发现的功能,**提供更加便捷的模型添加体验**。
|
||||
- ⚠️ 将 “MCP” 页面移动到了 “插件” 页面中
|
||||
- ⚠️ 将 “MCP” 页面中的工具管理移动到了 “插件” -> “管理行为” 中。
|
||||
- ⚠️ 将 “QQ 个人号(OneBot v11)” 机器人适配器类型更名为 “OneBot v11”,并将其 Logo 更改为 OneBot 的 Logo。
|
||||
- ⚠️ AstrBot WebChat 升级为 **AstrBot ChatUI**,入口从边栏修改为顶部(右上角)切换按钮。
|
||||
- 优化引用消息的逻辑,减少对模型输入缓存的破坏。
|
||||
- 优化当 Agent 达到最大步数时的处理。在达到最大步数后,会移除所有请求中的 tools 并告知模型根据上下文进行最终总结。
|
||||
- 优化 LLM tools 执行的错误处理,减少工具调用无限循环的问题。
|
||||
|
||||
|
||||
### 修复
|
||||
|
||||
- ‼️ 修复部分情况下,分段回复无法正常分段的问题。
|
||||
- 修复处理工具返回结果的过程中,导致一些直接发送图片的工具(如生图工具)无法正确发送到用户的问题。
|
||||
- 修复 WebChat 部分情况下,上一条消息文字内容增量到下一条消息的问题。
|
||||
|
||||
### 新增
|
||||
|
||||
- 支持**指令管理**,设置指令别名、解决指令冲突、查看指令详情等。入口:“插件” -> “管理行为”。
|
||||
- 支持 Google Gemini 3 系列引入的 [Thinking Level](https://ai.google.dev/gemini-api/docs/thinking#thinking-levels) 配置。
|
||||
- 支持记录每条 LLM 消息的耗时、Token 使用量、TTFT 数据,以及每次 Agent Loop 的各种统计数据。
|
||||
- AstrBot ChatUI 支持查看每条消息的 TTFT、Token 使用量数据。
|
||||
- AstrBot ChatUI 支持显示每次工具调用的耗时、参数和响应。
|
||||
- AstrBot ChatUI 支持渲染 Mermaid、LateX 内容,优化了 Code Block 的显示效果(使用 Monaco Editor),并减少 DOM 更新于内存占用。(Powered by [Simon-He95/markstream-vue](https://github.com/Simon-He95/markstream-vue))
|
||||
- 支持查看 Changelog 历史版本更新日志。
|
||||
- 🎄
|
||||
|
||||
Merry Christmas!
|
||||
@@ -0,0 +1,46 @@
|
||||
## What's Changed
|
||||
|
||||
> 📢 在升级前,请**完整阅读**本次更新日志。
|
||||
>
|
||||
> **特别提醒:**
|
||||
> 1. 本次升级**如果再降级**,会由于提供商配置的变更,导致提供商配置错乱,需要手动删除后重新添加。
|
||||
> 2. 此版本 WebUI 包体相较上一个版本增加约 **193%**,共约 **9.8 MB**,升级可能会需要一些时间。
|
||||
> 3. **升级后请务必确保 WebUI 和 AstrBot Core 版本一致**,否则会产生预期之外的情况。(判断方法:日志中出现 `WebUI 版本已是最新。` 即为一致的版本,`检测到 WebUI 版本 (xxx) 与当前 AstrBot 版本 (xxx) 不符。` 即为不一致的版本。此版本的判断方法也可通查看 WebUI 右上角是否出现 Bot / Chat 的切换按钮控件来判断是否是新版本的 WebUI)。
|
||||
> 4. 如果有任何问题请提交 [Issue](https://github.com/AstrBotDevs/AstrBot/issues) 并附带 `v4.10.0` tag。
|
||||
|
||||
## 4.10.0 -> 4.10.1
|
||||
|
||||
- fix(core): 修复极少数情况下由于指令管理导致的 AstrBot 启动失败的问题
|
||||
- fix(core): 修复当提供商源带有斜杠(“/”)时,无法删除 / 更新提供商源的问题(报错 405)
|
||||
- perf(core): 优化 OneBot 适配器的消息段解析逻辑,修复部分情况下无法正确解析消息段的问题
|
||||
|
||||
### 重构与优化
|
||||
|
||||
- 重构 Provider 页面和提供商的配置结构,将 Chat Provider 配置拆分为 Provider Source(提供商源)和 Provider(代表提供商源的各个模型),引入了提供商模型自动发现、模型元数据自动发现的功能,**提供更加便捷的模型添加体验**。
|
||||
- ⚠️ 将 “MCP” 页面移动到了 “插件” 页面中
|
||||
- ⚠️ 将 “MCP” 页面中的工具管理移动到了 “插件” -> “管理行为” 中。
|
||||
- ⚠️ 将 “QQ 个人号(OneBot v11)” 机器人适配器类型更名为 “OneBot v11”,并将其 Logo 更改为 OneBot 的 Logo。
|
||||
- ⚠️ AstrBot WebChat 升级为 **AstrBot ChatUI**,入口从边栏修改为顶部(右上角)切换按钮。
|
||||
- 优化引用消息的逻辑,减少对模型输入缓存的破坏。
|
||||
- 优化当 Agent 达到最大步数时的处理。在达到最大步数后,会移除所有请求中的 tools 并告知模型根据上下文进行最终总结。
|
||||
- 优化 LLM tools 执行的错误处理,减少工具调用无限循环的问题。
|
||||
|
||||
|
||||
### 修复
|
||||
|
||||
- ‼️ 修复部分情况下,分段回复无法正常分段的问题。
|
||||
- 修复处理工具返回结果的过程中,导致一些直接发送图片的工具(如生图工具)无法正确发送到用户的问题。
|
||||
- 修复 WebChat 部分情况下,上一条消息文字内容增量到下一条消息的问题。
|
||||
|
||||
### 新增
|
||||
|
||||
- 支持**指令管理**,设置指令别名、解决指令冲突、查看指令详情等。入口:“插件” -> “管理行为”。
|
||||
- 支持 Google Gemini 3 系列引入的 [Thinking Level](https://ai.google.dev/gemini-api/docs/thinking#thinking-levels) 配置。
|
||||
- 支持记录每条 LLM 消息的耗时、Token 使用量、TTFT 数据,以及每次 Agent Loop 的各种统计数据。
|
||||
- AstrBot ChatUI 支持查看每条消息的 TTFT、Token 使用量数据。
|
||||
- AstrBot ChatUI 支持显示每次工具调用的耗时、参数和响应。
|
||||
- AstrBot ChatUI 支持渲染 Mermaid、LateX 内容,优化了 Code Block 的显示效果(使用 Monaco Editor),并减少 DOM 更新于内存占用。(Powered by [Simon-He95/markstream-vue](https://github.com/Simon-He95/markstream-vue))
|
||||
- 支持查看 Changelog 历史版本更新日志。
|
||||
- 🎄
|
||||
|
||||
Merry Christmas!
|
||||
@@ -0,0 +1,9 @@
|
||||
## What's Changed
|
||||
|
||||
### 修复
|
||||
|
||||
1. ‼️‼️ 修复了由 `psutil` 新版本导致的启动时报错的问题。
|
||||
|
||||
### 新增
|
||||
|
||||
1. 插件指令管理支持管理别名。
|
||||
@@ -8,7 +8,7 @@
|
||||
<meta name="description" content="AstrBot Dashboard" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Poppins:wght@400;500;600;700&family=Roboto:wght@400;500;700&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit&family=Poppins:wght@400;500;600;700&family=Roboto:wght@400;500;700&display=swap"
|
||||
/>
|
||||
<title>AstrBot - 仪表盘</title>
|
||||
</head>
|
||||
|
||||
@@ -14,22 +14,26 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@guolao/vue-monaco-editor": "^1.5.4",
|
||||
"@mdit/plugin-katex": "^0.24.1",
|
||||
"@tiptap/starter-kit": "2.1.7",
|
||||
"@tiptap/vue-3": "2.1.7",
|
||||
"apexcharts": "3.42.0",
|
||||
"axios": ">=1.6.2 <1.10.0 || >1.10.0 <2.0.0",
|
||||
"axios-mock-adapter": "^1.22.0",
|
||||
"chance": "1.1.11",
|
||||
"d3": "^7.9.0",
|
||||
"date-fns": "2.30.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"js-md5": "^0.8.3",
|
||||
"katex": "^0.16.27",
|
||||
"lodash": "4.17.21",
|
||||
"marked": "^15.0.7",
|
||||
"markdown-it": "^14.1.0",
|
||||
"pinyin-pro": "^3.26.0",
|
||||
"markstream-vue": "0.0.3-beta.7",
|
||||
"mermaid": "^11.12.2",
|
||||
"pinia": "2.1.6",
|
||||
"pinyin-pro": "^3.26.0",
|
||||
"remixicon": "3.5.0",
|
||||
"shiki": "^3.20.0",
|
||||
"stream-markdown": "^0.0.11",
|
||||
"stream-monaco": "^0.0.8",
|
||||
"vee-validate": "4.11.3",
|
||||
"vite-plugin-vuetify": "1.0.2",
|
||||
"vue": "3.3.4",
|
||||
@@ -44,7 +48,6 @@
|
||||
"@mdi/font": "7.2.96",
|
||||
"@rushstack/eslint-patch": "1.3.3",
|
||||
"@types/chance": "1.1.3",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^20.5.7",
|
||||
"@vitejs/plugin-vue": "4.3.3",
|
||||
"@vue/eslint-config-prettier": "8.0.0",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 48 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
@@ -18,63 +18,39 @@
|
||||
@editTitle="showEditTitleDialog"
|
||||
@deleteConversation="handleDeleteConversation"
|
||||
@closeMobileSidebar="closeMobileSidebar"
|
||||
@toggleTheme="toggleTheme"
|
||||
@toggleFullscreen="toggleFullscreen"
|
||||
/>
|
||||
|
||||
<!-- 右侧聊天内容区域 -->
|
||||
<div class="chat-content-panel">
|
||||
|
||||
<div class="conversation-header fade-in">
|
||||
<div class="conversation-header fade-in" v-if="isMobile">
|
||||
<!-- 手机端菜单按钮 -->
|
||||
<v-btn icon class="mobile-menu-btn" @click="toggleMobileSidebar" v-if="isMobile" variant="text">
|
||||
<v-btn icon class="mobile-menu-btn" @click="toggleMobileSidebar" variant="text">
|
||||
<v-icon>mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<!-- <div v-if="currCid && getCurrentConversation">
|
||||
<h3
|
||||
style="max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||
{{ getCurrentConversation.title || tm('conversation.newConversation') }}</h3>
|
||||
<span style="font-size: 12px;">{{ formatDate(getCurrentConversation.updated_at) }}</span>
|
||||
</div> -->
|
||||
<div class="conversation-header-actions">
|
||||
<!-- router 推送到 /chatbox -->
|
||||
<v-tooltip :text="tm('actions.fullscreen')" v-if="!chatboxMode">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-icon v-bind="props"
|
||||
@click="router.push(currSessionId ? `/chatbox/${currSessionId}` : '/chatbox')"
|
||||
class="fullscreen-icon">mdi-fullscreen</v-icon>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<!-- 语言切换按钮 -->
|
||||
<v-tooltip :text="t('core.common.language')" v-if="chatboxMode">
|
||||
<template v-slot:activator="{ props }">
|
||||
<LanguageSwitcher variant="chatbox" />
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<!-- 主题切换按钮 -->
|
||||
<v-tooltip :text="isDark ? tm('modes.lightMode') : tm('modes.darkMode')" v-if="chatboxMode">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn v-bind="props" icon @click="toggleTheme" class="theme-toggle-icon"
|
||||
size="small" rounded="sm" style="margin-right: 8px;" variant="text">
|
||||
<v-icon>{{ isDark ? 'mdi-weather-night' : 'mdi-white-balance-sunny' }}</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<!-- router 推送到 /chat -->
|
||||
<v-tooltip :text="tm('actions.exitFullscreen')" v-if="chatboxMode">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-icon v-bind="props" @click="router.push(currSessionId ? `/chat/${currSessionId}` : '/chat')"
|
||||
class="fullscreen-icon">mdi-fullscreen-exit</v-icon>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MessageList v-if="messages && messages.length > 0" :messages="messages" :isDark="isDark"
|
||||
:isStreaming="isStreaming || isConvRunning" @openImagePreview="openImagePreview"
|
||||
@replyMessage="handleReplyMessage"
|
||||
ref="messageList" />
|
||||
<div class="message-list-wrapper" v-if="messages && messages.length > 0">
|
||||
<MessageList :messages="messages" :isDark="isDark"
|
||||
:isStreaming="isStreaming || isConvRunning"
|
||||
:isLoadingMessages="isLoadingMessages"
|
||||
@openImagePreview="openImagePreview"
|
||||
@replyMessage="handleReplyMessage"
|
||||
ref="messageList" />
|
||||
<div class="message-list-fade" :class="{ 'fade-dark': isDark }"></div>
|
||||
</div>
|
||||
<div class="welcome-container fade-in" v-else>
|
||||
<div class="welcome-title">
|
||||
<div v-if="isLoadingMessages" class="loading-overlay-welcome">
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
size="48"
|
||||
width="4"
|
||||
color="primary"
|
||||
></v-progress-circular>
|
||||
</div>
|
||||
<div v-else class="welcome-title">
|
||||
<span>Hello, I'm</span>
|
||||
<span class="bot-name">AstrBot ⭐</span>
|
||||
</div>
|
||||
@@ -173,6 +149,7 @@ const isMobile = ref(false);
|
||||
const mobileMenuOpen = ref(false);
|
||||
const imagePreviewDialog = ref(false);
|
||||
const previewImageUrl = ref('');
|
||||
const isLoadingMessages = ref(false);
|
||||
|
||||
// 使用 composables
|
||||
const {
|
||||
@@ -260,6 +237,14 @@ function toggleTheme() {
|
||||
theme.global.name.value = newTheme;
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (props.chatboxMode) {
|
||||
router.push(currSessionId.value ? `/chat/${currSessionId.value}` : '/chat');
|
||||
} else {
|
||||
router.push(currSessionId.value ? `/chatbox/${currSessionId.value}` : '/chatbox');
|
||||
}
|
||||
}
|
||||
|
||||
function openImagePreview(imageUrl: string) {
|
||||
previewImageUrl.value = imageUrl;
|
||||
imagePreviewDialog.value = true;
|
||||
@@ -303,11 +288,14 @@ function clearReply() {
|
||||
async function handleSelectConversation(sessionIds: string[]) {
|
||||
if (!sessionIds[0]) return;
|
||||
|
||||
// 立即更新选中状态,避免需要点击两次
|
||||
currSessionId.value = sessionIds[0];
|
||||
selectedSessions.value = [sessionIds[0]];
|
||||
|
||||
// 更新 URL
|
||||
const basePath = props.chatboxMode ? '/chatbox' : '/chat';
|
||||
if (route.path !== `${basePath}/${sessionIds[0]}`) {
|
||||
router.push(`${basePath}/${sessionIds[0]}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 手机端关闭侧边栏
|
||||
@@ -317,11 +305,15 @@ async function handleSelectConversation(sessionIds: string[]) {
|
||||
|
||||
// 清除引用状态
|
||||
clearReply();
|
||||
|
||||
currSessionId.value = sessionIds[0];
|
||||
selectedSessions.value = [sessionIds[0]];
|
||||
|
||||
await getSessionMsg(sessionIds[0], router);
|
||||
// 开始加载消息
|
||||
isLoadingMessages.value = true;
|
||||
|
||||
try {
|
||||
await getSessionMsg(sessionIds[0]);
|
||||
} finally {
|
||||
isLoadingMessages.value = false;
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
messageList.value?.scrollToBottom();
|
||||
@@ -510,6 +502,29 @@ onBeforeUnmount(() => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.message-list-wrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.message-list-fade {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 40px;
|
||||
background: linear-gradient(to top, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.message-list-fade.fade-dark {
|
||||
background: linear-gradient(to top, rgba(30, 30, 30, 1) 0%, rgba(30, 30, 30, 0) 100%);
|
||||
}
|
||||
|
||||
.conversation-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -543,6 +558,7 @@ onBeforeUnmount(() => {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
@@ -550,6 +566,12 @@ onBeforeUnmount(() => {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.loading-overlay-welcome {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bot-name {
|
||||
font-weight: 700;
|
||||
margin-left: 8px;
|
||||
@@ -575,5 +597,9 @@ onBeforeUnmount(() => {
|
||||
.chat-page-container {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.conversation-header {
|
||||
padding: 2px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
<template>
|
||||
<div class="input-area fade-in">
|
||||
<div class="input-container"
|
||||
style="width: 85%; max-width: 900px; margin: 0 auto; border: 1px solid #e0e0e0; border-radius: 24px;">
|
||||
:style="{
|
||||
width: '85%',
|
||||
maxWidth: '900px',
|
||||
margin: '0 auto',
|
||||
border: isDark ? 'none' : '1px solid #e0e0e0',
|
||||
borderRadius: '24px',
|
||||
boxShadow: isDark ? 'none' : '0px 2px 2px rgba(0, 0, 0, 0.1)',
|
||||
backgroundColor: isDark ? '#2d2d2d' : 'transparent'
|
||||
}">
|
||||
<!-- 引用预览区 -->
|
||||
<div class="reply-preview" v-if="props.replyTo">
|
||||
<div class="reply-content">
|
||||
@@ -16,8 +24,8 @@
|
||||
@keydown="handleKeyDown"
|
||||
:disabled="disabled"
|
||||
placeholder="Ask AstrBot..."
|
||||
style="width: 100%; resize: none; outline: none; border: 1px solid var(--v-theme-border); border-radius: 12px; padding: 8px 16px; min-height: 40px; font-family: inherit; font-size: 16px; background-color: var(--v-theme-surface);"></textarea>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; padding: 0px 12px;">
|
||||
style="width: 100%; resize: none; outline: none; border: 1px solid var(--v-theme-border); border-radius: 12px; padding: 12px 16px; min-height: 40px; font-family: inherit; font-size: 16px; background-color: var(--v-theme-surface);"></textarea>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; padding: 6px 14px;">
|
||||
<div style="display: flex; justify-content: flex-start; margin-top: 4px; align-items: center; gap: 8px;">
|
||||
<ConfigSelector
|
||||
:session-id="sessionId || null"
|
||||
@@ -26,7 +34,9 @@
|
||||
:initial-config-id="props.configId"
|
||||
@config-changed="handleConfigChange"
|
||||
/>
|
||||
<ProviderModelSelector v-if="showProviderSelector" ref="providerModelSelectorRef" />
|
||||
|
||||
<!-- Provider/Model Selector Menu -->
|
||||
<ProviderModelMenu v-if="showProviderSelector" ref="providerModelMenuRef" />
|
||||
|
||||
<v-tooltip :text="enableStreaming ? tm('streaming.enabled') : tm('streaming.disabled')" location="top">
|
||||
<template v-slot:activator="{ props }">
|
||||
@@ -84,8 +94,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import ProviderModelSelector from './ProviderModelSelector.vue';
|
||||
import { useCustomizerStore } from '@/stores/customizer';
|
||||
import ConfigSelector from './ConfigSelector.vue';
|
||||
import ProviderModelMenu from './ProviderModelMenu.vue';
|
||||
import type { Session } from '@/composables/useSessions';
|
||||
|
||||
interface StagedFileInfo {
|
||||
@@ -138,10 +149,11 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const { tm } = useModuleI18n('features/chat');
|
||||
const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark');
|
||||
|
||||
const inputField = ref<HTMLTextAreaElement | null>(null);
|
||||
const imageInputRef = ref<HTMLInputElement | null>(null);
|
||||
const providerModelSelectorRef = ref<InstanceType<typeof ProviderModelSelector> | null>(null);
|
||||
const providerModelMenuRef = ref<InstanceType<typeof ProviderModelMenu> | null>(null);
|
||||
const showProviderSelector = ref(true);
|
||||
|
||||
const localPrompt = computed({
|
||||
@@ -234,7 +246,7 @@ function getCurrentSelection() {
|
||||
if (!showProviderSelector.value) {
|
||||
return null;
|
||||
}
|
||||
return providerModelSelectorRef.value?.getCurrentSelection();
|
||||
return providerModelMenuRef.value?.getCurrentSelection();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -259,7 +271,7 @@ defineExpose({
|
||||
<style scoped>
|
||||
.input-area {
|
||||
padding: 16px;
|
||||
background-color: var(--v-theme-surface);
|
||||
background-color: transparent;
|
||||
position: relative;
|
||||
border-top: 1px solid var(--v-theme-border);
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-dialog v-model="dialog" max-width="480" persistent>
|
||||
<v-dialog v-model="dialog" max-width="480">
|
||||
<v-card>
|
||||
<v-card-title class="d-flex align-center justify-space-between">
|
||||
<span>选择配置文件</span>
|
||||
|
||||
@@ -5,21 +5,11 @@
|
||||
'mobile-sidebar-open': isMobile && mobileMenuOpen,
|
||||
'mobile-sidebar': isMobile
|
||||
}"
|
||||
:style="{ 'background-color': isDark ? sidebarCollapsed ? '#1e1e1e' : '#2d2d2d' : sidebarCollapsed ? '#ffffff' : '#f1f4f9' }"
|
||||
@mouseenter="handleSidebarMouseEnter"
|
||||
@mouseleave="handleSidebarMouseLeave">
|
||||
|
||||
<div style="display: flex; align-items: center; justify-content: center; padding: 16px; padding-bottom: 0px;"
|
||||
v-if="chatboxMode">
|
||||
<img width="50" src="@/assets/images/icon-no-shadow.svg" alt="AstrBot Logo">
|
||||
<span v-if="!sidebarCollapsed"
|
||||
style="font-weight: 1000; font-size: 26px; margin-left: 8px;">AstrBot</span>
|
||||
</div>
|
||||
:style="{ 'background-color': isDark ? sidebarCollapsed ? '#1e1e1e' : '#2d2d2d' : sidebarCollapsed ? '#ffffff' : '#f1f4f9' }">
|
||||
|
||||
<div class="sidebar-collapse-btn-container" v-if="!isMobile">
|
||||
<v-btn icon class="sidebar-collapse-btn" @click="toggleSidebar" variant="text" color="deep-purple">
|
||||
<v-icon>{{ (sidebarCollapsed || (!sidebarCollapsed && sidebarHoverExpanded)) ?
|
||||
'mdi-chevron-right' : 'mdi-chevron-left' }}</v-icon>
|
||||
<v-icon>{{ sidebarCollapsed ? 'mdi-chevron-right' : 'mdi-chevron-left' }}</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
@@ -30,19 +20,14 @@
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div style="padding: 16px; padding-top: 8px;">
|
||||
<div style="padding: 8px; opacity: 0.6;">
|
||||
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId"
|
||||
v-if="!sidebarCollapsed || isMobile" prepend-icon="mdi-plus"
|
||||
style="background-color: transparent !important; border-radius: 4px;">{{ tm('actions.newChat') }}</v-btn>
|
||||
<v-btn icon="mdi-plus" rounded="lg" @click="$emit('newChat')" :disabled="!currSessionId"
|
||||
v-if="!sidebarCollapsed || isMobile" prepend-icon="mdi-square-edit-outline">{{ tm('actions.newChat') }}</v-btn>
|
||||
<v-btn icon="mdi-square-edit-outline" rounded="xl" @click="$emit('newChat')" :disabled="!currSessionId"
|
||||
v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn>
|
||||
</div>
|
||||
|
||||
<div v-if="!sidebarCollapsed || isMobile">
|
||||
<v-divider class="mx-4"></v-divider>
|
||||
</div>
|
||||
|
||||
<div style="overflow-y: auto; flex-grow: 1;" :class="{ 'fade-in': sidebarHoverExpanded }"
|
||||
<div style="overflow-y: auto; flex-grow: 1;"
|
||||
v-if="!sidebarCollapsed || isMobile">
|
||||
<v-card v-if="sessions.length > 0" flat style="background-color: transparent;">
|
||||
<v-list density="compact" nav class="conversation-list"
|
||||
@@ -50,18 +35,19 @@
|
||||
@update:selected="$emit('selectConversation', $event)">
|
||||
<v-list-item v-for="item in sessions" :key="item.session_id" :value="item.session_id"
|
||||
rounded="lg" class="conversation-item" active-color="secondary">
|
||||
<v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title">
|
||||
<v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title"
|
||||
:style="{ color: isDark ? '#ffffff' : '#000000' }">
|
||||
{{ item.display_name || tm('conversation.newConversation') }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle v-if="!sidebarCollapsed || isMobile" class="timestamp">
|
||||
<!-- <v-list-item-subtitle v-if="!sidebarCollapsed || isMobile" class="timestamp">
|
||||
{{ new Date(item.updated_at).toLocaleString() }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item-subtitle> -->
|
||||
|
||||
<template v-if="!sidebarCollapsed || isMobile" v-slot:append>
|
||||
<div class="conversation-actions">
|
||||
<v-btn icon="mdi-pencil" size="x-small" variant="text"
|
||||
class="edit-title-btn"
|
||||
@click.stop="$emit('editTitle', item.session_id, item.display_name)" />
|
||||
@click.stop="$emit('editTitle', item.session_id, item.display_name ?? '')" />
|
||||
<v-btn icon="mdi-delete" size="x-small" variant="text"
|
||||
class="delete-conversation-btn" color="error"
|
||||
@click.stop="handleDeleteConversation(item)" />
|
||||
@@ -74,19 +60,83 @@
|
||||
<v-fade-transition>
|
||||
<div class="no-conversations" v-if="sessions.length === 0">
|
||||
<v-icon icon="mdi-message-text-outline" size="large" color="grey-lighten-1"></v-icon>
|
||||
<div class="no-conversations-text" v-if="!sidebarCollapsed || sidebarHoverExpanded || isMobile">
|
||||
<div class="no-conversations-text" v-if="!sidebarCollapsed || isMobile">
|
||||
{{ tm('conversation.noHistory') }}
|
||||
</div>
|
||||
</div>
|
||||
</v-fade-transition>
|
||||
</div>
|
||||
|
||||
<!-- 收起时的占位元素 -->
|
||||
<div class="sidebar-spacer" v-if="sidebarCollapsed && !isMobile"></div>
|
||||
|
||||
<!-- 底部设置按钮 -->
|
||||
<div class="sidebar-footer">
|
||||
<StyledMenu location="top" :close-on-content-click="false">
|
||||
<template v-slot:activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
v-bind="menuProps"
|
||||
:icon="sidebarCollapsed && !isMobile"
|
||||
:block="!sidebarCollapsed || isMobile"
|
||||
variant="text"
|
||||
class="settings-btn"
|
||||
:class="{ 'settings-btn-collapsed': sidebarCollapsed && !isMobile }"
|
||||
:prepend-icon="(!sidebarCollapsed || isMobile) ? 'mdi-cog-outline' : undefined"
|
||||
>
|
||||
<v-icon v-if="sidebarCollapsed && !isMobile">mdi-cog-outline</v-icon>
|
||||
<template v-if="!sidebarCollapsed || isMobile">{{ t('core.common.settings') }}</template>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<!-- 语言切换 -->
|
||||
<v-list-item class="styled-menu-item">
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-translate</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ t('core.common.language') }}</v-list-item-title>
|
||||
<template v-slot:append>
|
||||
<LanguageSwitcher variant="chatbox" />
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<!-- 主题切换 -->
|
||||
<v-list-item class="styled-menu-item" @click="$emit('toggleTheme')">
|
||||
<template v-slot:prepend>
|
||||
<v-icon>{{ isDark ? 'mdi-weather-night' : 'mdi-white-balance-sunny' }}</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ isDark ? tm('modes.lightMode') : tm('modes.darkMode') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<!-- 全屏/退出全屏 -->
|
||||
<v-list-item class="styled-menu-item" @click="$emit('toggleFullscreen')">
|
||||
<template v-slot:prepend>
|
||||
<v-icon>{{ chatboxMode ? 'mdi-fullscreen-exit' : 'mdi-fullscreen' }}</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ chatboxMode ? tm('actions.exitFullscreen') : tm('actions.fullscreen') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<!-- 提供商配置 -->
|
||||
<v-list-item class="styled-menu-item" @click="showProviderConfigDialog = true">
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-creation</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ tm('actions.providerConfig') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</StyledMenu>
|
||||
</div>
|
||||
|
||||
<!-- 提供商配置对话框 -->
|
||||
<ProviderConfigDialog v-model="showProviderConfigDialog" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||
import type { Session } from '@/composables/useSessions';
|
||||
import LanguageSwitcher from '@/components/shared/LanguageSwitcher.vue';
|
||||
import StyledMenu from '@/components/shared/StyledMenu.vue';
|
||||
import ProviderConfigDialog from '@/components/chat/ProviderConfigDialog.vue';
|
||||
|
||||
interface Props {
|
||||
sessions: Session[];
|
||||
@@ -106,15 +156,15 @@ const emit = defineEmits<{
|
||||
editTitle: [sessionId: string, title: string];
|
||||
deleteConversation: [sessionId: string];
|
||||
closeMobileSidebar: [];
|
||||
toggleTheme: [];
|
||||
toggleFullscreen: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { tm } = useModuleI18n('features/chat');
|
||||
|
||||
const sidebarCollapsed = ref(true);
|
||||
const sidebarHovered = ref(false);
|
||||
const sidebarHoverTimer = ref<number | null>(null);
|
||||
const sidebarHoverExpanded = ref(false);
|
||||
const sidebarHoverDelay = 100;
|
||||
const showProviderConfigDialog = ref(false);
|
||||
|
||||
// 从 localStorage 读取侧边栏折叠状态
|
||||
const savedCollapsedState = localStorage.getItem('sidebarCollapsed');
|
||||
@@ -125,40 +175,10 @@ if (savedCollapsedState !== null) {
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
if (sidebarHoverExpanded.value) {
|
||||
sidebarHoverExpanded.value = false;
|
||||
return;
|
||||
}
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value;
|
||||
localStorage.setItem('sidebarCollapsed', JSON.stringify(sidebarCollapsed.value));
|
||||
}
|
||||
|
||||
function handleSidebarMouseEnter() {
|
||||
if (!sidebarCollapsed.value || props.isMobile) return;
|
||||
|
||||
sidebarHovered.value = true;
|
||||
sidebarHoverTimer.value = window.setTimeout(() => {
|
||||
if (sidebarHovered.value) {
|
||||
sidebarHoverExpanded.value = true;
|
||||
sidebarCollapsed.value = false;
|
||||
}
|
||||
}, sidebarHoverDelay);
|
||||
}
|
||||
|
||||
function handleSidebarMouseLeave() {
|
||||
sidebarHovered.value = false;
|
||||
|
||||
if (sidebarHoverTimer.value) {
|
||||
clearTimeout(sidebarHoverTimer.value);
|
||||
sidebarHoverTimer.value = null;
|
||||
}
|
||||
|
||||
if (sidebarHoverExpanded.value) {
|
||||
sidebarCollapsed.value = true;
|
||||
}
|
||||
sidebarHoverExpanded.value = false;
|
||||
}
|
||||
|
||||
function handleDeleteConversation(session: Session) {
|
||||
const sessionTitle = session.display_name || tm('conversation.newConversation');
|
||||
const message = tm('conversation.confirmDelete', { name: sessionTitle });
|
||||
@@ -184,8 +204,8 @@ function handleDeleteConversation(session: Session) {
|
||||
}
|
||||
|
||||
.sidebar-collapsed {
|
||||
max-width: 75px;
|
||||
min-width: 75px;
|
||||
max-width: 60px;
|
||||
min-width: 60px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
@@ -206,7 +226,7 @@ function handleDeleteConversation(session: Session) {
|
||||
}
|
||||
|
||||
.sidebar-collapse-btn-container {
|
||||
margin: 16px;
|
||||
margin: 8px;
|
||||
margin-bottom: 0px;
|
||||
z-index: 10;
|
||||
}
|
||||
@@ -218,13 +238,19 @@ function handleDeleteConversation(session: Session) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
margin-bottom: 4px;
|
||||
border-radius: 8px !important;
|
||||
transition: all 0.2s ease;
|
||||
height: auto !important;
|
||||
min-height: 56px;
|
||||
.new-chat-btn {
|
||||
justify-content: flex-start;
|
||||
background-color: transparent !important;
|
||||
border-radius: 20px;
|
||||
padding: 8px 16px !important;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
/* margin-bottom: 4px; */
|
||||
border-radius: 20px !important;
|
||||
height: auto !important;
|
||||
/* min-height: 56px; */
|
||||
padding: 0px 16px !important;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -287,17 +313,31 @@ function handleDeleteConversation(session: Session) {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeInContent 0.3s ease;
|
||||
.sidebar-spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
@keyframes fadeInContent {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
.sidebar-footer {
|
||||
padding: 8px 8px;
|
||||
padding-bottom: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-btn {
|
||||
opacity: 0.6;
|
||||
justify-content: flex-start;
|
||||
padding: 8px 16px !important;
|
||||
border-radius: 20px !important;
|
||||
}
|
||||
|
||||
.settings-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.settings-btn-collapsed {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,375 @@
|
||||
<template>
|
||||
<v-dialog v-model="dialog" :max-width="isMobile ? undefined : '1400'" :fullscreen="isMobile" scrollable>
|
||||
<v-card class="provider-config-dialog" :class="{ 'mobile-dialog': isMobile }">
|
||||
<v-card-title class="d-flex align-center justify-space-between pa-4 pb-0">
|
||||
<div class="d-flex align-center ga-2">
|
||||
<span class="text-h2 font-weight-bold">{{ tm('title') }}</span>
|
||||
</div>
|
||||
<v-btn icon variant="text" @click="closeDialog">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-4 pt-0" :class="{ 'mobile-content': isMobile }"
|
||||
:style="isMobile ? {} : { height: 'calc(100vh - 200px); max-height: 800px;' }">
|
||||
<div :class="isMobile ? 'mobile-layout' : 'd-flex'" :style="isMobile ? {} : { height: '100%' }">
|
||||
<!-- 左侧:Provider Sources 列表 -->
|
||||
<div class="provider-sources-column" :class="{ 'mobile-sources': isMobile }"
|
||||
:style="isMobile ? {} : { width: '320px', minWidth: '320px', borderRight: '1px solid rgba(var(--v-border-color), var(--v-border-opacity))', overflowY: 'auto' }">
|
||||
<ProviderSourcesPanel :displayed-provider-sources="displayedProviderSources"
|
||||
:selected-provider-source="selectedProviderSource" :available-source-types="availableSourceTypes" :tm="tm"
|
||||
:resolve-source-icon="resolveSourceIcon" :get-source-display-name="getSourceDisplayName"
|
||||
@add-provider-source="addProviderSource" @select-provider-source="selectProviderSource"
|
||||
@delete-provider-source="deleteProviderSource" />
|
||||
</div>
|
||||
|
||||
<!-- 右侧:配置和模型 -->
|
||||
<div class="provider-config-column" :class="{ 'mobile-config': isMobile }"
|
||||
:style="isMobile ? {} : { flex: 1, overflowY: 'auto', minWidth: 0 }">
|
||||
<div v-if="selectedProviderSource" class="pa-4">
|
||||
<!-- Provider Source 配置 -->
|
||||
<div class="mb-4">
|
||||
<div class="d-flex align-center justify-space-between mb-3">
|
||||
<div>
|
||||
<div class="text-h5 font-weight-bold">{{ selectedProviderSource.id }}</div>
|
||||
<div class="text-caption text-medium-emphasis">{{ selectedProviderSource.api_base || 'N/A' }}</div>
|
||||
</div>
|
||||
<v-btn color="success" prepend-icon="mdi-check" :loading="savingSource" :disabled="!isSourceModified"
|
||||
@click="saveProviderSource" variant="flat">
|
||||
{{ tm('providerSources.save') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- 基础配置 -->
|
||||
<div class="mb-4">
|
||||
<AstrBotConfig v-if="basicSourceConfig" :iterable="basicSourceConfig" :metadata="configSchema"
|
||||
metadataKey="provider" :is-editing="true" />
|
||||
</div>
|
||||
|
||||
<!-- 高级配置 -->
|
||||
<v-expansion-panels variant="accordion" class="mb-4">
|
||||
<v-expansion-panel elevation="0" class="border rounded-lg">
|
||||
<v-expansion-panel-title>
|
||||
<span class="font-weight-medium">{{ tm('providerSources.advancedConfig') }}</span>
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<AstrBotConfig v-if="advancedSourceConfig" :iterable="advancedSourceConfig"
|
||||
:metadata="configSchema" metadataKey="provider" :is-editing="true" />
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
|
||||
<!-- 模型配置 -->
|
||||
<ProviderModelsPanel :entries="filteredMergedModelEntries" :available-count="availableModels.length"
|
||||
v-model:model-search="modelSearch" :loading-models="loadingModels"
|
||||
:is-source-modified="isSourceModified" :supports-image-input="supportsImageInput"
|
||||
:supports-tool-call="supportsToolCall" :supports-reasoning="supportsReasoning"
|
||||
:format-context-limit="formatContextLimit" :testing-providers="testingProviders" :tm="tm"
|
||||
@fetch-models="fetchAvailableModels" @open-manual-model="openManualModelDialog"
|
||||
@open-provider-edit="openProviderEdit" @toggle-provider-enable="toggleProviderEnable"
|
||||
@test-provider="testProvider" @delete-provider="deleteProvider"
|
||||
@add-model-provider="addModelProvider" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="d-flex align-center justify-center" style="height: 100%;">
|
||||
<div class="text-center text-medium-emphasis">
|
||||
<v-icon size="64" color="grey-lighten-1">mdi-cursor-default-click</v-icon>
|
||||
<p class="mt-4 text-h6">{{ tm('providerSources.selectHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- 手动添加模型对话框 -->
|
||||
<v-dialog v-model="showManualModelDialog" max-width="400">
|
||||
<v-card :title="tm('models.manualDialogTitle')">
|
||||
<v-card-text class="py-4">
|
||||
<v-text-field v-model="manualModelId" :label="tm('models.manualDialogModelLabel')" flat variant="solo-filled"
|
||||
autofocus clearable></v-text-field>
|
||||
<v-text-field :model-value="manualProviderId" flat variant="solo-filled"
|
||||
:label="tm('models.manualDialogPreviewLabel')" persistent-hint
|
||||
:hint="tm('models.manualDialogPreviewHint')"></v-text-field>
|
||||
</v-card-text>
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="text" @click="showManualModelDialog = false">取消</v-btn>
|
||||
<v-btn color="primary" @click="confirmManualModel">添加</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 已配置模型编辑对话框 -->
|
||||
<v-dialog v-model="showProviderEditDialog" width="800">
|
||||
<v-card :title="providerEditData?.id || tm('dialogs.config.editTitle')">
|
||||
<v-card-text class="py-4">
|
||||
<small style="color: gray;">不建议修改 ID,可能会导致指向该模型的相关配置(如默认模型、插件相关配置等)失效。</small>
|
||||
<AstrBotConfig v-if="providerEditData" :iterable="providerEditData" :metadata="configSchema"
|
||||
metadataKey="provider" :is-editing="true" />
|
||||
</v-card-text>
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="text" @click="showProviderEditDialog = false"
|
||||
:disabled="savingProviders.includes(providerEditData?.id)">
|
||||
{{ tm('dialogs.config.cancel') }}
|
||||
</v-btn>
|
||||
<v-btn color="primary" @click="saveEditedProvider" :loading="savingProviders.includes(providerEditData?.id)">
|
||||
{{ tm('dialogs.config.save') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useModuleI18n } from '@/i18n/composables'
|
||||
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue'
|
||||
import ProviderModelsPanel from '@/components/provider/ProviderModelsPanel.vue'
|
||||
import ProviderSourcesPanel from '@/components/provider/ProviderSourcesPanel.vue'
|
||||
import { useProviderSources } from '@/composables/useProviderSources'
|
||||
import { getProviderIcon } from '@/utils/providerUtils'
|
||||
import axios from 'axios'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const { tm } = useModuleI18n('features/provider')
|
||||
|
||||
// 检测是否为手机端
|
||||
const isMobile = ref(false)
|
||||
|
||||
function checkMobile() {
|
||||
isMobile.value = window.innerWidth <= 768
|
||||
}
|
||||
|
||||
const dialog = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
const snackbar = ref({
|
||||
show: false,
|
||||
message: '',
|
||||
color: 'success'
|
||||
})
|
||||
|
||||
function showMessage(message, color = 'success') {
|
||||
snackbar.value = { show: true, message, color }
|
||||
}
|
||||
|
||||
const {
|
||||
selectedProviderSource,
|
||||
availableModels,
|
||||
loadingModels,
|
||||
savingSource,
|
||||
testingProviders,
|
||||
isSourceModified,
|
||||
configSchema,
|
||||
manualModelId,
|
||||
modelSearch,
|
||||
availableSourceTypes,
|
||||
displayedProviderSources,
|
||||
filteredMergedModelEntries,
|
||||
basicSourceConfig,
|
||||
advancedSourceConfig,
|
||||
manualProviderId,
|
||||
resolveSourceIcon,
|
||||
getSourceDisplayName,
|
||||
supportsImageInput,
|
||||
supportsToolCall,
|
||||
supportsReasoning,
|
||||
formatContextLimit,
|
||||
selectProviderSource,
|
||||
addProviderSource,
|
||||
deleteProviderSource,
|
||||
saveProviderSource,
|
||||
fetchAvailableModels,
|
||||
addModelProvider,
|
||||
deleteProvider,
|
||||
testProvider,
|
||||
loadConfig,
|
||||
modelAlreadyConfigured,
|
||||
} = useProviderSources({
|
||||
defaultTab: 'chat_completion',
|
||||
tm,
|
||||
showMessage
|
||||
})
|
||||
|
||||
const showManualModelDialog = ref(false)
|
||||
const showProviderEditDialog = ref(false)
|
||||
const providerEditData = ref(null)
|
||||
const providerEditOriginalId = ref('')
|
||||
const savingProviders = ref([])
|
||||
|
||||
function closeDialog() {
|
||||
dialog.value = false
|
||||
}
|
||||
|
||||
function openManualModelDialog() {
|
||||
if (!selectedProviderSource.value) {
|
||||
showMessage(tm('providerSources.selectHint'), 'error')
|
||||
return
|
||||
}
|
||||
manualModelId.value = ''
|
||||
showManualModelDialog.value = true
|
||||
}
|
||||
|
||||
async function confirmManualModel() {
|
||||
const modelId = manualModelId.value.trim()
|
||||
if (!selectedProviderSource.value) {
|
||||
showMessage(tm('providerSources.selectHint'), 'error')
|
||||
return
|
||||
}
|
||||
if (!modelId) {
|
||||
showMessage(tm('models.manualModelRequired'), 'error')
|
||||
return
|
||||
}
|
||||
if (modelAlreadyConfigured(modelId)) {
|
||||
showMessage(tm('models.manualModelExists'), 'error')
|
||||
return
|
||||
}
|
||||
await addModelProvider(modelId)
|
||||
showManualModelDialog.value = false
|
||||
}
|
||||
|
||||
function openProviderEdit(provider) {
|
||||
providerEditData.value = JSON.parse(JSON.stringify(provider))
|
||||
providerEditOriginalId.value = provider.id
|
||||
showProviderEditDialog.value = true
|
||||
}
|
||||
|
||||
async function saveEditedProvider() {
|
||||
if (!providerEditData.value) return
|
||||
|
||||
savingProviders.value.push(providerEditData.value.id)
|
||||
try {
|
||||
const res = await axios.post('/api/config/provider/update', {
|
||||
id: providerEditOriginalId.value || providerEditData.value.id,
|
||||
config: providerEditData.value
|
||||
})
|
||||
|
||||
if (res.data.status === 'error') {
|
||||
throw new Error(res.data.message)
|
||||
}
|
||||
|
||||
showMessage(res.data.message || tm('providerSources.saveSuccess'))
|
||||
showProviderEditDialog.value = false
|
||||
await loadConfig()
|
||||
} catch (err) {
|
||||
showMessage(err.response?.data?.message || err.message || tm('providerSources.saveError'), 'error')
|
||||
} finally {
|
||||
savingProviders.value = savingProviders.value.filter(id => id !== providerEditData.value?.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleProviderEnable(provider, value) {
|
||||
provider.enable = value
|
||||
|
||||
try {
|
||||
const res = await axios.post('/api/config/provider/update', {
|
||||
id: provider.id,
|
||||
config: provider
|
||||
})
|
||||
|
||||
if (res.data.status === 'error') {
|
||||
throw new Error(res.data.message)
|
||||
}
|
||||
showMessage(res.data.message || tm('messages.success.statusUpdate'))
|
||||
} catch (error) {
|
||||
showMessage(error.response?.data?.message || error.message || tm('providerSources.saveError'), 'error')
|
||||
} finally {
|
||||
await loadConfig()
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 dialog 打开,加载配置
|
||||
watch(dialog, (newVal) => {
|
||||
if (newVal) {
|
||||
loadConfig()
|
||||
checkMobile()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
checkMobile()
|
||||
window.addEventListener('resize', checkMobile)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', checkMobile)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.provider-config-dialog {
|
||||
height: calc(100vh - 100px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.provider-config-dialog.mobile-dialog {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.provider-sources-column {
|
||||
overflow-y: auto;
|
||||
background-color: var(--v-theme-surface);
|
||||
}
|
||||
|
||||
.provider-config-column {
|
||||
background-color: var(--v-theme-background);
|
||||
}
|
||||
|
||||
.border {
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
|
||||
/* 手机端样式 */
|
||||
.mobile-content {
|
||||
padding: 8px !important;
|
||||
padding-top: 0 !important;
|
||||
height: calc(100vh - 64px) !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
.mobile-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mobile-sources {
|
||||
width: 100% !important;
|
||||
min-width: 100% !important;
|
||||
border-right: none !important;
|
||||
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mobile-config {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-width: 100% !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.provider-config-dialog :deep(.v-card-title) {
|
||||
padding: 12px 16px !important;
|
||||
}
|
||||
|
||||
.provider-config-dialog :deep(.v-card-title .text-h2) {
|
||||
font-size: 1.5rem !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<v-menu v-model="menuOpen" :close-on-content-click="false" location="top" @update:model-value="handleMenuToggle">
|
||||
<template v-slot:activator="{ props: menuProps }">
|
||||
<v-chip v-bind="menuProps" class="text-none provider-chip" variant="tonal" size="x-small">
|
||||
<v-icon start size="14">mdi-creation</v-icon>
|
||||
<span v-if="selectedProviderId">
|
||||
{{ selectedProviderId }}
|
||||
</span>
|
||||
<span v-else>Model</span>
|
||||
</v-chip>
|
||||
</template>
|
||||
<v-card class="provider-menu-card" min-width="280" max-width="400">
|
||||
<v-card-text class="pa-2">
|
||||
<v-text-field
|
||||
v-model="searchQuery"
|
||||
placeholder="Search..."
|
||||
hide-details
|
||||
variant="plain"
|
||||
flat
|
||||
density="compact"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
class="ml-2 mb-2 mr-2"
|
||||
clearable
|
||||
/>
|
||||
<v-list density="compact" nav class="provider-menu-list">
|
||||
<v-list-item v-for="provider in filteredProviders" :key="provider.id"
|
||||
:active="selectedProviderId === provider.id" @click="selectProvider(provider)" rounded="lg"
|
||||
class="provider-menu-item">
|
||||
<v-list-item-title class="text-body-2">{{ provider.id }}</v-list-item-title>
|
||||
<v-list-item-subtitle class="provider-subtitle">
|
||||
<span class="model-name">{{ provider.model }}</span>
|
||||
<span class="meta-icons">
|
||||
<v-tooltip text="支持图像输入" location="top" v-if="supportsImageInput(provider)">
|
||||
<template v-slot:activator="{ props: tipProps }">
|
||||
<v-icon v-bind="tipProps" size="12" color="grey">mdi-eye-outline</v-icon>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<v-tooltip text="支持工具调用" location="top" v-if="supportsToolCall(provider)">
|
||||
<template v-slot:activator="{ props: tipProps }">
|
||||
<v-icon v-bind="tipProps" size="12" color="grey">mdi-wrench</v-icon>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<v-tooltip text="支持推理" location="top" v-if="supportsReasoning(provider)">
|
||||
<template v-slot:activator="{ props: tipProps }">
|
||||
<v-icon v-bind="tipProps" size="12" color="grey">mdi-brain</v-icon>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</span>
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<div v-if="providerConfigs.length === 0" class="empty-hint">
|
||||
No available models
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import axios from 'axios';
|
||||
|
||||
interface ModelMetadata {
|
||||
modalities?: { input?: string[] };
|
||||
tool_call?: boolean;
|
||||
reasoning?: boolean;
|
||||
}
|
||||
|
||||
interface ProviderConfig {
|
||||
id: string;
|
||||
model: string;
|
||||
api_base?: string;
|
||||
model_metadata?: ModelMetadata;
|
||||
enable?: boolean;
|
||||
}
|
||||
|
||||
const providerConfigs = ref<ProviderConfig[]>([]);
|
||||
const selectedProviderId = ref('');
|
||||
const searchQuery = ref('');
|
||||
const menuOpen = ref(false);
|
||||
|
||||
const filteredProviders = computed(() => {
|
||||
if (!searchQuery.value) {
|
||||
return providerConfigs.value;
|
||||
}
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
return providerConfigs.value.filter(p =>
|
||||
p.id.toLowerCase().includes(query) ||
|
||||
p.model.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
function loadFromStorage() {
|
||||
const savedProvider = localStorage.getItem('selectedProvider');
|
||||
if (savedProvider) {
|
||||
selectedProviderId.value = savedProvider;
|
||||
}
|
||||
}
|
||||
|
||||
function saveToStorage() {
|
||||
if (selectedProviderId.value) {
|
||||
localStorage.setItem('selectedProvider', selectedProviderId.value);
|
||||
}
|
||||
}
|
||||
|
||||
function loadProviderConfigs() {
|
||||
axios.get('/api/config/provider/list', {
|
||||
params: { provider_type: 'chat_completion' }
|
||||
}).then(response => {
|
||||
if (response.data.status === 'ok') {
|
||||
// 过滤掉 enable 为 false 的配置
|
||||
providerConfigs.value = (response.data.data || []).filter(
|
||||
(p: ProviderConfig) => p.enable !== false
|
||||
);
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error('获取提供商列表失败:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function selectProvider(provider: ProviderConfig) {
|
||||
selectedProviderId.value = provider.id;
|
||||
saveToStorage();
|
||||
}
|
||||
|
||||
function supportsImageInput(provider: ProviderConfig): boolean {
|
||||
const inputs = provider.model_metadata?.modalities?.input || [];
|
||||
return inputs.includes('image');
|
||||
}
|
||||
|
||||
function supportsToolCall(provider: ProviderConfig): boolean {
|
||||
return Boolean(provider.model_metadata?.tool_call);
|
||||
}
|
||||
|
||||
function supportsReasoning(provider: ProviderConfig): boolean {
|
||||
return Boolean(provider.model_metadata?.reasoning);
|
||||
}
|
||||
|
||||
function getCurrentSelection() {
|
||||
const provider = providerConfigs.value.find(p => p.id === selectedProviderId.value);
|
||||
return {
|
||||
providerId: selectedProviderId.value,
|
||||
modelName: provider?.model || ''
|
||||
};
|
||||
}
|
||||
|
||||
function handleMenuToggle(isOpen: boolean) {
|
||||
if (isOpen) {
|
||||
// 每次打开菜单时重新获取数据
|
||||
loadProviderConfigs();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadFromStorage();
|
||||
loadProviderConfigs();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
getCurrentSelection
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.provider-chip {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.provider-menu-card {
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
.provider-menu-list {
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.provider-menu-item {
|
||||
margin-bottom: 2px;
|
||||
border-radius: 8px !important;
|
||||
min-height: 44px !important;
|
||||
}
|
||||
|
||||
.provider-menu-item:hover {
|
||||
background-color: rgba(103, 58, 183, 0.05);
|
||||
}
|
||||
|
||||
.provider-menu-item.v-list-item--active {
|
||||
background-color: rgba(103, 58, 183, 0.1);
|
||||
}
|
||||
|
||||
.provider-subtitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.model-name {
|
||||
font-size: 12px;
|
||||
color: var(--v-theme-secondaryText);
|
||||
}
|
||||
|
||||
.meta-icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 12px;
|
||||
color: var(--v-theme-secondaryText);
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
@@ -1,359 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 选择提供商和模型按钮 -->
|
||||
<v-chip class="text-none" variant="tonal" size="x-small"
|
||||
v-if="selectedProviderId && selectedModelName" @click="openDialog">
|
||||
<v-icon start size="14">mdi-creation</v-icon>
|
||||
{{ selectedProviderId }} / {{ selectedModelName }}
|
||||
</v-chip>
|
||||
<v-chip variant="tonal" rounded="xl" size="x-small" v-else @click="openDialog">
|
||||
选择模型
|
||||
</v-chip>
|
||||
|
||||
<!-- 选择提供商和模型对话框 -->
|
||||
<v-dialog v-model="showDialog" max-width="800" persistent>
|
||||
<v-card style="padding: 8px;">
|
||||
<v-card-title class="dialog-title">
|
||||
<span>选择提供商和模型</span>
|
||||
</v-card-title>
|
||||
<v-card-text class="pa-0">
|
||||
<div class="provider-model-container">
|
||||
<!-- 左侧提供商列表 -->
|
||||
<div class="provider-list-panel">
|
||||
<div class="panel-header">
|
||||
<h4>提供商</h4>
|
||||
</div>
|
||||
<v-list density="compact" nav class="provider-list">
|
||||
<v-list-item v-for="provider in providerConfigs" :key="provider.id" :value="provider.id"
|
||||
@click="selectProvider(provider)" :active="tempSelectedProviderId === provider.id"
|
||||
rounded="lg" class="provider-item">
|
||||
<v-list-item-title>{{ provider.id }}</v-list-item-title>
|
||||
<v-list-item-subtitle v-if="provider.api_base">{{ provider.api_base
|
||||
}}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<div v-if="providerConfigs.length === 0" class="empty-state">
|
||||
<v-icon icon="mdi-cloud-off-outline" size="large" color="grey-lighten-1"></v-icon>
|
||||
<div class="empty-text">暂无可用提供商</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧模型列表 -->
|
||||
<div class="model-list-panel">
|
||||
<div class="panel-header">
|
||||
<h4>模型</h4>
|
||||
<v-btn v-if="tempSelectedProviderId" icon="mdi-refresh" size="small" variant="text"
|
||||
@click="refreshModels" :loading="loadingModels">
|
||||
</v-btn>
|
||||
</div>
|
||||
<v-list density="compact" nav class="model-list" v-if="tempSelectedProviderId">
|
||||
|
||||
<v-text-field v-model="tempSelectedModelName" placeholder="自定义模型" hide-details solo variant="outlined" density="compact" class="mb-2 mx-2"></v-text-field>
|
||||
|
||||
<v-list-item v-for="model in modelList" :key="model" :value="model"
|
||||
@click="selectModel(model)" :active="tempSelectedModelName === model" rounded="lg"
|
||||
class="model-item">
|
||||
<v-list-item-title>{{ model }}</v-list-item-title>
|
||||
<v-list-item-subtitle v-if="model.description">{{ model.description
|
||||
}}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<div v-else class="empty-state">
|
||||
<v-icon icon="mdi-robot-outline" size="large" color="grey-lighten-1"></v-icon>
|
||||
<div class="empty-text">请先选择提供商</div>
|
||||
</div>
|
||||
<div v-if="tempSelectedProviderId && modelList.length === 0 && !loadingModels"
|
||||
class="empty-state">
|
||||
<v-icon icon="mdi-robot-off-outline" size="large" color="grey-lighten-1"></v-icon>
|
||||
<div class="empty-text">该提供商暂无可用模型</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn text @click="closeDialog" color="grey-darken-1">取消</v-btn>
|
||||
<v-btn text @click="confirmSelection" color="primary"
|
||||
:disabled="!tempSelectedProviderId || !tempSelectedModelName">
|
||||
确认选择
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
|
||||
export default {
|
||||
name: 'ProviderModelSelector',
|
||||
props: {
|
||||
initialProvider: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
initialModel: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
emits: ['selection-changed'],
|
||||
data() {
|
||||
return {
|
||||
showDialog: false,
|
||||
providerConfigs: [],
|
||||
modelList: [],
|
||||
selectedProviderId: '',
|
||||
selectedModelName: '',
|
||||
// 临时选择状态,用于对话框内的选择
|
||||
tempSelectedProviderId: '',
|
||||
tempSelectedModelName: '',
|
||||
loadingModels: false
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
// 从localStorage加载保存的选择
|
||||
this.loadFromStorage();
|
||||
// 初始化临时选择
|
||||
this.resetTempSelection();
|
||||
// 获取提供商列表
|
||||
this.loadProviderConfigs();
|
||||
// 如果有保存的选择,加载对应的模型列表
|
||||
if (this.selectedProviderId) {
|
||||
this.getProviderModels(this.selectedProviderId);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 从localStorage加载保存的选择
|
||||
loadFromStorage() {
|
||||
const savedProvider = localStorage.getItem('selectedProvider');
|
||||
const savedModel = localStorage.getItem('selectedModel');
|
||||
|
||||
if (savedProvider) {
|
||||
this.selectedProviderId = savedProvider;
|
||||
} else if (this.initialProvider) {
|
||||
this.selectedProviderId = this.initialProvider;
|
||||
}
|
||||
|
||||
if (savedModel) {
|
||||
this.selectedModelName = savedModel;
|
||||
} else if (this.initialModel) {
|
||||
this.selectedModelName = this.initialModel;
|
||||
}
|
||||
},
|
||||
|
||||
// 保存到localStorage
|
||||
saveToStorage() {
|
||||
if (this.selectedProviderId) {
|
||||
localStorage.setItem('selectedProvider', this.selectedProviderId);
|
||||
}
|
||||
if (this.selectedModelName) {
|
||||
localStorage.setItem('selectedModel', this.selectedModelName);
|
||||
}
|
||||
},
|
||||
|
||||
// 获取提供商配置列表
|
||||
loadProviderConfigs() {
|
||||
axios.get('/api/config/provider/list', {
|
||||
params: {
|
||||
provider_type: 'chat_completion'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.data.status === 'ok') {
|
||||
this.providerConfigs = response.data.data || [];
|
||||
} else {
|
||||
console.error('获取聊天完成提供商列表失败:', response.data.message);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('获取聊天完成提供商列表失败:', error);
|
||||
});
|
||||
},
|
||||
|
||||
// 获取指定提供商的模型列表
|
||||
getProviderModels(providerId) {
|
||||
this.loadingModels = true;
|
||||
axios.get('/api/config/provider/model_list', {
|
||||
params: {
|
||||
provider_id: providerId
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.data.status === 'ok') {
|
||||
this.modelList = response.data.data.models || [];
|
||||
} else {
|
||||
console.error('获取模型列表失败:', response.data.message);
|
||||
this.modelList = [];
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('获取模型列表失败:', error);
|
||||
this.modelList = [];
|
||||
})
|
||||
.finally(() => {
|
||||
this.loadingModels = false;
|
||||
});
|
||||
},
|
||||
|
||||
// 选择提供商
|
||||
selectProvider(provider) {
|
||||
this.tempSelectedProviderId = provider.id;
|
||||
this.tempSelectedModelName = ''; // 清空已选择的模型
|
||||
this.modelList = []; // 清空模型列表
|
||||
this.getProviderModels(provider.id); // 获取该提供商的模型列表
|
||||
},
|
||||
|
||||
// 选择模型
|
||||
selectModel(model) {
|
||||
this.tempSelectedModelName = model;
|
||||
},
|
||||
|
||||
// 刷新模型列表
|
||||
refreshModels() {
|
||||
if (this.tempSelectedProviderId) {
|
||||
this.getProviderModels(this.tempSelectedProviderId);
|
||||
}
|
||||
},
|
||||
|
||||
// 确认选择
|
||||
confirmSelection() {
|
||||
if (this.tempSelectedProviderId && this.tempSelectedModelName) {
|
||||
// 将临时选择应用到正式选择
|
||||
this.selectedProviderId = this.tempSelectedProviderId;
|
||||
this.selectedModelName = this.tempSelectedModelName;
|
||||
|
||||
// 保存到localStorage
|
||||
this.saveToStorage();
|
||||
|
||||
// 触发事件通知父组件
|
||||
this.$emit('selection-changed', {
|
||||
providerId: this.selectedProviderId,
|
||||
modelName: this.selectedModelName
|
||||
});
|
||||
|
||||
this.closeDialog();
|
||||
}
|
||||
},
|
||||
|
||||
// 关闭对话框
|
||||
closeDialog() {
|
||||
this.showDialog = false;
|
||||
// 重置临时选择为当前选择
|
||||
this.resetTempSelection();
|
||||
},
|
||||
|
||||
// 重置临时选择
|
||||
resetTempSelection() {
|
||||
this.tempSelectedProviderId = this.selectedProviderId;
|
||||
this.tempSelectedModelName = this.selectedModelName;
|
||||
// 如果有临时选择的提供商,重新加载模型列表
|
||||
if (this.tempSelectedProviderId) {
|
||||
this.getProviderModels(this.tempSelectedProviderId);
|
||||
}
|
||||
},
|
||||
|
||||
// 打开对话框
|
||||
openDialog() {
|
||||
this.resetTempSelection();
|
||||
this.showDialog = true;
|
||||
},
|
||||
|
||||
// 公开方法:获取当前选择
|
||||
getCurrentSelection() {
|
||||
return {
|
||||
providerId: this.selectedProviderId,
|
||||
modelName: this.selectedModelName
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 对话框标题样式 */
|
||||
.dialog-title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
/* 提供商和模型选择对话框样式 */
|
||||
.provider-model-container {
|
||||
display: flex;
|
||||
height: 500px;
|
||||
border: 1px solid var(--v-theme-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.provider-list-panel,
|
||||
.model-list-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--v-theme-surface);
|
||||
}
|
||||
|
||||
.provider-list-panel {
|
||||
border-right: 1px solid var(--v-theme-border);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--v-theme-border);
|
||||
background-color: var(--v-theme-containerBg);
|
||||
}
|
||||
|
||||
.panel-header h4 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--v-theme-primaryText);
|
||||
}
|
||||
|
||||
.provider-list,
|
||||
.model-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.provider-item,
|
||||
.model-item {
|
||||
margin-bottom: 4px;
|
||||
border-radius: 8px !important;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.provider-item:hover,
|
||||
.model-item:hover {
|
||||
background-color: rgba(103, 58, 183, 0.05);
|
||||
}
|
||||
|
||||
.provider-item.v-list-item--active,
|
||||
.model-item.v-list-item--active {
|
||||
background-color: rgba(103, 58, 183, 0.1);
|
||||
color: var(--v-theme-secondary);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 200px;
|
||||
opacity: 0.6;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 14px;
|
||||
color: var(--v-theme-secondaryText);
|
||||
}
|
||||
</style>
|
||||
+38
-328
@@ -4,42 +4,18 @@
|
||||
<!-- 页面标题 -->
|
||||
<v-row class="d-flex justify-space-between align-center px-4 py-3 pb-8">
|
||||
<div>
|
||||
<h1 class="text-h1 font-weight-bold mb-2">
|
||||
<v-icon color="black" class="me-2">mdi-function-variant</v-icon>{{ tm('title') }}
|
||||
</h1>
|
||||
<p class="text-subtitle-1 text-medium-emphasis mb-4 d-flex align-center">
|
||||
{{ tm('subtitle') }}
|
||||
<v-tooltip location="top">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-icon v-bind="props" size="small" color="primary" class="ms-1 cursor-pointer"
|
||||
@click="openurl('https://astrbot.app/use/function-calling.html')">
|
||||
mdi-information
|
||||
</v-icon>
|
||||
</template>
|
||||
<span>{{ tm('tooltip.info') }}</span>
|
||||
</v-tooltip>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<v-btn color="primary" prepend-icon="mdi-tools" class="me-2" variant="tonal" @click="showToolsDialog = true"
|
||||
rounded="xl" size="x-large">
|
||||
{{ tm('functionTools.buttons.view') }}({{ tools.length }})
|
||||
</v-btn>
|
||||
<v-btn color="success" prepend-icon="mdi-plus" class="me-2" variant="tonal"
|
||||
@click="showMcpServerDialog = true" rounded="xl" size="x-large">
|
||||
@click="showMcpServerDialog = true" >
|
||||
{{ tm('mcpServers.buttons.add') }}
|
||||
</v-btn>
|
||||
<v-btn color="success" prepend-icon="mdi-refresh" variant="tonal" @click="showSyncMcpServerDialog = true"
|
||||
rounded="xl" size="x-large">
|
||||
>
|
||||
{{ tm('mcpServers.buttons.sync') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-row>
|
||||
|
||||
<!-- 本地服务器列表 -->
|
||||
|
||||
<!-- MCP 服务器部分 -->
|
||||
|
||||
<div v-if="mcpServers.length === 0" class="text-center pa-8">
|
||||
<v-icon size="64" color="grey-lighten-1">mdi-server-off</v-icon>
|
||||
<p class="text-grey mt-4">{{ tm('mcpServers.empty') }}</p>
|
||||
@@ -57,7 +33,6 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="d-flex" style="gap: 8px;">
|
||||
<div>
|
||||
<div v-if="item.tools && item.tools.length > 0">
|
||||
@@ -67,8 +42,7 @@
|
||||
<template v-slot:activator="{ props: listToolsProps }">
|
||||
<span class="text-caption text-medium-emphasis cursor-pointer" v-bind="listToolsProps"
|
||||
style="text-decoration: underline;">
|
||||
{{ tm('mcpServers.status.availableTools', { count: item.tools.length }) }} ({{
|
||||
item.tools.length }})
|
||||
{{ tm('mcpServers.status.availableTools', { count: item.tools.length }) }} ({{ item.tools.length }})
|
||||
</span>
|
||||
</template>
|
||||
<template v-slot:default="{ isActive }">
|
||||
@@ -78,10 +52,7 @@
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<ul>
|
||||
<li v-for="(tool, idx) in item.tools" :key="idx" style="margin: 8px 0px;">{{
|
||||
tool
|
||||
}}
|
||||
</li>
|
||||
<li v-for="(tool, idx) in item.tools" :key="idx" style="margin: 8px 0px;">{{ tool }}</li>
|
||||
</ul>
|
||||
</v-card-text>
|
||||
<v-card-actions class="d-flex justify-end">
|
||||
@@ -91,8 +62,6 @@
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
|
||||
</v-dialog>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,8 +74,6 @@
|
||||
<v-progress-circular indeterminate color="primary" size="16"></v-progress-circular>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</template>
|
||||
</item-card>
|
||||
</v-col>
|
||||
@@ -183,8 +150,7 @@
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
|
||||
<!-- 添加/编辑 MCP 服务器对话框 -->
|
||||
<!-- 同步 MCP 服务器对话框 -->
|
||||
<v-dialog v-model="showSyncMcpServerDialog" max-width="500px" persistent>
|
||||
<v-card>
|
||||
<v-card-title class="bg-primary text-white py-3">
|
||||
@@ -240,115 +206,8 @@
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 函数工具对话框 -->
|
||||
<v-dialog v-model="showToolsDialog" max-width="800px">
|
||||
<v-card elevation="0" class="mt-4">
|
||||
<v-card-title class="d-flex align-center py-3 px-4">
|
||||
{{ tm('functionTools.title') }}
|
||||
<v-chip color="info" size="small" class="ml-2">{{ tools.length }}</v-chip>
|
||||
</v-card-title>
|
||||
<v-expand-transition>
|
||||
<v-card-text class="pa-0" v-if="showTools">
|
||||
<div class="pa-4">
|
||||
<div v-if="tools.length === 0" class="text-center pa-8">
|
||||
<v-icon size="64" color="grey-lighten-1">mdi-api-off</v-icon>
|
||||
<p class="text-grey mt-4">{{ tm('functionTools.empty') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<v-text-field v-model="toolSearch" prepend-inner-icon="mdi-magnify" :label="tm('functionTools.search')"
|
||||
variant="outlined" density="compact" class="mb-4" hide-details clearable></v-text-field>
|
||||
|
||||
<small>复选框代表该工具是否被启用。</small>
|
||||
|
||||
<v-expansion-panels v-model="openedPanel" multiple style="max-height: 500px; overflow-y: auto;">
|
||||
<v-expansion-panel v-for="(tool, index) in filteredTools" :key="index" :value="index"
|
||||
class="mb-2 tool-panel" rounded="lg">
|
||||
<v-expansion-panel-title>
|
||||
<v-row no-gutters align="center">
|
||||
<v-col cols="1">
|
||||
<v-checkbox v-model="tool.active" color="primary" hide-details density="compact" @click.stop
|
||||
@change="toggleToolStatus(tool)"></v-checkbox>
|
||||
</v-col>
|
||||
<v-col cols="3">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon color="primary" class="me-2" size="small">
|
||||
{{ tool.name.includes(':') ? 'mdi-server-network' : 'mdi-function-variant' }}
|
||||
</v-icon>
|
||||
<span class="text-body-1 text-high-emphasis font-weight-medium text-truncate"
|
||||
:title="tool.name">
|
||||
{{ formatToolName(tool.name) }}
|
||||
</span>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="8" class="text-grey">
|
||||
{{ tool.description }}
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-expansion-panel-title>
|
||||
|
||||
<v-expansion-panel-text>
|
||||
<v-card flat>
|
||||
<v-card-text>
|
||||
<p class="text-body-1 font-weight-medium mb-3">
|
||||
<v-icon color="primary" size="small" class="me-1">mdi-information</v-icon>
|
||||
{{ tm('functionTools.description') }}
|
||||
</p>
|
||||
<p class="text-body-2 ml-6 mb-4">{{ tool.description }}</p>
|
||||
|
||||
<template v-if="tool.parameters && tool.parameters.properties">
|
||||
<p class="text-body-1 font-weight-medium mb-3">
|
||||
<v-icon color="primary" size="small" class="me-1">mdi-code-json</v-icon>
|
||||
{{ tm('functionTools.parameters') }}
|
||||
</p>
|
||||
|
||||
<v-table density="compact" class="params-table mt-1">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ tm('functionTools.table.paramName') }}</th>
|
||||
<th>{{ tm('functionTools.table.type') }}</th>
|
||||
<th>{{ tm('functionTools.table.description') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(param, paramName) in tool.parameters.properties" :key="paramName">
|
||||
<td class="font-weight-medium">{{ paramName }}</td>
|
||||
<td>
|
||||
<v-chip size="x-small" color="primary" text class="text-caption">
|
||||
{{ param.type }}
|
||||
</v-chip>
|
||||
</td>
|
||||
<td>{{ param.description }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</template>
|
||||
<div v-else class="text-center pa-4 text-medium-emphasis">
|
||||
<v-icon size="large" color="grey-lighten-1">mdi-code-brackets</v-icon>
|
||||
<p>{{ tm('functionTools.noParameters') }}</p>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-expand-transition>
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="text" @click="showToolsDialog = false">
|
||||
{{ tm('dialogs.serverDetail.buttons.close') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 消息提示 -->
|
||||
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack"
|
||||
location="top">
|
||||
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack" location="top">
|
||||
{{ save_message }}
|
||||
</v-snackbar>
|
||||
</div>
|
||||
@@ -356,15 +215,13 @@
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
|
||||
import { VueMonacoEditor } from '@guolao/vue-monaco-editor';
|
||||
import ItemCard from '@/components/shared/ItemCard.vue';
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||
|
||||
export default {
|
||||
name: 'ToolUsePage',
|
||||
name: 'McpServersSection',
|
||||
components: {
|
||||
AstrBotConfig,
|
||||
VueMonacoEditor,
|
||||
ItemCard
|
||||
},
|
||||
@@ -377,20 +234,15 @@ export default {
|
||||
return {
|
||||
refreshInterval: null,
|
||||
mcpServers: [],
|
||||
tools: [],
|
||||
showMcpServerDialog: false,
|
||||
|
||||
selectedMcpServerProvider: "modelscope",
|
||||
mcpServerProviderList: ["modelscope"],
|
||||
selectedMcpServerProvider: 'modelscope',
|
||||
mcpServerProviderList: ['modelscope'],
|
||||
mcpProviderToken: '',
|
||||
|
||||
showSyncMcpServerDialog: false,
|
||||
addServerDialogMessage: "",
|
||||
showToolsDialog: false,
|
||||
showTools: true,
|
||||
addServerDialogMessage: '',
|
||||
loading: false,
|
||||
loadingGettingServers: false,
|
||||
mcpServerUpdateLoaders: {}, // record loading state for each server update
|
||||
mcpServerUpdateLoaders: {},
|
||||
isEditMode: false,
|
||||
serverConfigJson: '',
|
||||
jsonError: null,
|
||||
@@ -400,87 +252,50 @@ export default {
|
||||
tools: []
|
||||
},
|
||||
save_message_snack: false,
|
||||
save_message: "",
|
||||
save_message_success: "success",
|
||||
toolSearch: '',
|
||||
openedPanel: [], // 存储打开的面板索引
|
||||
}
|
||||
save_message: '',
|
||||
save_message_success: 'success'
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
filteredTools() {
|
||||
if (!this.toolSearch) return this.tools;
|
||||
|
||||
const searchTerm = this.toolSearch.toLowerCase();
|
||||
return this.tools.filter(tool =>
|
||||
tool.name.toLowerCase().includes(searchTerm) ||
|
||||
tool.description.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
},
|
||||
|
||||
isServerFormValid() {
|
||||
return !!this.currentServer.name && !this.jsonError;
|
||||
},
|
||||
|
||||
// 显示服务器配置的文本摘要
|
||||
getServerConfigSummary() {
|
||||
return (server) => {
|
||||
if (server.command) {
|
||||
return `${server.command} ${(server.args || []).join(' ')}`;
|
||||
}
|
||||
|
||||
// 如果没有command字段,尝试显示其他有意义的配置信息
|
||||
const configKeys = Object.keys(server).filter(key =>
|
||||
!['name', 'active', 'tools'].includes(key)
|
||||
);
|
||||
|
||||
if (configKeys.length > 0) {
|
||||
return this.tm('mcpServers.status.configSummary', { keys: configKeys.join(', ') });
|
||||
}
|
||||
|
||||
return this.tm('mcpServers.status.noConfig');
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.getServers();
|
||||
this.getTools();
|
||||
|
||||
this.refreshInterval = setInterval(() => {
|
||||
this.getServers();
|
||||
this.getTools();
|
||||
}, 5000);
|
||||
},
|
||||
|
||||
unmounted() {
|
||||
// 清除定时器 if it exists
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
openurl(url) {
|
||||
window.open(url, '_blank');
|
||||
},
|
||||
|
||||
formatToolName(name) {
|
||||
if (name.includes(':')) {
|
||||
// MCP 工具通常命名为 mcp:server:tool
|
||||
const parts = name.split(':');
|
||||
return parts[parts.length - 1]; // 返回最后一部分
|
||||
}
|
||||
return name;
|
||||
},
|
||||
|
||||
getServers() {
|
||||
this.loadingGettingServers = true;
|
||||
axios.get('/api/tools/mcp/servers')
|
||||
.then(response => {
|
||||
this.mcpServers = response.data.data || [];
|
||||
this.mcpServers.forEach(server => {
|
||||
// Ensure each server has a loader state
|
||||
if (!this.mcpServerUpdateLoaders[server.name]) {
|
||||
this.mcpServerUpdateLoaders[server.name] = false;
|
||||
}
|
||||
@@ -492,24 +307,12 @@ export default {
|
||||
this.loadingGettingServers = false;
|
||||
});
|
||||
},
|
||||
|
||||
getTools() {
|
||||
axios.get('/api/tools/list')
|
||||
.then(response => {
|
||||
this.tools = response.data.data || [];
|
||||
})
|
||||
.catch(error => {
|
||||
this.showError(this.tm('messages.getToolsError', { error: error.message }));
|
||||
});
|
||||
},
|
||||
|
||||
validateJson() {
|
||||
try {
|
||||
if (!this.serverConfigJson.trim()) {
|
||||
this.jsonError = this.tm('dialogs.addServer.errors.configEmpty');
|
||||
return false;
|
||||
}
|
||||
|
||||
JSON.parse(this.serverConfigJson);
|
||||
this.jsonError = null;
|
||||
return true;
|
||||
@@ -518,61 +321,51 @@ export default {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
setConfigTemplate(type = 'stdio') {
|
||||
let template = {};
|
||||
if (type === 'streamable_http') {
|
||||
template = {
|
||||
transport: "streamable_http",
|
||||
url: "your mcp server url",
|
||||
transport: 'streamable_http',
|
||||
url: 'your mcp server url',
|
||||
headers: {},
|
||||
timeout: 5,
|
||||
sse_read_timeout: 300,
|
||||
sse_read_timeout: 300
|
||||
};
|
||||
} else if (type === 'sse') {
|
||||
template = {
|
||||
transport: "sse",
|
||||
url: "your mcp server url",
|
||||
transport: 'sse',
|
||||
url: 'your mcp server url',
|
||||
headers: {},
|
||||
timeout: 5,
|
||||
sse_read_timeout: 300,
|
||||
sse_read_timeout: 300
|
||||
};
|
||||
} else {
|
||||
template = {
|
||||
command: "python",
|
||||
args: ["-m", "your_module"],
|
||||
command: 'python',
|
||||
args: ['-m', 'your_module']
|
||||
};
|
||||
}
|
||||
this.serverConfigJson = JSON.stringify(template, null, 2);
|
||||
},
|
||||
|
||||
saveServer() {
|
||||
if (!this.validateJson()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
// 解析JSON配置并与基本信息合并
|
||||
try {
|
||||
const configObj = JSON.parse(this.serverConfigJson);
|
||||
|
||||
// 创建要发送的完整配置对象
|
||||
const serverData = {
|
||||
name: this.currentServer.name,
|
||||
active: this.currentServer.active,
|
||||
...configObj
|
||||
};
|
||||
|
||||
const endpoint = this.isEditMode ? '/api/tools/mcp/update' : '/api/tools/mcp/add';
|
||||
|
||||
axios.post(endpoint, serverData)
|
||||
.then(response => {
|
||||
this.loading = false;
|
||||
this.showMcpServerDialog = false;
|
||||
this.addServerDialogMessage = "";
|
||||
this.addServerDialogMessage = '';
|
||||
this.getServers();
|
||||
this.getTools();
|
||||
this.showSuccess(response.data.message || this.tm('messages.saveSuccess'));
|
||||
this.resetForm();
|
||||
})
|
||||
@@ -585,14 +378,12 @@ export default {
|
||||
this.showError(this.tm('dialogs.addServer.errors.jsonParse', { error: e.message }));
|
||||
}
|
||||
},
|
||||
|
||||
deleteServer(server) {
|
||||
let serverName = server.name || server;
|
||||
const serverName = server.name || server;
|
||||
if (confirm(this.tm('dialogs.confirmDelete', { name: serverName }))) {
|
||||
axios.post('/api/tools/mcp/delete', { name: serverName })
|
||||
.then(response => {
|
||||
this.getServers();
|
||||
this.getTools();
|
||||
this.showSuccess(response.data.message || this.tm('messages.deleteSuccess'));
|
||||
})
|
||||
.catch(error => {
|
||||
@@ -600,37 +391,22 @@ export default {
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
editServer(server) {
|
||||
// 创建一个不包含基本字段的配置对象副本
|
||||
const configCopy = { ...server };
|
||||
|
||||
// 移除基本字段,只保留配置相关字段
|
||||
try {
|
||||
delete configCopy.name;
|
||||
delete configCopy.active;
|
||||
delete configCopy.tools;
|
||||
delete configCopy.errlogs;
|
||||
} catch (e) {
|
||||
console.error("Error removing basic fields: ", e);
|
||||
}
|
||||
|
||||
// 设置当前服务器的基本信息
|
||||
delete configCopy.name;
|
||||
delete configCopy.active;
|
||||
delete configCopy.tools;
|
||||
delete configCopy.errlogs;
|
||||
this.currentServer = {
|
||||
name: server.name,
|
||||
active: server.active,
|
||||
tools: server.tools || []
|
||||
};
|
||||
|
||||
// 将剩余配置转换为JSON字符串
|
||||
this.serverConfigJson = JSON.stringify(configCopy, null, 2);
|
||||
|
||||
this.isEditMode = true;
|
||||
this.showMcpServerDialog = true;
|
||||
},
|
||||
|
||||
updateServerStatus(server) {
|
||||
// 切换服务器状态
|
||||
this.mcpServerUpdateLoaders[server.name] = true;
|
||||
server.active = !server.active;
|
||||
axios.post('/api/tools/mcp/update', server)
|
||||
@@ -646,20 +422,16 @@ export default {
|
||||
this.mcpServerUpdateLoaders[server.name] = false;
|
||||
});
|
||||
},
|
||||
|
||||
closeServerDialog() {
|
||||
this.showMcpServerDialog = false;
|
||||
this.addServerDialogMessage = '';
|
||||
this.resetForm();
|
||||
},
|
||||
|
||||
testServerConnection() {
|
||||
if (!this.validateJson()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
let configObj;
|
||||
try {
|
||||
configObj = JSON.parse(this.serverConfigJson);
|
||||
@@ -668,9 +440,8 @@ export default {
|
||||
this.showError(this.tm('dialogs.addServer.errors.jsonParse', { error: e.message }));
|
||||
return;
|
||||
}
|
||||
|
||||
axios.post('/api/tools/mcp/test', {
|
||||
"mcp_server_config": configObj,
|
||||
mcp_server_config: configObj
|
||||
})
|
||||
.then(response => {
|
||||
this.loading = false;
|
||||
@@ -681,7 +452,6 @@ export default {
|
||||
this.showError(this.tm('messages.testError', { error: error.response?.data?.message || error.message }));
|
||||
});
|
||||
},
|
||||
|
||||
resetForm() {
|
||||
this.currentServer = {
|
||||
name: '',
|
||||
@@ -692,58 +462,26 @@ export default {
|
||||
this.jsonError = null;
|
||||
this.isEditMode = false;
|
||||
},
|
||||
|
||||
showSuccess(message) {
|
||||
this.save_message = message;
|
||||
this.save_message_success = "success";
|
||||
this.save_message_success = 'success';
|
||||
this.save_message_snack = true;
|
||||
},
|
||||
|
||||
showError(message) {
|
||||
this.save_message = message;
|
||||
this.save_message_success = "error";
|
||||
this.save_message_success = 'error';
|
||||
this.save_message_snack = true;
|
||||
},
|
||||
|
||||
// MCP 市场相关方法已移除
|
||||
|
||||
// 切换工具状态
|
||||
async toggleToolStatus(tool) {
|
||||
try {
|
||||
const response = await axios.post('/api/tools/toggle-tool', {
|
||||
name: tool.name,
|
||||
activate: tool.active
|
||||
});
|
||||
|
||||
if (response.data.status === 'ok') {
|
||||
this.showSuccess(response.data.message || this.tm('messages.toggleToolSuccess'));
|
||||
} else {
|
||||
// 如果失败,恢复原状态
|
||||
tool.active = !tool.active;
|
||||
this.showError(response.data.message || this.tm('messages.toggleToolError'));
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果失败,恢复原状态
|
||||
tool.active = !tool.active;
|
||||
this.showError(this.tm('messages.toggleToolError', { error: error.response?.data?.message || error.message }));
|
||||
}
|
||||
},
|
||||
|
||||
// 同步 MCP 服务器
|
||||
async syncMcpServers() {
|
||||
if (!this.selectedMcpServerProvider) {
|
||||
this.showError(this.tm('syncProvider.status.selectProvider'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
const requestData = {
|
||||
name: this.selectedMcpServerProvider
|
||||
};
|
||||
|
||||
// 根据不同平台添加相应的参数
|
||||
if (this.selectedMcpServerProvider === 'modelscope') {
|
||||
if (!this.mcpProviderToken.trim()) {
|
||||
this.showError(this.tm('syncProvider.status.enterToken'));
|
||||
@@ -752,61 +490,33 @@ export default {
|
||||
}
|
||||
requestData.access_token = this.mcpProviderToken.trim();
|
||||
}
|
||||
|
||||
const response = await axios.post('/api/tools/mcp/sync-provider', requestData);
|
||||
|
||||
if (response.data.status === 'ok') {
|
||||
this.showSuccess(response.data.message || this.tm('syncProvider.messages.syncSuccess'));
|
||||
this.showSyncMcpServerDialog = false;
|
||||
this.mcpProviderToken = '';
|
||||
// 刷新服务器列表
|
||||
this.getServers();
|
||||
this.getTools();
|
||||
} else {
|
||||
this.showError(response.data.message || this.tm('syncProvider.messages.syncError', { error: 'Unknown error' }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('同步 MCP 服务器失败:', error);
|
||||
this.showError(this.tm('syncProvider.messages.syncError', {
|
||||
error: error.response?.data?.message || error.message || '网络连接或访问令牌问题'
|
||||
this.showError(this.tm('syncProvider.messages.syncError', {
|
||||
error: error.response?.data?.message || error.message || '网络连接或访问令牌问题'
|
||||
}));
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tools-page {
|
||||
padding: 20px;
|
||||
padding: 0px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.tool-chips {
|
||||
max-height: 60px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tool-panel {
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tool-panel:hover {
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.params-table {
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.params-table th {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.monaco-container {
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 8px;
|
||||
@@ -814,4 +524,4 @@ export default {
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -0,0 +1,155 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
|
||||
const { tm } = useModuleI18n('features/command');
|
||||
|
||||
// Props
|
||||
const props = defineProps<{
|
||||
availablePlugins: string[];
|
||||
hasSystemPluginConflict: boolean;
|
||||
effectiveShowSystemPlugins: boolean;
|
||||
pluginFilter: string;
|
||||
typeFilter: string;
|
||||
permissionFilter: string;
|
||||
statusFilter: string;
|
||||
showSystemPlugins: boolean;
|
||||
searchQuery: string;
|
||||
}>();
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:pluginFilter', value: string): void;
|
||||
(e: 'update:typeFilter', value: string): void;
|
||||
(e: 'update:permissionFilter', value: string): void;
|
||||
(e: 'update:statusFilter', value: string): void;
|
||||
(e: 'update:showSystemPlugins', value: boolean): void;
|
||||
(e: 'update:searchQuery', value: string): void;
|
||||
}>();
|
||||
|
||||
// Computed items for selects
|
||||
const pluginItems = computed(() => [
|
||||
{ title: tm('filters.all'), value: 'all' },
|
||||
...props.availablePlugins.map(p => ({ title: p, value: p }))
|
||||
]);
|
||||
|
||||
const typeItems = [
|
||||
{ title: tm('filters.all'), value: 'all' },
|
||||
{ title: tm('type.group'), value: 'group' },
|
||||
{ title: tm('type.command'), value: 'command' },
|
||||
{ title: tm('type.subCommand'), value: 'sub_command' }
|
||||
];
|
||||
|
||||
const permissionItems = [
|
||||
{ title: tm('filters.all'), value: 'all' },
|
||||
{ title: tm('permission.everyone'), value: 'everyone' },
|
||||
{ title: tm('permission.admin'), value: 'admin' }
|
||||
];
|
||||
|
||||
const statusItems = [
|
||||
{ title: tm('filters.all'), value: 'all' },
|
||||
{ title: tm('filters.enabled'), value: 'enabled' },
|
||||
{ title: tm('filters.disabled'), value: 'disabled' },
|
||||
{ title: tm('filters.conflict'), value: 'conflict' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 过滤器行 -->
|
||||
<v-row class="mb-4" align="center">
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-select
|
||||
:model-value="pluginFilter"
|
||||
@update:model-value="emit('update:pluginFilter', $event)"
|
||||
:items="pluginItems"
|
||||
:label="tm('filters.byPlugin')"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="2">
|
||||
<v-select
|
||||
:model-value="typeFilter"
|
||||
@update:model-value="emit('update:typeFilter', $event)"
|
||||
:items="typeItems"
|
||||
:label="tm('filters.byType')"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="2">
|
||||
<v-select
|
||||
:model-value="permissionFilter"
|
||||
@update:model-value="emit('update:permissionFilter', $event)"
|
||||
:items="permissionItems"
|
||||
:label="tm('filters.byPermission')"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="2">
|
||||
<v-select
|
||||
:model-value="statusFilter"
|
||||
@update:model-value="emit('update:statusFilter', $event)"
|
||||
:items="statusItems"
|
||||
:label="tm('filters.byStatus')"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 搜索栏 + 统计信息行 -->
|
||||
<div class="mb-4 d-flex flex-wrap align-center ga-4">
|
||||
<div style="min-width: 200px; max-width: 350px; flex: 1; border: 1px solid #B9B9B9; border-radius: 16px;">
|
||||
<v-text-field
|
||||
:model-value="searchQuery"
|
||||
@update:model-value="emit('update:searchQuery', $event)"
|
||||
density="compact"
|
||||
:label="tm('search.placeholder')"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
hide-details
|
||||
single-line
|
||||
/>
|
||||
</div>
|
||||
<div class="d-flex align-center ga-4">
|
||||
<slot name="stats"></slot>
|
||||
<v-divider vertical class="mx-1" style="height: 20px;" />
|
||||
<v-checkbox
|
||||
:model-value="effectiveShowSystemPlugins"
|
||||
@update:model-value="emit('update:showSystemPlugins', !!$event)"
|
||||
:label="tm('filters.showSystemPlugins')"
|
||||
density="compact"
|
||||
hide-details
|
||||
:disabled="hasSystemPluginConflict"
|
||||
class="system-plugin-checkbox"
|
||||
>
|
||||
<template v-slot:label>
|
||||
<span class="text-body-2">{{ tm('filters.showSystemPlugins') }}</span>
|
||||
<v-tooltip v-if="hasSystemPluginConflict" location="top">
|
||||
<template v-slot:activator="{ props: tooltipProps }">
|
||||
<v-icon v-bind="tooltipProps" size="16" color="warning" class="ml-1">mdi-alert-circle</v-icon>
|
||||
</template>
|
||||
{{ tm('filters.systemPluginConflictHint') }}
|
||||
</v-tooltip>
|
||||
</template>
|
||||
</v-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.system-plugin-checkbox {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.system-plugin-checkbox :deep(.v-selection-control) {
|
||||
min-height: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,257 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import type { CommandItem, TypeInfo, StatusInfo } from '../types';
|
||||
|
||||
const { tm } = useModuleI18n('features/command');
|
||||
|
||||
// Props
|
||||
const props = defineProps<{
|
||||
items: CommandItem[];
|
||||
expandedGroups: Set<string>;
|
||||
loading?: boolean;
|
||||
}>();
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle-expand', cmd: CommandItem): void;
|
||||
(e: 'toggle-command', cmd: CommandItem): void;
|
||||
(e: 'rename', cmd: CommandItem): void;
|
||||
(e: 'view-details', cmd: CommandItem): void;
|
||||
}>();
|
||||
|
||||
// 表格表头
|
||||
const commandHeaders = computed(() => [
|
||||
{ title: tm('table.headers.command'), key: 'effective_command', minWidth: '100px' },
|
||||
{ title: tm('table.headers.type'), key: 'type', sortable: false, width: '100px' },
|
||||
{ title: tm('table.headers.plugin'), key: 'plugin', width: '140px' },
|
||||
{ title: tm('table.headers.description'), key: 'description', sortable: false },
|
||||
{ title: tm('table.headers.permission'), key: 'permission', sortable: false, width: '100px' },
|
||||
{ title: tm('table.headers.status'), key: 'enabled', sortable: false, width: '100px' },
|
||||
{ title: tm('table.headers.actions'), key: 'actions', sortable: false, width: '140px' }
|
||||
]);
|
||||
|
||||
// 检查组是否展开
|
||||
const isGroupExpanded = (cmd: CommandItem): boolean => {
|
||||
return props.expandedGroups.has(cmd.handler_full_name);
|
||||
};
|
||||
|
||||
// 获取类型信息
|
||||
const getTypeInfo = (type: string): TypeInfo => {
|
||||
switch (type) {
|
||||
case 'group':
|
||||
return { text: tm('type.group'), color: 'info', icon: 'mdi-folder-outline' };
|
||||
case 'sub_command':
|
||||
return { text: tm('type.subCommand'), color: 'secondary', icon: 'mdi-subdirectory-arrow-right' };
|
||||
default:
|
||||
return { text: tm('type.command'), color: 'primary', icon: 'mdi-console-line' };
|
||||
}
|
||||
};
|
||||
|
||||
// 获取权限颜色
|
||||
const getPermissionColor = (permission: string): string => {
|
||||
switch (permission) {
|
||||
case 'admin': return 'error';
|
||||
default: return 'success';
|
||||
}
|
||||
};
|
||||
|
||||
// 获取权限标签
|
||||
const getPermissionLabel = (permission: string): string => {
|
||||
switch (permission) {
|
||||
case 'admin': return tm('permission.admin');
|
||||
default: return tm('permission.everyone');
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态信息
|
||||
const getStatusInfo = (cmd: CommandItem): StatusInfo => {
|
||||
if (cmd.has_conflict) {
|
||||
return { text: tm('status.conflict'), color: 'warning', variant: 'flat' };
|
||||
}
|
||||
if (cmd.enabled) {
|
||||
return { text: tm('status.enabled'), color: 'success', variant: 'flat' };
|
||||
}
|
||||
return { text: tm('status.disabled'), color: 'error', variant: 'outlined' };
|
||||
};
|
||||
|
||||
// 获取行属性
|
||||
const getRowProps = ({ item }: { item: CommandItem }) => {
|
||||
const classes: string[] = [];
|
||||
if (item.has_conflict) {
|
||||
classes.push('conflict-row');
|
||||
}
|
||||
if (item.type === 'sub_command') {
|
||||
classes.push('sub-command-row');
|
||||
}
|
||||
if (item.is_group) {
|
||||
classes.push('group-row');
|
||||
}
|
||||
return classes.length > 0 ? { class: classes.join(' ') } : {};
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card class="rounded-lg overflow-hidden elevation-1">
|
||||
<v-data-table
|
||||
:headers="commandHeaders"
|
||||
:items="items"
|
||||
item-key="handler_full_name"
|
||||
hover
|
||||
:row-props="getRowProps"
|
||||
:loading="props.loading"
|
||||
>
|
||||
<template v-slot:item.effective_command="{ item }">
|
||||
<div class="d-flex align-center py-2">
|
||||
<!-- 展开/折叠按钮(针对指令组) -->
|
||||
<v-btn
|
||||
v-if="item.is_group && item.sub_commands?.length > 0"
|
||||
icon
|
||||
variant="text"
|
||||
size="x-small"
|
||||
class="mr-1"
|
||||
@click.stop="emit('toggle-expand', item)"
|
||||
>
|
||||
<v-icon size="18">{{ isGroupExpanded(item) ? 'mdi-chevron-down' : 'mdi-chevron-right' }}</v-icon>
|
||||
</v-btn>
|
||||
<!-- 子指令缩进 -->
|
||||
<div v-else-if="item.type === 'sub_command'" class="ml-6"></div>
|
||||
<div>
|
||||
<div class="text-subtitle-1 font-weight-medium">
|
||||
<code :class="{ 'sub-command-code': item.type === 'sub_command' }">{{ item.effective_command }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.type="{ item }">
|
||||
<v-chip
|
||||
:color="getTypeInfo(item.type).color"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
<v-icon start size="14">{{ getTypeInfo(item.type).icon }}</v-icon>
|
||||
{{ getTypeInfo(item.type).text }}{{ item.is_group && item.sub_commands?.length > 0 ? `(${item.sub_commands.length})` : '' }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.plugin="{ item }">
|
||||
<div class="text-body-2">{{ item.plugin_display_name || item.plugin }}</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.description="{ item }">
|
||||
<div class="text-body-2 text-medium-emphasis" style="max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||
{{ item.description || '-' }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.permission="{ item }">
|
||||
<v-chip :color="getPermissionColor(item.permission)" size="small" class="font-weight-medium">
|
||||
{{ getPermissionLabel(item.permission) }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.enabled="{ item }">
|
||||
<v-chip
|
||||
:color="getStatusInfo(item).color"
|
||||
size="small"
|
||||
class="font-weight-medium"
|
||||
:variant="getStatusInfo(item).variant"
|
||||
>
|
||||
{{ getStatusInfo(item).text }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.actions="{ item }">
|
||||
<div class="d-flex align-center">
|
||||
<v-btn-group density="default" variant="text" color="primary">
|
||||
<v-btn
|
||||
v-if="!item.enabled"
|
||||
icon
|
||||
size="small"
|
||||
color="success"
|
||||
@click="emit('toggle-command', item)"
|
||||
>
|
||||
<v-icon size="22">mdi-play</v-icon>
|
||||
<v-tooltip activator="parent" location="top">{{ tm('tooltips.enable') }}</v-tooltip>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-else
|
||||
icon
|
||||
size="small"
|
||||
color="error"
|
||||
@click="emit('toggle-command', item)"
|
||||
>
|
||||
<v-icon size="22">mdi-pause</v-icon>
|
||||
<v-tooltip activator="parent" location="top">{{ tm('tooltips.disable') }}</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-btn icon size="small" color="warning" @click="emit('rename', item)">
|
||||
<v-icon size="22">mdi-pencil</v-icon>
|
||||
<v-tooltip activator="parent" location="top">{{ tm('tooltips.rename') }}</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-btn icon size="small" @click="emit('view-details', item)">
|
||||
<v-icon size="22">mdi-information</v-icon>
|
||||
<v-tooltip activator="parent" location="top">{{ tm('tooltips.viewDetails') }}</v-tooltip>
|
||||
</v-btn>
|
||||
</v-btn-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:no-data>
|
||||
<div class="text-center pa-8">
|
||||
<v-icon size="64" color="info" class="mb-4">mdi-console-line</v-icon>
|
||||
<div class="text-h5 mb-2">{{ tm('empty.noCommands') }}</div>
|
||||
<div class="text-body-1 mb-4">{{ tm('empty.noCommandsDesc') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
code {
|
||||
background-color: rgba(var(--v-theme-primary), 0.1);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
code.sub-command-code {
|
||||
background-color: rgba(var(--v-theme-secondary), 0.1);
|
||||
color: rgb(var(--v-theme-secondary));
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 冲突行高亮 */
|
||||
.v-data-table .conflict-row {
|
||||
background: linear-gradient(90deg, rgba(var(--v-theme-warning), 0.15) 0%, rgba(var(--v-theme-warning), 0.05) 100%) !important;
|
||||
border-left: 3px solid rgb(var(--v-theme-warning)) !important;
|
||||
}
|
||||
|
||||
.v-data-table .conflict-row:hover {
|
||||
background: linear-gradient(90deg, rgba(var(--v-theme-warning), 0.25) 0%, rgba(var(--v-theme-warning), 0.1) 100%) !important;
|
||||
}
|
||||
|
||||
/* 指令组行样式 */
|
||||
.v-data-table .group-row {
|
||||
background-color: rgba(var(--v-theme-info), 0.05);
|
||||
}
|
||||
|
||||
.v-data-table .group-row:hover {
|
||||
background-color: rgba(var(--v-theme-info), 0.08) !important;
|
||||
}
|
||||
|
||||
/* 子指令行样式 */
|
||||
.v-data-table .sub-command-row {
|
||||
background-color: rgba(var(--v-theme-info), 0.05);
|
||||
}
|
||||
|
||||
.v-data-table .sub-command-row:hover {
|
||||
background-color: rgba(var(--v-theme-info), 0.08) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||
import type { CommandItem, TypeInfo } from '../types';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { tm } = useModuleI18n('features/command');
|
||||
|
||||
// Props
|
||||
defineProps<{
|
||||
show: boolean;
|
||||
command: CommandItem | null;
|
||||
}>();
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:show', value: boolean): void;
|
||||
}>();
|
||||
|
||||
// 获取类型信息
|
||||
const getTypeInfo = (type: string): TypeInfo => {
|
||||
switch (type) {
|
||||
case 'group':
|
||||
return { text: tm('type.group'), color: 'info', icon: 'mdi-folder-outline' };
|
||||
case 'sub_command':
|
||||
return { text: tm('type.subCommand'), color: 'secondary', icon: 'mdi-subdirectory-arrow-right' };
|
||||
default:
|
||||
return { text: tm('type.command'), color: 'primary', icon: 'mdi-console-line' };
|
||||
}
|
||||
};
|
||||
|
||||
// 获取权限颜色
|
||||
const getPermissionColor = (permission: string): string => {
|
||||
switch (permission) {
|
||||
case 'admin': return 'error';
|
||||
default: return 'success';
|
||||
}
|
||||
};
|
||||
|
||||
// 获取权限标签
|
||||
const getPermissionLabel = (permission: string): string => {
|
||||
switch (permission) {
|
||||
case 'admin': return tm('permission.admin');
|
||||
default: return tm('permission.everyone');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-dialog :model-value="show" @update:model-value="emit('update:show', $event)" max-width="500">
|
||||
<v-card v-if="command">
|
||||
<v-card-title class="text-h5">{{ tm('dialogs.details.title') }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-list density="compact">
|
||||
<v-list-item>
|
||||
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.type') }}</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
<v-chip
|
||||
:color="getTypeInfo(command.type).color"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
<v-icon start size="14">{{ getTypeInfo(command.type).icon }}</v-icon>
|
||||
{{ getTypeInfo(command.type).text }}
|
||||
</v-chip>
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.handler') }}</v-list-item-title>
|
||||
<v-list-item-subtitle><code>{{ command.handler_name }}</code></v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.module') }}</v-list-item-title>
|
||||
<v-list-item-subtitle><code>{{ command.module_path }}</code></v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.originalCommand') }}</v-list-item-title>
|
||||
<v-list-item-subtitle><code>{{ command.original_command }}</code></v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.effectiveCommand') }}</v-list-item-title>
|
||||
<v-list-item-subtitle><code>{{ command.effective_command }}</code></v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="command.parent_signature">
|
||||
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.parentGroup') }}</v-list-item-title>
|
||||
<v-list-item-subtitle><code>{{ command.parent_signature }}</code></v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="command.aliases.length > 0">
|
||||
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.aliases') }}</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
<v-chip v-for="alias in command.aliases" :key="alias" size="small" class="mr-1">
|
||||
{{ alias }}
|
||||
</v-chip>
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="command.is_group && command.sub_commands?.length > 0">
|
||||
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.subCommands') }}</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
<div class="d-flex flex-wrap ga-1 mt-1">
|
||||
<v-chip
|
||||
v-for="sub in command.sub_commands"
|
||||
:key="sub.handler_full_name"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
>
|
||||
{{ sub.current_fragment }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.permission') }}</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
<v-chip :color="getPermissionColor(command.permission)" size="small">
|
||||
{{ getPermissionLabel(command.permission) }}
|
||||
</v-chip>
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="command.has_conflict">
|
||||
<v-list-item-title class="font-weight-bold">{{ tm('dialogs.details.conflictStatus') }}</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
<v-chip color="warning" size="small">{{ tm('status.conflict') }}</v-chip>
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn color="primary" variant="text" @click="emit('update:show', false)">
|
||||
{{ t('core.actions.close') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
code {
|
||||
background-color: rgba(var(--v-theme-primary), 0.1);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,131 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import type { CommandItem } from '../types';
|
||||
|
||||
const { tm } = useModuleI18n('features/command');
|
||||
|
||||
// Props
|
||||
const props = defineProps<{
|
||||
show: boolean;
|
||||
command: CommandItem | null;
|
||||
newName: string;
|
||||
aliases: string[];
|
||||
loading: boolean;
|
||||
}>();
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:show', value: boolean): void;
|
||||
(e: 'update:newName', value: string): void;
|
||||
(e: 'update:aliases', value: string[]): void;
|
||||
(e: 'confirm'): void;
|
||||
}>();
|
||||
|
||||
const addAlias = () => {
|
||||
emit('update:aliases', [...props.aliases, '']);
|
||||
};
|
||||
|
||||
const removeAlias = (index: number) => {
|
||||
const newAliases = [...props.aliases];
|
||||
newAliases.splice(index, 1);
|
||||
emit('update:aliases', newAliases);
|
||||
};
|
||||
|
||||
const updateAlias = (index: number, value: string) => {
|
||||
const newAliases = [...props.aliases];
|
||||
newAliases[index] = value;
|
||||
emit('update:aliases', newAliases);
|
||||
};
|
||||
|
||||
const hasAliases = computed(() => (props.aliases || []).some(a => (a ?? '').toString().trim()));
|
||||
const showAliasEditor = ref(false);
|
||||
const aliasEditorEverOpened = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(open) => {
|
||||
if (!open) return;
|
||||
// 如果已有别名则默认展开,否则默认收起
|
||||
showAliasEditor.value = hasAliases.value;
|
||||
},
|
||||
);
|
||||
|
||||
watch(showAliasEditor, (open) => {
|
||||
if (open) aliasEditorEverOpened.value = true;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-dialog :model-value="show" @update:model-value="emit('update:show', $event)" max-width="500">
|
||||
<v-card>
|
||||
<v-card-title class="text-h5">{{ tm('dialogs.rename.title') }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
:model-value="newName"
|
||||
@update:model-value="emit('update:newName', $event)"
|
||||
:label="tm('dialogs.rename.newName')"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
autofocus
|
||||
class="mb-2"
|
||||
/>
|
||||
|
||||
<v-card variant="outlined" class="mt-2" elevation="0">
|
||||
<div
|
||||
class="d-flex align-center justify-space-between px-4 py-3"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="showAliasEditor = !showAliasEditor"
|
||||
@keydown.enter.prevent="showAliasEditor = !showAliasEditor"
|
||||
@keydown.space.prevent="showAliasEditor = !showAliasEditor"
|
||||
>
|
||||
<div class="text-subtitle-1">{{ tm('dialogs.rename.aliases') }}</div>
|
||||
<v-icon size="20">{{ showAliasEditor ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
|
||||
</div>
|
||||
<v-divider v-if="showAliasEditor" />
|
||||
<v-slide-y-transition>
|
||||
<div v-if="aliasEditorEverOpened" v-show="showAliasEditor" class="px-4 py-3">
|
||||
<div v-for="(alias, index) in aliases" :key="index" class="d-flex align-center mb-2">
|
||||
<v-text-field
|
||||
:model-value="alias"
|
||||
@update:model-value="updateAlias(index, $event)"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="flex-grow-1 mr-2"
|
||||
/>
|
||||
<v-btn icon="mdi-delete" variant="text" color="error" density="compact" @click="removeAlias(index)" />
|
||||
</div>
|
||||
<v-btn
|
||||
prepend-icon="mdi-plus"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
block
|
||||
size="small"
|
||||
class="mt-2"
|
||||
@click="addAlias"
|
||||
>
|
||||
{{ tm('dialogs.rename.addAlias') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-slide-y-transition>
|
||||
</v-card>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn color="grey" variant="text" @click="emit('update:show', false)">
|
||||
{{ tm('dialogs.rename.cancel') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="text"
|
||||
:loading="loading"
|
||||
@click="emit('confirm')"
|
||||
>
|
||||
{{ tm('dialogs.rename.confirm') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import type { ToolItem } from '../types';
|
||||
|
||||
const { tm: tmTool } = useModuleI18n('features/tooluse');
|
||||
const { tm: tmCommand } = useModuleI18n('features/command');
|
||||
|
||||
const props = defineProps<{
|
||||
items: ToolItem[];
|
||||
loading?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle-tool', tool: ToolItem): void;
|
||||
}>();
|
||||
|
||||
const toolHeaders = computed(() => [
|
||||
{ title: tmTool('functionTools.title'), key: 'name', minWidth: '160px' },
|
||||
{ title: tmTool('functionTools.description'), key: 'description' },
|
||||
{ title: tmTool('functionTools.table.origin'), key: 'origin', sortable: false, width: '120px' },
|
||||
{ title: tmTool('functionTools.table.originName'), key: 'origin_name', sortable: false, width: '160px' },
|
||||
{ title: tmCommand('status.enabled'), key: 'active', sortable: false, width: '120px' },
|
||||
{ title: tmTool('functionTools.table.actions'), key: 'actions', sortable: false, width: '120px' }
|
||||
]);
|
||||
|
||||
const parameterEntries = (tool: ToolItem) => Object.entries(tool.parameters?.properties || {});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card class="rounded-lg overflow-hidden elevation-1">
|
||||
<v-data-table
|
||||
:headers="toolHeaders"
|
||||
:items="items"
|
||||
item-key="name"
|
||||
hover
|
||||
show-expand
|
||||
class="tool-table"
|
||||
:loading="props.loading"
|
||||
>
|
||||
<template #item.name="{ item }">
|
||||
<div class="d-flex align-center py-2">
|
||||
<v-icon color="primary" class="mr-2" size="18">
|
||||
{{ item.name.includes(':') ? 'mdi-server-network' : 'mdi-function-variant' }}
|
||||
</v-icon>
|
||||
<div>
|
||||
<div class="text-subtitle-1 font-weight-medium">{{ item.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.description="{ item }">
|
||||
<div class="text-body-2 text-medium-emphasis" style="max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||
{{ item.description || '-' }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.origin="{ item }">
|
||||
<v-chip size="small" variant="tonal" color="info" class="text-caption font-weight-medium">
|
||||
{{ item.origin || '-' }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template #item.origin_name="{ item }">
|
||||
<div class="text-body-2 text-medium-emphasis" style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||
{{ item.origin_name || '-' }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item.active="{ item }">
|
||||
<v-chip :color="item.active ? 'success' : 'error'" size="small" class="font-weight-medium" :variant="item.active ? 'flat' : 'outlined'">
|
||||
{{ item.active ? tmCommand('status.enabled') : tmCommand('status.disabled') }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template #item.actions="{ item }">
|
||||
<v-switch
|
||||
:model-value="item.active"
|
||||
color="primary"
|
||||
density="compact"
|
||||
hide-details
|
||||
inset
|
||||
@update:model-value="emit('toggle-tool', item)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #no-data>
|
||||
<div class="text-center pa-8">
|
||||
<v-icon size="64" color="info" class="mb-4">mdi-function-variant</v-icon>
|
||||
<div class="text-h5 mb-2">{{ tmTool('functionTools.empty') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #expanded-row="{ item }">
|
||||
<td :colspan="toolHeaders.length + 1" class="pa-4">
|
||||
<div class="d-flex align-start ga-4">
|
||||
<v-icon size="20" color="primary">mdi-code-json</v-icon>
|
||||
<div class="flex-1">
|
||||
<div class="text-subtitle-2 font-weight-medium mb-2">{{ tmTool('functionTools.parameters') }}</div>
|
||||
<div v-if="parameterEntries(item).length === 0" class="text-caption text-medium-emphasis">
|
||||
{{ tmTool('functionTools.noParameters') }}
|
||||
</div>
|
||||
<v-table
|
||||
v-else
|
||||
density="compact"
|
||||
class="param-table"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-left text-caption text-medium-emphasis">{{ tmTool('functionTools.table.paramName') }}</th>
|
||||
<th class="text-left text-caption text-medium-emphasis" style="width: 140px;">{{ tmTool('functionTools.table.type') }}</th>
|
||||
<th class="text-left text-caption text-medium-emphasis">{{ tmTool('functionTools.table.description') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="([paramName, param]) in parameterEntries(item)" :key="paramName">
|
||||
<td class="font-weight-medium text-body-2">{{ paramName }}</td>
|
||||
<td class="text-body-2">
|
||||
<v-chip size="x-small" color="primary" class="text-caption">
|
||||
{{ param?.type || '-' }}
|
||||
</v-chip>
|
||||
</td>
|
||||
<td class="text-body-2 text-medium-emphasis">{{ param?.description || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.param-table {
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tool-table :deep(.v-data-table__td) {
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* 指令操作方法 Composable
|
||||
*/
|
||||
import { reactive } from 'vue';
|
||||
import axios from 'axios';
|
||||
import type { CommandItem, RenameDialogState, DetailsDialogState, TypeInfo, StatusInfo } from '../types';
|
||||
|
||||
export function useCommandActions(
|
||||
toast: (message: string, color?: string) => void,
|
||||
fetchCommands: () => Promise<void>
|
||||
) {
|
||||
// 重命名对话框状态
|
||||
const renameDialog = reactive<RenameDialogState>({
|
||||
show: false,
|
||||
command: null,
|
||||
newName: '',
|
||||
aliases: [],
|
||||
loading: false
|
||||
});
|
||||
|
||||
// 详情对话框状态
|
||||
const detailsDialog = reactive<DetailsDialogState>({
|
||||
show: false,
|
||||
command: null
|
||||
});
|
||||
|
||||
/**
|
||||
* 切换指令启用/禁用状态
|
||||
*/
|
||||
const toggleCommand = async (
|
||||
cmd: CommandItem,
|
||||
successMessage: string,
|
||||
errorMessage: string
|
||||
) => {
|
||||
try {
|
||||
const res = await axios.post('/api/commands/toggle', {
|
||||
handler_full_name: cmd.handler_full_name,
|
||||
enabled: !cmd.enabled
|
||||
});
|
||||
if (res.data.status === 'ok') {
|
||||
toast(successMessage, 'success');
|
||||
await fetchCommands();
|
||||
} else {
|
||||
toast(res.data.message || errorMessage, 'error');
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast(err?.message || errorMessage, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 打开重命名对话框
|
||||
*/
|
||||
const openRenameDialog = (cmd: CommandItem) => {
|
||||
renameDialog.command = cmd;
|
||||
renameDialog.newName = cmd.current_fragment || '';
|
||||
renameDialog.aliases = [...(cmd.aliases || [])];
|
||||
renameDialog.show = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 确认重命名
|
||||
*/
|
||||
const confirmRename = async (successMessage: string, errorMessage: string) => {
|
||||
if (!renameDialog.command || !renameDialog.newName.trim()) return;
|
||||
|
||||
renameDialog.loading = true;
|
||||
try {
|
||||
const res = await axios.post('/api/commands/rename', {
|
||||
handler_full_name: renameDialog.command.handler_full_name,
|
||||
new_name: renameDialog.newName.trim(),
|
||||
aliases: renameDialog.aliases.filter(a => a.trim())
|
||||
});
|
||||
if (res.data.status === 'ok') {
|
||||
toast(successMessage, 'success');
|
||||
renameDialog.show = false;
|
||||
await fetchCommands();
|
||||
} else {
|
||||
toast(res.data.message || errorMessage, 'error');
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast(err?.message || errorMessage, 'error');
|
||||
} finally {
|
||||
renameDialog.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 打开详情对话框
|
||||
*/
|
||||
const openDetailsDialog = (cmd: CommandItem) => {
|
||||
detailsDialog.command = cmd;
|
||||
detailsDialog.show = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取类型显示信息
|
||||
*/
|
||||
const getTypeInfo = (type: string, translations: { group: string; subCommand: string; command: string }): TypeInfo => {
|
||||
switch (type) {
|
||||
case 'group':
|
||||
return { text: translations.group, color: 'info', icon: 'mdi-folder-outline' };
|
||||
case 'sub_command':
|
||||
return { text: translations.subCommand, color: 'secondary', icon: 'mdi-subdirectory-arrow-right' };
|
||||
default:
|
||||
return { text: translations.command, color: 'primary', icon: 'mdi-console-line' };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取权限颜色
|
||||
*/
|
||||
const getPermissionColor = (permission: string): string => {
|
||||
switch (permission) {
|
||||
case 'admin': return 'error';
|
||||
default: return 'success';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取权限标签
|
||||
*/
|
||||
const getPermissionLabel = (permission: string, translations: { admin: string; everyone: string }): string => {
|
||||
switch (permission) {
|
||||
case 'admin': return translations.admin;
|
||||
default: return translations.everyone;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取状态显示信息
|
||||
*/
|
||||
const getStatusInfo = (
|
||||
cmd: CommandItem,
|
||||
translations: { conflict: string; enabled: string; disabled: string }
|
||||
): StatusInfo => {
|
||||
if (cmd.has_conflict) {
|
||||
return { text: translations.conflict, color: 'warning', variant: 'flat' };
|
||||
}
|
||||
if (cmd.enabled) {
|
||||
return { text: translations.enabled, color: 'success', variant: 'flat' };
|
||||
}
|
||||
return { text: translations.disabled, color: 'error', variant: 'outlined' };
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取表格行属性(用于冲突高亮和子指令样式)
|
||||
*/
|
||||
const getRowProps = ({ item }: { item: CommandItem }) => {
|
||||
const classes: string[] = [];
|
||||
if (item.has_conflict) {
|
||||
classes.push('conflict-row');
|
||||
}
|
||||
if (item.type === 'sub_command') {
|
||||
classes.push('sub-command-row');
|
||||
}
|
||||
if (item.is_group) {
|
||||
classes.push('group-row');
|
||||
}
|
||||
return classes.length > 0 ? { class: classes.join(' ') } : {};
|
||||
};
|
||||
|
||||
return {
|
||||
// 状态
|
||||
renameDialog,
|
||||
detailsDialog,
|
||||
|
||||
// 方法
|
||||
toggleCommand,
|
||||
openRenameDialog,
|
||||
confirmRename,
|
||||
openDetailsDialog,
|
||||
getTypeInfo,
|
||||
getPermissionColor,
|
||||
getPermissionLabel,
|
||||
getStatusInfo,
|
||||
getRowProps
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* 指令过滤逻辑 Composable
|
||||
*/
|
||||
import { ref, computed, type Ref } from 'vue';
|
||||
import type { CommandItem, FilterState } from '../types';
|
||||
|
||||
export function useCommandFilters(commands: Ref<CommandItem[]>) {
|
||||
// 过滤状态
|
||||
const searchQuery = ref('');
|
||||
const pluginFilter = ref('all');
|
||||
const permissionFilter = ref('all');
|
||||
const statusFilter = ref('all');
|
||||
const typeFilter = ref('all');
|
||||
const showSystemPlugins = ref(false);
|
||||
|
||||
// 展开的指令组
|
||||
const expandedGroups = ref<Set<string>>(new Set());
|
||||
|
||||
/**
|
||||
* 检查是否有涉及系统插件的冲突
|
||||
*/
|
||||
const hasSystemPluginConflict = computed(() => {
|
||||
return commands.value.some(cmd => cmd.has_conflict && cmd.reserved);
|
||||
});
|
||||
|
||||
/**
|
||||
* 实际是否显示系统插件(如果有系统插件冲突则强制显示)
|
||||
*/
|
||||
const effectiveShowSystemPlugins = computed(() => {
|
||||
return showSystemPlugins.value || hasSystemPluginConflict.value;
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取可用的插件列表(用于过滤下拉框)
|
||||
*/
|
||||
const availablePlugins = computed(() => {
|
||||
const plugins = new Set(
|
||||
commands.value
|
||||
.filter(cmd => effectiveShowSystemPlugins.value || !cmd.reserved)
|
||||
.map(cmd => cmd.plugin)
|
||||
);
|
||||
return Array.from(plugins).sort();
|
||||
});
|
||||
|
||||
/**
|
||||
* 检查指令是否匹配过滤条件
|
||||
*/
|
||||
const matchesFilters = (cmd: CommandItem, query: string): boolean => {
|
||||
// 系统插件过滤(除非显示系统插件)
|
||||
if (!effectiveShowSystemPlugins.value && cmd.reserved) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 搜索过滤
|
||||
if (query) {
|
||||
const matchesSearch =
|
||||
cmd.effective_command?.toLowerCase().includes(query) ||
|
||||
cmd.description?.toLowerCase().includes(query) ||
|
||||
cmd.plugin?.toLowerCase().includes(query);
|
||||
if (!matchesSearch) return false;
|
||||
}
|
||||
|
||||
// 插件过滤
|
||||
if (pluginFilter.value !== 'all' && cmd.plugin !== pluginFilter.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 权限过滤
|
||||
if (permissionFilter.value !== 'all') {
|
||||
if (permissionFilter.value === 'everyone') {
|
||||
if (cmd.permission !== 'everyone' && cmd.permission !== 'member') return false;
|
||||
} else if (cmd.permission !== permissionFilter.value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 状态过滤
|
||||
if (statusFilter.value !== 'all') {
|
||||
if (statusFilter.value === 'enabled' && !cmd.enabled) return false;
|
||||
if (statusFilter.value === 'disabled' && cmd.enabled) return false;
|
||||
if (statusFilter.value === 'conflict' && !cmd.has_conflict) return false;
|
||||
}
|
||||
|
||||
// 类型过滤
|
||||
if (typeFilter.value !== 'all') {
|
||||
if (typeFilter.value === 'group' && cmd.type !== 'group') return false;
|
||||
if (typeFilter.value === 'command' && cmd.type !== 'command') return false;
|
||||
if (typeFilter.value === 'sub_command' && cmd.type !== 'sub_command') return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 过滤后的指令列表(支持层级结构)
|
||||
*/
|
||||
const filteredCommands = computed(() => {
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
const conflictCmds: CommandItem[] = [];
|
||||
const normalCmds: CommandItem[] = [];
|
||||
|
||||
for (const cmd of commands.value) {
|
||||
// 对于指令组,检查组本身或子指令是否匹配
|
||||
if (cmd.is_group) {
|
||||
const groupMatches = matchesFilters(cmd, query);
|
||||
const matchingSubCmds = (cmd.sub_commands || []).filter(sub => matchesFilters(sub, query));
|
||||
|
||||
// 如果组匹配或有匹配的子指令,则包含它
|
||||
if (groupMatches || matchingSubCmds.length > 0) {
|
||||
if (cmd.has_conflict) {
|
||||
conflictCmds.push(cmd);
|
||||
} else {
|
||||
normalCmds.push(cmd);
|
||||
}
|
||||
|
||||
// 如果组已展开,添加匹配的子指令
|
||||
if (expandedGroups.value.has(cmd.handler_full_name)) {
|
||||
const subsToShow = query ? matchingSubCmds : (cmd.sub_commands || []);
|
||||
for (const sub of subsToShow) {
|
||||
if (sub.has_conflict) {
|
||||
conflictCmds.push(sub);
|
||||
} else {
|
||||
normalCmds.push(sub);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (cmd.type !== 'sub_command') {
|
||||
// 普通指令(子指令通过组处理)
|
||||
if (matchesFilters(cmd, query)) {
|
||||
if (cmd.has_conflict) {
|
||||
conflictCmds.push(cmd);
|
||||
} else {
|
||||
normalCmds.push(cmd);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按 effective_command 排序冲突指令,使其分组在一起
|
||||
conflictCmds.sort((a, b) => (a.effective_command || '').localeCompare(b.effective_command || ''));
|
||||
|
||||
return [...conflictCmds, ...normalCmds];
|
||||
});
|
||||
|
||||
/**
|
||||
* 切换指令组的展开/折叠状态
|
||||
*/
|
||||
const toggleGroupExpand = (cmd: CommandItem) => {
|
||||
if (!cmd.is_group) return;
|
||||
if (expandedGroups.value.has(cmd.handler_full_name)) {
|
||||
expandedGroups.value.delete(cmd.handler_full_name);
|
||||
} else {
|
||||
expandedGroups.value.add(cmd.handler_full_name);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查指令组是否已展开
|
||||
*/
|
||||
const isGroupExpanded = (cmd: CommandItem): boolean => {
|
||||
return expandedGroups.value.has(cmd.handler_full_name);
|
||||
};
|
||||
|
||||
return {
|
||||
// 状态
|
||||
searchQuery,
|
||||
pluginFilter,
|
||||
permissionFilter,
|
||||
statusFilter,
|
||||
typeFilter,
|
||||
showSystemPlugins,
|
||||
expandedGroups,
|
||||
|
||||
// 计算属性
|
||||
hasSystemPluginConflict,
|
||||
effectiveShowSystemPlugins,
|
||||
availablePlugins,
|
||||
filteredCommands,
|
||||
|
||||
// 方法
|
||||
matchesFilters,
|
||||
toggleGroupExpand,
|
||||
isGroupExpanded
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 指令数据管理 Composable
|
||||
*/
|
||||
import { ref, reactive } from 'vue';
|
||||
import axios from 'axios';
|
||||
import type { CommandItem, CommandSummary, SnackbarState, ToolItem } from '../types';
|
||||
|
||||
export function useComponentData() {
|
||||
const loading = ref(false);
|
||||
const commands = ref<CommandItem[]>([]);
|
||||
const tools = ref<ToolItem[]>([]);
|
||||
const toolsLoading = ref(false);
|
||||
const summary = reactive<CommandSummary>({
|
||||
disabled: 0,
|
||||
conflicts: 0
|
||||
});
|
||||
|
||||
const snackbar = reactive<SnackbarState>({
|
||||
show: false,
|
||||
message: '',
|
||||
color: 'success'
|
||||
});
|
||||
|
||||
/**
|
||||
* 显示 Toast 消息
|
||||
*/
|
||||
const toast = (message: string, color: string = 'success') => {
|
||||
snackbar.message = message;
|
||||
snackbar.color = color;
|
||||
snackbar.show = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取指令列表
|
||||
*/
|
||||
const fetchCommands = async (errorMessage: string) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await axios.get('/api/commands');
|
||||
if (res.data.status === 'ok') {
|
||||
commands.value = res.data.data.items || [];
|
||||
const s = res.data.data.summary || {};
|
||||
summary.disabled = s.disabled || 0;
|
||||
summary.conflicts = s.conflicts || 0;
|
||||
} else {
|
||||
toast(res.data.message || errorMessage, 'error');
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast(err?.message || errorMessage, 'error');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTools = async (errorMessage: string) => {
|
||||
toolsLoading.value = true;
|
||||
try {
|
||||
const res = await axios.get('/api/tools/list');
|
||||
if (res.data.status === 'ok') {
|
||||
tools.value = res.data.data || [];
|
||||
} else {
|
||||
toast(res.data.message || errorMessage, 'error');
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast(err?.message || errorMessage, 'error');
|
||||
} finally {
|
||||
toolsLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
loading,
|
||||
commands,
|
||||
tools,
|
||||
toolsLoading,
|
||||
summary,
|
||||
snackbar,
|
||||
toast,
|
||||
fetchCommands,
|
||||
fetchTools
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 组件管理页面 - 主入口
|
||||
*
|
||||
* 模块化结构:
|
||||
* - types.ts: 类型定义
|
||||
* - composables/useComponentData.ts: 数据获取和状态管理
|
||||
* - composables/useCommandFilters.ts: 过滤逻辑
|
||||
* - composables/useCommandActions.ts: 操作方法
|
||||
* - components/CommandFilters.vue: 过滤器组件
|
||||
* - components/CommandTable.vue: 表格组件
|
||||
* - components/RenameDialog.vue: 重命名对话框
|
||||
* - components/DetailsDialog.vue: 详情对话框
|
||||
*/
|
||||
import { computed, onActivated, onMounted, ref, watch} from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
|
||||
// Composables
|
||||
import { useComponentData } from './composables/useComponentData';
|
||||
import { useCommandFilters } from './composables/useCommandFilters';
|
||||
import { useCommandActions } from './composables/useCommandActions';
|
||||
|
||||
// Components
|
||||
import CommandFilters from './components/CommandFilters.vue';
|
||||
import CommandTable from './components/CommandTable.vue';
|
||||
import ToolTable from './components/ToolTable.vue';
|
||||
import RenameDialog from './components/RenameDialog.vue';
|
||||
import DetailsDialog from './components/DetailsDialog.vue';
|
||||
|
||||
// Types
|
||||
import type { CommandItem, ToolItem } from './types';
|
||||
|
||||
defineOptions({ name: 'ComponentPanel' });
|
||||
const props = withDefaults(defineProps<{ active?: boolean }>(), {
|
||||
active: true
|
||||
});
|
||||
|
||||
const { tm } = useModuleI18n('features/command');
|
||||
const { tm: tmTool } = useModuleI18n('features/tooluse');
|
||||
|
||||
const viewMode = ref<'commands' | 'tools'>('commands');
|
||||
const toolSearch = ref('');
|
||||
|
||||
// 数据管理
|
||||
const {
|
||||
loading,
|
||||
commands,
|
||||
tools,
|
||||
toolsLoading,
|
||||
summary,
|
||||
snackbar,
|
||||
toast,
|
||||
fetchCommands,
|
||||
fetchTools
|
||||
} = useComponentData();
|
||||
|
||||
// 过滤逻辑
|
||||
const {
|
||||
searchQuery,
|
||||
pluginFilter,
|
||||
permissionFilter,
|
||||
statusFilter,
|
||||
typeFilter,
|
||||
showSystemPlugins,
|
||||
expandedGroups,
|
||||
hasSystemPluginConflict,
|
||||
effectiveShowSystemPlugins,
|
||||
availablePlugins,
|
||||
filteredCommands,
|
||||
toggleGroupExpand
|
||||
} = useCommandFilters(commands);
|
||||
|
||||
// 操作方法
|
||||
const {
|
||||
renameDialog,
|
||||
detailsDialog,
|
||||
toggleCommand,
|
||||
openRenameDialog,
|
||||
confirmRename,
|
||||
openDetailsDialog
|
||||
} = useCommandActions(toast, () => fetchCommands(tm('messages.loadFailed')));
|
||||
|
||||
const filteredTools = computed(() => {
|
||||
const query = toolSearch.value.trim().toLowerCase();
|
||||
if (!query) return tools.value;
|
||||
return tools.value.filter(tool =>
|
||||
tool.name?.toLowerCase().includes(query) ||
|
||||
tool.description?.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
// 处理切换指令状态
|
||||
const handleToggleCommand = async (cmd: CommandItem) => {
|
||||
await toggleCommand(cmd, tm('messages.toggleSuccess'), tm('messages.toggleFailed'));
|
||||
};
|
||||
|
||||
const handleToggleTool = async (tool: ToolItem) => {
|
||||
const previous = tool.active;
|
||||
tool.active = !tool.active;
|
||||
try {
|
||||
const res = await axios.post('/api/tools/toggle-tool', {
|
||||
name: tool.name,
|
||||
activate: tool.active
|
||||
});
|
||||
if (res.data.status === 'ok') {
|
||||
toast(res.data.message || tmTool('messages.toggleToolSuccess'));
|
||||
} else {
|
||||
tool.active = previous;
|
||||
toast(res.data.message || tmTool('messages.toggleToolError', { error: '' }), 'error');
|
||||
}
|
||||
} catch (error: any) {
|
||||
tool.active = previous;
|
||||
toast(error?.response?.data?.message || error?.message || tmTool('messages.toggleToolError', { error: '' }), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// 处理确认重命名
|
||||
const handleConfirmRename = async () => {
|
||||
await confirmRename(tm('messages.renameSuccess'), tm('messages.renameFailed'));
|
||||
};
|
||||
|
||||
// 生命周期
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
fetchCommands(tm('messages.loadFailed')),
|
||||
fetchTools(tmTool('messages.getToolsError', { error: '' }))
|
||||
]);
|
||||
});
|
||||
|
||||
watch(() => props.active, async (isActive) => {
|
||||
if (!isActive) return;
|
||||
if (viewMode.value === 'commands') {
|
||||
await fetchCommands(tm('messages.loadFailed'));
|
||||
} else {
|
||||
await fetchTools(tmTool('messages.getToolsError', { error: '' }));
|
||||
}
|
||||
});
|
||||
|
||||
watch(viewMode, async (mode) => {
|
||||
if (mode === 'commands') {
|
||||
await fetchCommands(tm('messages.loadFailed'));
|
||||
} else {
|
||||
await fetchTools(tmTool('messages.getToolsError', { error: '' }));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-card variant="flat" style="background-color: transparent">
|
||||
<v-card-text style="padding: 20px 12px; padding-top: 0px;">
|
||||
<div class="d-flex justify-space-between align-center mb-6 flex-wrap ga-3">
|
||||
<v-btn-toggle v-model="viewMode" color="primary" variant="outlined" density="comfortable" mandatory>
|
||||
<v-btn value="commands">
|
||||
<v-icon size="18" class="mr-1">mdi-console-line</v-icon>
|
||||
{{ tm('type.command') }}
|
||||
</v-btn>
|
||||
<v-btn value="tools">
|
||||
<v-icon size="18" class="mr-1">mdi-function-variant</v-icon>
|
||||
{{ tmTool('functionTools.title') }}
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
<v-progress-linear
|
||||
v-if="viewMode === 'commands' && loading"
|
||||
indeterminate
|
||||
color="primary"
|
||||
style="max-width: 220px; flex: 1;"
|
||||
/>
|
||||
<v-progress-linear
|
||||
v-else-if="viewMode === 'tools' && toolsLoading"
|
||||
indeterminate
|
||||
color="primary"
|
||||
style="max-width: 220px; flex: 1;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="viewMode === 'commands'">
|
||||
<CommandFilters
|
||||
:plugin-filter="pluginFilter"
|
||||
@update:plugin-filter="pluginFilter = $event"
|
||||
:type-filter="typeFilter"
|
||||
@update:type-filter="typeFilter = $event"
|
||||
:permission-filter="permissionFilter"
|
||||
@update:permission-filter="permissionFilter = $event"
|
||||
:status-filter="statusFilter"
|
||||
@update:status-filter="statusFilter = $event"
|
||||
:show-system-plugins="showSystemPlugins"
|
||||
@update:show-system-plugins="showSystemPlugins = $event"
|
||||
:search-query="searchQuery"
|
||||
@update:search-query="searchQuery = $event"
|
||||
:available-plugins="availablePlugins"
|
||||
:has-system-plugin-conflict="hasSystemPluginConflict"
|
||||
:effective-show-system-plugins="effectiveShowSystemPlugins"
|
||||
>
|
||||
<template #stats>
|
||||
<div class="d-flex align-center">
|
||||
<v-icon size="18" color="primary" class="mr-1">mdi-console-line</v-icon>
|
||||
<span class="text-body-2 text-medium-emphasis mr-1">{{ tm('summary.total') }}:</span>
|
||||
<span class="text-body-1 font-weight-bold text-primary">{{ filteredCommands.length }}</span>
|
||||
</div>
|
||||
<v-divider vertical class="mx-1" style="height: 20px;" />
|
||||
<div class="d-flex align-center">
|
||||
<v-icon size="18" color="error" class="mr-1">mdi-close-circle-outline</v-icon>
|
||||
<span class="text-body-2 text-medium-emphasis mr-1">{{ tm('summary.disabled') }}:</span>
|
||||
<span class="text-body-1 font-weight-bold text-error">{{ summary.disabled }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</CommandFilters>
|
||||
|
||||
<v-alert
|
||||
v-if="summary.conflicts > 0"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
class="mb-4"
|
||||
prominent
|
||||
border="start"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="28">mdi-alert-circle</v-icon>
|
||||
</template>
|
||||
<v-alert-title class="text-subtitle-1 font-weight-bold">
|
||||
{{ tm('conflictAlert.title') }}
|
||||
</v-alert-title>
|
||||
<div class="text-body-2 mt-1">
|
||||
{{ tm('conflictAlert.description', { count: summary.conflicts }) }}
|
||||
</div>
|
||||
<div class="text-body-2 mt-2">
|
||||
<v-icon size="16" class="mr-1">mdi-lightbulb-outline</v-icon>
|
||||
{{ tm('conflictAlert.hint') }}
|
||||
</div>
|
||||
</v-alert>
|
||||
|
||||
<CommandTable
|
||||
:items="filteredCommands"
|
||||
:expanded-groups="expandedGroups"
|
||||
:loading="loading"
|
||||
@toggle-expand="toggleGroupExpand"
|
||||
@toggle-command="handleToggleCommand"
|
||||
@rename="openRenameDialog"
|
||||
@view-details="openDetailsDialog"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="d-flex flex-wrap align-center ga-3 mb-4">
|
||||
<div style="min-width: 240px; max-width: 380px; flex: 1;">
|
||||
<v-text-field
|
||||
v-model="toolSearch"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
:label="tmTool('functionTools.search')"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<div class="d-flex align-center ga-2">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon size="18" color="primary" class="mr-1">mdi-function-variant</v-icon>
|
||||
<span class="text-body-2 text-medium-emphasis mr-1">{{ tm('summary.total') }}:</span>
|
||||
<span class="text-body-1 font-weight-bold text-primary">{{ filteredTools.length }}</span>
|
||||
</div>
|
||||
<v-divider vertical class="mx-1" style="height: 20px;" />
|
||||
<div class="d-flex align-center">
|
||||
<v-icon size="18" color="success" class="mr-1">mdi-check-circle-outline</v-icon>
|
||||
<span class="text-body-2 text-medium-emphasis mr-1">{{ tm('status.enabled') }}:</span>
|
||||
<span class="text-body-1 font-weight-bold text-success">{{ filteredTools.filter(t => t.active).length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ToolTable
|
||||
:items="filteredTools"
|
||||
:loading="toolsLoading"
|
||||
@toggle-tool="handleToggleTool"
|
||||
/>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 重命名对话框 -->
|
||||
<RenameDialog
|
||||
:show="renameDialog.show"
|
||||
@update:show="renameDialog.show = $event"
|
||||
:new-name="renameDialog.newName"
|
||||
@update:new-name="renameDialog.newName = $event"
|
||||
:aliases="renameDialog.aliases"
|
||||
@update:aliases="renameDialog.aliases = $event"
|
||||
:command="renameDialog.command"
|
||||
:loading="renameDialog.loading"
|
||||
@confirm="handleConfirmRename"
|
||||
/>
|
||||
|
||||
<!-- 详情对话框 -->
|
||||
<DetailsDialog
|
||||
:show="detailsDialog.show"
|
||||
@update:show="detailsDialog.show = $event"
|
||||
:command="detailsDialog.command"
|
||||
/>
|
||||
|
||||
<!-- Snackbar -->
|
||||
<v-snackbar :timeout="2000" elevation="24" :color="snackbar.color" v-model="snackbar.show">
|
||||
{{ snackbar.message }}
|
||||
</v-snackbar>
|
||||
</template>
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 指令管理模块 - 类型定义
|
||||
*/
|
||||
|
||||
/** 指令项接口 */
|
||||
export interface CommandItem {
|
||||
handler_full_name: string;
|
||||
handler_name: string;
|
||||
plugin: string;
|
||||
plugin_display_name: string | null;
|
||||
module_path: string;
|
||||
description: string;
|
||||
type: CommandType;
|
||||
parent_signature: string;
|
||||
parent_group_handler: string;
|
||||
original_command: string;
|
||||
current_fragment: string;
|
||||
effective_command: string;
|
||||
aliases: string[];
|
||||
permission: PermissionType;
|
||||
enabled: boolean;
|
||||
is_group: boolean;
|
||||
has_conflict: boolean;
|
||||
reserved: boolean;
|
||||
sub_commands: CommandItem[];
|
||||
}
|
||||
|
||||
/** 指令类型 */
|
||||
export type CommandType = 'command' | 'group' | 'sub_command';
|
||||
|
||||
/** 权限类型 */
|
||||
export type PermissionType = 'admin' | 'everyone' | 'member';
|
||||
|
||||
/** 指令摘要统计 */
|
||||
export interface CommandSummary {
|
||||
disabled: number;
|
||||
conflicts: number;
|
||||
}
|
||||
|
||||
/** 过滤器状态 */
|
||||
export interface FilterState {
|
||||
searchQuery: string;
|
||||
pluginFilter: string;
|
||||
permissionFilter: string;
|
||||
statusFilter: string;
|
||||
typeFilter: string;
|
||||
showSystemPlugins: boolean;
|
||||
}
|
||||
|
||||
/** 重命名对话框状态 */
|
||||
export interface RenameDialogState {
|
||||
show: boolean;
|
||||
command: CommandItem | null;
|
||||
newName: string;
|
||||
aliases: string[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
/** 详情对话框状态 */
|
||||
export interface DetailsDialogState {
|
||||
show: boolean;
|
||||
command: CommandItem | null;
|
||||
}
|
||||
|
||||
/** Toast 消息状态 */
|
||||
export interface SnackbarState {
|
||||
show: boolean;
|
||||
message: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** 类型信息展示 */
|
||||
export interface TypeInfo {
|
||||
text: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
/** 状态信息展示 */
|
||||
export interface StatusInfo {
|
||||
text: string;
|
||||
color: string;
|
||||
variant: 'flat' | 'outlined' | 'text' | 'elevated' | 'tonal' | 'plain';
|
||||
}
|
||||
|
||||
/** MCP/函数工具参数定义 */
|
||||
export interface ToolParameter {
|
||||
type?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** MCP/函数工具对象 */
|
||||
export interface ToolItem {
|
||||
name: string;
|
||||
description: string;
|
||||
active: boolean;
|
||||
parameters?: {
|
||||
properties?: Record<string, ToolParameter>;
|
||||
};
|
||||
origin?: string;
|
||||
origin_name?: string;
|
||||
}
|
||||
|
||||
@@ -394,6 +394,9 @@ export default {
|
||||
// 配置抽屉
|
||||
showConfigDrawer: false,
|
||||
configDrawerTargetId: null,
|
||||
|
||||
// 保存更新前的平台 ID,防止用户修改 ID 后丢失原始定位
|
||||
originalUpdatingPlatformId: null,
|
||||
};
|
||||
},
|
||||
setup() {
|
||||
@@ -481,6 +484,7 @@ export default {
|
||||
updatingPlatformConfig: {
|
||||
handler(newConfig) {
|
||||
if (this.updatingMode && newConfig && newConfig.id) {
|
||||
this.originalUpdatingPlatformId = newConfig.id;
|
||||
this.getPlatformConfigs(newConfig.id);
|
||||
}
|
||||
},
|
||||
@@ -533,6 +537,8 @@ export default {
|
||||
|
||||
this.showConfigDrawer = false;
|
||||
this.configDrawerTargetId = null;
|
||||
|
||||
this.originalUpdatingPlatformId = null;
|
||||
},
|
||||
closeDialog() {
|
||||
this.resetForm();
|
||||
@@ -624,7 +630,7 @@ export default {
|
||||
}
|
||||
},
|
||||
async updatePlatform() {
|
||||
let id = this.updatingPlatformConfig.id;
|
||||
const id = this.originalUpdatingPlatformId || this.updatingPlatformConfig.id;
|
||||
if (!id) {
|
||||
this.loading = false;
|
||||
this.showError('更新失败,缺少平台 ID。');
|
||||
@@ -633,11 +639,15 @@ export default {
|
||||
|
||||
try {
|
||||
// 更新平台配置
|
||||
await axios.post('/api/config/platform/update', {
|
||||
let resp = await axios.post('/api/config/platform/update', {
|
||||
id: id,
|
||||
config: this.updatingPlatformConfig
|
||||
});
|
||||
})
|
||||
|
||||
if (resp.data.status === 'error') {
|
||||
throw new Error(resp.data.message || '平台更新失败');
|
||||
}
|
||||
|
||||
// 同时更新路由表
|
||||
await this.saveRoutesInternal();
|
||||
|
||||
@@ -885,7 +895,10 @@ export default {
|
||||
|
||||
// 内部保存路由表方法(不显示成功提示)
|
||||
async saveRoutesInternal() {
|
||||
if (!this.updatingPlatformConfig || !this.updatingPlatformConfig.id) {
|
||||
const originalPlatformId = this.originalUpdatingPlatformId || this.updatingPlatformConfig?.id;
|
||||
const newPlatformId = this.updatingPlatformConfig?.id || originalPlatformId;
|
||||
|
||||
if (!originalPlatformId && !newPlatformId) {
|
||||
throw new Error('无法获取平台 ID');
|
||||
}
|
||||
|
||||
@@ -895,9 +908,11 @@ export default {
|
||||
const fullRoutingTable = routesRes.data.data.routing;
|
||||
|
||||
// 删除该平台的所有旧路由
|
||||
const platformId = this.updatingPlatformConfig.id;
|
||||
for (const umop in fullRoutingTable) {
|
||||
if (this.isUmopMatchPlatform(umop, platformId)) {
|
||||
if (
|
||||
(originalPlatformId && this.isUmopMatchPlatform(umop, originalPlatformId)) ||
|
||||
(newPlatformId && this.isUmopMatchPlatform(umop, newPlatformId))
|
||||
) {
|
||||
delete fullRoutingTable[umop];
|
||||
}
|
||||
}
|
||||
@@ -906,7 +921,8 @@ export default {
|
||||
for (const route of this.platformRoutes) {
|
||||
const messageType = route.messageType === '*' ? '*' : route.messageType;
|
||||
const sessionId = route.sessionId === '*' ? '*' : route.sessionId;
|
||||
const newUmop = `${platformId}:${messageType}:${sessionId}`;
|
||||
const platformIdForRoute = newPlatformId || originalPlatformId;
|
||||
const newUmop = `${platformIdForRoute}:${messageType}:${sessionId}`;
|
||||
|
||||
if (route.configId) {
|
||||
fullRoutingTable[newUmop] = route.configId;
|
||||
|
||||
@@ -3,10 +3,6 @@
|
||||
<v-card :title="tm('dialogs.addProvider.title')">
|
||||
<v-card-text style="overflow-y: auto;">
|
||||
<v-tabs v-model="activeProviderTab" grow>
|
||||
<v-tab value="chat_completion" class="font-weight-medium px-3">
|
||||
<v-icon start>mdi-message-text</v-icon>
|
||||
{{ tm('dialogs.addProvider.tabs.basic') }}
|
||||
</v-tab>
|
||||
<v-tab value="agent_runner" class="font-weight-medium px-3">
|
||||
<v-icon start>mdi-cogs</v-icon>
|
||||
{{ tm('dialogs.addProvider.tabs.agentRunner') }}
|
||||
@@ -116,7 +112,7 @@ export default {
|
||||
|
||||
// 按提供商类型获取模板列表
|
||||
getTemplatesByType(type) {
|
||||
const templates = this.metadata['provider_group']?.metadata?.provider?.config_template || {};
|
||||
const templates = this.metadata.provider.config_template || {};
|
||||
const filtered = {};
|
||||
|
||||
for (const [name, template] of Object.entries(templates)) {
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<div class="mt-4">
|
||||
<div class="d-flex align-center ga-2 mb-2">
|
||||
<h3 class="text-h5 font-weight-bold mb-0">{{ tm('models.configured') }}</h3>
|
||||
<small style="color: grey;" v-if="availableCount">{{ tm('models.available') }} {{ availableCount }}</small>
|
||||
<v-text-field
|
||||
v-model="modelSearchProxy"
|
||||
density="compact"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
hide-details
|
||||
variant="solo-filled"
|
||||
flat
|
||||
class="ml-1"
|
||||
style="max-width: 240px;"
|
||||
:placeholder="tm('models.searchPlaceholder')"
|
||||
/>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
color="primary"
|
||||
prepend-icon="mdi-download"
|
||||
:loading="loadingModels"
|
||||
@click="emit('fetch-models')"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
>
|
||||
{{ isSourceModified ? tm('providerSources.saveAndFetchModels') : tm('providerSources.fetchModels') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
prepend-icon="mdi-pencil-plus"
|
||||
variant="text"
|
||||
size="small"
|
||||
class="ml-1"
|
||||
@click="emit('open-manual-model')"
|
||||
>
|
||||
{{ tm('models.manualAddButton') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-list
|
||||
density="compact"
|
||||
class="rounded-lg border"
|
||||
style="max-height: 520px; overflow-y: auto; font-family:system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;"
|
||||
>
|
||||
<template v-if="entries.length > 0">
|
||||
<template v-for="entry in entries" :key="entry.type === 'configured' ? `provider-${entry.provider.id}` : `model-${entry.model}`">
|
||||
<v-list-item
|
||||
v-if="entry.type === 'configured'"
|
||||
class="provider-compact-item"
|
||||
@click="emit('open-provider-edit', entry.provider)"
|
||||
>
|
||||
<v-list-item-title class="font-weight-medium text-truncate">
|
||||
{{ entry.provider.id }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-caption text-grey d-flex align-center ga-1" style="font-family: monospace;">
|
||||
<span>{{ entry.provider.model }}</span>
|
||||
<v-icon v-if="supportsImageInput(entry.metadata)" size="14" color="grey">
|
||||
mdi-eye-outline
|
||||
</v-icon>
|
||||
<v-icon v-if="supportsToolCall(entry.metadata)" size="14" color="grey">
|
||||
mdi-wrench
|
||||
</v-icon>
|
||||
<v-icon v-if="supportsReasoning(entry.metadata)" size="14" color="grey">
|
||||
mdi-brain
|
||||
</v-icon>
|
||||
<span v-if="formatContextLimit(entry.metadata)">
|
||||
{{ formatContextLimit(entry.metadata) }}
|
||||
</span>
|
||||
</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<div class="d-flex align-center ga-1" @click.stop>
|
||||
<v-switch
|
||||
v-model="entry.provider.enable"
|
||||
density="compact"
|
||||
inset
|
||||
hide-details
|
||||
color="primary"
|
||||
class="mr-1"
|
||||
@update:modelValue="emit('toggle-provider-enable', entry.provider, $event)"
|
||||
></v-switch>
|
||||
<v-tooltip location="top" max-width="300">
|
||||
{{ tm('availability.test') }}
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
icon="mdi-wrench"
|
||||
size="small"
|
||||
variant="text"
|
||||
:disabled="!entry.provider.enable"
|
||||
:loading="isProviderTesting(entry.provider.id)"
|
||||
v-bind="props"
|
||||
@click.stop="emit('test-provider', entry.provider)"
|
||||
></v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-btn icon="mdi-delete" size="small" variant="text" color="error" @click.stop="emit('delete-provider', entry.provider)"></v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item v-else class="cursor-pointer" @click="emit('add-model-provider', entry.model)">
|
||||
<v-list-item-title>{{ entry.model }}</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-caption text-grey d-flex align-center ga-1">
|
||||
<span>{{ entry.model }}</span>
|
||||
<v-icon v-if="supportsImageInput(entry.metadata)" size="14" color="grey">
|
||||
mdi-eye-outline
|
||||
</v-icon>
|
||||
<v-icon v-if="supportsToolCall(entry.metadata)" size="14" color="grey">
|
||||
mdi-wrench
|
||||
</v-icon>
|
||||
<v-icon v-if="supportsReasoning(entry.metadata)" size="14" color="grey">
|
||||
mdi-brain
|
||||
</v-icon>
|
||||
<span v-if="formatContextLimit(entry.metadata)">
|
||||
{{ formatContextLimit(entry.metadata) }}
|
||||
</span>
|
||||
</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<v-btn icon="mdi-plus" size="small" variant="text" color="primary"></v-btn>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="text-center pa-4 text-medium-emphasis">
|
||||
<v-icon size="48" color="grey-lighten-1">mdi-package-variant</v-icon>
|
||||
<p class="text-grey mt-2">{{ tm('models.empty') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</v-list>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
entries: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
availableCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
modelSearch: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
loadingModels: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isSourceModified: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
supportsImageInput: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
supportsToolCall: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
supportsReasoning: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
formatContextLimit: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
testingProviders: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
tm: {
|
||||
type: Function,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:modelSearch',
|
||||
'fetch-models',
|
||||
'open-manual-model',
|
||||
'open-provider-edit',
|
||||
'toggle-provider-enable',
|
||||
'test-provider',
|
||||
'delete-provider',
|
||||
'add-model-provider'
|
||||
])
|
||||
|
||||
const modelSearchProxy = computed({
|
||||
get: () => props.modelSearch,
|
||||
set: (val) => emit('update:modelSearch', val)
|
||||
})
|
||||
|
||||
const isProviderTesting = (providerId) => props.testingProviders.includes(providerId)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.border {
|
||||
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<v-card class="provider-sources-panel h-100" elevation="0">
|
||||
<div class="d-flex align-center justify-space-between px-4 pt-4 pb-2">
|
||||
<div class="d-flex align-center ga-2">
|
||||
<h3 class="mb-0">{{ tm('providerSources.title') }}</h3>
|
||||
</div>
|
||||
<v-menu>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
prepend-icon="mdi-plus"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
rounded="xl"
|
||||
size="small"
|
||||
>
|
||||
新增
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-for="sourceType in availableSourceTypes"
|
||||
:key="sourceType.value"
|
||||
@click="emitAddSource(sourceType.value)"
|
||||
>
|
||||
<v-list-item-title>{{ sourceType.label }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
|
||||
<div v-if="displayedProviderSources.length > 0">
|
||||
<v-list class="provider-source-list" nav density="compact" lines="two">
|
||||
<v-list-item
|
||||
v-for="source in displayedProviderSources"
|
||||
:key="source.isPlaceholder ? `template-${source.templateKey}` : source.id"
|
||||
:value="source.id"
|
||||
:active="isActive(source)"
|
||||
:class="['provider-source-list-item', { 'provider-source-list-item--active': isActive(source) }]"
|
||||
rounded="lg"
|
||||
@click="emitSelectSource(source)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-avatar size="32" class="bg-grey-lighten-4" rounded="0">
|
||||
<v-img v-if="source?.provider" :src="resolveSourceIcon(source)" alt="logo" cover></v-img>
|
||||
<v-icon v-else size="32">mdi-creation</v-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
<v-list-item-title class="font-weight-bold">{{ getSourceDisplayName(source) }}</v-list-item-title>
|
||||
<v-list-item-subtitle class="text-truncate">{{ source.api_base || 'N/A' }}</v-list-item-subtitle>
|
||||
<template #append>
|
||||
<div class="d-flex align-center ga-1">
|
||||
<v-btn
|
||||
v-if="!source.isPlaceholder"
|
||||
icon="mdi-delete"
|
||||
variant="text"
|
||||
size="x-small"
|
||||
color="error"
|
||||
@click.stop="emitDeleteSource(source)"
|
||||
></v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</div>
|
||||
<div v-else class="text-center py-8 px-4">
|
||||
<v-icon size="48" color="grey-lighten-1">mdi-api-off</v-icon>
|
||||
<p class="text-grey mt-2">{{ tm('providerSources.empty') }}</p>
|
||||
</div>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
displayedProviderSources: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
selectedProviderSource: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
availableSourceTypes: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
tm: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
resolveSourceIcon: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
getSourceDisplayName: {
|
||||
type: Function,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'add-provider-source',
|
||||
'select-provider-source',
|
||||
'delete-provider-source'
|
||||
])
|
||||
|
||||
const selectedId = computed(() => props.selectedProviderSource?.id || null)
|
||||
|
||||
const isActive = (source) => {
|
||||
if (source.isPlaceholder) return false
|
||||
return selectedId.value !== null && selectedId.value === source.id
|
||||
}
|
||||
|
||||
const emitAddSource = (type) => emit('add-provider-source', type)
|
||||
const emitSelectSource = (source) => emit('select-provider-source', source)
|
||||
const emitDeleteSource = (source) => emit('delete-provider-source', source)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.provider-sources-panel {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.provider-source-list {
|
||||
max-height: calc(100vh - 335px);
|
||||
overflow-y: auto;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.provider-source-list-item {
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.provider-source-list-item--active {
|
||||
background-color: #E8F0FE;
|
||||
border: 1px solid rgba(var(--v-theme-primary), 0.25);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.provider-source-list {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.provider-sources-panel {
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.v-theme--PurpleThemeDark .provider-source-list-item--active {
|
||||
background-color: #2d2d2d;
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
@@ -162,7 +162,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
||||
<!-- Regular Property -->
|
||||
<template v-else>
|
||||
<v-row v-if="!metadata[metadataKey].items[key]?.invisible && shouldShowItem(metadata[metadataKey].items[key], key)" class="config-row">
|
||||
<v-col cols="12" sm="7" class="property-info">
|
||||
<v-col cols="12" sm="6" class="property-info">
|
||||
<v-list-item density="compact">
|
||||
<v-list-item-title class="property-name">
|
||||
<span v-if="metadata[metadataKey].items[key]?.description">
|
||||
@@ -180,7 +180,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
||||
</v-list-item>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="5" class="config-input">
|
||||
<v-col cols="12" sm="6" class="config-input">
|
||||
<div v-if="metadata[metadataKey].items[key]" class="w-100">
|
||||
<!-- Special handling for specific metadata types -->
|
||||
<div v-if="metadata[metadataKey].items[key]?._special === 'select_provider'">
|
||||
@@ -540,6 +540,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.7;
|
||||
font-weight: normal;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.important-hint {
|
||||
@@ -573,7 +574,6 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.config-row:hover {
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import { useI18n } from '@/i18n/composables';
|
||||
import axios from 'axios';
|
||||
import { MarkdownRender, enableKatex, enableMermaid } from 'markstream-vue';
|
||||
import 'markstream-vue/index.css';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import 'highlight.js/styles/github.css';
|
||||
|
||||
enableKatex();
|
||||
enableMermaid();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const dialog = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
});
|
||||
|
||||
const changelogContent = ref('');
|
||||
const changelogLoading = ref(false);
|
||||
const changelogError = ref('');
|
||||
const changelogVersion = ref('');
|
||||
const selectedVersion = ref('');
|
||||
const availableVersions = ref([]);
|
||||
const loadingVersions = ref(false);
|
||||
|
||||
// 获取当前版本号(从版本信息中提取)
|
||||
async function getCurrentVersion() {
|
||||
try {
|
||||
const res = await axios.get('/api/stat/version');
|
||||
const version = res.data.data?.version || '';
|
||||
changelogVersion.value = version;
|
||||
selectedVersion.value = version;
|
||||
return version;
|
||||
} catch (err) {
|
||||
console.error('Failed to get version:', err);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// 加载更新日志
|
||||
async function loadChangelog(version) {
|
||||
const targetVersion = version || selectedVersion.value || changelogVersion.value;
|
||||
if (!targetVersion) {
|
||||
changelogError.value = t('core.navigation.changelogDialog.selectVersion');
|
||||
return;
|
||||
}
|
||||
|
||||
changelogLoading.value = true;
|
||||
changelogError.value = '';
|
||||
changelogContent.value = '';
|
||||
|
||||
try {
|
||||
const res = await axios.get('/api/stat/changelog', {
|
||||
params: { version: targetVersion }
|
||||
});
|
||||
|
||||
if (res.data.status === 'ok') {
|
||||
changelogContent.value = res.data.data.content;
|
||||
selectedVersion.value = targetVersion;
|
||||
} else {
|
||||
changelogError.value = res.data.message || t('core.navigation.changelogDialog.error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load changelog:', err);
|
||||
if (err.response?.status === 404 || err.response?.data?.message?.includes('not found')) {
|
||||
changelogError.value = t('core.navigation.changelogDialog.notFound');
|
||||
} else {
|
||||
changelogError.value = t('core.navigation.changelogDialog.error');
|
||||
}
|
||||
} finally {
|
||||
changelogLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有可用版本列表
|
||||
async function loadAvailableVersions() {
|
||||
loadingVersions.value = true;
|
||||
try {
|
||||
const res = await axios.get('/api/stat/changelog/list');
|
||||
if (res.data.status === 'ok') {
|
||||
availableVersions.value = res.data.data.versions || [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load versions:', err);
|
||||
} finally {
|
||||
loadingVersions.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 版本选择变化时加载对应的更新日志
|
||||
function onVersionChange() {
|
||||
if (selectedVersion.value) {
|
||||
loadChangelog(selectedVersion.value);
|
||||
}
|
||||
}
|
||||
|
||||
// 监听对话框打开,初始化数据
|
||||
watch(dialog, async (newValue) => {
|
||||
if (newValue) {
|
||||
// 加载版本列表
|
||||
await loadAvailableVersions();
|
||||
|
||||
// 获取当前版本
|
||||
if (!changelogVersion.value) {
|
||||
await getCurrentVersion();
|
||||
}
|
||||
|
||||
// 如果当前版本在列表中,默认选择当前版本
|
||||
if (changelogVersion.value && availableVersions.value.includes(changelogVersion.value)) {
|
||||
selectedVersion.value = changelogVersion.value;
|
||||
await loadChangelog();
|
||||
} else if (availableVersions.value.length > 0) {
|
||||
// 否则选择第一个(最新的)
|
||||
selectedVersion.value = availableVersions.value[0];
|
||||
await loadChangelog(availableVersions.value[0]);
|
||||
}
|
||||
} else {
|
||||
// 关闭时重置状态
|
||||
changelogContent.value = '';
|
||||
changelogError.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化时获取版本号
|
||||
getCurrentVersion();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-dialog
|
||||
:model-value="dialog"
|
||||
@update:model-value="dialog = $event"
|
||||
:width="$vuetify.display.smAndDown ? '100%' : '800'"
|
||||
:fullscreen="$vuetify.display.xs"
|
||||
max-width="1000"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title class="d-flex justify-space-between align-center">
|
||||
<span class="text-h3">{{ t('core.navigation.changelogDialog.title') }}</span>
|
||||
<v-btn icon @click="dialog = false" flat>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<!-- 版本选择器 -->
|
||||
<div class="mb-4">
|
||||
<v-select
|
||||
v-model="selectedVersion"
|
||||
:items="availableVersions"
|
||||
:label="t('core.navigation.changelogDialog.selectVersion')"
|
||||
:loading="loadingVersions"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
@update:model-value="onVersionChange"
|
||||
>
|
||||
<template v-slot:item="{ item, props }">
|
||||
<v-list-item v-bind="props" :title="`v${item.value}`">
|
||||
<template v-slot:append v-if="item.value === changelogVersion">
|
||||
<v-chip size="x-small" color="primary" variant="tonal">
|
||||
{{ t('core.navigation.changelogDialog.current') }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
<template v-slot:selection="{ item }">
|
||||
<span>v{{ item.value }}</span>
|
||||
</template>
|
||||
</v-select>
|
||||
</div>
|
||||
|
||||
<!-- 更新日志内容 -->
|
||||
<div style="max-height: 70vh; overflow-y: auto;">
|
||||
<div v-if="changelogLoading" class="text-center py-8">
|
||||
<v-progress-circular indeterminate color="primary"></v-progress-circular>
|
||||
<div class="mt-4">{{ t('core.navigation.changelogDialog.loading') }}</div>
|
||||
</div>
|
||||
<v-alert v-else-if="changelogError" type="error" variant="tonal" border="start">
|
||||
{{ changelogError }}
|
||||
</v-alert>
|
||||
<div v-else-if="changelogContent" class="changelog-content">
|
||||
<MarkdownRender :content="changelogContent" :typewriter="false" class="markdown-content" />
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="blue-darken-1" variant="text" @click="dialog = false">
|
||||
{{ t('core.common.close') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.changelog-content {
|
||||
padding: 8px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-menu offset="12" location="bottom center">
|
||||
<StyledMenu offset="12" location="bottom center">
|
||||
<template v-slot:activator="{ props: activatorProps }">
|
||||
<v-btn
|
||||
v-bind="activatorProps"
|
||||
@@ -22,25 +22,21 @@
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-card class="language-dropdown" elevation="8" rounded="lg">
|
||||
<v-list density="compact" class="pa-1">
|
||||
<v-list-item
|
||||
v-for="lang in languages"
|
||||
:key="lang.code"
|
||||
:value="lang.code"
|
||||
@click="changeLanguage(lang.code)"
|
||||
:class="{ 'v-list-item--active': currentLocale === lang.code, 'language-item-selected': currentLocale === lang.code }"
|
||||
class="language-item"
|
||||
rounded="md"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<span class="language-flag">{{ lang.flag }}</span>
|
||||
</template>
|
||||
<v-list-item-title>{{ lang.name }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
<v-list-item
|
||||
v-for="lang in languages"
|
||||
:key="lang.code"
|
||||
:value="lang.code"
|
||||
@click="changeLanguage(lang.code)"
|
||||
:class="{ 'styled-menu-item-active': currentLocale === lang.code }"
|
||||
class="styled-menu-item"
|
||||
rounded="md"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<span class="language-flag">{{ lang.flag }}</span>
|
||||
</template>
|
||||
<v-list-item-title>{{ lang.name }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</StyledMenu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -48,6 +44,7 @@ import { computed } from 'vue'
|
||||
import { useI18n, useLanguageSwitcher } from '@/i18n/composables'
|
||||
import { useCustomizerStore } from '@/stores/customizer'
|
||||
import type { Locale } from '@/i18n/types'
|
||||
import StyledMenu from '@/components/shared/StyledMenu.vue'
|
||||
|
||||
// 定义props来控制样式变体
|
||||
const props = withDefaults(defineProps<{
|
||||
@@ -110,49 +107,4 @@ const changeLanguage = async (langCode: string) => {
|
||||
:deep(.v-theme--PurpleThemeDark) .language-switcher--default:hover {
|
||||
background: rgba(114, 46, 209, 0.12) !important;
|
||||
}
|
||||
|
||||
.language-dropdown {
|
||||
min-width: 100px;
|
||||
width: fit-content;
|
||||
border: 1px solid rgba(94, 53, 177, 0.15) !important;
|
||||
background: #f8f6fc !important;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
/* 深色模式下的下拉框样式 */
|
||||
:deep(.v-theme--PurpleThemeDark) .language-dropdown {
|
||||
background: #2a2733 !important;
|
||||
border: 1px solid rgba(110, 60, 180, 0.692) !important;
|
||||
}
|
||||
|
||||
.language-item {
|
||||
margin: 2px 0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.language-item:hover {
|
||||
background: rgba(94, 53, 177, 0.08) !important;
|
||||
}
|
||||
|
||||
.language-item-selected {
|
||||
background: rgba(94, 53, 177, 0.15) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.language-item-selected:hover {
|
||||
background: rgba(94, 53, 177, 0.2) !important;
|
||||
}
|
||||
|
||||
/* 深色模式下的列表项悬停效果 */
|
||||
:deep(.v-theme--PurpleThemeDark) .language-item:hover {
|
||||
background: rgba(114, 46, 209, 0.12) !important;
|
||||
}
|
||||
|
||||
:deep(.v-theme--PurpleThemeDark) .language-item-selected {
|
||||
background: rgba(114, 46, 209, 0.2) !important;
|
||||
}
|
||||
|
||||
:deep(.v-theme--PurpleThemeDark) .language-item-selected:hover {
|
||||
background: rgba(114, 46, 209, 0.25) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,15 @@
|
||||
<template>
|
||||
<div class="d-flex align-center justify-space-between">
|
||||
<div>
|
||||
<div class="d-flex align-center justify-space-between ga-2">
|
||||
<div v-if="isSingleItemMode" class="flex-grow-1 d-flex align-center ga-2">
|
||||
<v-text-field
|
||||
v-model="singleItemValue"
|
||||
hide-details
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="flex-grow-1"
|
||||
></v-text-field>
|
||||
</div>
|
||||
<div v-else>
|
||||
<span v-if="!modelValue || modelValue.length === 0" style="color: rgb(var(--v-theme-primaryText));">
|
||||
{{ t('core.common.list.noItems') }}
|
||||
</span>
|
||||
@@ -14,7 +23,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<v-btn size="small" color="primary" variant="tonal" @click="openDialog">
|
||||
{{ buttonText || t('core.common.list.modifyButton') }}
|
||||
{{ preferSingleItem ? '添加更多' : (buttonText || t('core.common.list.modifyButton')) }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
@@ -167,6 +176,10 @@ const props = defineProps({
|
||||
maxDisplayItems: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
preferSingleItem: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
@@ -180,6 +193,21 @@ const editIndex = ref(-1)
|
||||
const editItem = ref('')
|
||||
const showBatchImport = ref(false)
|
||||
const batchImportText = ref('')
|
||||
const isSingleItemMode = computed(() => (props.modelValue?.length ?? 0) <= 1 && props.preferSingleItem)
|
||||
const singleItemValue = computed({
|
||||
get: () => props.modelValue?.[0] ?? '',
|
||||
set: (value) => {
|
||||
const newItems = [...(props.modelValue || [])]
|
||||
|
||||
if (newItems.length === 0) {
|
||||
newItems.push(value)
|
||||
} else {
|
||||
newItems[0] = value
|
||||
}
|
||||
|
||||
emit('update:modelValue', newItems)
|
||||
}
|
||||
})
|
||||
|
||||
// 计算要显示的项目
|
||||
const displayItems = computed(() => {
|
||||
|
||||
@@ -14,8 +14,20 @@
|
||||
<!-- Provider Selection Dialog -->
|
||||
<v-dialog v-model="dialog" max-width="600px">
|
||||
<v-card>
|
||||
<v-card-title class="text-h3 py-4" style="font-weight: normal;">
|
||||
{{ tm('providerSelector.dialogTitle') }}
|
||||
<v-card-title
|
||||
class="text-h3 py-4 d-flex align-center justify-space-between gap-4 flex-wrap"
|
||||
style="font-weight: normal;"
|
||||
>
|
||||
<span>{{ tm('providerSelector.dialogTitle') }}</span>
|
||||
<v-btn
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-plus"
|
||||
@click="openProviderDrawer"
|
||||
>
|
||||
{{ tm('providerSelector.createProvider') }}
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-0" style="max-height: 400px; overflow-y: auto;">
|
||||
@@ -51,7 +63,7 @@
|
||||
<v-list-item-title>{{ provider.id }}</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
{{ provider.type || provider.provider_type || tm('providerSelector.unknownType') }}
|
||||
<span v-if="provider.model_config?.model">- {{ provider.model_config.model }}</span>
|
||||
<span v-if="provider.model">- {{ provider.model }}</span>
|
||||
</v-list-item-subtitle>
|
||||
|
||||
<template v-slot:append>
|
||||
@@ -79,12 +91,33 @@
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-overlay
|
||||
v-model="providerDrawer"
|
||||
class="provider-drawer-overlay"
|
||||
location="right"
|
||||
transition="slide-x-reverse-transition"
|
||||
:scrim="true"
|
||||
@click:outside="closeProviderDrawer"
|
||||
>
|
||||
<v-card class="provider-drawer-card" elevation="12">
|
||||
<div class="provider-drawer-header">
|
||||
<v-btn icon variant="text" @click="closeProviderDrawer">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
<div class="provider-drawer-content">
|
||||
<ProviderPage :default-tab="defaultTab" />
|
||||
</div>
|
||||
</v-card>
|
||||
</v-overlay>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { useModuleI18n } from '@/i18n/composables'
|
||||
import ProviderPage from '@/views/ProviderPage.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -112,12 +145,26 @@ const dialog = ref(false)
|
||||
const providerList = ref([])
|
||||
const loading = ref(false)
|
||||
const selectedProvider = ref('')
|
||||
const providerDrawer = ref(false)
|
||||
|
||||
const defaultTab = computed(() => {
|
||||
if (props.providerType === 'agent_runner' && props.providerSubtype) {
|
||||
return `select_agent_runner_provider:${props.providerSubtype}`
|
||||
}
|
||||
return props.providerType || 'chat_completion'
|
||||
})
|
||||
|
||||
// 监听 modelValue 变化,同步到 selectedProvider
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
selectedProvider.value = newValue || ''
|
||||
}, { immediate: true })
|
||||
|
||||
watch(providerDrawer, (isOpen, wasOpen) => {
|
||||
if (!isOpen && wasOpen) {
|
||||
loadProviders()
|
||||
}
|
||||
})
|
||||
|
||||
async function openDialog() {
|
||||
selectedProvider.value = props.modelValue || ''
|
||||
dialog.value = true
|
||||
@@ -170,6 +217,14 @@ function cancelSelection() {
|
||||
selectedProvider.value = props.modelValue || ''
|
||||
dialog.value = false
|
||||
}
|
||||
|
||||
function openProviderDrawer() {
|
||||
providerDrawer.value = true
|
||||
}
|
||||
|
||||
function closeProviderDrawer() {
|
||||
providerDrawer.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -184,4 +239,35 @@ function cancelSelection() {
|
||||
.v-list-item.v-list-item--active {
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
|
||||
.provider-drawer-overlay {
|
||||
align-items: stretch;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.provider-drawer-card {
|
||||
width: clamp(360px, 70vw, 1200px);
|
||||
height: calc(100vh - 32px);
|
||||
margin: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.provider-drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px 12px 20px;
|
||||
}
|
||||
|
||||
.provider-drawer-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.provider-drawer-content > * {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, computed } from 'vue';
|
||||
import axios from 'axios';
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import hljs from 'highlight.js';
|
||||
import { MarkdownRender, enableKatex, enableMermaid } from 'markstream-vue';
|
||||
import 'markstream-vue/index.css';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import 'highlight.js/styles/github.css';
|
||||
import { useI18n } from '@/i18n/composables';
|
||||
|
||||
enableKatex();
|
||||
enableMermaid();
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
@@ -74,29 +78,6 @@ function openRepoInNewTab() {
|
||||
}
|
||||
}
|
||||
|
||||
// 配置markdown-it,启用代码高亮
|
||||
const md = new MarkdownIt({
|
||||
html: true, // 启用HTML标签
|
||||
breaks: true, // 换行转<br>
|
||||
linkify: true, // 自动转链接
|
||||
typographer: false, // 禁用智能引号
|
||||
highlight: function(code, lang) {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
return hljs.highlight(code, { language: lang }).value;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
return hljs.highlightAuto(code).value;
|
||||
}
|
||||
});
|
||||
|
||||
// 渲染Markdown内容
|
||||
function renderMarkdown(content) {
|
||||
if (!content) return '';
|
||||
return md.render(content);
|
||||
}
|
||||
|
||||
// 刷新README内容
|
||||
function refreshReadme() {
|
||||
@@ -150,7 +131,9 @@ const _show = computed({
|
||||
</div>
|
||||
|
||||
<!-- 内容显示 -->
|
||||
<div v-else-if="content" class="markdown-body" v-html="renderMarkdown(content)"></div>
|
||||
<div v-else-if="content" class="markdown-body">
|
||||
<MarkdownRender :content="content" :typewriter="false" class="markdown-content" />
|
||||
</div>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<div v-else-if="error" class="d-flex flex-column align-center justify-center" style="height: 100%;">
|
||||
@@ -301,6 +284,9 @@ const _show = computed({
|
||||
<script>
|
||||
export default {
|
||||
name: 'ReadmeDialog',
|
||||
components: {
|
||||
MarkdownRender
|
||||
},
|
||||
computed: {
|
||||
_show: {
|
||||
get() {
|
||||
|
||||
@@ -121,7 +121,8 @@ import sidebarItems from '@/layouts/full/vertical-sidebar/sidebarItem';
|
||||
import {
|
||||
getSidebarCustomization,
|
||||
setSidebarCustomization,
|
||||
clearSidebarCustomization
|
||||
clearSidebarCustomization,
|
||||
resolveSidebarItems
|
||||
} from '@/utils/sidebarCustomization';
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -133,35 +134,12 @@ const draggedItem = ref(null);
|
||||
|
||||
function initializeItems() {
|
||||
const customization = getSidebarCustomization();
|
||||
|
||||
if (customization) {
|
||||
// Load from customization
|
||||
const allItemsMap = new Map();
|
||||
|
||||
sidebarItems.forEach(item => {
|
||||
if (item.children) {
|
||||
item.children.forEach(child => {
|
||||
allItemsMap.set(child.title, child);
|
||||
});
|
||||
} else {
|
||||
allItemsMap.set(item.title, item);
|
||||
}
|
||||
});
|
||||
|
||||
mainItems.value = customization.mainItems
|
||||
.map(title => allItemsMap.get(title))
|
||||
.filter(item => item);
|
||||
|
||||
moreItems.value = customization.moreItems
|
||||
.map(title => allItemsMap.get(title))
|
||||
.filter(item => item);
|
||||
} else {
|
||||
// Load default structure
|
||||
mainItems.value = sidebarItems.filter(item => !item.children);
|
||||
|
||||
const moreGroup = sidebarItems.find(item => item.title === 'core.navigation.groups.more');
|
||||
moreItems.value = moreGroup ? [...moreGroup.children] : [];
|
||||
}
|
||||
const { mainItems: resolvedMain, moreItems: resolvedMore } = resolveSidebarItems(
|
||||
sidebarItems,
|
||||
customization
|
||||
);
|
||||
mainItems.value = resolvedMain;
|
||||
moreItems.value = resolvedMore;
|
||||
}
|
||||
|
||||
function openDialog() {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<v-menu v-bind="$attrs" :close-on-content-click="closeOnContentClick">
|
||||
<template v-slot:activator="{ props: activatorProps }">
|
||||
<slot name="activator" :props="activatorProps"></slot>
|
||||
</template>
|
||||
|
||||
<v-card class="styled-menu-card" elevation="8" rounded="lg">
|
||||
<v-list density="compact" class="styled-menu-list pa-1">
|
||||
<slot></slot>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
withDefaults(defineProps<{
|
||||
closeOnContentClick?: boolean
|
||||
}>(), {
|
||||
closeOnContentClick: true
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.styled-menu-card {
|
||||
min-width: 100px;
|
||||
width: fit-content;
|
||||
border: 1px solid rgba(94, 53, 177, 0.15) !important;
|
||||
background: #f8f6fc !important;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.styled-menu-list {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
:deep(.styled-menu-item) {
|
||||
margin: 2px 0;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
:deep(.styled-menu-item:hover) {
|
||||
background: rgba(94, 53, 177, 0.08) !important;
|
||||
}
|
||||
|
||||
:deep(.styled-menu-item-active) {
|
||||
background: rgba(94, 53, 177, 0.15) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.styled-menu-item-active:hover) {
|
||||
background: rgba(94, 53, 177, 0.2) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 深色模式下的下拉框样式 - 需要全局样式才能检测主题 */
|
||||
.v-theme--PurpleThemeDark .styled-menu-card {
|
||||
background: #2a2733 !important;
|
||||
border: 1px solid rgba(110, 60, 180, 0.692) !important;
|
||||
}
|
||||
|
||||
/* 深色模式下的列表项悬停效果 */
|
||||
.v-theme--PurpleThemeDark .styled-menu-item:hover {
|
||||
background: rgba(114, 46, 209, 0.12) !important;
|
||||
}
|
||||
|
||||
.v-theme--PurpleThemeDark .styled-menu-item-active {
|
||||
background: rgba(114, 46, 209, 0.2) !important;
|
||||
}
|
||||
|
||||
.v-theme--PurpleThemeDark .styled-menu-item-active:hover {
|
||||
background: rgba(114, 46, 209, 0.25) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -2,19 +2,29 @@ import { ref, reactive, type Ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useToast } from '@/utils/toast';
|
||||
|
||||
// 新格式消息部分的类型定义
|
||||
export interface MessagePart {
|
||||
type: 'plain' | 'image' | 'record' | 'file' | 'video' | 'reply';
|
||||
text?: string; // for plain
|
||||
attachment_id?: string; // for image, record, file, video
|
||||
filename?: string; // for file (filename from backend)
|
||||
message_id?: number; // for reply (PlatformSessionHistoryMessage.id)
|
||||
// 工具调用信息
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
args: Record<string, any>;
|
||||
ts: number; // 开始时间戳
|
||||
result?: string; // 工具调用结果
|
||||
finished_ts?: number; // 完成时间戳
|
||||
}
|
||||
|
||||
// 引用信息
|
||||
export interface ReplyInfo {
|
||||
messageId: number;
|
||||
messageContent: string;
|
||||
// Token 使用统计
|
||||
export interface TokenUsage {
|
||||
input_other: number;
|
||||
input_cached: number;
|
||||
output: number;
|
||||
}
|
||||
|
||||
// Agent 统计信息
|
||||
export interface AgentStats {
|
||||
token_usage: TokenUsage;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
time_to_first_token: number;
|
||||
}
|
||||
|
||||
// 文件信息结构
|
||||
@@ -24,24 +34,33 @@ export interface FileInfo {
|
||||
attachment_id?: string; // 用于按需下载
|
||||
}
|
||||
|
||||
// 引用消息信息
|
||||
export interface ReplyTo {
|
||||
message_id: number;
|
||||
message_content?: string; // 被引用消息的内容(解析后填充)
|
||||
// 消息部分的类型定义
|
||||
export interface MessagePart {
|
||||
type: 'plain' | 'image' | 'record' | 'file' | 'video' | 'reply' | 'tool_call';
|
||||
text?: string; // for plain
|
||||
attachment_id?: string; // for image, record, file, video
|
||||
filename?: string; // for file (filename from backend)
|
||||
message_id?: number; // for reply (PlatformSessionHistoryMessage.id)
|
||||
tool_calls?: ToolCall[]; // for tool_call
|
||||
// embedded fields - 加载后填充
|
||||
embedded_url?: string; // blob URL for image, record
|
||||
embedded_file?: FileInfo; // for file (保留 attachment_id 用于按需下载)
|
||||
reply_content?: string; // for reply - 被引用消息的内容
|
||||
}
|
||||
|
||||
// 引用信息 (用于发送消息时)
|
||||
export interface ReplyInfo {
|
||||
messageId: number;
|
||||
messageContent: string;
|
||||
}
|
||||
|
||||
// 简化的消息内容结构
|
||||
export interface MessageContent {
|
||||
type: string;
|
||||
message: string | MessagePart[]; // 支持旧格式(string)和新格式(MessagePart[])
|
||||
reasoning?: string;
|
||||
image_url?: string[];
|
||||
audio_url?: string;
|
||||
file_url?: FileInfo[];
|
||||
embedded_images?: string[];
|
||||
embedded_audio?: string;
|
||||
embedded_files?: FileInfo[];
|
||||
isLoading?: boolean;
|
||||
reply_to?: ReplyTo; // 引用的消息
|
||||
type: string; // 'user' | 'bot'
|
||||
message: MessagePart[]; // 消息部分列表 (保持顺序)
|
||||
reasoning?: string; // reasoning content (for bot)
|
||||
isLoading?: boolean; // loading state
|
||||
agentStats?: AgentStats; // agent 统计信息 (for bot)
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
@@ -93,55 +112,67 @@ export function useMessages(
|
||||
}
|
||||
}
|
||||
|
||||
// 解析新格式消息为旧格式兼容的结构 (用于显示)
|
||||
// 解析消息内容,填充 embedded 字段 (保持原始顺序)
|
||||
async function parseMessageContent(content: any): Promise<void> {
|
||||
const message = content.message;
|
||||
|
||||
// 如果 message 是数组 (新格式)
|
||||
if (Array.isArray(message)) {
|
||||
let textParts: string[] = [];
|
||||
let imageUrls: string[] = [];
|
||||
let audioUrl: string | undefined;
|
||||
let fileInfos: FileInfo[] = [];
|
||||
let replyTo: ReplyTo | undefined;
|
||||
// 如果 message 是字符串 (旧格式),转换为数组格式
|
||||
if (typeof message === 'string') {
|
||||
const parts: MessagePart[] = [];
|
||||
let text = message;
|
||||
|
||||
// 处理旧格式的特殊标记
|
||||
if (text.startsWith('[IMAGE]')) {
|
||||
const img = text.replace('[IMAGE]', '');
|
||||
const imageUrl = await getMediaFile(img);
|
||||
parts.push({
|
||||
type: 'image',
|
||||
embedded_url: imageUrl
|
||||
});
|
||||
} else if (text.startsWith('[RECORD]')) {
|
||||
const audio = text.replace('[RECORD]', '');
|
||||
const audioUrl = await getMediaFile(audio);
|
||||
parts.push({
|
||||
type: 'record',
|
||||
embedded_url: audioUrl
|
||||
});
|
||||
} else if (text) {
|
||||
parts.push({
|
||||
type: 'plain',
|
||||
text: text
|
||||
});
|
||||
}
|
||||
|
||||
content.message = parts;
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果 message 是数组 (新格式),遍历并填充 embedded 字段
|
||||
if (Array.isArray(message)) {
|
||||
for (const part of message as MessagePart[]) {
|
||||
if (part.type === 'plain' && part.text) {
|
||||
textParts.push(part.text);
|
||||
} else if (part.type === 'image' && part.attachment_id) {
|
||||
const url = await getAttachment(part.attachment_id);
|
||||
if (url) imageUrls.push(url);
|
||||
if (part.type === 'image' && part.attachment_id) {
|
||||
part.embedded_url = await getAttachment(part.attachment_id);
|
||||
} else if (part.type === 'record' && part.attachment_id) {
|
||||
audioUrl = await getAttachment(part.attachment_id);
|
||||
part.embedded_url = await getAttachment(part.attachment_id);
|
||||
} else if (part.type === 'file' && part.attachment_id) {
|
||||
// file 类型不预加载,保留 attachment_id 以便点击时下载
|
||||
fileInfos.push({
|
||||
part.embedded_file = {
|
||||
attachment_id: part.attachment_id,
|
||||
filename: part.filename || 'file'
|
||||
});
|
||||
} else if (part.type === 'reply' && part.message_id) {
|
||||
replyTo = { message_id: part.message_id };
|
||||
};
|
||||
}
|
||||
// video 类型可以后续扩展
|
||||
}
|
||||
|
||||
// 转换为旧格式兼容的结构
|
||||
content.message = textParts.join('\n');
|
||||
content.reply_to = replyTo;
|
||||
if (content.type === 'user') {
|
||||
content.image_url = imageUrls.length > 0 ? imageUrls : undefined;
|
||||
content.audio_url = audioUrl;
|
||||
content.file_url = fileInfos.length > 0 ? fileInfos : undefined;
|
||||
} else {
|
||||
content.embedded_images = imageUrls.length > 0 ? imageUrls : undefined;
|
||||
content.embedded_audio = audioUrl;
|
||||
content.embedded_files = fileInfos.length > 0 ? fileInfos : undefined;
|
||||
// plain, reply, tool_call, video 保持原样
|
||||
}
|
||||
}
|
||||
// 如果 message 是字符串 (旧格式),保持原有处理逻辑
|
||||
|
||||
// 处理 agent_stats (snake_case -> camelCase)
|
||||
if (content.agent_stats) {
|
||||
content.agentStats = content.agent_stats;
|
||||
delete content.agent_stats;
|
||||
}
|
||||
}
|
||||
|
||||
async function getSessionMessages(sessionId: string, router: any) {
|
||||
async function getSessionMessages(sessionId: string) {
|
||||
if (!sessionId) return;
|
||||
|
||||
try {
|
||||
@@ -157,50 +188,14 @@ export function useMessages(
|
||||
|
||||
// 如果会话还在运行,3秒后重新获取消息
|
||||
setTimeout(() => {
|
||||
getSessionMessages(currSessionId.value, router);
|
||||
getSessionMessages(currSessionId.value);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// 处理历史消息中的媒体文件
|
||||
// 处理历史消息
|
||||
for (let i = 0; i < history.length; i++) {
|
||||
let content = history[i].content;
|
||||
|
||||
// 首先尝试解析新格式消息
|
||||
await parseMessageContent(content);
|
||||
|
||||
// 以下是旧格式的兼容处理 (message 是字符串的情况)
|
||||
if (typeof content.message === 'string') {
|
||||
if (content.message?.startsWith('[IMAGE]')) {
|
||||
let img = content.message.replace('[IMAGE]', '');
|
||||
const imageUrl = await getMediaFile(img);
|
||||
if (!content.embedded_images) {
|
||||
content.embedded_images = [];
|
||||
}
|
||||
content.embedded_images.push(imageUrl);
|
||||
content.message = '';
|
||||
}
|
||||
|
||||
if (content.message?.startsWith('[RECORD]')) {
|
||||
let audio = content.message.replace('[RECORD]', '');
|
||||
const audioUrl = await getMediaFile(audio);
|
||||
content.embedded_audio = audioUrl;
|
||||
content.message = '';
|
||||
}
|
||||
}
|
||||
|
||||
// 旧格式中的 image_url 和 audio_url 字段处理
|
||||
if (content.image_url && content.image_url.length > 0) {
|
||||
for (let j = 0; j < content.image_url.length; j++) {
|
||||
// 检查是否已经是 blob URL (新格式解析后的结果)
|
||||
if (!content.image_url[j].startsWith('blob:')) {
|
||||
content.image_url[j] = await getMediaFile(content.image_url[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (content.audio_url && !content.audio_url.startsWith('blob:')) {
|
||||
content.audio_url = await getMediaFile(content.audio_url);
|
||||
}
|
||||
}
|
||||
|
||||
messages.value = history;
|
||||
@@ -217,47 +212,66 @@ export function useMessages(
|
||||
selectedModelName: string,
|
||||
replyTo: ReplyInfo | null = null
|
||||
) {
|
||||
// Create user message
|
||||
// 构建用户消息的 message 部分
|
||||
const userMessageParts: MessagePart[] = [];
|
||||
|
||||
// 添加引用消息段
|
||||
if (replyTo) {
|
||||
userMessageParts.push({
|
||||
type: 'reply',
|
||||
message_id: replyTo.messageId,
|
||||
reply_content: replyTo.messageContent
|
||||
});
|
||||
}
|
||||
|
||||
// 添加纯文本消息段
|
||||
if (prompt) {
|
||||
userMessageParts.push({
|
||||
type: 'plain',
|
||||
text: prompt
|
||||
});
|
||||
}
|
||||
|
||||
// 添加文件消息段
|
||||
for (const f of stagedFiles) {
|
||||
const partType = f.type === 'image' ? 'image' :
|
||||
f.type === 'record' ? 'record' : 'file';
|
||||
|
||||
// 获取嵌入 URL
|
||||
const embeddedUrl = await getAttachment(f.attachment_id);
|
||||
|
||||
userMessageParts.push({
|
||||
type: partType as 'image' | 'record' | 'file',
|
||||
attachment_id: f.attachment_id,
|
||||
filename: f.original_name,
|
||||
embedded_url: partType !== 'file' ? embeddedUrl : undefined,
|
||||
embedded_file: partType === 'file' ? {
|
||||
attachment_id: f.attachment_id,
|
||||
filename: f.original_name
|
||||
} : undefined
|
||||
});
|
||||
}
|
||||
|
||||
// 添加录音(如果有)
|
||||
if (audioName) {
|
||||
userMessageParts.push({
|
||||
type: 'record',
|
||||
embedded_url: audioName // 录音使用本地 URL
|
||||
});
|
||||
}
|
||||
|
||||
// 创建用户消息
|
||||
const userMessage: MessageContent = {
|
||||
type: 'user',
|
||||
message: prompt,
|
||||
image_url: [],
|
||||
audio_url: undefined,
|
||||
file_url: [],
|
||||
reply_to: replyTo ? { message_id: replyTo.messageId } : undefined
|
||||
message: userMessageParts
|
||||
};
|
||||
|
||||
// 分离图片和文件
|
||||
const imageFiles = stagedFiles.filter(f => f.type === 'image');
|
||||
const nonImageFiles = stagedFiles.filter(f => f.type !== 'image');
|
||||
|
||||
// 使用 attachment_id 获取图片内容(避免 blob URL 被 revoke 后 404)
|
||||
if (imageFiles.length > 0) {
|
||||
const imageUrls = await Promise.all(
|
||||
imageFiles.map(f => getAttachment(f.attachment_id))
|
||||
);
|
||||
userMessage.image_url = imageUrls.filter(url => url !== '');
|
||||
}
|
||||
|
||||
// 使用 blob URL 作为音频预览(录音不走 attachment)
|
||||
if (audioName) {
|
||||
userMessage.audio_url = audioName;
|
||||
}
|
||||
|
||||
// 文件不预加载,只显示文件名和 attachment_id
|
||||
if (nonImageFiles.length > 0) {
|
||||
userMessage.file_url = nonImageFiles.map(f => ({
|
||||
filename: f.original_name,
|
||||
attachment_id: f.attachment_id
|
||||
}));
|
||||
}
|
||||
|
||||
messages.value.push({ content: userMessage });
|
||||
|
||||
// 添加一个加载中的机器人消息占位符
|
||||
const loadingMessage = reactive({
|
||||
const loadingMessage = reactive<MessageContent>({
|
||||
type: 'bot',
|
||||
message: '',
|
||||
message: [],
|
||||
reasoning: '',
|
||||
isLoading: true
|
||||
});
|
||||
@@ -272,12 +286,11 @@ export function useMessages(
|
||||
// 收集所有 attachment_id
|
||||
const files = stagedFiles.map(f => f.attachment_id);
|
||||
|
||||
// 构建 message 参数
|
||||
// 当 files 或 reply 存在时,message 是 list,否则是 str
|
||||
// 构建发送给后端的 message 参数
|
||||
let messageToSend: string | MessagePart[];
|
||||
if (files.length > 0 || replyTo) {
|
||||
const parts: MessagePart[] = [];
|
||||
|
||||
|
||||
// 添加引用消息段
|
||||
if (replyTo) {
|
||||
parts.push({
|
||||
@@ -285,7 +298,7 @@ export function useMessages(
|
||||
message_id: replyTo.messageId
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// 添加纯文本消息段
|
||||
if (prompt) {
|
||||
parts.push({
|
||||
@@ -293,17 +306,17 @@ export function useMessages(
|
||||
text: prompt
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// 添加文件消息段
|
||||
for (const f of stagedFiles) {
|
||||
const partType = f.type === 'image' ? 'image' :
|
||||
f.type === 'record' ? 'record' : 'file';
|
||||
const partType = f.type === 'image' ? 'image' :
|
||||
f.type === 'record' ? 'record' : 'file';
|
||||
parts.push({
|
||||
type: partType as 'image' | 'record' | 'file',
|
||||
attachment_id: f.attachment_id
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
messageToSend = parts;
|
||||
} else {
|
||||
messageToSend = prompt;
|
||||
@@ -331,7 +344,7 @@ export function useMessages(
|
||||
const reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let in_streaming = false;
|
||||
let message_obj: any = null;
|
||||
let message_obj: MessageContent | null = null;
|
||||
|
||||
isStreaming.value = true;
|
||||
|
||||
@@ -340,6 +353,10 @@ export function useMessages(
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
console.log('SSE stream completed');
|
||||
// 流式传输结束后,获取最终消息并重新渲染
|
||||
if (currSessionId.value) {
|
||||
await getSessionMessages(currSessionId.value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -378,8 +395,10 @@ export function useMessages(
|
||||
const imageUrl = await getMediaFile(img);
|
||||
let bot_resp: MessageContent = {
|
||||
type: 'bot',
|
||||
message: '',
|
||||
embedded_images: [imageUrl]
|
||||
message: [{
|
||||
type: 'image',
|
||||
embedded_url: imageUrl
|
||||
}]
|
||||
};
|
||||
messages.value.push({ content: bot_resp });
|
||||
} else if (chunk_json.type === 'record') {
|
||||
@@ -387,43 +406,122 @@ export function useMessages(
|
||||
const audioUrl = await getMediaFile(audio);
|
||||
let bot_resp: MessageContent = {
|
||||
type: 'bot',
|
||||
message: '',
|
||||
embedded_audio: audioUrl
|
||||
message: [{
|
||||
type: 'record',
|
||||
embedded_url: audioUrl
|
||||
}]
|
||||
};
|
||||
messages.value.push({ content: bot_resp });
|
||||
} else if (chunk_json.type === 'file') {
|
||||
// 格式: [FILE]filename|original_name
|
||||
let fileData = chunk_json.data.replace('[FILE]', '');
|
||||
let [filename, originalName] = fileData.includes('|')
|
||||
? fileData.split('|', 2)
|
||||
let [filename, originalName] = fileData.includes('|')
|
||||
? fileData.split('|', 2)
|
||||
: [fileData, fileData];
|
||||
const fileUrl = await getMediaFile(filename);
|
||||
let bot_resp: MessageContent = {
|
||||
type: 'bot',
|
||||
message: '',
|
||||
embedded_files: [{
|
||||
url: fileUrl,
|
||||
filename: originalName
|
||||
message: [{
|
||||
type: 'file',
|
||||
embedded_file: {
|
||||
url: fileUrl,
|
||||
filename: originalName
|
||||
}
|
||||
}]
|
||||
};
|
||||
messages.value.push({ content: bot_resp });
|
||||
} else if (chunk_json.type === 'plain') {
|
||||
const chain_type = chunk_json.chain_type || 'normal';
|
||||
|
||||
if (!in_streaming) {
|
||||
message_obj = reactive({
|
||||
type: 'bot',
|
||||
message: chain_type === 'reasoning' ? '' : chunk_json.data,
|
||||
reasoning: chain_type === 'reasoning' ? chunk_json.data : '',
|
||||
});
|
||||
messages.value.push({ content: message_obj });
|
||||
in_streaming = true;
|
||||
} else {
|
||||
if (chain_type === 'reasoning') {
|
||||
// 使用 reactive 对象,直接修改属性会触发响应式更新
|
||||
message_obj.reasoning = (message_obj.reasoning || '') + chunk_json.data;
|
||||
if (chain_type === 'tool_call') {
|
||||
// 解析工具调用数据
|
||||
const toolCallData = JSON.parse(chunk_json.data);
|
||||
const toolCall: ToolCall = {
|
||||
id: toolCallData.id,
|
||||
name: toolCallData.name,
|
||||
args: toolCallData.args,
|
||||
ts: toolCallData.ts
|
||||
};
|
||||
|
||||
if (!in_streaming) {
|
||||
message_obj = reactive<MessageContent>({
|
||||
type: 'bot',
|
||||
message: [{
|
||||
type: 'tool_call',
|
||||
tool_calls: [toolCall]
|
||||
}]
|
||||
});
|
||||
messages.value.push({ content: message_obj });
|
||||
in_streaming = true;
|
||||
} else {
|
||||
message_obj.message = (message_obj.message || '') + chunk_json.data;
|
||||
// 找到最后一个 tool_call part 或创建新的
|
||||
const lastPart = message_obj!.message[message_obj!.message.length - 1];
|
||||
if (lastPart?.type === 'tool_call') {
|
||||
// 检查是否已存在相同id的tool_call
|
||||
const existingIndex = lastPart.tool_calls!.findIndex((tc: ToolCall) => tc.id === toolCall.id);
|
||||
if (existingIndex === -1) {
|
||||
lastPart.tool_calls!.push(toolCall);
|
||||
}
|
||||
} else {
|
||||
// 添加新的 tool_call part
|
||||
message_obj!.message.push({
|
||||
type: 'tool_call',
|
||||
tool_calls: [toolCall]
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (chain_type === 'tool_call_result') {
|
||||
// 解析工具调用结果数据
|
||||
const resultData = JSON.parse(chunk_json.data);
|
||||
|
||||
if (message_obj) {
|
||||
// 遍历所有 tool_call parts 找到对应的 tool_call
|
||||
for (const part of message_obj.message) {
|
||||
if (part.type === 'tool_call' && part.tool_calls) {
|
||||
const toolCall = part.tool_calls.find((tc: ToolCall) => tc.id === resultData.id);
|
||||
if (toolCall) {
|
||||
toolCall.result = resultData.result;
|
||||
toolCall.finished_ts = resultData.ts;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (chain_type === 'reasoning') {
|
||||
if (!in_streaming) {
|
||||
message_obj = reactive<MessageContent>({
|
||||
type: 'bot',
|
||||
message: [],
|
||||
reasoning: chunk_json.data
|
||||
});
|
||||
messages.value.push({ content: message_obj });
|
||||
in_streaming = true;
|
||||
} else {
|
||||
message_obj!.reasoning = (message_obj!.reasoning || '') + chunk_json.data;
|
||||
}
|
||||
} else {
|
||||
// normal text
|
||||
if (!in_streaming) {
|
||||
message_obj = reactive<MessageContent>({
|
||||
type: 'bot',
|
||||
message: [{
|
||||
type: 'plain',
|
||||
text: chunk_json.data
|
||||
}]
|
||||
});
|
||||
messages.value.push({ content: message_obj });
|
||||
in_streaming = true;
|
||||
} else {
|
||||
// 找到最后一个 plain part 或创建新的
|
||||
const lastPart = message_obj!.message[message_obj!.message.length - 1];
|
||||
if (lastPart?.type === 'plain') {
|
||||
lastPart.text = (lastPart.text || '') + chunk_json.data;
|
||||
} else {
|
||||
message_obj!.message.push({
|
||||
type: 'plain',
|
||||
text: chunk_json.data
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (chunk_json.type === 'update_title') {
|
||||
@@ -435,6 +533,11 @@ export function useMessages(
|
||||
lastBotMsg.id = chunk_json.data.id;
|
||||
lastBotMsg.created_at = chunk_json.data.created_at;
|
||||
}
|
||||
} else if (chunk_json.type === 'agent_stats') {
|
||||
// 更新当前 bot 消息的 agent 统计信息
|
||||
if (message_obj) {
|
||||
message_obj.agentStats = chunk_json.data;
|
||||
}
|
||||
}
|
||||
|
||||
if ((chunk_json.type === 'break' && chunk_json.streaming) || !chunk_json.streaming) {
|
||||
@@ -480,4 +583,3 @@ export function useMessages(
|
||||
getAttachment
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { getProviderIcon } from '@/utils/providerUtils'
|
||||
|
||||
export interface UseProviderSourcesOptions {
|
||||
defaultTab?: string
|
||||
tm: (key: string, params?: Record<string, unknown>) => string
|
||||
showMessage: (message: string, color?: string) => void
|
||||
}
|
||||
|
||||
export function resolveDefaultTab(value?: string) {
|
||||
const normalized = (value || '').toLowerCase()
|
||||
|
||||
if (normalized.startsWith('select_agent_runner_provider') || normalized === 'agent_runner') {
|
||||
return 'agent_runner'
|
||||
}
|
||||
|
||||
if (normalized === 'select_provider_stt' || normalized === 'speech_to_text' || normalized.includes('stt')) {
|
||||
return 'speech_to_text'
|
||||
}
|
||||
|
||||
if (normalized === 'select_provider_tts' || normalized === 'text_to_speech' || normalized.includes('tts')) {
|
||||
return 'text_to_speech'
|
||||
}
|
||||
|
||||
if (normalized.includes('embedding')) {
|
||||
return 'embedding'
|
||||
}
|
||||
|
||||
if (normalized.includes('rerank')) {
|
||||
return 'rerank'
|
||||
}
|
||||
|
||||
return 'chat_completion'
|
||||
}
|
||||
|
||||
export function useProviderSources(options: UseProviderSourcesOptions) {
|
||||
const { tm, showMessage } = options
|
||||
|
||||
// ===== State =====
|
||||
const config = ref<Record<string, any>>({})
|
||||
const metadata = ref<Record<string, any>>({})
|
||||
const providerSources = ref<any[]>([])
|
||||
const providers = ref<any[]>([])
|
||||
const selectedProviderType = ref<string>(resolveDefaultTab(options.defaultTab))
|
||||
const selectedProviderSource = ref<any | null>(null)
|
||||
const selectedProviderSourceOriginalId = ref<string | null>(null)
|
||||
const editableProviderSource = ref<any | null>(null)
|
||||
const availableModels = ref<any[]>([])
|
||||
const modelMetadata = ref<Record<string, any>>({})
|
||||
const loadingModels = ref(false)
|
||||
const savingSource = ref(false)
|
||||
const testingProviders = ref<string[]>([])
|
||||
const isSourceModified = ref(false)
|
||||
const configSchema = ref<Record<string, any>>({})
|
||||
const providerTemplates = ref<Record<string, any>>({})
|
||||
const manualModelId = ref('')
|
||||
const modelSearch = ref('')
|
||||
|
||||
let suppressSourceWatch = false
|
||||
|
||||
const providerTypes = [
|
||||
{ value: 'chat_completion', label: tm('providers.tabs.chatCompletion'), icon: 'mdi-message-text' },
|
||||
{ value: 'agent_runner', label: tm('providers.tabs.agentRunner'), icon: 'mdi-robot' },
|
||||
{ value: 'speech_to_text', label: tm('providers.tabs.speechToText'), icon: 'mdi-microphone-message' },
|
||||
{ value: 'text_to_speech', label: tm('providers.tabs.textToSpeech'), icon: 'mdi-volume-high' },
|
||||
{ value: 'embedding', label: tm('providers.tabs.embedding'), icon: 'mdi-code-json' },
|
||||
{ value: 'rerank', label: tm('providers.tabs.rerank'), icon: 'mdi-compare-vertical' }
|
||||
]
|
||||
|
||||
// ===== Computed =====
|
||||
const availableSourceTypes = computed(() => {
|
||||
if (!providerTemplates.value || Object.keys(providerTemplates.value).length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const types: Array<{ value: string; label: string }> = []
|
||||
for (const [templateName, template] of Object.entries(providerTemplates.value)) {
|
||||
if (template.provider_type === selectedProviderType.value) {
|
||||
types.push({ value: templateName, label: templateName })
|
||||
}
|
||||
}
|
||||
|
||||
return types
|
||||
})
|
||||
|
||||
const filteredProviderSources = computed(() => {
|
||||
if (!providerSources.value) return []
|
||||
|
||||
return providerSources.value.filter((source) =>
|
||||
source.provider_type === selectedProviderType.value ||
|
||||
(source.type && isTypeMatchingProviderType(source.type, selectedProviderType.value))
|
||||
)
|
||||
})
|
||||
|
||||
const displayedProviderSources = computed(() => {
|
||||
const existing = filteredProviderSources.value || []
|
||||
const existingProviders = new Set(existing.map((src: any) => src.provider).filter(Boolean))
|
||||
const placeholders: any[] = []
|
||||
|
||||
if (providerTemplates.value && Object.keys(providerTemplates.value).length > 0) {
|
||||
for (const [templateKey, template] of Object.entries(providerTemplates.value)) {
|
||||
if (template.provider_type !== selectedProviderType.value) continue
|
||||
if (!template.provider) continue
|
||||
if (existingProviders.has(template.provider)) continue
|
||||
|
||||
placeholders.push({
|
||||
id: template.id || templateKey,
|
||||
provider: template.provider,
|
||||
provider_type: template.provider_type,
|
||||
type: template.type,
|
||||
api_base: template.api_base || '',
|
||||
templateKey,
|
||||
isPlaceholder: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return [...existing, ...placeholders]
|
||||
})
|
||||
|
||||
const sourceProviders = computed(() => {
|
||||
if (!selectedProviderSource.value || !providers.value) return []
|
||||
|
||||
return providers.value.filter((p) => p.provider_source_id === selectedProviderSource.value.id)
|
||||
})
|
||||
|
||||
const existingModelsForSelectedSource = computed(() => {
|
||||
if (!selectedProviderSource.value) return new Set<string>()
|
||||
return new Set(sourceProviders.value.map((p: any) => p.model))
|
||||
})
|
||||
|
||||
const sortedAvailableModels = computed(() => {
|
||||
const existing = existingModelsForSelectedSource.value
|
||||
return [...(availableModels.value || [])].sort((a, b) => {
|
||||
const aName = typeof a === 'string' ? a : a?.name
|
||||
const bName = typeof b === 'string' ? b : b?.name
|
||||
const aExists = existing.has(aName)
|
||||
const bExists = existing.has(bName)
|
||||
if (aExists && !bExists) return -1
|
||||
if (!aExists && bExists) return 1
|
||||
return 0
|
||||
})
|
||||
})
|
||||
|
||||
const mergedModelEntries = computed(() => {
|
||||
const configuredEntries = (sourceProviders.value || []).map((provider: any) => ({
|
||||
type: 'configured',
|
||||
provider,
|
||||
metadata: getModelMetadata(provider.model)
|
||||
}))
|
||||
|
||||
const availableEntries = (sortedAvailableModels.value || [])
|
||||
.filter((item: any) => {
|
||||
const name = typeof item === 'string' ? item : item?.name
|
||||
return !existingModelsForSelectedSource.value.has(name)
|
||||
})
|
||||
.map((item: any) => {
|
||||
const name = typeof item === 'string' ? item : item?.name
|
||||
return {
|
||||
type: 'available',
|
||||
model: name,
|
||||
metadata: typeof item === 'object' ? item?.metadata : getModelMetadata(name)
|
||||
}
|
||||
})
|
||||
|
||||
return [...configuredEntries, ...availableEntries]
|
||||
})
|
||||
|
||||
const filteredMergedModelEntries = computed(() => {
|
||||
const term = modelSearch.value.trim().toLowerCase()
|
||||
if (!term) return mergedModelEntries.value
|
||||
|
||||
return mergedModelEntries.value.filter((entry: any) => {
|
||||
if (entry.type === 'configured') {
|
||||
const id = entry.provider.id?.toLowerCase() || ''
|
||||
const model = entry.provider.model?.toLowerCase() || ''
|
||||
return id.includes(term) || model.includes(term)
|
||||
}
|
||||
|
||||
const model = entry.model?.toLowerCase() || ''
|
||||
return model.includes(term)
|
||||
})
|
||||
})
|
||||
|
||||
const manualProviderId = computed(() => {
|
||||
if (!selectedProviderSource.value) return ''
|
||||
const modelId = manualModelId.value.trim()
|
||||
if (!modelId) return ''
|
||||
return `${selectedProviderSource.value.id}/${modelId}`
|
||||
})
|
||||
|
||||
const basicSourceConfig = computed(() => {
|
||||
if (!editableProviderSource.value) return null
|
||||
|
||||
const fields = ['id', 'key', 'api_base']
|
||||
const basic: Record<string, any> = {}
|
||||
|
||||
fields.forEach((field) => {
|
||||
Object.defineProperty(basic, field, {
|
||||
get() {
|
||||
return editableProviderSource.value![field]
|
||||
},
|
||||
set(val) {
|
||||
editableProviderSource.value![field] = val
|
||||
},
|
||||
enumerable: true
|
||||
})
|
||||
})
|
||||
|
||||
return basic
|
||||
})
|
||||
|
||||
const advancedSourceConfig = computed(() => {
|
||||
if (!editableProviderSource.value) return null
|
||||
|
||||
const excluded = ['id', 'key', 'api_base', 'enable', 'type', 'provider_type', 'provider']
|
||||
const advanced: Record<string, any> = {}
|
||||
|
||||
for (const key of Object.keys(editableProviderSource.value)) {
|
||||
if (excluded.includes(key)) continue
|
||||
Object.defineProperty(advanced, key, {
|
||||
get() {
|
||||
return editableProviderSource.value![key]
|
||||
},
|
||||
set(val) {
|
||||
editableProviderSource.value![key] = val
|
||||
},
|
||||
enumerable: true
|
||||
})
|
||||
}
|
||||
|
||||
return advanced
|
||||
})
|
||||
|
||||
const filteredProviders = computed(() => {
|
||||
if (!providers.value || selectedProviderType.value === 'chat_completion') {
|
||||
return []
|
||||
}
|
||||
|
||||
return providers.value.filter((provider: any) => getProviderType(provider) === selectedProviderType.value)
|
||||
})
|
||||
|
||||
// ===== Watches =====
|
||||
watch(editableProviderSource, () => {
|
||||
if (suppressSourceWatch) return
|
||||
if (!editableProviderSource.value) return
|
||||
isSourceModified.value = true
|
||||
}, { deep: true })
|
||||
|
||||
// ===== Helper Functions =====
|
||||
function isTypeMatchingProviderType(type?: string, providerType?: string) {
|
||||
if (!type || !providerType) return false
|
||||
if (providerType === 'chat_completion') {
|
||||
return type.includes('chat_completion')
|
||||
}
|
||||
return type.includes(providerType)
|
||||
}
|
||||
|
||||
function resolveSourceIcon(source: any) {
|
||||
if (!source) return ''
|
||||
return getProviderIcon(source.provider) || ''
|
||||
}
|
||||
|
||||
function getSourceDisplayName(source: any) {
|
||||
if (!source) return ''
|
||||
if (source.isPlaceholder) return source.templateKey || source.id || ''
|
||||
return source.id
|
||||
}
|
||||
|
||||
function getModelMetadata(modelName?: string) {
|
||||
if (!modelName) return null
|
||||
return modelMetadata.value?.[modelName] || null
|
||||
}
|
||||
|
||||
function supportsImageInput(meta: any) {
|
||||
const inputs = meta?.modalities?.input || []
|
||||
return inputs.includes('image')
|
||||
}
|
||||
|
||||
function supportsToolCall(meta: any) {
|
||||
return Boolean(meta?.tool_call)
|
||||
}
|
||||
|
||||
function supportsReasoning(meta: any) {
|
||||
return Boolean(meta?.reasoning)
|
||||
}
|
||||
|
||||
function formatContextLimit(meta: any) {
|
||||
const ctx = meta?.limit?.context
|
||||
if (!ctx || typeof ctx !== 'number') return ''
|
||||
if (ctx >= 1_000_000) return `${Math.round(ctx / 1_000_000)}M`
|
||||
if (ctx >= 1_000) return `${Math.round(ctx / 1_000)}K`
|
||||
return `${ctx}`
|
||||
}
|
||||
|
||||
function getProviderType(provider: any) {
|
||||
if (!provider) return undefined
|
||||
if (provider.provider_type) {
|
||||
return provider.provider_type
|
||||
}
|
||||
|
||||
const oldVersionProviderTypeMapping: Record<string, string> = {
|
||||
openai_chat_completion: 'chat_completion',
|
||||
anthropic_chat_completion: 'chat_completion',
|
||||
googlegenai_chat_completion: 'chat_completion',
|
||||
zhipu_chat_completion: 'chat_completion',
|
||||
dify: 'agent_runner',
|
||||
coze: 'agent_runner',
|
||||
dashscope: 'chat_completion',
|
||||
openai_whisper_api: 'speech_to_text',
|
||||
openai_whisper_selfhost: 'speech_to_text',
|
||||
sensevoice_stt_selfhost: 'speech_to_text',
|
||||
openai_tts_api: 'text_to_speech',
|
||||
edge_tts: 'text_to_speech',
|
||||
gsvi_tts_api: 'text_to_speech',
|
||||
fishaudio_tts_api: 'text_to_speech',
|
||||
dashscope_tts: 'text_to_speech',
|
||||
azure_tts: 'text_to_speech',
|
||||
minimax_tts_api: 'text_to_speech',
|
||||
volcengine_tts: 'text_to_speech'
|
||||
}
|
||||
return oldVersionProviderTypeMapping[provider.type]
|
||||
}
|
||||
|
||||
function selectProviderSource(source: any) {
|
||||
if (source?.isPlaceholder && source.templateKey) {
|
||||
addProviderSource(source.templateKey)
|
||||
return
|
||||
}
|
||||
|
||||
selectedProviderSource.value = source
|
||||
selectedProviderSourceOriginalId.value = source?.id || null
|
||||
suppressSourceWatch = true
|
||||
editableProviderSource.value = source ? JSON.parse(JSON.stringify(source)) : null
|
||||
nextTick(() => {
|
||||
suppressSourceWatch = false
|
||||
})
|
||||
availableModels.value = []
|
||||
modelMetadata.value = {}
|
||||
isSourceModified.value = false
|
||||
}
|
||||
|
||||
function extractSourceFieldsFromTemplate(template: Record<string, any>) {
|
||||
const sourceFields: Record<string, any> = {}
|
||||
const excludeKeys = ['id', 'enable', 'model', 'provider_source_id', 'modalities', 'custom_extra_body']
|
||||
|
||||
for (const [key, value] of Object.entries(template)) {
|
||||
if (!excludeKeys.includes(key)) {
|
||||
sourceFields[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return sourceFields
|
||||
}
|
||||
|
||||
function generateUniqueSourceId(baseId: string) {
|
||||
const existingIds = new Set(providerSources.value.map((s: any) => s.id))
|
||||
if (!existingIds.has(baseId)) return baseId
|
||||
|
||||
let counter = 1
|
||||
let candidate = `${baseId}_${counter}`
|
||||
while (existingIds.has(candidate)) {
|
||||
counter += 1
|
||||
candidate = `${baseId}_${counter}`
|
||||
}
|
||||
|
||||
return candidate
|
||||
}
|
||||
|
||||
function addProviderSource(templateKey: string) {
|
||||
const template = providerTemplates.value[templateKey]
|
||||
if (!template) {
|
||||
showMessage('未找到对应的模板配置', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
const newId = generateUniqueSourceId(template.id)
|
||||
const newSource = {
|
||||
...extractSourceFieldsFromTemplate(template),
|
||||
id: newId,
|
||||
type: template.type,
|
||||
provider_type: template.provider_type,
|
||||
provider: template.provider,
|
||||
enable: true
|
||||
}
|
||||
|
||||
providerSources.value.push(newSource)
|
||||
selectedProviderSource.value = newSource
|
||||
selectedProviderSourceOriginalId.value = newId
|
||||
editableProviderSource.value = JSON.parse(JSON.stringify(newSource))
|
||||
availableModels.value = []
|
||||
modelMetadata.value = {}
|
||||
isSourceModified.value = true
|
||||
}
|
||||
|
||||
async function deleteProviderSource(source: any) {
|
||||
if (!confirm(tm('providerSources.deleteConfirm', { id: source.id }))) return
|
||||
|
||||
try {
|
||||
await axios.post('/api/config/provider_sources/delete', { id: source.id })
|
||||
|
||||
providers.value = providers.value.filter((p) => p.provider_source_id !== source.id)
|
||||
providerSources.value = providerSources.value.filter((s) => s.id !== source.id)
|
||||
|
||||
if (selectedProviderSource.value?.id === source.id) {
|
||||
selectedProviderSource.value = null
|
||||
selectedProviderSourceOriginalId.value = null
|
||||
editableProviderSource.value = null
|
||||
}
|
||||
|
||||
showMessage(tm('providerSources.deleteSuccess'))
|
||||
} catch (error: any) {
|
||||
showMessage(error.message || tm('providerSources.deleteError'), 'error')
|
||||
} finally {
|
||||
await loadConfig()
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProviderSource() {
|
||||
if (!selectedProviderSource.value) return
|
||||
|
||||
savingSource.value = true
|
||||
const originalId = selectedProviderSourceOriginalId.value || selectedProviderSource.value.id
|
||||
try {
|
||||
const response = await axios.post('/api/config/provider_sources/update', {
|
||||
config: editableProviderSource.value,
|
||||
original_id: originalId
|
||||
})
|
||||
|
||||
if (response.data.status !== 'ok') {
|
||||
throw new Error(response.data.message)
|
||||
}
|
||||
|
||||
if (editableProviderSource.value!.id !== originalId) {
|
||||
providers.value = providers.value.map((p) =>
|
||||
p.provider_source_id === originalId
|
||||
? { ...p, provider_source_id: editableProviderSource.value!.id }
|
||||
: p
|
||||
)
|
||||
selectedProviderSourceOriginalId.value = editableProviderSource.value!.id
|
||||
}
|
||||
|
||||
const idx = providerSources.value.findIndex((ps) => ps.id === originalId)
|
||||
if (idx !== -1) {
|
||||
providerSources.value[idx] = JSON.parse(JSON.stringify(editableProviderSource.value))
|
||||
selectedProviderSource.value = providerSources.value[idx]
|
||||
}
|
||||
|
||||
suppressSourceWatch = true
|
||||
editableProviderSource.value = selectedProviderSource.value
|
||||
nextTick(() => {
|
||||
suppressSourceWatch = false
|
||||
})
|
||||
|
||||
isSourceModified.value = false
|
||||
showMessage(response.data.message || tm('providerSources.saveSuccess'))
|
||||
return true
|
||||
} catch (error: any) {
|
||||
showMessage(error.response?.data?.message || error.message || tm('providerSources.saveError'), 'error')
|
||||
return false
|
||||
} finally {
|
||||
savingSource.value = false
|
||||
loadConfig()
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAvailableModels() {
|
||||
if (!selectedProviderSource.value) return
|
||||
|
||||
if (isSourceModified.value) {
|
||||
const saved = await saveProviderSource()
|
||||
if (!saved) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
loadingModels.value = true
|
||||
try {
|
||||
const sourceId = editableProviderSource.value?.id || selectedProviderSource.value.id
|
||||
const response = await axios.get('/api/config/provider_sources/models', {
|
||||
params: { source_id: sourceId }
|
||||
})
|
||||
if (response.data.status === 'ok') {
|
||||
const metadataMap = response.data.data.model_metadata || {}
|
||||
modelMetadata.value = metadataMap
|
||||
availableModels.value = (response.data.data.models || []).map((model: string) => ({
|
||||
name: model,
|
||||
metadata: metadataMap?.[model] || null
|
||||
}))
|
||||
if (availableModels.value.length === 0) {
|
||||
showMessage(tm('models.noModelsFound'), 'info')
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.data.message)
|
||||
}
|
||||
} catch (error: any) {
|
||||
modelMetadata.value = {}
|
||||
showMessage(error.response?.data?.message || error.message || tm('models.fetchError'), 'error')
|
||||
} finally {
|
||||
loadingModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addModelProvider(modelName: string) {
|
||||
if (!selectedProviderSource.value) return
|
||||
|
||||
const sourceId = editableProviderSource.value?.id || selectedProviderSource.value.id
|
||||
const newId = `${sourceId}/${modelName}`
|
||||
|
||||
const modalities = ['text']
|
||||
if (supportsImageInput(getModelMetadata(modelName))) {
|
||||
modalities.push('image')
|
||||
}
|
||||
if (supportsToolCall(getModelMetadata(modelName))) {
|
||||
modalities.push('tool_use')
|
||||
}
|
||||
|
||||
const newProvider = {
|
||||
id: newId,
|
||||
enable: false,
|
||||
provider_source_id: sourceId,
|
||||
model: modelName,
|
||||
modalities,
|
||||
custom_extra_body: {}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await axios.post('/api/config/provider/new', newProvider)
|
||||
if (res.data.status === 'error') {
|
||||
throw new Error(res.data.message)
|
||||
}
|
||||
providers.value.push(newProvider)
|
||||
showMessage(res.data.message || tm('models.addSuccess', { model: modelName }))
|
||||
} catch (error: any) {
|
||||
showMessage(error.response?.data?.message || error.message || tm('providerSources.saveError'), 'error')
|
||||
} finally {
|
||||
await loadConfig()
|
||||
}
|
||||
}
|
||||
|
||||
function modelAlreadyConfigured(modelName: string) {
|
||||
return existingModelsForSelectedSource.value.has(modelName)
|
||||
}
|
||||
|
||||
async function deleteProvider(provider: any) {
|
||||
if (!confirm(tm('models.deleteConfirm', { id: provider.id }))) return
|
||||
|
||||
try {
|
||||
await axios.post('/api/config/provider/delete', { id: provider.id })
|
||||
providers.value = providers.value.filter((p) => p.id !== provider.id)
|
||||
showMessage(tm('models.deleteSuccess'))
|
||||
} catch (error: any) {
|
||||
showMessage(error.message || tm('models.deleteError'), 'error')
|
||||
} finally {
|
||||
await loadConfig()
|
||||
}
|
||||
}
|
||||
|
||||
async function testProvider(provider: any) {
|
||||
testingProviders.value.push(provider.id)
|
||||
try {
|
||||
const response = await axios.get('/api/config/provider/check_one', { params: { id: provider.id } })
|
||||
if (response.data.status === 'ok' && response.data.data.error === null) {
|
||||
showMessage(tm('models.testSuccess', { id: provider.id }))
|
||||
} else {
|
||||
throw new Error(response.data.data.error || tm('models.testError'))
|
||||
}
|
||||
} catch (error: any) {
|
||||
showMessage(error.response?.data?.message || error.message || tm('models.testError'), 'error')
|
||||
} finally {
|
||||
testingProviders.value = testingProviders.value.filter((id) => id !== provider.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
loadProviderTemplate()
|
||||
}
|
||||
|
||||
async function loadProviderTemplate() {
|
||||
try {
|
||||
const response = await axios.get('/api/config/provider/template')
|
||||
if (response.data.status === 'ok') {
|
||||
configSchema.value = response.data.data.config_schema || {}
|
||||
if (configSchema.value.provider?.config_template) {
|
||||
providerTemplates.value = configSchema.value.provider.config_template
|
||||
}
|
||||
providerSources.value = response.data.data.provider_sources || []
|
||||
providers.value = response.data.data.providers || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load provider template:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function updateDefaultTab(value: string) {
|
||||
selectedProviderType.value = resolveDefaultTab(value)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadProviderTemplate()
|
||||
})
|
||||
|
||||
return {
|
||||
// state
|
||||
config,
|
||||
metadata,
|
||||
providerSources,
|
||||
providers,
|
||||
selectedProviderType,
|
||||
selectedProviderSource,
|
||||
selectedProviderSourceOriginalId,
|
||||
editableProviderSource,
|
||||
availableModels,
|
||||
modelMetadata,
|
||||
loadingModels,
|
||||
savingSource,
|
||||
testingProviders,
|
||||
isSourceModified,
|
||||
configSchema,
|
||||
providerTemplates,
|
||||
manualModelId,
|
||||
modelSearch,
|
||||
|
||||
// computed
|
||||
providerTypes,
|
||||
availableSourceTypes,
|
||||
displayedProviderSources,
|
||||
sourceProviders,
|
||||
mergedModelEntries,
|
||||
filteredMergedModelEntries,
|
||||
filteredProviders,
|
||||
basicSourceConfig,
|
||||
advancedSourceConfig,
|
||||
manualProviderId,
|
||||
|
||||
// helpers
|
||||
resolveSourceIcon,
|
||||
getSourceDisplayName,
|
||||
getModelMetadata,
|
||||
supportsImageInput,
|
||||
supportsToolCall,
|
||||
supportsReasoning,
|
||||
formatContextLimit,
|
||||
getProviderType,
|
||||
|
||||
// methods
|
||||
updateDefaultTab,
|
||||
selectProviderSource,
|
||||
addProviderSource,
|
||||
deleteProviderSource,
|
||||
saveProviderSource,
|
||||
fetchAvailableModels,
|
||||
addModelProvider,
|
||||
deleteProvider,
|
||||
modelAlreadyConfigured,
|
||||
testProvider,
|
||||
loadConfig,
|
||||
loadProviderTemplate
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,13 @@ export function useSessions(chatboxMode: boolean = false) {
|
||||
selectedSessions.value = [pendingSessionId.value];
|
||||
pendingSessionId.value = null;
|
||||
}
|
||||
} else if (!currSessionId.value && sessions.value.length > 0) {
|
||||
} else if (currSessionId.value) {
|
||||
// 如果当前有选中的会话,确保它在列表中并被选中
|
||||
const session = sessions.value.find(s => s.session_id === currSessionId.value);
|
||||
if (session) {
|
||||
selectedSessions.value = [currSessionId.value];
|
||||
}
|
||||
} else if (sessions.value.length > 0) {
|
||||
// 默认选择第一个会话
|
||||
const firstSession = sessions.value[0];
|
||||
selectedSessions.value = [firstSession.session_id];
|
||||
@@ -65,6 +71,10 @@ export function useSessions(chatboxMode: boolean = false) {
|
||||
router.push(`${basePath}/${sessionId}`);
|
||||
|
||||
await getSessions();
|
||||
|
||||
// 确保新创建的会话被选中高亮
|
||||
selectedSessions.value = [sessionId];
|
||||
|
||||
return sessionId;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
@@ -19,5 +19,6 @@
|
||||
"submit": "Submit",
|
||||
"reset": "Reset",
|
||||
"clear": "Clear",
|
||||
"save": "Save"
|
||||
"save": "Save",
|
||||
"close": "Close"
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
"uninstall": "Uninstall",
|
||||
"update": "Update",
|
||||
"language": "Language",
|
||||
"settings": "Settings",
|
||||
"locale": "en-US",
|
||||
"type": "Type",
|
||||
"press": "Press",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"dashboard": "Dashboard",
|
||||
"platforms": "Platforms",
|
||||
"providers": "Providers",
|
||||
"commands": "Commands",
|
||||
"persona": "Persona",
|
||||
"toolUse": "MCP Tools",
|
||||
"config": "Config",
|
||||
@@ -14,10 +15,19 @@
|
||||
"knowledgeBase": "Knowledge Base",
|
||||
"about": "About",
|
||||
"settings": "Settings",
|
||||
"changelog": "Changelog",
|
||||
"documentation": "Documentation",
|
||||
"github": "GitHub",
|
||||
"drag": "Drag",
|
||||
"groups": {
|
||||
"more": "More Features"
|
||||
},
|
||||
"changelogDialog": {
|
||||
"title": "Changelog",
|
||||
"loading": "Loading...",
|
||||
"error": "Failed to load",
|
||||
"notFound": "Changelog for this version not found",
|
||||
"selectVersion": "Select Version",
|
||||
"current": "Current"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@
|
||||
"cancelSelection": "Cancel",
|
||||
"clearSelection": "None",
|
||||
"clearSelectionSubtitle": "Clear current selection",
|
||||
"unknownType": "Unknown type"
|
||||
"unknownType": "Unknown type",
|
||||
"createProvider": "Create Provider",
|
||||
"manageProviders": "Provider Management"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,8 @@
|
||||
"editTitle": "Edit Title",
|
||||
"fullscreen": "Fullscreen Mode",
|
||||
"exitFullscreen": "Exit Fullscreen",
|
||||
"reply": "Reply"
|
||||
"reply": "Reply",
|
||||
"providerConfig": "AI Configuration"
|
||||
},
|
||||
"conversation": {
|
||||
"newConversation": "New Conversation",
|
||||
@@ -80,6 +81,14 @@
|
||||
"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.",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"title": "Command Management",
|
||||
"summary": {
|
||||
"total": "Displayed commands",
|
||||
"disabled": "Disabled",
|
||||
"conflicts": "Conflicts"
|
||||
},
|
||||
"conflictAlert": {
|
||||
"title": "Command Conflicts Detected",
|
||||
"description": "There are {count} conflicting commands. Conflicting commands will trigger multiple plugins simultaneously, which may cause unexpected behavior.",
|
||||
"hint": "Click the \"Rename\" button to rename conflicting commands and resolve conflicts."
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
"command": "Command",
|
||||
"type": "Type",
|
||||
"plugin": "Plugin",
|
||||
"description": "Description",
|
||||
"permission": "Permission",
|
||||
"status": "Status",
|
||||
"actions": "Actions"
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"command": "Command",
|
||||
"group": "Group",
|
||||
"subCommand": "Sub-command"
|
||||
},
|
||||
"status": {
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"conflict": "Conflict"
|
||||
},
|
||||
"permission": {
|
||||
"everyone": "Everyone",
|
||||
"admin": "Admin"
|
||||
},
|
||||
"tooltips": {
|
||||
"enable": "Enable command",
|
||||
"disable": "Disable command",
|
||||
"rename": "Rename command",
|
||||
"viewDetails": "View details"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename": {
|
||||
"title": "Rename Command",
|
||||
"newName": "New command name",
|
||||
"aliases": "Manage aliases",
|
||||
"addAlias": "Add alias",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm"
|
||||
},
|
||||
"details": {
|
||||
"title": "Command Details",
|
||||
"type": "Command Type",
|
||||
"handler": "Handler",
|
||||
"module": "Module Path",
|
||||
"originalCommand": "Original Command",
|
||||
"effectiveCommand": "Effective Command",
|
||||
"parentGroup": "Parent Group",
|
||||
"subCommands": "Sub-commands",
|
||||
"aliases": "Aliases",
|
||||
"permission": "Permission",
|
||||
"conflictStatus": "Conflict Status"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"toggleSuccess": "Command status updated",
|
||||
"toggleFailed": "Failed to update command status",
|
||||
"renameSuccess": "Command renamed",
|
||||
"renameFailed": "Rename failed",
|
||||
"loadFailed": "Failed to load commands"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search commands..."
|
||||
},
|
||||
"empty": {
|
||||
"noCommands": "No Commands",
|
||||
"noCommandsDesc": "No commands found"
|
||||
},
|
||||
"filters": {
|
||||
"all": "All",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"conflict": "Conflict",
|
||||
"byPlugin": "Filter by plugin",
|
||||
"byType": "Filter by type",
|
||||
"byPermission": "Filter by permission",
|
||||
"byStatus": "Filter by status",
|
||||
"showSystemPlugins": "Show system plugins commands",
|
||||
"systemPluginConflictHint": "System plugin conflicts detected. Resolve conflicts to hide."
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
"title": "Extension Management",
|
||||
"subtitle": "Manage and configure system extensions",
|
||||
"tabs": {
|
||||
"installed": "Installed",
|
||||
"installedPlugins": "Installed Plugins",
|
||||
"installedMcpServers": "Installed MCP Servers",
|
||||
"handlersOperation": "Manage Handlers",
|
||||
"market": "Extension Market"
|
||||
},
|
||||
"search": {
|
||||
@@ -197,5 +199,12 @@
|
||||
"errors": {
|
||||
"confirmNotRegistered": "$confirm not properly registered"
|
||||
}
|
||||
},
|
||||
"conflicts": {
|
||||
"title": "Command Conflicts Detected",
|
||||
"message": "This will cause some commands to work abnormally. It is recommended to go to the [Command Management] panel to handle it.",
|
||||
"pairs": "command conflicts",
|
||||
"goToManage": "Go to Manage",
|
||||
"later": "Later"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"title": "Service Provider Management",
|
||||
"subtitle": "Manage model service providers",
|
||||
"title": "Providers",
|
||||
"subtitle": "Can configure chat models in \"Chat Completion\". Additionally, \"Agent Runner\" includes integrations with third-party services like Dify, Coze, and Alibaba Bailian(DashScope).",
|
||||
"providers": {
|
||||
"title": "Service Providers",
|
||||
"settings": "Settings",
|
||||
@@ -85,5 +85,50 @@
|
||||
"confirm": {
|
||||
"delete": "Are you sure you want to delete service provider {id}?"
|
||||
}
|
||||
},
|
||||
"providerTypes": {
|
||||
"title": "Provider Types"
|
||||
},
|
||||
"providerSources": {
|
||||
"title": "Provider Sources",
|
||||
"empty": "No provider sources",
|
||||
"selectHint": "Please select a provider source",
|
||||
"save": "Save Configuration",
|
||||
"saveAndFetchModels": "Save and Fetch Models",
|
||||
"fetchModels": "Fetch Model List",
|
||||
"saveSuccess": "Provider source saved successfully",
|
||||
"saveError": "Failed to save provider source",
|
||||
"deleteConfirm": "Are you sure you want to delete provider source {id}? This will also delete all associated model configurations.",
|
||||
"deleteSuccess": "Provider source deleted successfully",
|
||||
"deleteError": "Failed to delete provider source",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"advancedConfig": "Advanced Configuration...",
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"apiKey": "API Key",
|
||||
"baseUrl": "Base URL"
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
"available": "Available Models",
|
||||
"configured": "Configured Models",
|
||||
"empty": "No configured models yet. Click \"Fetch Models\" above to add.",
|
||||
"noModelsFound": "No available models found",
|
||||
"fetchError": "Failed to fetch models",
|
||||
"addSuccess": "Model {model} added successfully",
|
||||
"deleteConfirm": "Are you sure you want to delete model {id}?",
|
||||
"deleteSuccess": "Model deleted successfully",
|
||||
"deleteError": "Failed to delete model",
|
||||
"testSuccess": "Model {id} test passed",
|
||||
"testError": "Model test failed",
|
||||
"searchPlaceholder": "Search models or ID",
|
||||
"manualAddButton": "Custom Model",
|
||||
"manualDialogTitle": "Add Custom Model",
|
||||
"manualDialogModelLabel": "Model ID (e.g. gpt-4.1-mini)",
|
||||
"manualDialogPreviewLabel": "Display ID (auto generated)",
|
||||
"manualDialogPreviewHint": "Generated as sourceId/modelId",
|
||||
"manualModelRequired": "Please enter a model ID",
|
||||
"manualModelExists": "Model already exists"
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,10 @@
|
||||
"paramName": "Parameter Name",
|
||||
"type": "Type",
|
||||
"description": "Description",
|
||||
"required": "Required"
|
||||
"required": "Required",
|
||||
"origin": "Origin",
|
||||
"originName": "Origin Name",
|
||||
"actions": "Actions"
|
||||
}
|
||||
},
|
||||
"marketplace": {
|
||||
|
||||
@@ -19,5 +19,6 @@
|
||||
"submit": "提交",
|
||||
"reset": "重置",
|
||||
"clear": "清空",
|
||||
"save": "保存"
|
||||
"save": "保存",
|
||||
"close": "关闭"
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
"uninstall": "卸载",
|
||||
"update": "更新",
|
||||
"language": "语言",
|
||||
"settings": "设置",
|
||||
"locale": "zh-CN",
|
||||
"type": "输入",
|
||||
"press": "按",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"dashboard": "数据统计",
|
||||
"platforms": "机器人",
|
||||
"providers": "模型提供商",
|
||||
"commands": "指令管理",
|
||||
"persona": "人格设定",
|
||||
"toolUse": "MCP",
|
||||
"extension": "插件",
|
||||
@@ -14,10 +15,19 @@
|
||||
"knowledgeBase": "知识库",
|
||||
"about": "关于",
|
||||
"settings": "设置",
|
||||
"changelog": "更新日志",
|
||||
"documentation": "官方文档",
|
||||
"github": "GitHub",
|
||||
"drag": "拖拽",
|
||||
"groups": {
|
||||
"more": "更多功能"
|
||||
},
|
||||
"changelogDialog": {
|
||||
"title": "更新日志",
|
||||
"loading": "加载中...",
|
||||
"error": "加载失败",
|
||||
"notFound": "未找到该版本的更新日志",
|
||||
"selectVersion": "选择版本",
|
||||
"current": "当前"
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,8 @@
|
||||
"cancelSelection": "取消",
|
||||
"clearSelection": "不选择",
|
||||
"clearSelectionSubtitle": "清除当前选择",
|
||||
"unknownType": "未知类型"
|
||||
"unknownType": "未知类型",
|
||||
"createProvider": "创建提供商",
|
||||
"manageProviders": "提供商管理"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user