mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-05 20:30:14 +08:00
Compare commits
19 Commits
v4.23.5
...
feat/conv-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d3a09f3db | ||
|
|
071f7b5701 | ||
|
|
2ce6b1b885 | ||
|
|
8ca8231176 | ||
|
|
6ba01a4775 | ||
|
|
f02444146d | ||
|
|
415da218f6 | ||
|
|
07b37b98de | ||
|
|
bbda1e678f | ||
|
|
3c1d0cd2c2 | ||
|
|
d16ed4e552 | ||
|
|
55c1558686 | ||
|
|
17aea1aa2c | ||
|
|
d4cdeeae72 | ||
|
|
5ce02da6df | ||
|
|
5d79c99938 | ||
|
|
f0a1dd79c4 | ||
|
|
8d9ae55c8f | ||
|
|
aaec41e505 |
@@ -1,3 +1,6 @@
|
||||
from sqlalchemy import case, func, select
|
||||
from sqlmodel import col
|
||||
|
||||
from astrbot.api import sp, star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
from astrbot.core import logger
|
||||
@@ -7,6 +10,7 @@ from astrbot.core.agent.runners.deerflow.constants import (
|
||||
DEERFLOW_THREAD_ID_KEY,
|
||||
)
|
||||
from astrbot.core.agent.runners.deerflow.deerflow_api_client import DeerFlowAPIClient
|
||||
from astrbot.core.db.po import ProviderStat
|
||||
from astrbot.core.utils.active_event_registry import active_event_registry
|
||||
|
||||
from .utils.rst_scene import RstScene
|
||||
@@ -246,3 +250,62 @@ class ConversationCommands:
|
||||
f"✅ Switched to new conversation: {cid[:4]}。"
|
||||
),
|
||||
)
|
||||
|
||||
async def stats(self, message: AstrMessageEvent) -> None:
|
||||
"""Show token usage statistics for the current conversation."""
|
||||
umo = message.unified_msg_origin
|
||||
cid = await self.context.conversation_manager.get_curr_conversation_id(umo)
|
||||
|
||||
if not cid:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
"❌ You are not in a conversation. Use /new to create one."
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
db = self.context.get_db()
|
||||
async with db.get_db() as session:
|
||||
result = await session.execute(
|
||||
select(
|
||||
func.count(case((col(ProviderStat.id).is_not(None), 1))).label(
|
||||
"record_count",
|
||||
),
|
||||
func.coalesce(func.sum(ProviderStat.token_input_other), 0).label(
|
||||
"total_input_other",
|
||||
),
|
||||
func.coalesce(func.sum(ProviderStat.token_input_cached), 0).label(
|
||||
"total_input_cached",
|
||||
),
|
||||
func.coalesce(func.sum(ProviderStat.token_output), 0).label(
|
||||
"total_output",
|
||||
),
|
||||
).where(
|
||||
col(ProviderStat.agent_type) == "internal",
|
||||
col(ProviderStat.conversation_id) == cid,
|
||||
)
|
||||
)
|
||||
stats = result.one()
|
||||
|
||||
if stats.record_count == 0:
|
||||
message.set_result(
|
||||
MessageEventResult().message(
|
||||
"📊 No stats available for this conversation yet."
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
total_input_other = stats.total_input_other
|
||||
total_input_cached = stats.total_input_cached
|
||||
total_output = stats.total_output
|
||||
total_tokens = total_input_other + total_input_cached + total_output
|
||||
|
||||
ret = (
|
||||
f"📊 Conversation Token usage (ID: {cid[:8]}...)\n"
|
||||
f"Total: {total_tokens:,}\n"
|
||||
f"Input (cached): {total_input_cached:,}\n"
|
||||
f"Input (other): {total_input_other:,}\n"
|
||||
f"Output: {total_output:,}\n"
|
||||
)
|
||||
|
||||
message.set_result(MessageEventResult().message(ret))
|
||||
|
||||
@@ -47,6 +47,11 @@ class Main(star.Star):
|
||||
"""Create new conversation"""
|
||||
await self.conversation_c.new_conv(message)
|
||||
|
||||
@filter.command("stats")
|
||||
async def stats(self, message: AstrMessageEvent) -> None:
|
||||
"""Show token usage statistics for the current conversation"""
|
||||
await self.conversation_c.stats(message)
|
||||
|
||||
@filter.permission_type(filter.PermissionType.ADMIN)
|
||||
@filter.command("provider")
|
||||
async def provider(
|
||||
|
||||
@@ -183,10 +183,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
self.stats.end_time = time.time()
|
||||
|
||||
parts = []
|
||||
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
|
||||
if llm_resp.reasoning_content is not None or llm_resp.reasoning_signature:
|
||||
parts.append(
|
||||
ThinkPart(
|
||||
think=llm_resp.reasoning_content,
|
||||
think=llm_resp.reasoning_content or "",
|
||||
encrypted=llm_resp.reasoning_signature,
|
||||
)
|
||||
)
|
||||
@@ -876,10 +876,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
|
||||
# 将结果添加到上下文中
|
||||
parts = []
|
||||
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
|
||||
if llm_resp.reasoning_content is not None or llm_resp.reasoning_signature:
|
||||
parts.append(
|
||||
ThinkPart(
|
||||
think=llm_resp.reasoning_content,
|
||||
think=llm_resp.reasoning_content or "",
|
||||
encrypted=llm_resp.reasoning_signature,
|
||||
)
|
||||
)
|
||||
@@ -1361,10 +1361,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
self.stats.end_time = time.time()
|
||||
|
||||
parts = []
|
||||
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
|
||||
if llm_resp.reasoning_content is not None or llm_resp.reasoning_signature:
|
||||
parts.append(
|
||||
ThinkPart(
|
||||
think=llm_resp.reasoning_content,
|
||||
think=llm_resp.reasoning_content or "",
|
||||
encrypted=llm_resp.reasoning_signature,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -77,6 +77,8 @@ from astrbot.core.tools.web_search_tools import (
|
||||
BaiduWebSearchTool,
|
||||
BochaWebSearchTool,
|
||||
BraveWebSearchTool,
|
||||
FirecrawlExtractWebPageTool,
|
||||
FirecrawlWebSearchTool,
|
||||
TavilyExtractWebPageTool,
|
||||
TavilyWebSearchTool,
|
||||
normalize_legacy_web_search_config,
|
||||
@@ -1047,6 +1049,9 @@ async def _apply_web_search_tools(
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(BochaWebSearchTool))
|
||||
elif provider == "brave":
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(BraveWebSearchTool))
|
||||
elif provider == "firecrawl":
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FirecrawlWebSearchTool))
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FirecrawlExtractWebPageTool))
|
||||
elif provider == "baidu_ai_search":
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(BaiduWebSearchTool))
|
||||
|
||||
|
||||
@@ -3202,6 +3202,7 @@ CONFIG_METADATA_3 = {
|
||||
"baidu_ai_search",
|
||||
"bocha",
|
||||
"brave",
|
||||
"firecrawl",
|
||||
],
|
||||
"condition": {
|
||||
"provider_settings.web_search": True,
|
||||
@@ -3237,6 +3238,16 @@ CONFIG_METADATA_3 = {
|
||||
"provider_settings.web_search": True,
|
||||
},
|
||||
},
|
||||
"provider_settings.websearch_firecrawl_key": {
|
||||
"description": "Firecrawl API Key",
|
||||
"type": "list",
|
||||
"items": {"type": "string"},
|
||||
"hint": "可添加多个 Key 进行轮询。",
|
||||
"condition": {
|
||||
"provider_settings.websearch_provider": "firecrawl",
|
||||
"provider_settings.web_search": True,
|
||||
},
|
||||
},
|
||||
"provider_settings.websearch_baidu_app_builder_key": {
|
||||
"description": "百度千帆智能云 APP Builder API Key",
|
||||
"type": "string",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, cast
|
||||
@@ -140,6 +141,8 @@ class WecomServer:
|
||||
|
||||
@register_platform_adapter("wecom", "wecom 适配器", support_streaming_message=False)
|
||||
class WecomPlatformAdapter(Platform):
|
||||
WECHAT_KF_TEXT_CONTENT_DEDUP_TTL_SECONDS = 15
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
platform_config: dict,
|
||||
@@ -166,6 +169,7 @@ class WecomPlatformAdapter(Platform):
|
||||
|
||||
self.server = WecomServer(self._event_queue, self.config)
|
||||
self.agent_id: str | None = None
|
||||
self._wechat_kf_seen_text_messages: dict[str, float] = {}
|
||||
|
||||
self.client = WeChatClient(
|
||||
self.config["corpid"].strip(),
|
||||
@@ -210,6 +214,28 @@ class WecomPlatformAdapter(Platform):
|
||||
|
||||
self.server.callback = callback
|
||||
|
||||
def _is_duplicate_wechat_kf_text_message(self, session_id: str, text: str) -> bool:
|
||||
normalized_text = text.strip()
|
||||
if not normalized_text:
|
||||
return False
|
||||
|
||||
now = time.monotonic()
|
||||
expired_keys = [
|
||||
key
|
||||
for key, expires_at in self._wechat_kf_seen_text_messages.items()
|
||||
if expires_at <= now
|
||||
]
|
||||
for key in expired_keys:
|
||||
self._wechat_kf_seen_text_messages.pop(key, None)
|
||||
|
||||
dedup_key = f"{session_id}:{normalized_text}"
|
||||
if dedup_key in self._wechat_kf_seen_text_messages:
|
||||
return True
|
||||
self._wechat_kf_seen_text_messages[dedup_key] = (
|
||||
now + self.WECHAT_KF_TEXT_CONTENT_DEDUP_TTL_SECONDS
|
||||
)
|
||||
return False
|
||||
|
||||
@override
|
||||
async def send_by_session(
|
||||
self,
|
||||
@@ -390,6 +416,13 @@ class WecomPlatformAdapter(Platform):
|
||||
abm.message_str = ""
|
||||
if msgtype == "text":
|
||||
text = msg.get("text", {}).get("content", "").strip()
|
||||
if self._is_duplicate_wechat_kf_text_message(abm.session_id, text):
|
||||
logger.debug(
|
||||
"忽略 15 秒内重复微信客服文本消息 session_id=%s text=%s",
|
||||
abm.session_id,
|
||||
text,
|
||||
)
|
||||
return None
|
||||
abm.message = [Plain(text=text)]
|
||||
abm.message_str = text
|
||||
elif msgtype == "image":
|
||||
|
||||
@@ -353,7 +353,7 @@ class LLMResponse:
|
||||
"""Tool call IDs."""
|
||||
tools_call_extra_content: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
"""Tool call extra content. tool_call_id -> extra_content dict"""
|
||||
reasoning_content: str = ""
|
||||
reasoning_content: str | None = None
|
||||
"""The reasoning content extracted from the LLM, if any."""
|
||||
reasoning_signature: str | None = None
|
||||
"""The signature of the reasoning content, if any."""
|
||||
@@ -404,8 +404,6 @@ class LLMResponse:
|
||||
raw_completion (ChatCompletion, optional): 原始响应, OpenAI 格式. Defaults to None.
|
||||
|
||||
"""
|
||||
if reasoning_content is None:
|
||||
reasoning_content = ""
|
||||
if tools_call_args is None:
|
||||
tools_call_args = []
|
||||
if tools_call_name is None:
|
||||
|
||||
@@ -39,7 +39,7 @@ class ProviderAnthropic(Provider):
|
||||
stop_reason: str | None = None,
|
||||
) -> None:
|
||||
has_text_output = bool((llm_response.completion_text or "").strip())
|
||||
has_reasoning_output = bool(llm_response.reasoning_content.strip())
|
||||
has_reasoning_output = bool((llm_response.reasoning_content or "").strip())
|
||||
has_tool_output = bool(llm_response.tools_call_args)
|
||||
if has_text_output or has_reasoning_output or has_tool_output:
|
||||
return
|
||||
|
||||
@@ -462,7 +462,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
finish_reason: str | None = None,
|
||||
) -> None:
|
||||
has_text_output = bool((llm_response.completion_text or "").strip())
|
||||
has_reasoning_output = bool(llm_response.reasoning_content.strip())
|
||||
has_reasoning_output = bool((llm_response.reasoning_content or "").strip())
|
||||
has_tool_output = bool(llm_response.tools_call_args)
|
||||
if has_text_output or has_reasoning_output or has_tool_output:
|
||||
return
|
||||
|
||||
@@ -65,7 +65,7 @@ class ProviderMiniMaxTTSAPI(TTSProvider):
|
||||
self.audio_setting: dict = {
|
||||
"sample_rate": 32000,
|
||||
"bitrate": 128000,
|
||||
"format": "mp3",
|
||||
"format": "wav",
|
||||
}
|
||||
|
||||
self.concat_base_url: str = f"{self.api_base}?GroupId={self.group_id}"
|
||||
@@ -147,7 +147,7 @@ class ProviderMiniMaxTTSAPI(TTSProvider):
|
||||
async def get_audio(self, text: str) -> str:
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
path = os.path.join(temp_dir, f"minimax_tts_api_{uuid.uuid4()}.mp3")
|
||||
path = os.path.join(temp_dir, f"minimax_tts_api_{uuid.uuid4()}.wav")
|
||||
|
||||
try:
|
||||
# 直接将异步生成器传递给 _audio_play 方法
|
||||
|
||||
@@ -519,6 +519,42 @@ class ProviderOpenAIOfficial(Provider):
|
||||
except NotFoundError as e:
|
||||
raise Exception(f"获取模型列表失败:{e}")
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_assistant_messages(payloads: dict) -> None:
|
||||
"""在请求发送前过滤/规范化空的 assistant 消息。
|
||||
|
||||
严格 API(Moonshot、DeepSeek Reasoner 等)会在 assistant 消息同时缺少
|
||||
``content`` 和 ``tool_calls`` 时返回 400。把 ``""`` / ``None`` / ``[]``
|
||||
都视作空内容:无 tool_calls 时整条过滤掉;有 tool_calls 时将 content
|
||||
设为 ``None`` 以符合 OpenAI 规范。就地修改 ``payloads["messages"]``。
|
||||
"""
|
||||
messages = payloads.get("messages")
|
||||
if not isinstance(messages, list):
|
||||
return
|
||||
|
||||
def _is_empty(content: Any) -> bool:
|
||||
return content is None or content == "" or content == []
|
||||
|
||||
cleaned: list[Any] = []
|
||||
for idx, msg in enumerate(messages):
|
||||
if not isinstance(msg, dict) or msg.get("role") != "assistant":
|
||||
cleaned.append(msg)
|
||||
continue
|
||||
|
||||
content = msg.get("content")
|
||||
tool_calls = msg.get("tool_calls")
|
||||
|
||||
if _is_empty(content) and not tool_calls:
|
||||
logger.warning(f"过滤第 {idx} 条空 assistant 消息 (无工具调用)")
|
||||
continue
|
||||
|
||||
if _is_empty(content) and tool_calls:
|
||||
msg["content"] = None
|
||||
|
||||
cleaned.append(msg)
|
||||
|
||||
payloads["messages"] = cleaned
|
||||
|
||||
async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse:
|
||||
if tools:
|
||||
model = payloads.get("model", "").lower()
|
||||
@@ -548,26 +584,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
|
||||
model = payloads.get("model", "").lower()
|
||||
|
||||
if "messages" in payloads and isinstance(payloads["messages"], list):
|
||||
cleaned_messages = []
|
||||
for idx, msg in enumerate(payloads["messages"]):
|
||||
# 过滤空的 assistant 消息,防止严格 API(如 Moonshot)返回 400 错误
|
||||
if msg.get("role") == "assistant":
|
||||
content = msg.get("content")
|
||||
tool_calls = msg.get("tool_calls")
|
||||
|
||||
# 情况1: 空/null content 且无 tool_calls -> 过滤掉
|
||||
if not tool_calls and (content == "" or content is None):
|
||||
logger.warning(f"过滤第 {idx} 条空 assistant 消息 (无工具调用)")
|
||||
continue
|
||||
|
||||
# 情况2: 空 content 但有 tool_calls -> 设为 None (符合 OpenAI 规范)
|
||||
if content == "" and tool_calls:
|
||||
msg["content"] = None
|
||||
|
||||
cleaned_messages.append(msg)
|
||||
|
||||
payloads["messages"] = cleaned_messages
|
||||
self._sanitize_assistant_messages(payloads)
|
||||
|
||||
completion = await self.client.chat.completions.create(
|
||||
**payloads,
|
||||
@@ -619,6 +636,8 @@ class ProviderOpenAIOfficial(Provider):
|
||||
del payloads[key]
|
||||
self._apply_provider_specific_extra_body_overrides(extra_body)
|
||||
|
||||
self._sanitize_assistant_messages(payloads)
|
||||
|
||||
stream = await self.client.chat.completions.create(
|
||||
**payloads,
|
||||
stream=True,
|
||||
@@ -652,9 +671,9 @@ class ProviderOpenAIOfficial(Provider):
|
||||
reasoning = self._extract_reasoning_content(chunk)
|
||||
_y = False
|
||||
llm_response.id = chunk.id
|
||||
llm_response.reasoning_content = ""
|
||||
llm_response.reasoning_content = None
|
||||
llm_response.completion_text = ""
|
||||
if reasoning:
|
||||
if reasoning is not None:
|
||||
llm_response.reasoning_content = reasoning
|
||||
_y = True
|
||||
if delta and delta.content:
|
||||
@@ -682,22 +701,28 @@ class ProviderOpenAIOfficial(Provider):
|
||||
def _extract_reasoning_content(
|
||||
self,
|
||||
completion: ChatCompletion | ChatCompletionChunk,
|
||||
) -> str:
|
||||
) -> str | None:
|
||||
"""Extract reasoning content from OpenAI ChatCompletion if available."""
|
||||
reasoning_text = ""
|
||||
|
||||
def _get_reasoning_attr(obj: Any) -> str | None:
|
||||
fields_set = getattr(obj, "model_fields_set", None)
|
||||
if isinstance(fields_set, set) and self.reasoning_key in fields_set:
|
||||
attr = getattr(obj, self.reasoning_key, "")
|
||||
return "" if attr is None else str(attr)
|
||||
attr = getattr(obj, self.reasoning_key, None)
|
||||
return None if attr is None else str(attr)
|
||||
|
||||
if not completion.choices:
|
||||
return reasoning_text
|
||||
return None
|
||||
if isinstance(completion, ChatCompletion):
|
||||
choice = completion.choices[0]
|
||||
reasoning_attr = getattr(choice.message, self.reasoning_key, None)
|
||||
if reasoning_attr:
|
||||
reasoning_text = str(reasoning_attr)
|
||||
reasoning_attr = _get_reasoning_attr(choice.message)
|
||||
elif isinstance(completion, ChatCompletionChunk):
|
||||
delta = completion.choices[0].delta
|
||||
reasoning_attr = getattr(delta, self.reasoning_key, None)
|
||||
if reasoning_attr:
|
||||
reasoning_text = str(reasoning_attr)
|
||||
return reasoning_text
|
||||
reasoning_attr = _get_reasoning_attr(delta)
|
||||
else:
|
||||
return None
|
||||
return reasoning_attr
|
||||
|
||||
def _extract_usage(self, usage: CompletionUsage | dict) -> TokenUsage:
|
||||
ptd = getattr(usage, "prompt_tokens_details", None)
|
||||
@@ -840,7 +865,9 @@ class ProviderOpenAIOfficial(Provider):
|
||||
|
||||
# parse the reasoning content if any
|
||||
# the priority is higher than the <think> tag extraction
|
||||
llm_response.reasoning_content = self._extract_reasoning_content(completion)
|
||||
reasoning_content = self._extract_reasoning_content(completion)
|
||||
if reasoning_content is not None:
|
||||
llm_response.reasoning_content = reasoning_content
|
||||
|
||||
# parse tool calls if any
|
||||
if choice.message.tool_calls and tools is not None:
|
||||
@@ -887,7 +914,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
"API 返回的 completion 由于内容安全过滤被拒绝(非 AstrBot)。",
|
||||
)
|
||||
has_text_output = bool((llm_response.completion_text or "").strip())
|
||||
has_reasoning_output = bool(llm_response.reasoning_content.strip())
|
||||
has_reasoning_output = bool((llm_response.reasoning_content or "").strip())
|
||||
if (
|
||||
not has_text_output
|
||||
and not has_reasoning_output
|
||||
@@ -963,24 +990,39 @@ class ProviderOpenAIOfficial(Provider):
|
||||
"""Finally convert the payload. Such as think part conversion, tool inject."""
|
||||
model = payloads.get("model", "").lower()
|
||||
is_gemini = "gemini" in model
|
||||
|
||||
deepseek_reasoning_models = {"deepseek-v4-pro", "deepseek-v4-flash"}
|
||||
is_deepseek_v4_reasoning = (
|
||||
model in deepseek_reasoning_models
|
||||
or "api.deepseek.com" in self.client.base_url.host
|
||||
)
|
||||
for message in payloads.get("messages", []):
|
||||
if message.get("role") == "assistant" and isinstance(
|
||||
message.get("content"), list
|
||||
):
|
||||
reasoning_content = ""
|
||||
reasoning_content_present = False
|
||||
new_content = [] # not including think part
|
||||
for part in message["content"]:
|
||||
if part.get("type") == "think":
|
||||
reasoning_content_present = True
|
||||
reasoning_content += str(part.get("think"))
|
||||
else:
|
||||
new_content.append(part)
|
||||
# Some providers (Grok, etc.) reject empty content lists.
|
||||
# When all parts were think blocks, fall back to None.
|
||||
message["content"] = new_content or None
|
||||
if reasoning_content:
|
||||
if reasoning_content_present:
|
||||
message["reasoning_content"] = reasoning_content
|
||||
|
||||
if (
|
||||
message.get("role") == "assistant"
|
||||
and is_deepseek_v4_reasoning
|
||||
and "reasoning_content" not in message
|
||||
):
|
||||
# DeepSeek v4 reasoning models require the field on assistant
|
||||
# history messages, even when the reasoning content is empty.
|
||||
message["reasoning_content"] = ""
|
||||
|
||||
# Gemini 的 function_response 要求 google.protobuf.Struct(即 JSON 对象),
|
||||
# 纯文本会触发 400 Invalid argument,需要包一层 JSON。
|
||||
if is_gemini and message.get("role") == "tool":
|
||||
|
||||
@@ -20,3 +20,4 @@ class ProviderOpenRouter(ProviderOpenAIOfficial):
|
||||
self.client._custom_headers["X-OpenRouter-Categories"] = (
|
||||
"general-chat,personal-agent" # type: ignore
|
||||
)
|
||||
self.reasoning_key = "reasoning"
|
||||
|
||||
@@ -43,7 +43,7 @@ from astrbot.core.agent.tool import ToolExecResult
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
from astrbot.core.computer.computer_client import get_booter
|
||||
from astrbot.core.computer.file_read_utils import read_file_tool_result
|
||||
from astrbot.core.message.components import File
|
||||
from astrbot.core.message.components import File, Image
|
||||
from astrbot.core.utils.astrbot_path import (
|
||||
get_astrbot_skills_path,
|
||||
get_astrbot_system_tmp_path,
|
||||
@@ -64,6 +64,7 @@ _COMPUTER_RUNTIME_TOOL_CONFIG = {
|
||||
_SANDBOX_RUNTIME_TOOL_CONFIG = {
|
||||
"provider_settings.computer_use_runtime": "sandbox",
|
||||
}
|
||||
_IMAGE_FILE_SUFFIXES = {".bmp", ".gif", ".jpeg", ".jpg", ".png", ".webp"}
|
||||
|
||||
|
||||
def _restricted_env_path_labels(umo: str) -> list[str]:
|
||||
@@ -729,11 +730,21 @@ class FileDownloadTool(FunctionTool):
|
||||
if also_send_to_user:
|
||||
try:
|
||||
name = os.path.basename(local_path)
|
||||
if Path(local_path).suffix.lower() in _IMAGE_FILE_SUFFIXES:
|
||||
message_component = Image.fromFileSystem(local_path)
|
||||
sent_as = "image"
|
||||
else:
|
||||
message_component = File(name=name, file=local_path)
|
||||
sent_as = "file"
|
||||
await context.context.event.send(
|
||||
MessageChain(chain=[File(name=name, file=local_path)])
|
||||
MessageChain(chain=[message_component])
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending file message: {e}")
|
||||
return (
|
||||
f"File downloaded successfully to {local_path} "
|
||||
f"but sending to user failed: {e}"
|
||||
)
|
||||
|
||||
# remove
|
||||
# try:
|
||||
@@ -741,7 +752,10 @@ class FileDownloadTool(FunctionTool):
|
||||
# except Exception as e:
|
||||
# logger.error(f"Error removing temp file {local_path}: {e}")
|
||||
|
||||
return f"File downloaded successfully to {local_path} and sent to user."
|
||||
return (
|
||||
f"File downloaded successfully to {local_path} "
|
||||
f"and sent to user as {sent_as}."
|
||||
)
|
||||
|
||||
return f"File downloaded successfully to {local_path}"
|
||||
except Exception as e:
|
||||
|
||||
@@ -19,6 +19,8 @@ WEB_SEARCH_TOOL_NAMES = [
|
||||
"tavily_extract_web_page",
|
||||
"web_search_bocha",
|
||||
"web_search_brave",
|
||||
"web_search_firecrawl",
|
||||
"firecrawl_extract_web_page",
|
||||
]
|
||||
_TAVILY_WEB_SEARCH_TOOL_CONFIG = {
|
||||
"provider_settings.web_search": True,
|
||||
@@ -32,6 +34,10 @@ _BRAVE_WEB_SEARCH_TOOL_CONFIG = {
|
||||
"provider_settings.web_search": True,
|
||||
"provider_settings.websearch_provider": "brave",
|
||||
}
|
||||
_FIRECRAWL_WEB_SEARCH_TOOL_CONFIG = {
|
||||
"provider_settings.web_search": True,
|
||||
"provider_settings.websearch_provider": "firecrawl",
|
||||
}
|
||||
_BAIDU_WEB_SEARCH_TOOL_CONFIG = {
|
||||
"provider_settings.web_search": True,
|
||||
"provider_settings.websearch_provider": "baidu_ai_search",
|
||||
@@ -69,6 +75,7 @@ class _KeyRotator:
|
||||
_TAVILY_KEY_ROTATOR = _KeyRotator("websearch_tavily_key", "Tavily")
|
||||
_BOCHA_KEY_ROTATOR = _KeyRotator("websearch_bocha_key", "BoCha")
|
||||
_BRAVE_KEY_ROTATOR = _KeyRotator("websearch_brave_key", "Brave")
|
||||
_FIRECRAWL_KEY_ROTATOR = _KeyRotator("websearch_firecrawl_key", "Firecrawl")
|
||||
|
||||
|
||||
def normalize_legacy_web_search_config(cfg) -> None:
|
||||
@@ -91,6 +98,7 @@ def normalize_legacy_web_search_config(cfg) -> None:
|
||||
"websearch_tavily_key",
|
||||
"websearch_bocha_key",
|
||||
"websearch_brave_key",
|
||||
"websearch_firecrawl_key",
|
||||
):
|
||||
value = provider_settings.get(setting_name)
|
||||
if isinstance(value, str):
|
||||
@@ -258,6 +266,72 @@ async def _brave_search(
|
||||
]
|
||||
|
||||
|
||||
async def _firecrawl_search(
|
||||
provider_settings: dict,
|
||||
payload: dict,
|
||||
) -> list[SearchResult]:
|
||||
firecrawl_key = await _FIRECRAWL_KEY_ROTATOR.get(provider_settings)
|
||||
header = {
|
||||
"Authorization": f"Bearer {firecrawl_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.post(
|
||||
"https://api.firecrawl.dev/v2/search",
|
||||
json=payload,
|
||||
headers=header,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
reason = await response.text()
|
||||
raise Exception(
|
||||
f"Firecrawl web search failed: {reason}, status: {response.status}",
|
||||
)
|
||||
data = await response.json()
|
||||
rows = data.get("data", [])
|
||||
if isinstance(rows, dict):
|
||||
rows = rows.get("web", [])
|
||||
return [
|
||||
SearchResult(
|
||||
title=item.get("title", ""),
|
||||
url=item.get("url", ""),
|
||||
snippet=(
|
||||
item.get("description")
|
||||
or item.get("snippet")
|
||||
or item.get("markdown")
|
||||
or ""
|
||||
),
|
||||
)
|
||||
for item in rows
|
||||
if item.get("url")
|
||||
]
|
||||
|
||||
|
||||
async def _firecrawl_scrape(provider_settings: dict, payload: dict) -> dict:
|
||||
firecrawl_key = await _FIRECRAWL_KEY_ROTATOR.get(provider_settings)
|
||||
header = {
|
||||
"Authorization": f"Bearer {firecrawl_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.post(
|
||||
"https://api.firecrawl.dev/v2/scrape",
|
||||
json=payload,
|
||||
headers=header,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
reason = await response.text()
|
||||
raise Exception(
|
||||
f"Firecrawl web scraper failed: {reason}, status: {response.status}",
|
||||
)
|
||||
data = await response.json()
|
||||
result = data.get("data", {})
|
||||
if not result:
|
||||
raise ValueError(
|
||||
"Error: Firecrawl web scraper does not return any results."
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
async def _baidu_search(
|
||||
provider_settings: dict,
|
||||
payload: dict,
|
||||
@@ -548,6 +622,124 @@ class BraveWebSearchTool(FunctionTool[AstrAgentContext]):
|
||||
return _search_result_payload(results)
|
||||
|
||||
|
||||
@builtin_tool(config=_FIRECRAWL_WEB_SEARCH_TOOL_CONFIG)
|
||||
@pydantic_dataclass
|
||||
class FirecrawlWebSearchTool(FunctionTool[AstrAgentContext]):
|
||||
name: str = "web_search_firecrawl"
|
||||
description: str = (
|
||||
"A web search tool based on Firecrawl Search API, used to retrieve web "
|
||||
"pages related to the user's query."
|
||||
)
|
||||
parameters: dict = Field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Required. Search query."},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Optional. Number of results to return. Range: 1-100. Default is 5.",
|
||||
},
|
||||
"location": {
|
||||
"type": "string",
|
||||
"description": "Optional. Geographic location for search results.",
|
||||
},
|
||||
"country": {
|
||||
"type": "string",
|
||||
"description": 'Optional. Country code for search results, for example "US" or "CN".',
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"description": "Optional. Request timeout in milliseconds.",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(self, context, **kwargs) -> ToolExecResult:
|
||||
_, provider_settings, _ = _get_runtime(context)
|
||||
if not provider_settings.get("websearch_firecrawl_key", []):
|
||||
return "Error: Firecrawl API key is not configured in AstrBot."
|
||||
|
||||
payload = {
|
||||
"query": kwargs["query"],
|
||||
"limit": kwargs.get("limit", 5),
|
||||
"sources": ["web"],
|
||||
}
|
||||
for key in ("location", "country", "timeout"):
|
||||
if kwargs.get(key):
|
||||
payload[key] = kwargs[key]
|
||||
|
||||
results = await _firecrawl_search(provider_settings, payload)
|
||||
if not results:
|
||||
return "Error: Firecrawl web searcher does not return any results."
|
||||
return _search_result_payload(results)
|
||||
|
||||
|
||||
@builtin_tool(config=_FIRECRAWL_WEB_SEARCH_TOOL_CONFIG)
|
||||
@pydantic_dataclass
|
||||
class FirecrawlExtractWebPageTool(FunctionTool[AstrAgentContext]):
|
||||
name: str = "firecrawl_extract_web_page"
|
||||
description: str = "Extract the content of a web page using Firecrawl."
|
||||
parameters: dict = Field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "Required. A URL to extract content from.",
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"description": 'Optional. Output format, one of "markdown", "html", "rawHtml", "summary". Default is "markdown".',
|
||||
},
|
||||
"only_main_content": {
|
||||
"type": "boolean",
|
||||
"description": "Optional. Whether to extract only the main page content. Default is true.",
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"description": "Optional. Request timeout in milliseconds.",
|
||||
},
|
||||
"max_age": {
|
||||
"type": "integer",
|
||||
"description": "Optional. Maximum cache age in milliseconds.",
|
||||
},
|
||||
},
|
||||
"required": ["url"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(self, context, **kwargs) -> ToolExecResult:
|
||||
_, provider_settings, _ = _get_runtime(context)
|
||||
if not provider_settings.get("websearch_firecrawl_key", []):
|
||||
return "Error: Firecrawl API key is not configured in AstrBot."
|
||||
|
||||
url = str(kwargs.get("url", "")).strip()
|
||||
if not url:
|
||||
return "Error: url must be a non-empty string."
|
||||
|
||||
output_format = kwargs.get("format", "markdown")
|
||||
if output_format not in ["markdown", "html", "rawHtml", "summary"]:
|
||||
output_format = "markdown"
|
||||
|
||||
payload = {
|
||||
"url": url,
|
||||
"formats": [output_format],
|
||||
"onlyMainContent": kwargs.get("only_main_content", True),
|
||||
}
|
||||
if kwargs.get("timeout"):
|
||||
payload["timeout"] = kwargs["timeout"]
|
||||
if kwargs.get("max_age"):
|
||||
payload["maxAge"] = kwargs["max_age"]
|
||||
|
||||
result = await _firecrawl_scrape(provider_settings, payload)
|
||||
content = result.get(output_format, "")
|
||||
result_url = result.get("url") or url
|
||||
ret = f"URL: {result_url}\nContent: {content}" if content else ""
|
||||
return ret or "Error: Firecrawl web scraper does not return any results."
|
||||
|
||||
|
||||
@builtin_tool(config=_BAIDU_WEB_SEARCH_TOOL_CONFIG)
|
||||
@pydantic_dataclass
|
||||
class BaiduWebSearchTool(FunctionTool[AstrAgentContext]):
|
||||
|
||||
@@ -436,19 +436,30 @@ async def compress_image(
|
||||
optimize = IMAGE_COMPRESS_DEFAULT_OPTIMIZE
|
||||
min_file_size_bytes = int(IMAGE_COMPRESS_DEFAULT_MIN_FILE_SIZE_MB * 1024 * 1024)
|
||||
data = None
|
||||
|
||||
def _exceeds_max_size(source: bytes | Path) -> bool:
|
||||
try:
|
||||
fp = io.BytesIO(source) if isinstance(source, bytes) else source
|
||||
with PILImage.open(fp) as opened_img:
|
||||
return max(opened_img.size) > max_size
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
# Skip compression for remote images and return the original value.
|
||||
if url_or_path.startswith("http"):
|
||||
return url_or_path
|
||||
elif url_or_path.startswith("data:image"):
|
||||
_header, encoded = url_or_path.split(",", 1)
|
||||
data = base64.b64decode(encoded)
|
||||
if len(data) < min_file_size_bytes:
|
||||
if len(data) < min_file_size_bytes and not _exceeds_max_size(data):
|
||||
return url_or_path
|
||||
else:
|
||||
local_path = Path(url_or_path)
|
||||
if not local_path.exists():
|
||||
return url_or_path
|
||||
if local_path.stat().st_size < min_file_size_bytes:
|
||||
if local_path.stat().st_size < min_file_size_bytes and not _exceeds_max_size(
|
||||
local_path
|
||||
):
|
||||
return url_or_path
|
||||
with local_path.open("rb") as f:
|
||||
data = f.read()
|
||||
|
||||
@@ -5,8 +5,9 @@ import ssl
|
||||
import httpx
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.utils.http_ssl_common import build_ssl_context_with_certifi
|
||||
|
||||
_SYSTEM_SSL_CTX = ssl.create_default_context()
|
||||
_SYSTEM_SSL_CTX = build_ssl_context_with_certifi()
|
||||
|
||||
|
||||
def is_connection_error(exc: BaseException) -> bool:
|
||||
@@ -92,9 +93,9 @@ def create_proxy_client(
|
||||
) -> httpx.AsyncClient:
|
||||
"""Create an httpx AsyncClient with proxy configuration if provided.
|
||||
|
||||
Uses the system SSL certificate store instead of certifi, which avoids
|
||||
SSL verification failures for endpoints whose CA chain is not in certifi
|
||||
but is trusted by the operating system.
|
||||
Uses a hybrid SSL context that combines the system SSL certificate store
|
||||
with certifi as a fallback, ensuring compatibility across different
|
||||
environments including Windows where the system store may be incomplete.
|
||||
|
||||
Note: The caller is responsible for closing the client when done.
|
||||
Consider using the client as a context manager or calling aclose() explicitly.
|
||||
@@ -103,11 +104,11 @@ def create_proxy_client(
|
||||
provider_label: The provider name for log prefix (e.g., "OpenAI", "Gemini")
|
||||
proxy: The proxy address (e.g., "http://127.0.0.1:7890"), or None/empty
|
||||
headers: Optional custom headers to include in every request
|
||||
verify: Optional override for TLS verification. Defaults to the shared
|
||||
system SSL context when not provided.
|
||||
verify: Optional override for TLS verification. Defaults to the hybrid
|
||||
SSL context (system store + certifi) when not provided.
|
||||
|
||||
Returns:
|
||||
An httpx.AsyncClient created with the shared system SSL context; the proxy is applied only if one is provided.
|
||||
An httpx.AsyncClient created with the hybrid SSL context (system store + certifi); the proxy is applied only if one is provided.
|
||||
"""
|
||||
resolved_verify = _SYSTEM_SSL_CTX if verify is None else verify
|
||||
if proxy:
|
||||
|
||||
@@ -5,6 +5,7 @@ import re
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from copy import deepcopy
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, cast
|
||||
|
||||
from quart import Response as QuartResponse
|
||||
@@ -32,6 +33,16 @@ from .route import Response, Route, RouteContext
|
||||
SSE_HEARTBEAT = ": heartbeat\n\n"
|
||||
|
||||
|
||||
def _sanitize_upload_filename(filename: str | None) -> str:
|
||||
if not filename:
|
||||
return f"{uuid.uuid4()!s}"
|
||||
normalized = filename.replace("\\", "/")
|
||||
name = PurePosixPath(normalized).name.replace("\x00", "").strip()
|
||||
if name in ("", ".", ".."):
|
||||
return f"{uuid.uuid4()!s}"
|
||||
return name
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def track_conversation(convs: dict, conv_id: str):
|
||||
convs[conv_id] = True
|
||||
@@ -333,7 +344,7 @@ class ChatRoute(Route):
|
||||
return Response().error("Missing key: file").__dict__
|
||||
|
||||
file = post_data["file"]
|
||||
filename = file.filename or f"{uuid.uuid4()!s}"
|
||||
filename = _sanitize_upload_filename(file.filename)
|
||||
content_type = file.content_type or "application/octet-stream"
|
||||
|
||||
# 根据 content_type 判断文件类型并添加扩展名
|
||||
@@ -346,12 +357,16 @@ class ChatRoute(Route):
|
||||
else:
|
||||
attach_type = "file"
|
||||
|
||||
path = os.path.join(self.attachments_dir, filename)
|
||||
await file.save(path)
|
||||
attachments_dir = Path(self.attachments_dir).resolve(strict=False)
|
||||
file_path = (attachments_dir / filename).resolve(strict=False)
|
||||
if not file_path.is_relative_to(attachments_dir):
|
||||
return Response().error("Invalid filename").__dict__
|
||||
|
||||
await file.save(str(file_path))
|
||||
|
||||
# 创建 attachment 记录
|
||||
attachment = await self.db.insert_attachment(
|
||||
path=path,
|
||||
path=str(file_path),
|
||||
type=attach_type,
|
||||
mime_type=content_type,
|
||||
)
|
||||
@@ -766,6 +781,44 @@ class ChatRoute(Route):
|
||||
message_accumulator = BotMessageAccumulator()
|
||||
agent_stats = {}
|
||||
refs = {}
|
||||
|
||||
async def flush_pending_bot_message():
|
||||
nonlocal message_accumulator, agent_stats, refs
|
||||
if not (message_accumulator.has_content() or refs or agent_stats):
|
||||
return None
|
||||
|
||||
message_parts_to_save = message_accumulator.build_message_parts(
|
||||
include_pending_tool_calls=True
|
||||
)
|
||||
plain_text = collect_plain_text_from_message_parts(
|
||||
message_parts_to_save
|
||||
)
|
||||
|
||||
try:
|
||||
extracted_refs = self._extract_web_search_refs(
|
||||
plain_text,
|
||||
message_parts_to_save,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Failed to extract web search refs: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
extracted_refs = refs
|
||||
|
||||
saved_record = await self._save_bot_message(
|
||||
webchat_conv_id,
|
||||
message_parts_to_save,
|
||||
agent_stats,
|
||||
extracted_refs,
|
||||
llm_checkpoint_id,
|
||||
platform_history_id,
|
||||
)
|
||||
message_accumulator = BotMessageAccumulator()
|
||||
agent_stats = {}
|
||||
refs = {}
|
||||
return saved_record
|
||||
|
||||
try:
|
||||
# Emit session_id first so clients can bind the stream immediately.
|
||||
session_info = {
|
||||
@@ -885,35 +938,7 @@ class ChatRoute(Route):
|
||||
should_save = True
|
||||
|
||||
if should_save:
|
||||
message_parts_to_save = (
|
||||
message_accumulator.build_message_parts(
|
||||
include_pending_tool_calls=True
|
||||
)
|
||||
)
|
||||
plain_text = collect_plain_text_from_message_parts(
|
||||
message_parts_to_save
|
||||
)
|
||||
|
||||
# 提取 web_search_tavily 引用
|
||||
try:
|
||||
refs = self._extract_web_search_refs(
|
||||
plain_text,
|
||||
message_parts_to_save,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Failed to extract web search refs: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
saved_record = await self._save_bot_message(
|
||||
webchat_conv_id,
|
||||
message_parts_to_save,
|
||||
agent_stats,
|
||||
refs,
|
||||
llm_checkpoint_id,
|
||||
platform_history_id,
|
||||
)
|
||||
saved_record = await flush_pending_bot_message()
|
||||
# 发送保存的消息信息给前端
|
||||
if saved_record and not client_disconnected:
|
||||
saved_info = {
|
||||
@@ -930,15 +955,18 @@ class ChatRoute(Route):
|
||||
yield f"data: {json.dumps(saved_info, ensure_ascii=False)}\n\n"
|
||||
except Exception:
|
||||
pass
|
||||
message_accumulator = BotMessageAccumulator()
|
||||
agent_stats = {}
|
||||
refs = {}
|
||||
|
||||
if msg_type == "end":
|
||||
break
|
||||
except BaseException as e:
|
||||
logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True)
|
||||
finally:
|
||||
try:
|
||||
await flush_pending_bot_message()
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Failed to persist pending webchat message: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
webchat_queue_mgr.remove_back_queue(message_id)
|
||||
|
||||
# 将消息放入会话特定的队列
|
||||
|
||||
@@ -453,6 +453,7 @@ class LiveChatRoute(Route):
|
||||
llm_checkpoint_id = str(uuid.uuid4())
|
||||
|
||||
try:
|
||||
pending_bot_message_flusher = None
|
||||
chat_queue = webchat_queue_mgr.get_or_create_queue(session_id)
|
||||
await chat_queue.put(
|
||||
(
|
||||
@@ -499,9 +500,47 @@ class LiveChatRoute(Route):
|
||||
agent_stats = {}
|
||||
refs = {}
|
||||
|
||||
async def flush_pending_bot_message():
|
||||
nonlocal message_accumulator, agent_stats, refs
|
||||
if not (message_accumulator.has_content() or refs or agent_stats):
|
||||
return None
|
||||
|
||||
message_parts_to_save = message_accumulator.build_message_parts(
|
||||
include_pending_tool_calls=True
|
||||
)
|
||||
plain_text = collect_plain_text_from_message_parts(
|
||||
message_parts_to_save
|
||||
)
|
||||
try:
|
||||
extracted_refs = self._extract_web_search_refs(
|
||||
plain_text,
|
||||
message_parts_to_save,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"[Live Chat] Failed to extract web search refs: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
extracted_refs = refs
|
||||
|
||||
saved_record = await self._save_bot_message(
|
||||
session_id,
|
||||
message_parts_to_save,
|
||||
agent_stats,
|
||||
extracted_refs,
|
||||
llm_checkpoint_id,
|
||||
)
|
||||
message_accumulator = BotMessageAccumulator()
|
||||
agent_stats = {}
|
||||
refs = {}
|
||||
return saved_record
|
||||
|
||||
pending_bot_message_flusher = flush_pending_bot_message
|
||||
|
||||
while True:
|
||||
if session.should_interrupt:
|
||||
session.should_interrupt = False
|
||||
await flush_pending_bot_message()
|
||||
break
|
||||
|
||||
try:
|
||||
@@ -574,30 +613,7 @@ class LiveChatRoute(Route):
|
||||
should_save = True
|
||||
|
||||
if should_save:
|
||||
message_parts_to_save = message_accumulator.build_message_parts(
|
||||
include_pending_tool_calls=True
|
||||
)
|
||||
plain_text = collect_plain_text_from_message_parts(
|
||||
message_parts_to_save
|
||||
)
|
||||
try:
|
||||
refs = self._extract_web_search_refs(
|
||||
plain_text,
|
||||
message_parts_to_save,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"[Live Chat] Failed to extract web search refs: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
saved_record = await self._save_bot_message(
|
||||
session_id,
|
||||
message_parts_to_save,
|
||||
agent_stats,
|
||||
refs,
|
||||
llm_checkpoint_id,
|
||||
)
|
||||
saved_record = await flush_pending_bot_message()
|
||||
if saved_record:
|
||||
await self._send_chat_payload(
|
||||
session,
|
||||
@@ -614,10 +630,6 @@ class LiveChatRoute(Route):
|
||||
},
|
||||
)
|
||||
|
||||
message_accumulator = BotMessageAccumulator()
|
||||
agent_stats = {}
|
||||
refs = {}
|
||||
|
||||
if msg_type == "end":
|
||||
break
|
||||
|
||||
@@ -633,6 +645,14 @@ class LiveChatRoute(Route):
|
||||
},
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
if pending_bot_message_flusher is not None:
|
||||
await pending_bot_message_flusher()
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"[Live Chat] Failed to persist pending chat message: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
session.is_processing = False
|
||||
webchat_queue_mgr.remove_back_queue(message_id)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit&family=Poppins:wght@400;500;600;700&family=Roboto:wght@400;500;700&display=swap"
|
||||
href="https://fonts.googleapis.com/css2?family=Outfit&family=Noto+Sans+SC:wght@100..900&display=swap"
|
||||
/>
|
||||
<!-- VAD (Voice Activity Detection) Libraries -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/onnxruntime-web@1.22.0/dist/ort.wasm.min.js"></script>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Auto-generated MDI subset – 247 icons */
|
||||
/* Auto-generated MDI subset – 261 icons */
|
||||
/* Do not edit manually. Run: pnpm run subset-icons */
|
||||
|
||||
@font-face {
|
||||
@@ -236,6 +236,10 @@
|
||||
content: "\F0167";
|
||||
}
|
||||
|
||||
.mdi-code-braces::before {
|
||||
content: "\F0169";
|
||||
}
|
||||
|
||||
.mdi-code-json::before {
|
||||
content: "\F0626";
|
||||
}
|
||||
@@ -324,6 +328,10 @@
|
||||
content: "\F1634";
|
||||
}
|
||||
|
||||
.mdi-database-search-outline::before {
|
||||
content: "\F1636";
|
||||
}
|
||||
|
||||
.mdi-delete::before {
|
||||
content: "\F01B4";
|
||||
}
|
||||
@@ -396,6 +404,10 @@
|
||||
content: "\F022E";
|
||||
}
|
||||
|
||||
.mdi-file-delimited-outline::before {
|
||||
content: "\F0EA5";
|
||||
}
|
||||
|
||||
.mdi-file-document::before {
|
||||
content: "\F0219";
|
||||
}
|
||||
@@ -416,6 +428,10 @@
|
||||
content: "\F021C";
|
||||
}
|
||||
|
||||
.mdi-file-music-outline::before {
|
||||
content: "\F0E2A";
|
||||
}
|
||||
|
||||
.mdi-file-outline::before {
|
||||
content: "\F0224";
|
||||
}
|
||||
@@ -436,6 +452,10 @@
|
||||
content: "\F0A4D";
|
||||
}
|
||||
|
||||
.mdi-file-video-outline::before {
|
||||
content: "\F0E2C";
|
||||
}
|
||||
|
||||
.mdi-file-word-box::before {
|
||||
content: "\F022D";
|
||||
}
|
||||
@@ -536,6 +556,10 @@
|
||||
content: "\F0EFE";
|
||||
}
|
||||
|
||||
.mdi-image-outline::before {
|
||||
content: "\F0976";
|
||||
}
|
||||
|
||||
.mdi-import::before {
|
||||
content: "\F02FA";
|
||||
}
|
||||
@@ -564,10 +588,38 @@
|
||||
content: "\F0315";
|
||||
}
|
||||
|
||||
.mdi-language-css3::before {
|
||||
content: "\F031C";
|
||||
}
|
||||
|
||||
.mdi-language-html5::before {
|
||||
content: "\F031D";
|
||||
}
|
||||
|
||||
.mdi-language-java::before {
|
||||
content: "\F0B37";
|
||||
}
|
||||
|
||||
.mdi-language-javascript::before {
|
||||
content: "\F031E";
|
||||
}
|
||||
|
||||
.mdi-language-markdown::before {
|
||||
content: "\F0354";
|
||||
}
|
||||
|
||||
.mdi-language-markdown-outline::before {
|
||||
content: "\F0F5B";
|
||||
}
|
||||
|
||||
.mdi-language-python::before {
|
||||
content: "\F0320";
|
||||
}
|
||||
|
||||
.mdi-language-typescript::before {
|
||||
content: "\F06E6";
|
||||
}
|
||||
|
||||
.mdi-layers-outline::before {
|
||||
content: "\F09FE";
|
||||
}
|
||||
@@ -688,6 +740,10 @@
|
||||
content: "\F03D6";
|
||||
}
|
||||
|
||||
.mdi-package-variant-closed::before {
|
||||
content: "\F03D7";
|
||||
}
|
||||
|
||||
.mdi-page-first::before {
|
||||
content: "\F0600";
|
||||
}
|
||||
@@ -812,10 +868,6 @@
|
||||
content: "\F167A";
|
||||
}
|
||||
|
||||
.mdi-send::before {
|
||||
content: "\F048A";
|
||||
}
|
||||
|
||||
.mdi-server::before {
|
||||
content: "\F048B";
|
||||
}
|
||||
@@ -1004,6 +1056,10 @@
|
||||
content: "\F05B7";
|
||||
}
|
||||
|
||||
.mdi-wrench-outline::before {
|
||||
content: "\F0BE0";
|
||||
}
|
||||
|
||||
.mdi-zip-box::before {
|
||||
content: "\F05C4";
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="props.active"
|
||||
class="chat-ui"
|
||||
:class="{ 'is-dark': isDark, 'sidebar-collapsed': isSidebarCollapsed }"
|
||||
>
|
||||
@@ -34,6 +35,25 @@
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-btn
|
||||
class="new-chat-btn sidebar-provider-btn"
|
||||
:class="{
|
||||
'icon-only': isSidebarCollapsed,
|
||||
'sidebar-workspace-btn--active': isProviderWorkspace,
|
||||
}"
|
||||
variant="text"
|
||||
:icon="isSidebarCollapsed"
|
||||
@click="openProviderWorkspace"
|
||||
>
|
||||
<v-icon
|
||||
size="20"
|
||||
class="sidebar-action-icon"
|
||||
:class="{ 'mr-2': !isSidebarCollapsed }"
|
||||
>mdi-creation</v-icon
|
||||
>
|
||||
<span v-if="!isSidebarCollapsed">{{ tm("actions.providerConfig") }}</span>
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
class="new-chat-btn"
|
||||
:class="{ 'icon-only': isSidebarCollapsed }"
|
||||
@@ -66,7 +86,7 @@
|
||||
v-for="session in sessions"
|
||||
:key="session.session_id"
|
||||
class="session-item"
|
||||
:class="{ active: currSessionId === session.session_id }"
|
||||
:class="{ active: !isProviderWorkspace && currSessionId === session.session_id }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="selectSession(session.session_id)"
|
||||
@@ -243,19 +263,6 @@
|
||||
</v-card>
|
||||
</v-menu>
|
||||
|
||||
<v-list-item
|
||||
class="styled-menu-item"
|
||||
rounded="md"
|
||||
@click="providerDialog = true"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon size="18">mdi-robot-outline</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{
|
||||
tm("actions.providerConfig")
|
||||
}}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item
|
||||
class="styled-menu-item"
|
||||
rounded="md"
|
||||
@@ -278,12 +285,19 @@
|
||||
<main
|
||||
class="chat-main"
|
||||
:class="{
|
||||
'empty-chat':
|
||||
'empty-chat': !isProviderWorkspace &&
|
||||
!selectedProject && !loadingMessages && !activeMessages.length,
|
||||
}"
|
||||
>
|
||||
<section v-if="isProviderWorkspace" class="provider-workspace-shell">
|
||||
<ProviderChatCompletionPanel
|
||||
class="provider-workspace-page"
|
||||
:show-border="false"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<ProjectView
|
||||
v-if="selectedProject"
|
||||
v-else-if="selectedProject"
|
||||
:project="selectedProject"
|
||||
:sessions="projectSessions"
|
||||
@select-session="selectProjectSession"
|
||||
@@ -424,7 +438,6 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ProviderConfigDialog v-model="providerDialog" />
|
||||
<ProjectDialog
|
||||
v-model="projectDialogOpen"
|
||||
:project="editingProject"
|
||||
@@ -492,7 +505,6 @@ import { useRoute, useRouter } from "vue-router";
|
||||
import { useDisplay } from "vuetify";
|
||||
import axios from "axios";
|
||||
import StyledMenu from "@/components/shared/StyledMenu.vue";
|
||||
import ProviderConfigDialog from "@/components/chat/ProviderConfigDialog.vue";
|
||||
import ProjectDialog, {
|
||||
type ProjectFormData,
|
||||
} from "@/components/chat/ProjectDialog.vue";
|
||||
@@ -516,6 +528,7 @@ import {
|
||||
import { useMediaHandling } from "@/composables/useMediaHandling";
|
||||
import { useProjects } from "@/composables/useProjects";
|
||||
import { useCustomizerStore } from "@/stores/customizer";
|
||||
import ProviderChatCompletionPanel from "@/components/provider/ProviderChatCompletionPanel.vue";
|
||||
import {
|
||||
useI18n,
|
||||
useLanguageSwitcher,
|
||||
@@ -525,8 +538,9 @@ import type { Locale } from "@/i18n/types";
|
||||
import { askForConfirmation, useConfirmDialog } from "@/utils/confirmDialog";
|
||||
import { useToast } from "@/utils/toast";
|
||||
|
||||
const props = withDefaults(defineProps<{ chatboxMode?: boolean }>(), {
|
||||
const props = withDefaults(defineProps<{ chatboxMode?: boolean; active?: boolean }>(), {
|
||||
chatboxMode: false,
|
||||
active: true,
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
@@ -574,8 +588,10 @@ const {
|
||||
cleanupMediaCache,
|
||||
} = useMediaHandling();
|
||||
|
||||
type WorkspaceView = "chat" | "providers";
|
||||
|
||||
const sidebarCollapsed = ref(false);
|
||||
const providerDialog = ref(false);
|
||||
const activeWorkspace = ref<WorkspaceView>("chat");
|
||||
const projectDialogOpen = ref(false);
|
||||
const editingProject = ref<Project | null>(null);
|
||||
const sessionTitleDialogOpen = ref(false);
|
||||
@@ -630,6 +646,9 @@ const chatSidebarDrawer = computed({
|
||||
const isSidebarCollapsed = computed(() =>
|
||||
lgAndUp.value ? sidebarCollapsed.value : !customizer.chatSidebarOpen,
|
||||
);
|
||||
const isProviderWorkspace = computed(
|
||||
() => activeWorkspace.value === "providers",
|
||||
);
|
||||
const activeReasoningParts = computed<MessagePart[]>(() => {
|
||||
if (!activeReasoningTarget.value) return [];
|
||||
const blocks = buildMessageBlocks(
|
||||
@@ -734,7 +753,9 @@ onMounted(async () => {
|
||||
try {
|
||||
await Promise.all([getSessions(), getProjects()]);
|
||||
const routeSessionId = getRouteSessionId();
|
||||
if (routeSessionId) {
|
||||
if (routeSessionId === "models") {
|
||||
activeWorkspace.value = "providers";
|
||||
} else if (routeSessionId) {
|
||||
await selectSession(routeSessionId, false);
|
||||
}
|
||||
} finally {
|
||||
@@ -750,10 +771,16 @@ watch(
|
||||
() => route.params.conversationId,
|
||||
async () => {
|
||||
const routeSessionId = getRouteSessionId();
|
||||
if (routeSessionId === "models") {
|
||||
activeWorkspace.value = "providers";
|
||||
return;
|
||||
}
|
||||
if (routeSessionId && routeSessionId !== currSessionId.value) {
|
||||
showChatWorkspace();
|
||||
selectedProjectId.value = null;
|
||||
await selectSession(routeSessionId, false);
|
||||
} else if (!routeSessionId && currSessionId.value) {
|
||||
showChatWorkspace();
|
||||
currSessionId.value = "";
|
||||
}
|
||||
},
|
||||
@@ -780,11 +807,36 @@ function closeMobileSidebar() {
|
||||
}
|
||||
}
|
||||
|
||||
function closeSecondaryPanels() {
|
||||
threadSelection.visible = false;
|
||||
threadPanelOpen.value = false;
|
||||
activeThread.value = null;
|
||||
reasoningPanelOpen.value = false;
|
||||
activeReasoningTarget.value = null;
|
||||
refsSidebarOpen.value = false;
|
||||
selectedRefs.value = null;
|
||||
}
|
||||
|
||||
function showChatWorkspace() {
|
||||
activeWorkspace.value = "chat";
|
||||
}
|
||||
|
||||
async function openProviderWorkspace() {
|
||||
closeSecondaryPanels();
|
||||
activeWorkspace.value = "providers";
|
||||
const targetPath = `${basePath()}/models`;
|
||||
if (route.path !== targetPath) {
|
||||
await router.push(targetPath);
|
||||
}
|
||||
closeMobileSidebar();
|
||||
}
|
||||
|
||||
function sessionTitle(session: Session) {
|
||||
return session.display_name?.trim() || tm("conversation.newConversation");
|
||||
}
|
||||
|
||||
async function startNewChat() {
|
||||
showChatWorkspace();
|
||||
selectedProjectId.value = null;
|
||||
replyTarget.value = null;
|
||||
newChat();
|
||||
@@ -802,6 +854,7 @@ function openEditProjectDialog(project: Project) {
|
||||
}
|
||||
|
||||
async function selectProject(projectId: string) {
|
||||
showChatWorkspace();
|
||||
selectedProjectId.value = projectId;
|
||||
currSessionId.value = "";
|
||||
replyTarget.value = null;
|
||||
@@ -910,6 +963,7 @@ async function saveProject(formData: ProjectFormData, projectId?: string) {
|
||||
}
|
||||
|
||||
async function selectSession(sessionId: string, pushRoute = true) {
|
||||
showChatWorkspace();
|
||||
selectedProjectId.value = null;
|
||||
currSessionId.value = sessionId;
|
||||
replyTarget.value = null;
|
||||
@@ -1375,6 +1429,10 @@ function toggleTheme() {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-provider-btn {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.new-chat-btn:not(.icon-only),
|
||||
.settings-btn:not(.icon-only) {
|
||||
padding-inline: 12px;
|
||||
@@ -1400,6 +1458,11 @@ function toggleTheme() {
|
||||
background: var(--chat-session-active-bg);
|
||||
}
|
||||
|
||||
.sidebar-workspace-btn--active {
|
||||
background: var(--chat-session-active-bg);
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
|
||||
.chevron-collapsed {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
@@ -1512,6 +1575,17 @@ function toggleTheme() {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.provider-workspace-shell {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.provider-workspace-page {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.messages-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
@@ -293,6 +293,15 @@
|
||||
/>
|
||||
</template>
|
||||
<v-card class="stats-card" elevation="4">
|
||||
<div
|
||||
v-if="cachedInputTokens(messageContent(msg).agentStats) > 0"
|
||||
class="stats-row"
|
||||
>
|
||||
<span>{{ tm("stats.cachedTokens") }}</span>
|
||||
<strong>{{
|
||||
cachedInputTokens(messageContent(msg).agentStats)
|
||||
}}</strong>
|
||||
</div>
|
||||
<div class="stats-row">
|
||||
<span>{{ tm("stats.inputTokens") }}</span>
|
||||
<strong>{{ inputTokens(messageContent(msg).agentStats) }}</strong>
|
||||
@@ -407,6 +416,7 @@ import type {
|
||||
MessagePart,
|
||||
} from "@/composables/useMessages";
|
||||
import { useI18n, useModuleI18n } from "@/i18n/composables";
|
||||
import { copyToClipboard } from "@/utils/clipboard";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -809,7 +819,7 @@ function toolCallStatusText(tool: Record<string, unknown>) {
|
||||
async function copyMessage(message: ChatRecord) {
|
||||
const text = plainTextFromMessage(message);
|
||||
if (!text) return;
|
||||
await navigator.clipboard?.writeText(text);
|
||||
await copyToClipboard(text);
|
||||
}
|
||||
|
||||
async function downloadPart(part: MessagePart) {
|
||||
@@ -849,13 +859,17 @@ function formatTime(value: string) {
|
||||
|
||||
function inputTokens(stats: any) {
|
||||
const usage = stats?.token_usage || {};
|
||||
return (usage.input_other || 0) + (usage.input_cached || 0);
|
||||
return usage.input_other || 0;
|
||||
}
|
||||
|
||||
function outputTokens(stats: any) {
|
||||
return stats?.token_usage?.output || 0;
|
||||
}
|
||||
|
||||
function cachedInputTokens(stats: any) {
|
||||
return stats?.token_usage?.input_cached || 0;
|
||||
}
|
||||
|
||||
function agentDuration(stats: any) {
|
||||
const directDuration = readPositiveNumber(stats, [
|
||||
"duration",
|
||||
|
||||
@@ -185,6 +185,15 @@
|
||||
/>
|
||||
</template>
|
||||
<v-card class="stats-card" elevation="4">
|
||||
<div
|
||||
v-if="cachedInputTokens(messageContent(msg).agentStats) > 0"
|
||||
class="stats-row"
|
||||
>
|
||||
<span>{{ tm("stats.cachedTokens") }}</span>
|
||||
<strong>{{
|
||||
cachedInputTokens(messageContent(msg).agentStats)
|
||||
}}</strong>
|
||||
</div>
|
||||
<div class="stats-row">
|
||||
<span>{{ tm("stats.inputTokens") }}</span>
|
||||
<strong>{{
|
||||
@@ -268,6 +277,7 @@ import type {
|
||||
MessagePart,
|
||||
} from "@/composables/useMessages";
|
||||
import { useModuleI18n } from "@/i18n/composables";
|
||||
import { copyToClipboard } from "@/utils/clipboard";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -470,7 +480,7 @@ function parseJsonSafe(value: unknown) {
|
||||
async function copyMessage(message: ChatRecord) {
|
||||
const text = plainTextFromMessage(message);
|
||||
if (!text) return;
|
||||
await navigator.clipboard?.writeText(text);
|
||||
await copyToClipboard(text, { container: messageListRoot.value });
|
||||
}
|
||||
|
||||
async function downloadPart(part: MessagePart) {
|
||||
@@ -511,13 +521,17 @@ function formatTime(value: string) {
|
||||
|
||||
function inputTokens(stats: any) {
|
||||
const usage = stats?.token_usage || {};
|
||||
return (usage.input_other || 0) + (usage.input_cached || 0);
|
||||
return usage.input_other || 0;
|
||||
}
|
||||
|
||||
function outputTokens(stats: any) {
|
||||
return stats?.token_usage?.output || 0;
|
||||
}
|
||||
|
||||
function cachedInputTokens(stats: any) {
|
||||
return stats?.token_usage?.input_cached || 0;
|
||||
}
|
||||
|
||||
function agentDuration(stats: any) {
|
||||
const directDuration = readPositiveNumber(stats, [
|
||||
"duration",
|
||||
|
||||
@@ -303,7 +303,7 @@ export default {
|
||||
part.tool_calls.forEach(toolCall => {
|
||||
// 检查是否是支持引用解析的 web_search 工具调用
|
||||
if (
|
||||
!['web_search_baidu', 'web_search_tavily', 'web_search_bocha', 'web_search_brave'].includes(toolCall.name) ||
|
||||
!['web_search_baidu', 'web_search_tavily', 'web_search_bocha', 'web_search_brave', 'web_search_firecrawl'].includes(toolCall.name) ||
|
||||
!toolCall.result
|
||||
) {
|
||||
return;
|
||||
|
||||
@@ -1,138 +1,22 @@
|
||||
<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-audio-input="supportsAudioInput"
|
||||
: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-dialog
|
||||
v-model="dialog"
|
||||
max-width="1600"
|
||||
>
|
||||
<v-card class="provider-config-dialog">
|
||||
<div class="provider-config-dialog__body">
|
||||
<ProviderChatCompletionPanel
|
||||
class="provider-config-dialog__page"
|
||||
:show-border="false"
|
||||
/>
|
||||
</div>
|
||||
</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'
|
||||
import { computed } from 'vue'
|
||||
import ProviderChatCompletionPanel from '@/components/provider/ProviderChatCompletionPanel.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
@@ -142,236 +26,73 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
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,
|
||||
supportsAudioInput,
|
||||
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;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
gap: 16px;
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border-radius: 28px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
.provider-config-dialog__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-width: 100% !important;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.provider-config-dialog :deep(.v-card-title) {
|
||||
padding: 12px 16px !important;
|
||||
.provider-config-dialog__page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:deep(.v-overlay__content) {
|
||||
width: min(1600px, 70vw);
|
||||
height: min(920px, 70dvh);
|
||||
max-width: 70vw;
|
||||
max-height: 70dvh;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.provider-config-dialog {
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.provider-config-dialog :deep(.v-card-title .text-h2) {
|
||||
font-size: 1.5rem !important;
|
||||
:deep(.v-overlay__content) {
|
||||
width: calc(100dvw - 24px);
|
||||
height: calc(100dvh - 24px);
|
||||
max-width: calc(100dvw - 24px);
|
||||
max-height: calc(100dvh - 24px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.provider-config-dialog {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.provider-config-dialog__body {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
:deep(.v-overlay__content) {
|
||||
width: 100dvw;
|
||||
height: 100dvh;
|
||||
max-width: 100dvw;
|
||||
max-height: 100dvh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
<template>
|
||||
<div class="provider-chat-panel">
|
||||
<div
|
||||
class="provider-workbench"
|
||||
:class="{ 'provider-workbench--borderless': !props.showBorder }"
|
||||
>
|
||||
<div class="provider-workbench__sidebar">
|
||||
<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-workbench__divider"></div>
|
||||
|
||||
<div class="provider-workbench__main">
|
||||
<div v-if="selectedProviderSource" class="provider-config-shell">
|
||||
<div class="provider-config-header">
|
||||
<div class="provider-config-headline">
|
||||
<div class="provider-config-title">{{ selectedProviderSource.id }}</div>
|
||||
<div class="provider-config-subtitle">
|
||||
{{ selectedProviderSource.api_base || 'N/A' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="provider-config-actions">
|
||||
<v-btn
|
||||
color="primary"
|
||||
prepend-icon="mdi-content-save-outline"
|
||||
:loading="savingSource"
|
||||
:disabled="!isSourceModified"
|
||||
variant="tonal"
|
||||
rounded="xl"
|
||||
@click="saveProviderSource"
|
||||
>
|
||||
{{ tm('providerSources.save') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<div class="provider-config-body">
|
||||
<section class="provider-section">
|
||||
<div class="provider-section-head">
|
||||
<div class="provider-section-title">{{ tm('providers.settings') }}</div>
|
||||
</div>
|
||||
<AstrBotConfig
|
||||
v-if="basicSourceConfig"
|
||||
:iterable="basicSourceConfig"
|
||||
:metadata="providerSourceSchema"
|
||||
metadataKey="provider"
|
||||
:is-editing="true"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<v-divider v-if="advancedSourceConfig"></v-divider>
|
||||
|
||||
<section v-if="advancedSourceConfig" class="provider-section">
|
||||
<div class="provider-section-head">
|
||||
<div class="provider-section-title">{{ tm('providerSources.advancedConfig') }}</div>
|
||||
</div>
|
||||
<AstrBotConfig
|
||||
:iterable="advancedSourceConfig"
|
||||
:metadata="providerSourceSchema"
|
||||
metadataKey="provider"
|
||||
:is-editing="true"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<section class="provider-section provider-section--models">
|
||||
<ProviderModelsPanel
|
||||
:entries="filteredMergedModelEntries"
|
||||
:available-count="availableModels.length"
|
||||
v-model:model-search="modelSearch"
|
||||
:loading-models="loadingModels"
|
||||
:is-source-modified="isSourceModified"
|
||||
:supports-image-input="supportsImageInput"
|
||||
:supports-audio-input="supportsAudioInput"
|
||||
: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"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="provider-empty-state">
|
||||
<v-icon size="48" color="grey-lighten-1">mdi-cursor-default-click</v-icon>
|
||||
<p class="mt-2">{{ tm('providerSources.selectHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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,可能会导致指向该模型的相关配置(如默认模型、插件相关配置等)失效。旧版本 AstrBot 的 “提供商 ID” 是下方的 “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"
|
||||
:disabled="savingProviders.includes(providerEditData?.id)"
|
||||
@click="showProviderEditDialog = false"
|
||||
>
|
||||
{{ tm('dialogs.config.cancel') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
:loading="savingProviders.includes(providerEditData?.id)"
|
||||
@click="saveEditedProvider"
|
||||
>
|
||||
{{ tm('dialogs.config.save') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="3000" location="top">
|
||||
{{ snackbar.message }}
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import axios from 'axios'
|
||||
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'
|
||||
|
||||
const props = defineProps({
|
||||
showBorder: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const { tm } = useModuleI18n('features/provider')
|
||||
|
||||
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,
|
||||
providerSourceSchema,
|
||||
manualModelId,
|
||||
modelSearch,
|
||||
availableSourceTypes,
|
||||
displayedProviderSources,
|
||||
filteredMergedModelEntries,
|
||||
basicSourceConfig,
|
||||
advancedSourceConfig,
|
||||
manualProviderId,
|
||||
resolveSourceIcon,
|
||||
getSourceDisplayName,
|
||||
supportsImageInput,
|
||||
supportsAudioInput,
|
||||
supportsToolCall,
|
||||
supportsReasoning,
|
||||
formatContextLimit,
|
||||
selectProviderSource,
|
||||
addProviderSource,
|
||||
deleteProviderSource,
|
||||
saveProviderSource,
|
||||
fetchAvailableModels,
|
||||
addModelProvider,
|
||||
deleteProvider,
|
||||
testProvider,
|
||||
toggleProviderEnable,
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.provider-chat-panel {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.provider-workbench {
|
||||
flex: 1;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
border-radius: 24px;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 320px) 1px minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.provider-workbench--borderless {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.provider-workbench__sidebar,
|
||||
.provider-workbench__main {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.provider-workbench__sidebar,
|
||||
.provider-workbench__main,
|
||||
.provider-workbench__divider {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.provider-workbench__divider {
|
||||
background: rgba(var(--v-theme-on-surface), 0.08);
|
||||
}
|
||||
|
||||
.provider-workbench__main {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.provider-config-shell {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.provider-config-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
padding: 18px 22px 14px;
|
||||
}
|
||||
|
||||
.provider-config-headline {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.provider-config-title {
|
||||
font-size: 21px;
|
||||
line-height: 1.1;
|
||||
font-weight: 680;
|
||||
letter-spacing: -0.03em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.provider-config-subtitle {
|
||||
margin-top: 6px;
|
||||
color: rgba(var(--v-theme-on-surface), 0.62);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.provider-config-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.provider-config-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.provider-section {
|
||||
padding: 18px 22px;
|
||||
}
|
||||
|
||||
.provider-section--models {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.provider-section-head {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.provider-section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.provider-empty-state {
|
||||
flex: 1;
|
||||
min-height: 420px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(var(--v-theme-on-surface), 0.56);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.provider-workbench {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto 1px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.provider-workbench__sidebar,
|
||||
.provider-workbench__main,
|
||||
.provider-workbench__divider {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.provider-workbench__divider {
|
||||
min-height: 1px;
|
||||
}
|
||||
|
||||
.provider-config-header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.provider-config-actions :deep(.v-btn) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.provider-section {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.provider-chat-panel {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.provider-workbench {
|
||||
border-radius: 16px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.provider-workbench--borderless {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.provider-workbench__main {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.provider-config-body {
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
.provider-config-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.provider-empty-state {
|
||||
min-height: 260px;
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,37 +1,40 @@
|
||||
<template>
|
||||
<div class="provider-models-panel">
|
||||
<div class="provider-models-head">
|
||||
<div class="provider-models-toolbar">
|
||||
<div class="provider-models-title-wrap">
|
||||
<h3 class="provider-models-title">{{ tm('models.configured') }}</h3>
|
||||
<small v-if="availableCount" class="provider-models-subtitle">{{ tm('models.available') }} {{ availableCount }}</small>
|
||||
<h3 class="provider-models-title">{{ tm('models.title') }}</h3>
|
||||
<small class="provider-models-subtitle">{{ tm('models.available') }} {{ availableCount }}</small>
|
||||
</div>
|
||||
<v-text-field
|
||||
v-model="modelSearchProxy"
|
||||
density="compact"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
clearable
|
||||
hide-details
|
||||
variant="solo-filled"
|
||||
flat
|
||||
class="provider-models-search"
|
||||
:placeholder="tm('models.searchPlaceholder')"
|
||||
/>
|
||||
<div class="provider-models-actions">
|
||||
|
||||
<div class="provider-models-toolbar__actions">
|
||||
<v-text-field
|
||||
v-model="modelSearchProxy"
|
||||
density="compact"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
clearable
|
||||
hide-details
|
||||
variant="solo-filled"
|
||||
flat
|
||||
class="provider-models-search"
|
||||
:placeholder="tm('models.searchPlaceholder')"
|
||||
/>
|
||||
|
||||
<v-btn
|
||||
color="primary"
|
||||
prepend-icon="mdi-download"
|
||||
:loading="loadingModels"
|
||||
@click="emit('fetch-models')"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
rounded="xl"
|
||||
@click="emit('fetch-models')"
|
||||
>
|
||||
{{ isSourceModified ? tm('providerSources.saveAndFetchModels') : tm('providerSources.fetchModels') }}
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
color="primary"
|
||||
prepend-icon="mdi-pencil-plus"
|
||||
variant="text"
|
||||
size="small"
|
||||
rounded="xl"
|
||||
@click="emit('open-manual-model')"
|
||||
>
|
||||
{{ tm('models.manualAddButton') }}
|
||||
@@ -39,130 +42,152 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-list
|
||||
density="compact"
|
||||
class="provider-models-list"
|
||||
>
|
||||
<template v-if="entries.length > 0">
|
||||
<template v-for="entry in entries" :key="entry.type === 'configured' ? `provider-${entry.provider.id}` : `model-${entry.model}`">
|
||||
<v-tooltip location="top" max-width="400" v-if="entry.type === 'configured'">
|
||||
<template #activator="{ props }">
|
||||
<v-list-item
|
||||
v-bind="props"
|
||||
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="provider-model-subtitle d-flex align-center ga-1">
|
||||
<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="supportsAudioInput(entry.metadata)" size="14" color="grey">
|
||||
mdi-music-note-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-connection"
|
||||
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>
|
||||
<div class="provider-models-sections">
|
||||
<section class="provider-models-section">
|
||||
<div class="provider-models-section__head">
|
||||
<div class="provider-models-section__title">{{ tm('models.configured') }}</div>
|
||||
<v-chip size="x-small" variant="tonal" label>{{ configuredEntries.length }}</v-chip>
|
||||
</div>
|
||||
|
||||
<v-tooltip location="top" max-width="300">
|
||||
{{ tm('models.configure') }}
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
icon="mdi-cog"
|
||||
size="small"
|
||||
variant="text"
|
||||
v-bind="props"
|
||||
@click.stop="emit('open-provider-edit', entry.provider)"
|
||||
></v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<div v-if="configuredEntries.length" class="provider-models-list">
|
||||
<v-tooltip
|
||||
v-for="entry in configuredEntries"
|
||||
:key="entry.provider.id"
|
||||
location="top"
|
||||
max-width="400"
|
||||
>
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<div v-bind="tooltipProps" class="provider-model-row">
|
||||
<button
|
||||
type="button"
|
||||
class="provider-model-row__main"
|
||||
@click="emit('open-provider-edit', entry.provider)"
|
||||
>
|
||||
<div class="provider-model-row__title">{{ entry.provider.id }}</div>
|
||||
<div class="provider-model-row__subtitle">{{ entry.provider.model }}</div>
|
||||
<div class="provider-model-row__meta">
|
||||
<span
|
||||
v-for="item in capabilityIcons(entry.metadata)"
|
||||
:key="item.icon"
|
||||
class="provider-model-row__badge"
|
||||
>
|
||||
<v-icon size="14">{{ item.icon }}</v-icon>
|
||||
</span>
|
||||
<span
|
||||
v-if="formatContextLimit(entry.metadata)"
|
||||
class="provider-model-row__badge provider-model-row__badge--text"
|
||||
>
|
||||
{{ formatContextLimit(entry.metadata) }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<v-btn icon="mdi-delete" size="small" variant="text" color="error" @click.stop="emit('delete-provider', entry.provider)"></v-btn>
|
||||
<div class="provider-model-row__actions" @click.stop>
|
||||
<v-switch
|
||||
v-model="entry.provider.enable"
|
||||
density="compact"
|
||||
inset
|
||||
hide-details
|
||||
color="primary"
|
||||
class="provider-model-row__switch"
|
||||
@update:modelValue="emit('toggle-provider-enable', entry.provider, $event)"
|
||||
></v-switch>
|
||||
|
||||
<v-btn
|
||||
icon="mdi-connection"
|
||||
size="small"
|
||||
variant="text"
|
||||
:disabled="!entry.provider.enable"
|
||||
:loading="isProviderTesting(entry.provider.id)"
|
||||
@click.stop="emit('test-provider', entry.provider)"
|
||||
></v-btn>
|
||||
<v-btn
|
||||
icon="mdi-cog-outline"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click.stop="emit('open-provider-edit', entry.provider)"
|
||||
></v-btn>
|
||||
<v-btn
|
||||
icon="mdi-delete-outline"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click.stop="emit('delete-provider', entry.provider)"
|
||||
></v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
<div>
|
||||
<div><strong>{{ tm('models.tooltips.providerId') }}:</strong> {{ entry.provider.id }}</div>
|
||||
<div><strong>{{ tm('models.tooltips.modelId') }}:</strong> {{ entry.provider.model }}</div>
|
||||
</div>
|
||||
<div><strong>{{ tm('models.tooltips.providerId') }}:</strong> {{ entry.provider.id }}</div>
|
||||
<div><strong>{{ tm('models.tooltips.modelId') }}:</strong> {{ entry.provider.model }}</div>
|
||||
</v-tooltip>
|
||||
|
||||
<v-tooltip location="top" max-width="400" v-else>
|
||||
<template #activator="{ props }">
|
||||
<v-list-item v-bind="props" 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="provider-model-subtitle 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="supportsAudioInput(entry.metadata)" size="14" color="grey">
|
||||
mdi-music-note-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>
|
||||
<div>
|
||||
<div><strong>{{ tm('models.tooltips.modelId') }}:</strong> {{ entry.model }}</div>
|
||||
</div>
|
||||
</v-tooltip>
|
||||
</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 v-else class="provider-models-empty">
|
||||
<v-icon size="36" color="grey-lighten-1">mdi-package-variant-closed</v-icon>
|
||||
<p>{{ tm('models.empty') }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<section class="provider-models-section provider-models-section--available">
|
||||
<div class="provider-models-section__head">
|
||||
<div class="provider-models-section__title">{{ tm('models.available') }}</div>
|
||||
<v-chip size="x-small" variant="tonal" label>{{ availableEntries.length }}</v-chip>
|
||||
</div>
|
||||
|
||||
<div v-if="availableEntries.length" class="provider-models-list">
|
||||
<v-tooltip
|
||||
v-for="entry in availableEntries"
|
||||
:key="entry.model"
|
||||
location="top"
|
||||
max-width="400"
|
||||
>
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<div v-bind="tooltipProps" class="provider-model-row">
|
||||
<button
|
||||
type="button"
|
||||
class="provider-model-row__main"
|
||||
@click="emit('add-model-provider', entry.model)"
|
||||
>
|
||||
<div class="provider-model-row__title provider-model-row__title--mono">{{ entry.model }}</div>
|
||||
<div class="provider-model-row__meta">
|
||||
<span
|
||||
v-for="item in capabilityIcons(entry.metadata)"
|
||||
:key="item.icon"
|
||||
class="provider-model-row__badge"
|
||||
>
|
||||
<v-icon size="14">{{ item.icon }}</v-icon>
|
||||
</span>
|
||||
<span
|
||||
v-if="formatContextLimit(entry.metadata)"
|
||||
class="provider-model-row__badge provider-model-row__badge--text"
|
||||
>
|
||||
{{ formatContextLimit(entry.metadata) }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="provider-model-row__actions">
|
||||
<v-btn
|
||||
icon="mdi-plus"
|
||||
size="small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
@click.stop="emit('add-model-provider', entry.model)"
|
||||
></v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div><strong>{{ tm('models.tooltips.modelId') }}:</strong> {{ entry.model }}</div>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
|
||||
<div v-else class="provider-models-empty provider-models-empty--small">
|
||||
<v-icon size="36" color="grey-lighten-1">mdi-database-search-outline</v-icon>
|
||||
<p>{{ tm('models.noModelsFound') }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -237,91 +262,266 @@ const modelSearchProxy = computed({
|
||||
set: (val) => emit('update:modelSearch', normalizeTextInput(val))
|
||||
})
|
||||
|
||||
const configuredEntries = computed(() =>
|
||||
(props.entries || []).filter((entry) => entry.type === 'configured')
|
||||
)
|
||||
|
||||
const availableEntries = computed(() =>
|
||||
(props.entries || []).filter((entry) => entry.type === 'available')
|
||||
)
|
||||
|
||||
const capabilityIcons = (metadata) => {
|
||||
const icons = []
|
||||
if (props.supportsImageInput(metadata)) {
|
||||
icons.push({ icon: 'mdi-image-outline' })
|
||||
}
|
||||
if (props.supportsAudioInput(metadata)) {
|
||||
icons.push({ icon: 'mdi-music-note-outline' })
|
||||
}
|
||||
if (props.supportsToolCall(metadata)) {
|
||||
icons.push({ icon: 'mdi-wrench-outline' })
|
||||
}
|
||||
if (props.supportsReasoning(metadata)) {
|
||||
icons.push({ icon: 'mdi-brain' })
|
||||
}
|
||||
return icons
|
||||
}
|
||||
|
||||
const isProviderTesting = (providerId) => props.testingProviders.includes(providerId)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.provider-models-panel {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.provider-models-head {
|
||||
.provider-models-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.provider-models-title-wrap {
|
||||
min-width: 0;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.provider-models-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
line-height: 1.3;
|
||||
font-weight: 650;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.provider-models-title-wrap {
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.provider-models-subtitle {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: rgba(var(--v-theme-on-surface), 0.6);
|
||||
color: rgba(var(--v-theme-on-surface), 0.56);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.provider-models-search {
|
||||
max-width: 240px;
|
||||
.provider-models-toolbar__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.provider-models-actions {
|
||||
margin-left: auto;
|
||||
.provider-models-search {
|
||||
flex: 0 1 240px;
|
||||
min-width: 180px;
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.provider-models-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.provider-models-section {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.provider-models-section--available {
|
||||
padding-top: 22px;
|
||||
}
|
||||
|
||||
.provider-models-section__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.provider-models-section__title {
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.provider-models-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.provider-model-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.06);
|
||||
}
|
||||
|
||||
.provider-model-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.provider-model-row__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.provider-model-row__title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.provider-model-row__title--mono {
|
||||
font-family:
|
||||
ui-monospace,
|
||||
SFMono-Regular,
|
||||
Menlo,
|
||||
Monaco,
|
||||
Consolas,
|
||||
"Liberation Mono",
|
||||
"Courier New",
|
||||
monospace;
|
||||
}
|
||||
|
||||
.provider-model-row__subtitle {
|
||||
margin-top: 4px;
|
||||
color: rgba(var(--v-theme-on-surface), 0.56);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.provider-model-row__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.provider-models-list {
|
||||
max-height: 520px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
|
||||
border-radius: 14px;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
.provider-model-row__badge {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.04);
|
||||
color: rgba(var(--v-theme-on-surface), 0.58);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.provider-compact-item {
|
||||
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||
.provider-model-row__badge--text {
|
||||
width: auto;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.provider-models-list :deep(.v-list-item:last-child) {
|
||||
border-bottom: 0;
|
||||
.provider-model-row__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.provider-model-subtitle {
|
||||
color: rgba(var(--v-theme-on-surface), 0.62);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
.provider-model-row__switch {
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
.provider-models-empty {
|
||||
min-height: 160px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: rgba(var(--v-theme-on-surface), 0.56);
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.provider-models-head {
|
||||
.provider-models-empty--small {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.provider-models-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.provider-models-title-wrap {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.provider-models-toolbar__actions {
|
||||
align-items: stretch;
|
||||
justify-content: stretch;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.provider-models-search {
|
||||
flex: 1 1 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.provider-models-actions {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
.provider-models-toolbar__actions :deep(.v-btn) {
|
||||
flex: 1 1 160px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.provider-models-toolbar__actions :deep(.v-btn__content) {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.provider-models-panel {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.provider-model-row {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px 0;
|
||||
}
|
||||
|
||||
.provider-model-row__actions {
|
||||
align-self: flex-end;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,125 +1,150 @@
|
||||
<template>
|
||||
<v-card class="provider-sources-panel h-100" elevation="0">
|
||||
<div class="provider-sources-panel">
|
||||
<div class="provider-sources-head">
|
||||
<div class="provider-sources-title-wrap">
|
||||
<div class="provider-sources-title-row">
|
||||
<h3 class="provider-sources-title">{{ tm('providerSources.title') }}</h3>
|
||||
<v-chip size="x-small" variant="tonal" label>
|
||||
{{ displayedProviderSources.length }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<div class="provider-sources-head__copy">
|
||||
<h3 class="provider-sources-title">{{ tm('providerSources.title') }}</h3>
|
||||
</div>
|
||||
<StyledMenu>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
prepend-icon="mdi-plus"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
>
|
||||
{{ tm('providerSources.add') }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list-item
|
||||
v-for="sourceType in availableSourceTypes"
|
||||
:key="sourceType.value"
|
||||
class="styled-menu-item"
|
||||
@click="emitAddSource(sourceType.value)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-avatar size="18" rounded="0" class="me-2">
|
||||
<v-img v-if="sourceType.icon" :src="sourceType.icon" alt="provider icon" cover></v-img>
|
||||
<v-icon v-else size="16">mdi-shape-outline</v-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
<v-list-item-title>{{ sourceType.label }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</StyledMenu>
|
||||
</div>
|
||||
|
||||
<div v-if="isMobile && displayedProviderSources.length > 0" class="provider-sources-mobile">
|
||||
<div class="d-flex align-center ga-2">
|
||||
<v-select
|
||||
:model-value="selectedId"
|
||||
:items="mobileSourceItems"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
:label="tm('providerSources.selectCreated')"
|
||||
variant="solo-filled"
|
||||
density="comfortable"
|
||||
flat
|
||||
hide-details
|
||||
class="mobile-source-select"
|
||||
@update:model-value="onMobileSourceChange"
|
||||
<div class="provider-sources-controls">
|
||||
<div class="provider-sources-mobile-select">
|
||||
<v-select
|
||||
:model-value="selectedSourceValue"
|
||||
:items="sourceOptions"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
density="compact"
|
||||
variant="solo-filled"
|
||||
flat
|
||||
hide-details
|
||||
:placeholder="tm('providerSources.selectHint')"
|
||||
@update:model-value="selectSourceByValue"
|
||||
>
|
||||
<template #item="{ props: itemProps, item }">
|
||||
<v-list-item v-bind="itemProps">
|
||||
<template #prepend>
|
||||
<v-avatar size="18" rounded="0" class="me-2">
|
||||
<v-img v-if="item.raw.icon" :src="item.raw.icon" alt="provider icon" cover></v-img>
|
||||
<v-icon v-else size="16">mdi-shape-outline</v-icon>
|
||||
<template #selection="{ item }">
|
||||
<div class="provider-source-select-value">
|
||||
<v-avatar size="22" rounded="lg" class="provider-source-avatar">
|
||||
<v-img
|
||||
v-if="item.raw.source?.provider"
|
||||
:src="resolveSourceIcon(item.raw.source)"
|
||||
alt="provider logo"
|
||||
cover
|
||||
></v-img>
|
||||
<v-icon v-else size="14">mdi-creation</v-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<span>{{ item.raw.title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #item="{ props: itemProps, item }">
|
||||
<v-list-item
|
||||
v-bind="itemProps"
|
||||
:subtitle="item.raw.subtitle"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-avatar size="24" rounded="lg" class="provider-source-avatar me-2">
|
||||
<v-img
|
||||
v-if="item.raw.source?.provider"
|
||||
:src="resolveSourceIcon(item.raw.source)"
|
||||
alt="provider logo"
|
||||
cover
|
||||
></v-img>
|
||||
<v-icon v-else size="14">mdi-creation</v-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-select>
|
||||
</div>
|
||||
|
||||
<StyledMenu>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
prepend-icon="mdi-plus"
|
||||
color="primary"
|
||||
variant="text"
|
||||
size="small"
|
||||
rounded="xl"
|
||||
>
|
||||
{{ tm('providerSources.add') }}
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-select>
|
||||
<v-btn
|
||||
v-if="selectedProviderSource"
|
||||
icon="mdi-delete"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="error"
|
||||
@click.stop="emitDeleteSource(selectedProviderSource)"
|
||||
></v-btn>
|
||||
|
||||
<v-list-item
|
||||
v-for="sourceType in availableSourceTypes"
|
||||
:key="sourceType.value"
|
||||
class="styled-menu-item"
|
||||
@click="emitAddSource(sourceType.value)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-avatar size="18" rounded="0" class="me-2 provider-source-avatar">
|
||||
<v-img
|
||||
v-if="sourceType.icon"
|
||||
:src="sourceType.icon"
|
||||
alt="provider icon"
|
||||
cover
|
||||
></v-img>
|
||||
<v-icon v-else size="16">mdi-shape-outline</v-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
<v-list-item-title>{{ sourceType.label }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</StyledMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="displayedProviderSources.length > 0" class="provider-sources-list-wrap">
|
||||
<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="28" class="provider-source-avatar" rounded="0">
|
||||
<v-img v-if="source?.provider" :src="resolveSourceIcon(source)" alt="logo" cover></v-img>
|
||||
<v-icon v-else size="20">mdi-creation</v-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
<v-list-item-title class="provider-source-title">{{ getSourceDisplayName(source) }}</v-list-item-title>
|
||||
<v-list-item-subtitle class="provider-source-subtitle 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"
|
||||
:ripple="false"
|
||||
@click.stop="emitDeleteSource(source)"
|
||||
></v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<div v-if="displayedProviderSources.length > 0" class="provider-sources-list">
|
||||
<button
|
||||
v-for="source in displayedProviderSources"
|
||||
:key="source.isPlaceholder ? `template-${source.templateKey}` : source.id"
|
||||
type="button"
|
||||
:class="[
|
||||
'provider-source-item',
|
||||
{
|
||||
'provider-source-item--active': isActive(source)
|
||||
}
|
||||
]"
|
||||
@click="emitSelectSource(source)"
|
||||
>
|
||||
<v-avatar size="28" rounded="lg" class="provider-source-item__avatar provider-source-avatar">
|
||||
<v-img
|
||||
v-if="source?.provider"
|
||||
:src="resolveSourceIcon(source)"
|
||||
alt="provider logo"
|
||||
cover
|
||||
></v-img>
|
||||
<v-icon v-else size="16">mdi-creation</v-icon>
|
||||
</v-avatar>
|
||||
|
||||
<div class="provider-source-item__content">
|
||||
<div class="provider-source-item__title">
|
||||
{{ getSourceDisplayName(source) }}
|
||||
</div>
|
||||
<div class="provider-source-item__subtitle">
|
||||
{{ source.api_base || sourceBadge(source) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="provider-source-item__actions">
|
||||
<v-btn
|
||||
v-if="!source.isPlaceholder"
|
||||
icon="mdi-delete-outline"
|
||||
variant="text"
|
||||
size="small"
|
||||
@click.stop="emitDeleteSource(source)"
|
||||
></v-btn>
|
||||
</div>
|
||||
</button>
|
||||
</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-else class="provider-sources-empty">
|
||||
<v-icon size="44" color="grey-lighten-1">mdi-api-off</v-icon>
|
||||
<p class="provider-sources-empty__text">{{ tm('providerSources.empty') }}</p>
|
||||
</div>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import StyledMenu from '@/components/shared/StyledMenu.vue'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -155,144 +180,217 @@ const emit = defineEmits([
|
||||
'delete-provider-source'
|
||||
])
|
||||
|
||||
const { smAndDown } = useDisplay()
|
||||
const selectedId = computed(() => props.selectedProviderSource?.id || null)
|
||||
const isMobile = computed(() => smAndDown.value)
|
||||
const mobileSourceItems = computed(() =>
|
||||
(props.displayedProviderSources || []).map((source) => ({
|
||||
value: source.id,
|
||||
label: props.getSourceDisplayName(source),
|
||||
icon: props.resolveSourceIcon(source),
|
||||
source
|
||||
}))
|
||||
)
|
||||
|
||||
const isActive = (source) => {
|
||||
if (source.isPlaceholder) return false
|
||||
return selectedId.value !== null && selectedId.value === source.id
|
||||
}
|
||||
|
||||
const onMobileSourceChange = (sourceId) => {
|
||||
const matched = mobileSourceItems.value.find((item) => item.value === sourceId)
|
||||
if (matched?.source) {
|
||||
emitSelectSource(matched.source)
|
||||
}
|
||||
}
|
||||
const sourceBadge = (source) => source.provider || source.templateKey || 'source'
|
||||
|
||||
const sourceValue = (source) => (
|
||||
source.isPlaceholder ? `template:${source.templateKey}` : `source:${source.id}`
|
||||
)
|
||||
|
||||
const sourceOptions = computed(() =>
|
||||
props.displayedProviderSources.map((source) => ({
|
||||
title: props.getSourceDisplayName(source),
|
||||
subtitle: source.api_base || sourceBadge(source),
|
||||
value: sourceValue(source),
|
||||
source
|
||||
}))
|
||||
)
|
||||
|
||||
const selectedSourceValue = computed(() => {
|
||||
if (!props.selectedProviderSource) return null
|
||||
return sourceValue(props.selectedProviderSource)
|
||||
})
|
||||
|
||||
const emitAddSource = (type) => emit('add-provider-source', type)
|
||||
const emitSelectSource = (source) => emit('select-provider-source', source)
|
||||
const emitDeleteSource = (source) => emit('delete-provider-source', source)
|
||||
|
||||
const selectSourceByValue = (value) => {
|
||||
const option = sourceOptions.value.find((item) => item.value === value)
|
||||
if (option?.source) {
|
||||
emitSelectSource(option.source)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.provider-sources-panel {
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
|
||||
border-radius: 16px;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
min-height: 320px;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.provider-sources-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 18px 18px 12px;
|
||||
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.1);
|
||||
gap: 12px;
|
||||
padding: 20px 20px 12px;
|
||||
}
|
||||
|
||||
.provider-sources-title-row {
|
||||
.provider-sources-head__copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.provider-sources-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.provider-sources-title {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
line-height: 1.2;
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.provider-sources-mobile {
|
||||
padding: 16px;
|
||||
padding: 8px 20px 16px;
|
||||
}
|
||||
|
||||
.provider-sources-list-wrap {
|
||||
padding: 8px 8px 10px;
|
||||
.provider-sources-mobile-select {
|
||||
display: none;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.provider-source-list {
|
||||
.provider-source-select-value {
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.provider-source-select-value span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.provider-sources-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
padding: 6px 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.provider-source-list-item {
|
||||
margin-bottom: 2px;
|
||||
border: 1px solid transparent;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
.provider-source-item {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.provider-source-list-item--active {
|
||||
background-color: rgba(var(--v-theme-primary), 0.06);
|
||||
border: 1px solid transparent;
|
||||
.provider-source-item:hover,
|
||||
.provider-source-item--active {
|
||||
background: rgba(var(--v-theme-on-surface), 0.05);
|
||||
}
|
||||
|
||||
.provider-source-avatar {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.provider-source-title {
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.provider-source-subtitle {
|
||||
color: rgba(var(--v-theme-on-surface), 0.62);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.provider-source-list :deep(.v-list-item__prepend) {
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
|
||||
.provider-source-list :deep(.v-list-item__content) {
|
||||
.provider-source-item__content {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.provider-source-list :deep(.v-list-item__append) {
|
||||
.provider-source-item__title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.provider-source-item__subtitle {
|
||||
margin-top: 4px;
|
||||
color: rgba(var(--v-theme-on-surface), 0.54);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.provider-source-item__actions {
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.provider-source-list-item:hover {
|
||||
background-color: rgba(var(--v-theme-on-surface), 0.025);
|
||||
}
|
||||
|
||||
.provider-source-list-item:hover :deep(.v-list-item__append),
|
||||
.provider-source-list-item--active :deep(.v-list-item__append) {
|
||||
.provider-source-item:hover .provider-source-item__actions,
|
||||
.provider-source-item--active .provider-source-item__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.provider-sources-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.provider-sources-empty__text {
|
||||
margin: 0;
|
||||
color: rgba(var(--v-theme-on-surface), 0.56);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.provider-source-list {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.provider-sources-panel {
|
||||
min-height: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.provider-sources-head {
|
||||
padding: 16px 16px 8px;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.provider-sources-mobile-select {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.provider-sources-controls {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.provider-sources-list {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.provider-sources-empty {
|
||||
min-height: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.provider-sources-controls :deep(.v-btn) {
|
||||
min-width: max-content;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.v-theme--PurpleThemeDark .provider-source-list-item--active {
|
||||
background-color: rgba(var(--v-theme-primary), 0.1);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -168,7 +168,10 @@
|
||||
</v-btn>
|
||||
</div>
|
||||
<div class="provider-drawer-content">
|
||||
<ProviderPage :default-tab="defaultTab" />
|
||||
<ProviderChatCompletionPanel
|
||||
v-if="defaultTab === 'chat_completion'"
|
||||
/>
|
||||
<ProviderPage v-else :default-tab="defaultTab" />
|
||||
</div>
|
||||
</v-card>
|
||||
</v-overlay>
|
||||
@@ -178,6 +181,7 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { useModuleI18n } from '@/i18n/composables'
|
||||
import ProviderChatCompletionPanel from '@/components/provider/ProviderChatCompletionPanel.vue'
|
||||
import ProviderPage from '@/views/ProviderPage.vue'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -426,4 +430,49 @@ function closeProviderDrawer() {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.provider-drawer-card {
|
||||
width: calc(100dvw - 24px);
|
||||
height: calc(100dvh - 24px);
|
||||
margin: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.provider-name-text {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.provider-drawer-overlay {
|
||||
align-items: stretch;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.provider-drawer-card {
|
||||
width: 100dvw;
|
||||
height: 100dvh;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.provider-drawer-header {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.provider-drawer-content {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
:deep(.v-overlay__content) {
|
||||
width: 100dvw;
|
||||
max-width: 100dvw;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:deep(.v-dialog > .v-overlay__content) {
|
||||
width: calc(100dvw - 24px);
|
||||
max-width: calc(100dvw - 24px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,6 +5,7 @@ import MarkdownIt from "markdown-it";
|
||||
import axios from "axios";
|
||||
import DOMPurify from "dompurify";
|
||||
import { useI18n } from "@/i18n/composables";
|
||||
import { copyToClipboard } from "@/utils/clipboard";
|
||||
import {
|
||||
escapeHtml,
|
||||
ensureShikiLanguages,
|
||||
@@ -349,19 +350,13 @@ watch([content, locale, isDark], () => {
|
||||
updateRenderedHtml();
|
||||
}, { immediate: true });
|
||||
|
||||
function handleContainerClick(event) {
|
||||
async function handleContainerClick(event) {
|
||||
const btn = event.target.closest(".copy-code-btn");
|
||||
if (btn) {
|
||||
const code = btn.closest(".code-block-wrapper")?.querySelector("code");
|
||||
if (code) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard
|
||||
.writeText(code.textContent)
|
||||
.then(() => showCopyFeedback(btn, true))
|
||||
.catch(() => tryFallbackCopy(code.textContent, btn));
|
||||
} else {
|
||||
tryFallbackCopy(code.textContent, btn);
|
||||
}
|
||||
const success = await copyToClipboard(code.textContent || "");
|
||||
showCopyFeedback(btn, success);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -382,25 +377,6 @@ function handleContainerClick(event) {
|
||||
target.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
function tryFallbackCopy(text, btn) {
|
||||
try {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
Object.assign(textArea.style, {
|
||||
position: "absolute",
|
||||
opacity: "0",
|
||||
zIndex: "-1",
|
||||
});
|
||||
btn.parentNode.appendChild(textArea);
|
||||
textArea.select();
|
||||
const success = document.execCommand("copy");
|
||||
btn.parentNode.removeChild(textArea);
|
||||
showCopyFeedback(btn, success);
|
||||
} catch (err) {
|
||||
showCopyFeedback(btn, false);
|
||||
}
|
||||
}
|
||||
|
||||
function showCopyFeedback(btn, success) {
|
||||
if (copyFeedbackTimer.value) clearTimeout(copyFeedbackTimer.value);
|
||||
btn.setAttribute("title", t(`core.common.${success ? "copied" : "error"}`));
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"fullscreen": "Fullscreen Mode",
|
||||
"exitFullscreen": "Exit Fullscreen",
|
||||
"reply": "Reply",
|
||||
"providerConfig": "AI Configuration",
|
||||
"providerConfig": "Model Configuration",
|
||||
"toolsUsed": "Tool Used",
|
||||
"toolCallUsed": "Used {name} tool",
|
||||
"pythonCodeAnalysis": "Python Code Analysis Used"
|
||||
@@ -137,9 +137,9 @@
|
||||
},
|
||||
"stats": {
|
||||
"tokens": "Tokens",
|
||||
"inputTokens": "Input Tokens",
|
||||
"inputTokens": "Input (other)",
|
||||
"outputTokens": "Output Tokens",
|
||||
"cachedTokens": "Cached Tokens",
|
||||
"cachedTokens": "Input (cached)",
|
||||
"duration": "Duration",
|
||||
"ttft": "Time to First Token"
|
||||
},
|
||||
|
||||
@@ -125,6 +125,10 @@
|
||||
"description": "Brave Search API Key",
|
||||
"hint": "Multiple keys can be added for rotation."
|
||||
},
|
||||
"websearch_firecrawl_key": {
|
||||
"description": "Firecrawl API Key",
|
||||
"hint": "Multiple keys can be added for rotation."
|
||||
},
|
||||
"websearch_baidu_app_builder_key": {
|
||||
"description": "Baidu Qianfan Smart Cloud APP Builder API Key",
|
||||
"hint": "Reference: [https://console.bce.baidu.com/iam/#/iam/apikey/list](https://console.bce.baidu.com/iam/#/iam/apikey/list)"
|
||||
|
||||
@@ -123,6 +123,7 @@
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
"title": "Models",
|
||||
"available": "Available Models",
|
||||
"configured": "Configured Models",
|
||||
"empty": "No configured models yet. Click \"Fetch Models\" above to add.",
|
||||
|
||||
@@ -137,9 +137,9 @@
|
||||
},
|
||||
"stats": {
|
||||
"tokens": "Токены",
|
||||
"inputTokens": "Входящие",
|
||||
"inputTokens": "Входящие (прочие)",
|
||||
"outputTokens": "Исходящие",
|
||||
"cachedTokens": "Кэшированные",
|
||||
"cachedTokens": "Входящие (кэш)",
|
||||
"duration": "Время",
|
||||
"ttft": "Время до первого токена"
|
||||
},
|
||||
|
||||
@@ -125,6 +125,10 @@
|
||||
"description": "API-ключ Brave Search",
|
||||
"hint": "Можно добавить несколько ключей для ротации."
|
||||
},
|
||||
"websearch_firecrawl_key": {
|
||||
"description": "API-ключ Firecrawl",
|
||||
"hint": "Можно добавить несколько ключей для ротации."
|
||||
},
|
||||
"websearch_baidu_app_builder_key": {
|
||||
"description": "API-ключ Baidu Qianfan APP Builder",
|
||||
"hint": "Ссылка: [https://console.bce.baidu.com/iam/#/iam/apikey/list](https://console.bce.baidu.com/iam/#/iam/apikey/list)"
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
"title": "Модели",
|
||||
"available": "Доступные модели",
|
||||
"configured": "Настроенные модели",
|
||||
"empty": "Модели не настроены. Нажмите «Загрузить список моделей» выше.",
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
"fullscreen": "全屏模式",
|
||||
"exitFullscreen": "退出全屏",
|
||||
"reply": "引用回复",
|
||||
"providerConfig": "AI 配置",
|
||||
"providerConfig": "模型配置",
|
||||
"toolsUsed": "已使用工具",
|
||||
"toolCallUsed": "已使用 {name} 工具",
|
||||
"pythonCodeAnalysis": "已使用 Python 代码分析"
|
||||
@@ -137,9 +137,9 @@
|
||||
},
|
||||
"stats": {
|
||||
"tokens": "Token",
|
||||
"inputTokens": "输入 Token",
|
||||
"inputTokens": "输入(其他)",
|
||||
"outputTokens": "输出 Token",
|
||||
"cachedTokens": "缓存 Token",
|
||||
"cachedTokens": "输入(缓存)",
|
||||
"duration": "耗时",
|
||||
"ttft": "首字时间"
|
||||
},
|
||||
|
||||
@@ -127,6 +127,10 @@
|
||||
"description": "Brave Search API Key",
|
||||
"hint": "可添加多个 Key 进行轮询。"
|
||||
},
|
||||
"websearch_firecrawl_key": {
|
||||
"description": "Firecrawl API Key",
|
||||
"hint": "可添加多个 Key 进行轮询。"
|
||||
},
|
||||
"websearch_baidu_app_builder_key": {
|
||||
"description": "百度千帆智能云 APP Builder API Key",
|
||||
"hint": "参考:[https://console.bce.baidu.com/iam/#/iam/apikey/list](https://console.bce.baidu.com/iam/#/iam/apikey/list)"
|
||||
|
||||
@@ -124,6 +124,7 @@
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
"title": "模型",
|
||||
"available": "可用模型",
|
||||
"configured": "已配置的模型",
|
||||
"empty": "暂无已配置的模型,点击上方的\"获取模型列表\"添加",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView, useRoute } from 'vue-router';
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { ref, onMounted, computed, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import VerticalSidebarVue from './vertical-sidebar/VerticalSidebar.vue';
|
||||
import VerticalHeaderVue from './vertical-header/VerticalHeader.vue';
|
||||
@@ -18,13 +18,19 @@ const { locale } = useI18n();
|
||||
const route = useRoute();
|
||||
const routerLoadingStore = useRouterLoadingStore();
|
||||
const isCurrentChatRoute = computed(() => route.path === '/chat' || route.path.startsWith('/chat/'));
|
||||
|
||||
const shouldMountChat = ref(isCurrentChatRoute.value);
|
||||
|
||||
const showSidebar = computed(() => !isCurrentChatRoute.value)
|
||||
|
||||
const migrationDialog = ref<InstanceType<typeof MigrationDialog> | null>(null);
|
||||
const showFirstNoticeDialog = ref(false);
|
||||
|
||||
watch(isCurrentChatRoute, (isChatRoute) => {
|
||||
if (isChatRoute) {
|
||||
shouldMountChat.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
const checkMigration = async (): Promise<boolean> => {
|
||||
try {
|
||||
const response = await axios.get('/api/stat/version');
|
||||
@@ -116,10 +122,14 @@ onMounted(() => {
|
||||
minHeight: isCurrentChatRoute ? 'unset' : undefined
|
||||
}">
|
||||
<div :style="{ height: '100%', width: '100%', overflow: isCurrentChatRoute ? 'hidden' : undefined }">
|
||||
<div v-if="isCurrentChatRoute" style="height: 100%; width: 100%; overflow: hidden;">
|
||||
<Chat />
|
||||
<div
|
||||
v-if="shouldMountChat"
|
||||
v-show="isCurrentChatRoute"
|
||||
style="height: 100%; width: 100%; overflow: hidden;"
|
||||
>
|
||||
<Chat :active="isCurrentChatRoute" />
|
||||
</div>
|
||||
<RouterView v-else />
|
||||
<RouterView v-if="!isCurrentChatRoute" />
|
||||
</div>
|
||||
</v-container>
|
||||
</v-main>
|
||||
|
||||
@@ -19,7 +19,7 @@ $code-text-color: #111827 !default;
|
||||
--astrbot-code-color: #{$code-text-color};
|
||||
}
|
||||
|
||||
$body-font-family: 'Roboto', $cjk-sans-fallback, sans-serif !default;
|
||||
$body-font-family: $cjk-sans-fallback, sans-serif !default;
|
||||
$heading-font-family: $body-font-family !default;
|
||||
$btn-font-weight: 400 !default;
|
||||
$btn-letter-spacing: 0 !default;
|
||||
|
||||
@@ -79,14 +79,6 @@ $sizes: (
|
||||
// font family
|
||||
|
||||
body {
|
||||
.Poppins {
|
||||
font-family: 'Poppins', $cjk-sans-fallback, sans-serif !important;
|
||||
}
|
||||
|
||||
.Inter {
|
||||
font-family: 'Inter', $cjk-sans-fallback, sans-serif !important;
|
||||
}
|
||||
|
||||
.Outfit {
|
||||
font-family: 'Outfit', $cjk-sans-fallback, sans-serif !important;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export const useCustomizerStore = defineStore("customizer", {
|
||||
Sidebar_drawer: config.Sidebar_drawer,
|
||||
Customizer_drawer: config.Customizer_drawer,
|
||||
mini_sidebar: config.mini_sidebar,
|
||||
fontTheme: "Poppins",
|
||||
fontTheme: "Noto Sans SC",
|
||||
uiTheme: config.uiTheme,
|
||||
inputBg: config.inputBg,
|
||||
chatSidebarOpen: false // chat mode mobile sidebar state
|
||||
|
||||
92
dashboard/src/utils/clipboard.ts
Normal file
92
dashboard/src/utils/clipboard.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
interface CopyToClipboardOptions {
|
||||
container?: HTMLElement | null;
|
||||
}
|
||||
|
||||
export async function copyToClipboard(
|
||||
text: string,
|
||||
options: CopyToClipboardOptions = {},
|
||||
): Promise<boolean> {
|
||||
const container = options.container;
|
||||
const debugInfo = {
|
||||
length: text?.length ?? 0,
|
||||
trimmedLength: text?.trim().length ?? 0,
|
||||
isSecureContext: typeof window !== "undefined" ? window.isSecureContext : false,
|
||||
hasClipboardApi:
|
||||
typeof navigator !== "undefined" && !!navigator.clipboard?.writeText,
|
||||
containerTag: container?.tagName ?? null,
|
||||
containerInBody:
|
||||
typeof document !== "undefined" && !!container && document.body.contains(container),
|
||||
};
|
||||
|
||||
if (!text) {
|
||||
console.debug("[clipboard] empty text payload", debugInfo);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.debug("[clipboard] copy request", debugInfo);
|
||||
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
console.info("[clipboard] copied via Clipboard API", debugInfo);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn("[clipboard] Clipboard API failed, falling back:", err, debugInfo);
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackOk = fallbackCopy(text, container);
|
||||
if (fallbackOk) {
|
||||
console.info("[clipboard] fallback succeeded via document.execCommand('copy')", debugInfo);
|
||||
} else {
|
||||
console.warn("[clipboard] fallback failed via document.execCommand('copy')", debugInfo);
|
||||
}
|
||||
return fallbackOk;
|
||||
}
|
||||
|
||||
function fallbackCopy(text: string, container?: HTMLElement | null): boolean {
|
||||
if (typeof document === "undefined" || !document.body) return false;
|
||||
|
||||
const mountTarget =
|
||||
container && document.body.contains(container) ? container : document.body;
|
||||
const textArea = document.createElement("textarea");
|
||||
const activeElement =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
const selection = document.getSelection();
|
||||
const selectedRanges = selection
|
||||
? Array.from({ length: selection.rangeCount }, (_, index) =>
|
||||
selection.getRangeAt(index).cloneRange(),
|
||||
)
|
||||
: [];
|
||||
|
||||
textArea.value = text;
|
||||
textArea.readOnly = true;
|
||||
Object.assign(textArea.style, {
|
||||
position: "fixed",
|
||||
left: "-9999px",
|
||||
top: "0",
|
||||
opacity: "0",
|
||||
pointerEvents: "none",
|
||||
});
|
||||
|
||||
mountTarget.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
textArea.setSelectionRange(0, text.length);
|
||||
|
||||
try {
|
||||
return document.execCommand("copy");
|
||||
} catch (err) {
|
||||
console.error("Fallback copy failed:", err);
|
||||
return false;
|
||||
} finally {
|
||||
if (textArea.parentNode) {
|
||||
textArea.parentNode.removeChild(textArea);
|
||||
}
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
selectedRanges.forEach((range) => selection.addRange(range));
|
||||
}
|
||||
activeElement?.focus?.();
|
||||
}
|
||||
}
|
||||
@@ -379,6 +379,7 @@ import {
|
||||
askForConfirmation as askForConfirmationDialog,
|
||||
useConfirmDialog
|
||||
} from '@/utils/confirmDialog';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
|
||||
export default {
|
||||
name: 'ConversationPage',
|
||||
@@ -638,10 +639,10 @@ export default {
|
||||
},
|
||||
|
||||
async copyUmoSource(item) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.formatUmoSource(item));
|
||||
const ok = await copyToClipboard(this.formatUmoSource(item));
|
||||
if (ok) {
|
||||
this.showSuccessMessage(this.tm('messages.copySuccess'));
|
||||
} catch (error) {
|
||||
} else {
|
||||
this.showErrorMessage(this.tm('messages.copyError'));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -241,6 +241,7 @@ import {
|
||||
askForConfirmation as askForConfirmationDialog,
|
||||
useConfirmDialog
|
||||
} from '@/utils/confirmDialog';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
|
||||
export default {
|
||||
name: 'PlatformPage',
|
||||
@@ -608,10 +609,10 @@ export default {
|
||||
|
||||
async copyWebhookUrl(webhookUuid) {
|
||||
const url = this.getWebhookUrl(webhookUuid);
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
const ok = await copyToClipboard(url);
|
||||
if (ok) {
|
||||
this.showSuccess(this.tm('webhookCopied'));
|
||||
} catch (err) {
|
||||
} else {
|
||||
this.showError(this.tm('webhookCopyFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="provider-page">
|
||||
<v-container fluid class="pa-0">
|
||||
<!-- 页面标题 -->
|
||||
<v-row class="d-flex justify-space-between align-center px-4 py-3 pb-4">
|
||||
<div>
|
||||
<h1 class="text-h1 font-weight-bold mb-2">
|
||||
@@ -12,121 +11,139 @@
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="selectedProviderType !== 'chat_completion'">
|
||||
<v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" @click="showAddProviderDialog = true"
|
||||
rounded="xl" size="x-large">
|
||||
<v-btn
|
||||
color="primary"
|
||||
prepend-icon="mdi-plus"
|
||||
variant="tonal"
|
||||
rounded="xl"
|
||||
size="x-large"
|
||||
@click="showAddProviderDialog = true"
|
||||
>
|
||||
{{ tm('providers.addProvider') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-row>
|
||||
|
||||
<div>
|
||||
<!-- Provider Type 标签页 -->
|
||||
<v-tabs v-model="selectedProviderType" bg-color="transparent" class="mb-4">
|
||||
<v-tab v-for="type in providerTypes" :key="type.value" :value="type.value" class="font-weight-medium px-3">
|
||||
<v-tab
|
||||
v-for="type in providerTypes"
|
||||
:key="type.value"
|
||||
:value="type.value"
|
||||
class="font-weight-medium px-3"
|
||||
>
|
||||
<v-icon start>{{ type.icon }}</v-icon>
|
||||
{{ type.label }}
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<!-- Chat Completion: 左侧列表 + 右侧上下卡片布局 -->
|
||||
<div v-if="selectedProviderType === 'chat_completion'" class="provider-workbench">
|
||||
<v-row class="provider-workbench__shell">
|
||||
<v-col cols="12" md="4" lg="3" class="provider-workbench__sources">
|
||||
<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"
|
||||
/>
|
||||
</v-col>
|
||||
<div class="provider-workbench__sidebar">
|
||||
<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>
|
||||
|
||||
<v-col cols="12" md="8" lg="9" class="provider-workbench__settings">
|
||||
<v-card class="provider-config-card provider-settings-panel h-100" elevation="0">
|
||||
<div v-if="selectedProviderSource" class="provider-config-header">
|
||||
<div class="provider-config-headline">
|
||||
<div class="provider-config-kicker">{{ tm('providers.settings') }}</div>
|
||||
<div class="provider-config-title">{{ selectedProviderSource.id }}</div>
|
||||
<div class="provider-config-subtitle">
|
||||
{{ selectedProviderSource.api_base || 'N/A' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider-workbench__divider"></div>
|
||||
|
||||
<div class="provider-config-actions">
|
||||
<v-btn
|
||||
color="primary"
|
||||
prepend-icon="mdi-content-save-outline"
|
||||
:loading="savingSource"
|
||||
:disabled="!isSourceModified"
|
||||
@click="saveProviderSource"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ tm('providerSources.save') }}
|
||||
</v-btn>
|
||||
<div class="provider-workbench__main">
|
||||
<div v-if="selectedProviderSource" class="provider-config-shell">
|
||||
<div class="provider-config-header">
|
||||
<div class="provider-config-headline">
|
||||
<div class="provider-config-title">{{ selectedProviderSource.id }}</div>
|
||||
<div class="provider-config-subtitle">
|
||||
{{ selectedProviderSource.api_base || 'N/A' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-card-text class="provider-config-body">
|
||||
<template v-if="selectedProviderSource">
|
||||
<section class="provider-section">
|
||||
<div class="provider-section-head">
|
||||
<div class="provider-section-title">{{ tm('providers.settings') }}</div>
|
||||
</div>
|
||||
<AstrBotConfig v-if="basicSourceConfig" :iterable="basicSourceConfig" :metadata="providerSourceSchema"
|
||||
metadataKey="provider" :is-editing="true" />
|
||||
</section>
|
||||
<div class="provider-config-actions">
|
||||
<v-btn
|
||||
color="primary"
|
||||
prepend-icon="mdi-content-save-outline"
|
||||
:loading="savingSource"
|
||||
:disabled="!isSourceModified"
|
||||
variant="tonal"
|
||||
rounded="xl"
|
||||
@click="saveProviderSource"
|
||||
>
|
||||
{{ tm('providerSources.save') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-if="advancedSourceConfig" class="provider-section">
|
||||
<div class="provider-section-head">
|
||||
<div class="provider-section-title">{{ tm('providerSources.advancedConfig') }}</div>
|
||||
</div>
|
||||
<AstrBotConfig
|
||||
:iterable="advancedSourceConfig"
|
||||
:metadata="providerSourceSchema"
|
||||
metadataKey="provider"
|
||||
:is-editing="true"
|
||||
/>
|
||||
</section>
|
||||
<v-divider></v-divider>
|
||||
|
||||
<section class="provider-section provider-section--models">
|
||||
<ProviderModelsPanel
|
||||
:entries="filteredMergedModelEntries"
|
||||
:available-count="availableModels.length"
|
||||
v-model:model-search="modelSearch"
|
||||
:loading-models="loadingModels"
|
||||
:is-source-modified="isSourceModified"
|
||||
:supports-image-input="supportsImageInput"
|
||||
:supports-audio-input="supportsAudioInput"
|
||||
: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"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
<div v-else class="provider-empty-state">
|
||||
<v-icon size="48" color="grey-lighten-1">mdi-cursor-default-click</v-icon>
|
||||
<p class="mt-2">{{ tm('providerSources.selectHint') }}</p>
|
||||
<div class="provider-config-body">
|
||||
<section class="provider-section">
|
||||
<div class="provider-section-head">
|
||||
<div class="provider-section-title">{{ tm('providers.settings') }}</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<AstrBotConfig
|
||||
v-if="basicSourceConfig"
|
||||
:iterable="basicSourceConfig"
|
||||
:metadata="providerSourceSchema"
|
||||
metadataKey="provider"
|
||||
:is-editing="true"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<v-divider v-if="advancedSourceConfig"></v-divider>
|
||||
|
||||
<section v-if="advancedSourceConfig" class="provider-section">
|
||||
<div class="provider-section-head">
|
||||
<div class="provider-section-title">{{ tm('providerSources.advancedConfig') }}</div>
|
||||
</div>
|
||||
<AstrBotConfig
|
||||
:iterable="advancedSourceConfig"
|
||||
:metadata="providerSourceSchema"
|
||||
metadataKey="provider"
|
||||
:is-editing="true"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<section class="provider-section provider-section--models">
|
||||
<ProviderModelsPanel
|
||||
:entries="filteredMergedModelEntries"
|
||||
:available-count="availableModels.length"
|
||||
v-model:model-search="modelSearch"
|
||||
:loading-models="loadingModels"
|
||||
:is-source-modified="isSourceModified"
|
||||
:supports-image-input="supportsImageInput"
|
||||
:supports-audio-input="supportsAudioInput"
|
||||
: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"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="provider-empty-state">
|
||||
<v-icon size="48" color="grey-lighten-1">mdi-cursor-default-click</v-icon>
|
||||
<p class="mt-2">{{ tm('providerSources.selectHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 其他类型: 卡片布局 -->
|
||||
<template v-else>
|
||||
<v-row v-if="filteredProviders.length === 0">
|
||||
<v-col cols="12" class="text-center pa-8">
|
||||
@@ -136,20 +153,30 @@
|
||||
</v-row>
|
||||
<v-row v-else>
|
||||
<v-col v-for="(provider, index) in filteredProviders" :key="index" cols="12" md="6" lg="4" xl="3">
|
||||
<item-card :item="provider" title-field="id" enabled-field="enable"
|
||||
:loading="isProviderTesting(provider.id)" @toggle-enabled="toggleProviderEnable(provider, !provider.enable)"
|
||||
:bglogo="getProviderIcon(provider.provider)" @delete="deleteProvider" @edit="configExistingProvider"
|
||||
@copy="copyProvider" :show-copy-button="true">
|
||||
|
||||
<item-card
|
||||
:item="provider"
|
||||
title-field="id"
|
||||
enabled-field="enable"
|
||||
:loading="isProviderTesting(provider.id)"
|
||||
:bglogo="getProviderIcon(provider.provider)"
|
||||
:show-copy-button="true"
|
||||
@toggle-enabled="toggleProviderEnable(provider, !provider.enable)"
|
||||
@delete="deleteProvider"
|
||||
@edit="configExistingProvider"
|
||||
@copy="copyProvider"
|
||||
>
|
||||
<template #item-details="{ item }">
|
||||
<!-- 测试状态 chip -->
|
||||
<v-tooltip v-if="getProviderStatus(item.id)" location="top" max-width="300">
|
||||
<template v-slot:activator="{ props }">
|
||||
<template #activator="{ props }">
|
||||
<v-chip v-bind="props" :color="getStatusColor(getProviderStatus(item.id).status)" size="small">
|
||||
<v-icon start size="small">
|
||||
{{ getProviderStatus(item.id).status === 'available' ? 'mdi-check-circle' :
|
||||
getProviderStatus(item.id).status === 'unavailable' ? 'mdi-alert-circle' :
|
||||
'mdi-clock-outline' }}
|
||||
{{
|
||||
getProviderStatus(item.id).status === 'available'
|
||||
? 'mdi-check-circle'
|
||||
: getProviderStatus(item.id).status === 'unavailable'
|
||||
? 'mdi-alert-circle'
|
||||
: 'mdi-clock-outline'
|
||||
}}
|
||||
</v-icon>
|
||||
{{ getStatusText(getProviderStatus(item.id).status) }}
|
||||
</v-chip>
|
||||
@@ -160,9 +187,17 @@
|
||||
<span v-else>{{ getStatusText(getProviderStatus(item.id).status) }}</span>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
|
||||
<template #actions="{ item }">
|
||||
<v-btn style="z-index: 100000;" variant="tonal" color="info" rounded="xl" size="small"
|
||||
:loading="isProviderTesting(item.id)" @click="testSingleProvider(item)">
|
||||
<v-btn
|
||||
style="z-index: 100000;"
|
||||
variant="tonal"
|
||||
color="info"
|
||||
rounded="xl"
|
||||
size="small"
|
||||
:loading="isProviderTesting(item.id)"
|
||||
@click="testSingleProvider(item)"
|
||||
>
|
||||
{{ tm('availability.test') }}
|
||||
</v-btn>
|
||||
</template>
|
||||
@@ -173,18 +208,32 @@
|
||||
</div>
|
||||
</v-container>
|
||||
|
||||
<!-- 添加提供商对话框 -->
|
||||
<AddNewProvider v-model:show="showAddProviderDialog" :metadata="configSchema"
|
||||
<AddNewProvider
|
||||
v-model:show="showAddProviderDialog"
|
||||
:metadata="configSchema"
|
||||
:current-provider-type="selectedProviderType"
|
||||
@select-template="selectProviderTemplate" />
|
||||
@select-template="selectProviderTemplate"
|
||||
/>
|
||||
|
||||
<!-- 手动添加模型对话框 -->
|
||||
<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-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>
|
||||
@@ -194,56 +243,69 @@
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 配置对话框 -->
|
||||
<v-dialog v-model="showProviderCfg" width="900" persistent>
|
||||
<v-card
|
||||
:title="updatingMode ? tm('dialogs.config.editTitle') : tm('dialogs.config.addTitle') + ` ${newSelectedProviderName} ` + tm('dialogs.config.provider')">
|
||||
:title="updatingMode ? tm('dialogs.config.editTitle') : tm('dialogs.config.addTitle') + ` ${newSelectedProviderName} ` + tm('dialogs.config.provider')"
|
||||
>
|
||||
<v-card-text class="py-4">
|
||||
<AstrBotConfig :iterable="newSelectedProviderConfig" :metadata="configSchema"
|
||||
metadataKey="provider" :is-editing="updatingMode" />
|
||||
<AstrBotConfig
|
||||
:iterable="newSelectedProviderConfig"
|
||||
:metadata="configSchema"
|
||||
metadataKey="provider"
|
||||
:is-editing="updatingMode"
|
||||
/>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="text" @click="showProviderCfg = false" :disabled="loading">
|
||||
<v-btn variant="text" :disabled="loading" @click="showProviderCfg = false">
|
||||
{{ tm('dialogs.config.cancel') }}
|
||||
</v-btn>
|
||||
<v-btn color="primary" @click="newProvider" :loading="loading">
|
||||
<v-btn color="primary" :loading="loading" @click="newProvider">
|
||||
{{ tm('dialogs.config.save') }}
|
||||
</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,可能会导致指向该模型的相关配置(如默认模型、插件相关配置等)失效。旧版本 AstrBot 的 “提供商 ID” 是下方的 “ID”。</small>
|
||||
<AstrBotConfig v-if="providerEditData" :iterable="providerEditData" :metadata="configSchema"
|
||||
metadataKey="provider" :is-editing="true" />
|
||||
<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)">
|
||||
<v-btn
|
||||
variant="text"
|
||||
:disabled="savingProviders.includes(providerEditData?.id)"
|
||||
@click="showProviderEditDialog = false"
|
||||
>
|
||||
{{ tm('dialogs.config.cancel') }}
|
||||
</v-btn>
|
||||
<v-btn color="primary" @click="saveEditedProvider" :loading="savingProviders.includes(providerEditData?.id)">
|
||||
<v-btn
|
||||
color="primary"
|
||||
:loading="savingProviders.includes(providerEditData?.id)"
|
||||
@click="saveEditedProvider"
|
||||
>
|
||||
{{ tm('dialogs.config.save') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 消息提示 -->
|
||||
<v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="3000" location="top">
|
||||
{{ snackbar.message }}
|
||||
</v-snackbar>
|
||||
|
||||
<!-- Agent Runner 测试提示对话框 -->
|
||||
<v-dialog v-model="showAgentRunnerDialog" max-width="520" persistent>
|
||||
<v-card>
|
||||
<v-card-title class="text-h3 d-flex align-center">
|
||||
@@ -340,14 +402,13 @@ const {
|
||||
deleteProvider,
|
||||
modelAlreadyConfigured,
|
||||
testProvider,
|
||||
loadConfig,
|
||||
loadConfig
|
||||
} = useProviderSources({
|
||||
defaultTab: props.defaultTab,
|
||||
tm,
|
||||
showMessage
|
||||
})
|
||||
|
||||
// 非 chat 类型的状态
|
||||
const showAddProviderDialog = ref(false)
|
||||
const showProviderCfg = ref(false)
|
||||
const newSelectedProviderName = ref('')
|
||||
@@ -361,7 +422,6 @@ const showProviderEditDialog = ref(false)
|
||||
const providerEditData = ref(null)
|
||||
const providerEditOriginalId = ref('')
|
||||
const showManualModelDialog = ref(false)
|
||||
|
||||
const savingProviders = ref([])
|
||||
|
||||
function openProviderEdit(provider) {
|
||||
@@ -401,7 +461,6 @@ watch(() => props.defaultTab, (val) => {
|
||||
updateDefaultTab(val)
|
||||
})
|
||||
|
||||
// ===== 非 chat 类型的方法 =====
|
||||
function getEmptyText() {
|
||||
return tm('providers.empty.typed', { type: selectedProviderType.value })
|
||||
}
|
||||
@@ -421,7 +480,6 @@ function configExistingProvider(provider) {
|
||||
newProviderOriginalId.value = provider.id
|
||||
newSelectedProviderConfig.value = {}
|
||||
|
||||
// 比对默认配置模版,看看是否有更新
|
||||
let templates = configSchema.value.provider.config_template || {}
|
||||
let defaultConfig = {}
|
||||
for (let key in templates) {
|
||||
@@ -484,20 +542,20 @@ async function newProvider() {
|
||||
config: newSelectedProviderConfig.value
|
||||
})
|
||||
if (res.data.status === 'error') {
|
||||
showMessage(res.data.message || "更新失败!", 'error')
|
||||
showMessage(res.data.message || '更新失败!', 'error')
|
||||
return
|
||||
}
|
||||
showMessage(res.data.message || "更新成功!")
|
||||
showMessage(res.data.message || '更新成功!')
|
||||
if (wasUpdating) {
|
||||
updatingMode.value = false
|
||||
}
|
||||
} else {
|
||||
const res = await axios.post('/api/config/provider/new', newSelectedProviderConfig.value)
|
||||
if (res.data.status === 'error') {
|
||||
showMessage(res.data.message || "添加失败!", 'error')
|
||||
showMessage(res.data.message || '添加失败!', 'error')
|
||||
return
|
||||
}
|
||||
showMessage(res.data.message || "添加成功!")
|
||||
showMessage(res.data.message || '添加成功!')
|
||||
}
|
||||
showProviderCfg.value = false
|
||||
} catch (err) {
|
||||
@@ -674,47 +732,43 @@ function goToConfigPage() {
|
||||
router.push('/config')
|
||||
showAgentRunnerDialog.value = false
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.provider-page {
|
||||
--provider-surface: rgb(var(--v-theme-surface));
|
||||
--provider-text: rgb(var(--v-theme-on-surface));
|
||||
--provider-muted: rgba(var(--v-theme-on-surface), 0.68);
|
||||
--provider-subtle: rgba(var(--v-theme-on-surface), 0.56);
|
||||
--provider-border: rgba(var(--v-theme-on-surface), 0.1);
|
||||
--provider-border-strong: rgba(var(--v-theme-on-surface), 0.14);
|
||||
--provider-soft: rgba(var(--v-theme-primary), 0.08);
|
||||
--provider-border: rgba(var(--v-theme-on-surface), 0.08);
|
||||
padding: 20px;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.provider-workbench {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.provider-workbench__shell {
|
||||
width: 100%;
|
||||
max-width: 1500px;
|
||||
}
|
||||
|
||||
.provider-workbench__sources,
|
||||
.provider-workbench__settings {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.provider-config-card {
|
||||
min-height: 280px;
|
||||
border: 1px solid var(--provider-border);
|
||||
border-radius: 16px;
|
||||
border-radius: 24px;
|
||||
background: var(--provider-surface);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 320px) 1px minmax(0, 1fr);
|
||||
min-height: 760px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.provider-settings-panel {
|
||||
.provider-workbench__sidebar,
|
||||
.provider-workbench__main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.provider-workbench__divider {
|
||||
background: var(--provider-border);
|
||||
}
|
||||
|
||||
.provider-workbench__main {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.provider-config-shell {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -723,63 +777,46 @@ function goToConfigPage() {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 20px 20px 16px;
|
||||
border-bottom: 1px solid var(--provider-border);
|
||||
gap: 16px;
|
||||
padding: 18px 22px 14px;
|
||||
}
|
||||
|
||||
.provider-config-headline {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.provider-config-kicker {
|
||||
color: var(--provider-subtle);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.provider-config-title {
|
||||
margin-top: 8px;
|
||||
font-size: 22px;
|
||||
font-size: 21px;
|
||||
line-height: 1.1;
|
||||
font-weight: 650;
|
||||
font-weight: 680;
|
||||
letter-spacing: -0.03em;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--provider-text);
|
||||
}
|
||||
|
||||
.provider-config-subtitle {
|
||||
margin-top: 8px;
|
||||
color: var(--provider-muted);
|
||||
margin-top: 6px;
|
||||
color: rgba(var(--v-theme-on-surface), 0.62);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.provider-config-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.provider-config-body {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 18px 20px 20px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.provider-section {
|
||||
border: 1px solid var(--provider-border);
|
||||
border-radius: 14px;
|
||||
background: rgba(var(--v-theme-primary), 0.02);
|
||||
padding: 16px;
|
||||
padding: 18px 22px;
|
||||
}
|
||||
|
||||
.provider-section--models {
|
||||
padding: 18px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.provider-section-head {
|
||||
@@ -790,30 +827,92 @@ function goToConfigPage() {
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
line-height: 1.4;
|
||||
color: var(--provider-text);
|
||||
}
|
||||
|
||||
.provider-empty-state {
|
||||
flex: 1;
|
||||
min-height: 420px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--provider-muted);
|
||||
color: rgba(var(--v-theme-on-surface), 0.56);
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.provider-config-card {
|
||||
.provider-page {
|
||||
padding: 12px;
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
|
||||
.provider-workbench {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto 1px auto;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.provider-workbench__divider {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.provider-config-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
align-items: stretch;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.provider-config-actions :deep(.v-btn) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.provider-section {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.provider-page {
|
||||
padding: 8px;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.provider-page :deep(.v-container) > .v-row:first-child {
|
||||
margin: 0;
|
||||
padding: 8px 4px 16px !important;
|
||||
}
|
||||
|
||||
.provider-page :deep(.v-container) > .v-row:first-child > div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.provider-page :deep(.v-container) > .v-row:first-child .v-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.provider-page :deep(.v-tabs) {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.provider-workbench {
|
||||
border-radius: 16px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.provider-workbench__main {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.provider-config-body {
|
||||
padding: 18px;
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
.provider-config-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.provider-empty-state {
|
||||
min-height: 260px;
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -236,6 +236,7 @@ import SidebarCustomizer from '@/components/shared/SidebarCustomizer.vue';
|
||||
import BackupDialog from '@/components/shared/BackupDialog.vue';
|
||||
import StorageCleanupPanel from '@/components/shared/StorageCleanupPanel.vue';
|
||||
import { restartAstrBot as restartAstrBotRuntime } from '@/utils/restartAstrBot';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import { useTheme } from 'vuetify';
|
||||
import { PurpleTheme } from '@/theme/LightTheme';
|
||||
@@ -338,50 +339,9 @@ const loadApiKeys = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const tryExecCommandCopy = (text) => {
|
||||
let textArea = null;
|
||||
try {
|
||||
if (typeof document === 'undefined' || !document.body) return false;
|
||||
textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.setAttribute('readonly', '');
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.opacity = '0';
|
||||
textArea.style.pointerEvents = 'none';
|
||||
textArea.style.left = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
textArea.setSelectionRange(0, text.length);
|
||||
return document.execCommand('copy');
|
||||
} catch (_) {
|
||||
return false;
|
||||
} finally {
|
||||
try {
|
||||
if (textArea?.parentNode) {
|
||||
textArea.parentNode.removeChild(textArea);
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const copyTextToClipboard = async (text) => {
|
||||
if (!text) return false;
|
||||
if (tryExecCommandCopy(text)) return true;
|
||||
if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) return false;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const copyCreatedApiKey = async () => {
|
||||
if (!createdApiKeyPlaintext.value) return;
|
||||
const ok = await copyTextToClipboard(createdApiKeyPlaintext.value);
|
||||
const ok = await copyToClipboard(createdApiKeyPlaintext.value);
|
||||
if (ok) {
|
||||
showToast(tm('apiKey.messages.copySuccess'), 'success');
|
||||
} else {
|
||||
|
||||
@@ -1618,3 +1618,109 @@ async def test_query_does_not_filter_user_or_system_messages(monkeypatch):
|
||||
assert messages[2] == {"role": "user", "content": "hello"}
|
||||
finally:
|
||||
await provider.terminate()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_stream_filters_empty_assistant_message(monkeypatch):
|
||||
"""Regression for #7721: streaming path must also filter empty assistant messages.
|
||||
|
||||
Previously only ``_query`` sanitized the payload; ``_query_stream`` forwarded
|
||||
the raw history and strict providers (e.g. DeepSeek Reasoner) returned 400 on
|
||||
the next turn after a tool call whose assistant entry had reasoning only.
|
||||
"""
|
||||
provider = _make_provider()
|
||||
try:
|
||||
captured_kwargs = {}
|
||||
|
||||
async def fake_stream():
|
||||
yield ChatCompletionChunk.model_validate(
|
||||
{
|
||||
"id": "chatcmpl-stream",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 0,
|
||||
"model": "deepseek-reasoner",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"delta": {"role": "assistant", "content": "ok"},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
async def fake_create(**kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
return fake_stream()
|
||||
|
||||
monkeypatch.setattr(provider.client.chat.completions, "create", fake_create)
|
||||
|
||||
payloads = {
|
||||
"model": "deepseek-reasoner",
|
||||
"messages": [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": ""}, # should be filtered
|
||||
{"role": "user", "content": "world"},
|
||||
],
|
||||
}
|
||||
|
||||
async for _ in provider._query_stream(payloads=payloads, tools=None):
|
||||
pass
|
||||
|
||||
messages = captured_kwargs["messages"]
|
||||
assert len(messages) == 2
|
||||
assert messages[0] == {"role": "user", "content": "hello"}
|
||||
assert messages[1] == {"role": "user", "content": "world"}
|
||||
finally:
|
||||
await provider.terminate()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_filters_empty_list_content_assistant_message(monkeypatch):
|
||||
"""Empty-list content (``content == []``) must also be filtered, not just ``""`` / ``None``."""
|
||||
provider = _make_provider()
|
||||
try:
|
||||
captured_kwargs = {}
|
||||
|
||||
async def fake_create(**kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
return ChatCompletion.model_validate(
|
||||
{
|
||||
"id": "chatcmpl-test",
|
||||
"object": "chat.completion",
|
||||
"created": 0,
|
||||
"model": "gpt-4o-mini",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "ok"},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 1,
|
||||
"completion_tokens": 1,
|
||||
"total_tokens": 2,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(provider.client.chat.completions, "create", fake_create)
|
||||
|
||||
payloads = {
|
||||
"model": "gpt-4o-mini",
|
||||
"messages": [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": []}, # should be filtered
|
||||
{"role": "user", "content": "again"},
|
||||
],
|
||||
}
|
||||
|
||||
await provider._query(payloads=payloads, tools=None)
|
||||
|
||||
messages = captured_kwargs["messages"]
|
||||
assert len(messages) == 2
|
||||
assert messages[0] == {"role": "user", "content": "hi"}
|
||||
assert messages[1] == {"role": "user", "content": "again"}
|
||||
finally:
|
||||
await provider.terminate()
|
||||
|
||||
@@ -398,6 +398,37 @@ class TestBuiltinToolInjection:
|
||||
assert req.func_tool is not None
|
||||
assert req.func_tool.get_tool("web_search_baidu") is builtin_tool
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_web_search_tools_adds_firecrawl_search_and_extract_tools(
|
||||
self, mock_event, mock_context
|
||||
):
|
||||
"""Test Firecrawl web search injects search and extract tools."""
|
||||
module = ama
|
||||
req = ProviderRequest()
|
||||
mock_context.get_config.return_value = {
|
||||
"provider_settings": {
|
||||
"web_search": True,
|
||||
"websearch_provider": "firecrawl",
|
||||
}
|
||||
}
|
||||
search_tool = MagicMock(spec=FunctionTool)
|
||||
search_tool.name = "web_search_firecrawl"
|
||||
extract_tool = MagicMock(spec=FunctionTool)
|
||||
extract_tool.name = "firecrawl_extract_web_page"
|
||||
tool_mgr = MagicMock()
|
||||
tool_mgr.get_builtin_tool.side_effect = [search_tool, extract_tool]
|
||||
mock_context.get_llm_tool_manager.return_value = tool_mgr
|
||||
|
||||
await module._apply_web_search_tools(mock_event, req, mock_context)
|
||||
|
||||
assert tool_mgr.get_builtin_tool.call_args_list == [
|
||||
((module.FirecrawlWebSearchTool,),),
|
||||
((module.FirecrawlExtractWebPageTool,),),
|
||||
]
|
||||
assert req.func_tool is not None
|
||||
assert req.func_tool.get_tool("web_search_firecrawl") is search_tool
|
||||
assert req.func_tool.get_tool("firecrawl_extract_web_page") is extract_tool
|
||||
|
||||
def test_proactive_cron_job_tools_uses_builtin_tool_manager(self, mock_context):
|
||||
"""Test cron tool injection through the builtin tool manager."""
|
||||
module = ama
|
||||
|
||||
@@ -2,6 +2,8 @@ from astrbot.core import sp
|
||||
from astrbot.core.provider.func_tool_manager import FunctionToolManager
|
||||
from astrbot.core.tools.computer_tools.shell import ExecuteShellTool
|
||||
from astrbot.core.tools.message_tools import SendMessageToUserTool
|
||||
from astrbot.core.tools.web_search_tools import FirecrawlExtractWebPageTool
|
||||
from astrbot.core.tools.web_search_tools import FirecrawlWebSearchTool
|
||||
|
||||
|
||||
def test_get_builtin_tool_by_class_returns_cached_instance():
|
||||
@@ -38,3 +40,15 @@ def test_computer_tools_are_registered_as_builtin_tools():
|
||||
|
||||
assert tool.name == "astrbot_execute_shell"
|
||||
assert manager.is_builtin_tool("astrbot_execute_shell") is True
|
||||
|
||||
|
||||
def test_firecrawl_tools_are_registered_as_builtin_tools():
|
||||
manager = FunctionToolManager()
|
||||
|
||||
search_tool = manager.get_builtin_tool(FirecrawlWebSearchTool)
|
||||
extract_tool = manager.get_builtin_tool(FirecrawlExtractWebPageTool)
|
||||
|
||||
assert search_tool.name == "web_search_firecrawl"
|
||||
assert extract_tool.name == "firecrawl_extract_web_page"
|
||||
assert manager.is_builtin_tool("web_search_firecrawl") is True
|
||||
assert manager.is_builtin_tool("firecrawl_extract_web_page") is True
|
||||
|
||||
32
tests/unit/test_upload_filename_sanitization.py
Normal file
32
tests/unit/test_upload_filename_sanitization.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Tests for upload filename sanitization."""
|
||||
|
||||
from astrbot.dashboard.routes.chat import _sanitize_upload_filename
|
||||
|
||||
|
||||
def test_sanitize_upload_filename_strips_posix_traversal():
|
||||
assert _sanitize_upload_filename("../../outside.txt") == "outside.txt"
|
||||
|
||||
|
||||
def test_sanitize_upload_filename_strips_windows_traversal():
|
||||
assert _sanitize_upload_filename(r"..\\..\\outside.txt") == "outside.txt"
|
||||
|
||||
|
||||
def test_sanitize_upload_filename_strips_fakepath():
|
||||
assert _sanitize_upload_filename(r"C:\\fakepath\\photo.png") == "photo.png"
|
||||
|
||||
|
||||
def test_sanitize_upload_filename_falls_back_for_empty_values():
|
||||
generated = _sanitize_upload_filename("")
|
||||
|
||||
assert generated
|
||||
assert generated not in {".", ".."}
|
||||
assert "/" not in generated
|
||||
assert "\\" not in generated
|
||||
|
||||
|
||||
def test_sanitize_upload_filename_removes_embedded_null_bytes():
|
||||
assert _sanitize_upload_filename("evil\x00.txt") == "evil.txt"
|
||||
assert _sanitize_upload_filename("\x00leading.txt") == "leading.txt"
|
||||
assert _sanitize_upload_filename("trailing\x00.txt\x00") == "trailing.txt"
|
||||
assert _sanitize_upload_filename("mid\x00dle.txt") == "middle.txt"
|
||||
|
||||
380
tests/unit/test_web_search_tools.py
Normal file
380
tests/unit/test_web_search_tools.py
Normal file
@@ -0,0 +1,380 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from astrbot.core.tools import web_search_tools as tools
|
||||
|
||||
|
||||
class _FakeConfig(dict):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.saved = False
|
||||
|
||||
def save_config(self):
|
||||
self.saved = True
|
||||
|
||||
|
||||
def test_normalize_legacy_web_search_config_migrates_firecrawl_key():
|
||||
config = _FakeConfig(
|
||||
{"provider_settings": {"websearch_firecrawl_key": "firecrawl-key"}}
|
||||
)
|
||||
|
||||
tools.normalize_legacy_web_search_config(config)
|
||||
|
||||
assert config["provider_settings"]["websearch_firecrawl_key"] == ["firecrawl-key"]
|
||||
assert config.saved is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_firecrawl_search_maps_web_results(monkeypatch):
|
||||
async def fake_firecrawl_search(provider_settings, payload):
|
||||
assert provider_settings["websearch_firecrawl_key"] == ["firecrawl-key"]
|
||||
assert payload == {
|
||||
"query": "AstrBot",
|
||||
"limit": 3,
|
||||
"sources": ["web"],
|
||||
"country": "US",
|
||||
}
|
||||
return [
|
||||
tools.SearchResult(
|
||||
title="AstrBot",
|
||||
url="https://example.com",
|
||||
snippet="Search result",
|
||||
)
|
||||
]
|
||||
|
||||
monkeypatch.setattr(tools, "_firecrawl_search", fake_firecrawl_search)
|
||||
tool = tools.FirecrawlWebSearchTool()
|
||||
context = _context_with_provider_settings(
|
||||
{"websearch_firecrawl_key": ["firecrawl-key"]}
|
||||
)
|
||||
|
||||
result = await tool.call(context, query="AstrBot", limit=3, country="US")
|
||||
|
||||
assert json.loads(result)["results"] == [
|
||||
{
|
||||
"title": "AstrBot",
|
||||
"url": "https://example.com",
|
||||
"snippet": "Search result",
|
||||
"index": json.loads(result)["results"][0]["index"],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_firecrawl_search_maps_v2_data_list(monkeypatch):
|
||||
session = _FakeFirecrawlSession(
|
||||
_FakeFirecrawlResponse(
|
||||
status=200,
|
||||
json_data={
|
||||
"success": True,
|
||||
"data": [
|
||||
{
|
||||
"title": "AstrBot",
|
||||
"url": "https://example.com",
|
||||
"description": "Search result",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def fake_client_session(*, trust_env):
|
||||
session.trust_env = trust_env
|
||||
return session
|
||||
|
||||
monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session)
|
||||
|
||||
results = await tools._firecrawl_search(
|
||||
{"websearch_firecrawl_key": ["firecrawl-key"]},
|
||||
{"query": "AstrBot", "limit": 5, "sources": ["web"]},
|
||||
)
|
||||
|
||||
assert session.posted == {
|
||||
"url": "https://api.firecrawl.dev/v2/search",
|
||||
"json": {"query": "AstrBot", "limit": 5, "sources": ["web"]},
|
||||
"headers": {
|
||||
"Authorization": "Bearer firecrawl-key",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
assert results == [
|
||||
tools.SearchResult(
|
||||
title="AstrBot", url="https://example.com", snippet="Search result"
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_firecrawl_search_maps_v2_grouped_web_data(monkeypatch):
|
||||
session = _FakeFirecrawlSession(
|
||||
_FakeFirecrawlResponse(
|
||||
status=200,
|
||||
json_data={
|
||||
"success": True,
|
||||
"data": {
|
||||
"web": [
|
||||
{
|
||||
"title": "AstrBot",
|
||||
"url": "https://example.com",
|
||||
"description": "Search result",
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def fake_client_session(*, trust_env):
|
||||
session.trust_env = trust_env
|
||||
return session
|
||||
|
||||
monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session)
|
||||
|
||||
results = await tools._firecrawl_search(
|
||||
{"websearch_firecrawl_key": ["firecrawl-key"]},
|
||||
{"query": "AstrBot", "limit": 5, "sources": ["web"]},
|
||||
)
|
||||
|
||||
assert results == [
|
||||
tools.SearchResult(
|
||||
title="AstrBot", url="https://example.com", snippet="Search result"
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_firecrawl_search_payload_omits_tbs_and_uses_default_limit(monkeypatch):
|
||||
async def fake_firecrawl_search(provider_settings, payload):
|
||||
assert payload == {
|
||||
"query": "AstrBot",
|
||||
"limit": 5,
|
||||
"sources": ["web"],
|
||||
"country": "US",
|
||||
}
|
||||
return [
|
||||
tools.SearchResult(
|
||||
title="AstrBot",
|
||||
url="https://example.com",
|
||||
snippet="Search result",
|
||||
)
|
||||
]
|
||||
|
||||
monkeypatch.setattr(tools, "_firecrawl_search", fake_firecrawl_search)
|
||||
tool = tools.FirecrawlWebSearchTool()
|
||||
context = _context_with_provider_settings(
|
||||
{"websearch_firecrawl_key": ["firecrawl-key"]}
|
||||
)
|
||||
|
||||
result = await tool.call(
|
||||
context,
|
||||
query="AstrBot",
|
||||
tbs="qdr:d",
|
||||
country="US",
|
||||
)
|
||||
|
||||
assert json.loads(result)["results"][0]["url"] == "https://example.com"
|
||||
assert "tbs" not in tool.parameters["properties"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_firecrawl_extract_returns_scraped_markdown(monkeypatch):
|
||||
async def fake_firecrawl_scrape(provider_settings, payload):
|
||||
assert provider_settings["websearch_firecrawl_key"] == ["firecrawl-key"]
|
||||
assert payload == {
|
||||
"url": "https://example.com",
|
||||
"formats": ["markdown"],
|
||||
"onlyMainContent": True,
|
||||
}
|
||||
return {"url": "https://example.com", "markdown": "# Example"}
|
||||
|
||||
monkeypatch.setattr(tools, "_firecrawl_scrape", fake_firecrawl_scrape)
|
||||
tool = tools.FirecrawlExtractWebPageTool()
|
||||
context = _context_with_provider_settings(
|
||||
{"websearch_firecrawl_key": ["firecrawl-key"]}
|
||||
)
|
||||
|
||||
result = await tool.call(context, url="https://example.com")
|
||||
|
||||
assert result == "URL: https://example.com\nContent: # Example"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_firecrawl_search_uses_session_context(monkeypatch):
|
||||
session = _FakeFirecrawlSession(
|
||||
_FakeFirecrawlResponse(
|
||||
status=200,
|
||||
json_data={
|
||||
"success": True,
|
||||
"data": [
|
||||
{
|
||||
"title": "AstrBot",
|
||||
"url": "https://example.com",
|
||||
"description": "Search result",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def fake_client_session(*, trust_env):
|
||||
session.trust_env = trust_env
|
||||
return session
|
||||
|
||||
monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session)
|
||||
|
||||
await tools._firecrawl_search(
|
||||
{"websearch_firecrawl_key": ["firecrawl-key"]},
|
||||
{"query": "AstrBot"},
|
||||
)
|
||||
|
||||
assert session.trust_env is True
|
||||
assert session.entered is True
|
||||
assert session.exited is True
|
||||
assert session.posted == {
|
||||
"url": "https://api.firecrawl.dev/v2/search",
|
||||
"json": {"query": "AstrBot"},
|
||||
"headers": {
|
||||
"Authorization": "Bearer firecrawl-key",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_firecrawl_search_raises_error_for_http_errors(monkeypatch):
|
||||
session = _FakeFirecrawlSession(
|
||||
_FakeFirecrawlResponse(status=401, text_data="Unauthorized")
|
||||
)
|
||||
|
||||
def fake_client_session(*, trust_env):
|
||||
session.trust_env = trust_env
|
||||
return session
|
||||
|
||||
monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session)
|
||||
|
||||
with pytest.raises(
|
||||
Exception,
|
||||
match="Firecrawl web search failed: Unauthorized, status: 401",
|
||||
):
|
||||
await tools._firecrawl_search(
|
||||
{"websearch_firecrawl_key": ["firecrawl-key"]},
|
||||
{"query": "AstrBot"},
|
||||
)
|
||||
|
||||
assert session.trust_env is True
|
||||
assert session.entered is True
|
||||
assert session.exited is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_firecrawl_scrape_uses_request_setup(monkeypatch):
|
||||
session = _FakeFirecrawlSession(
|
||||
_FakeFirecrawlResponse(
|
||||
status=200,
|
||||
json_data={
|
||||
"success": True,
|
||||
"data": {"url": "https://example.com", "markdown": "# Example"},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def fake_client_session(*, trust_env):
|
||||
session.trust_env = trust_env
|
||||
return session
|
||||
|
||||
monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session)
|
||||
|
||||
result = await tools._firecrawl_scrape(
|
||||
{"websearch_firecrawl_key": ["firecrawl-key"]},
|
||||
{"url": "https://example.com", "formats": ["markdown"]},
|
||||
)
|
||||
|
||||
assert result == {"url": "https://example.com", "markdown": "# Example"}
|
||||
assert session.trust_env is True
|
||||
assert session.entered is True
|
||||
assert session.exited is True
|
||||
assert session.posted == {
|
||||
"url": "https://api.firecrawl.dev/v2/scrape",
|
||||
"json": {"url": "https://example.com", "formats": ["markdown"]},
|
||||
"headers": {
|
||||
"Authorization": "Bearer firecrawl-key",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_firecrawl_scrape_raises_error_for_http_errors(monkeypatch):
|
||||
session = _FakeFirecrawlSession(
|
||||
_FakeFirecrawlResponse(status=401, text_data="Unauthorized")
|
||||
)
|
||||
|
||||
def fake_client_session(*, trust_env):
|
||||
session.trust_env = trust_env
|
||||
return session
|
||||
|
||||
monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session)
|
||||
|
||||
with pytest.raises(
|
||||
Exception,
|
||||
match="Firecrawl web scraper failed: Unauthorized, status: 401",
|
||||
):
|
||||
await tools._firecrawl_scrape(
|
||||
{"websearch_firecrawl_key": ["firecrawl-key"]},
|
||||
{"url": "https://example.com", "formats": ["markdown"]},
|
||||
)
|
||||
|
||||
assert session.trust_env is True
|
||||
assert session.entered is True
|
||||
assert session.exited is True
|
||||
|
||||
|
||||
class _FakeFirecrawlResponse:
|
||||
def __init__(self, status=200, json_data=None, text_data=""):
|
||||
self.status = status
|
||||
self.json_data = json_data or {}
|
||||
self.text_data = text_data
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return None
|
||||
|
||||
async def json(self):
|
||||
return self.json_data
|
||||
|
||||
async def text(self):
|
||||
return self.text_data
|
||||
|
||||
|
||||
class _FakeFirecrawlSession:
|
||||
def __init__(self, response):
|
||||
self.response = response
|
||||
self.trust_env = None
|
||||
self.entered = False
|
||||
self.exited = False
|
||||
self.posted = None
|
||||
|
||||
async def __aenter__(self):
|
||||
self.entered = True
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
self.exited = True
|
||||
return None
|
||||
|
||||
def post(self, url, json, headers):
|
||||
self.posted = {"url": url, "json": json, "headers": headers}
|
||||
return self.response
|
||||
|
||||
|
||||
def _context_with_provider_settings(provider_settings):
|
||||
config = {"provider_settings": provider_settings}
|
||||
agent_context = SimpleNamespace(
|
||||
context=SimpleNamespace(get_config=lambda umo: config),
|
||||
event=SimpleNamespace(unified_msg_origin="test:private:session"),
|
||||
)
|
||||
return SimpleNamespace(context=agent_context)
|
||||
Reference in New Issue
Block a user