Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f4a31cf8c | |||
| 23549f13d6 | |||
| 869d11f9a6 | |||
| 02e73b82ee | |||
| f85f87f545 | |||
| 1fff5713f3 | |||
| 8453ec36f0 | |||
| d5b3ce8424 | |||
| 80cbbfa5ca | |||
| 9177bb660f | |||
| a3df39a01a | |||
| 25dce05cbb | |||
| 1542ea3e03 | |||
| 6084abbcfe | |||
| ed19b63914 | |||
| 4efeb85296 | |||
| fc76665615 | |||
| 3a044bb71a | |||
| cddd606562 | |||
| 7a5bc51c11 | |||
| 9f939b4b6f | |||
| 80a86f5b1b | |||
| a0ce1855ab | |||
| a4b43b884a | |||
| 824c0f6667 | |||
| a030fe8491 | |||
| 3a9429e8ef | |||
| c4eb1ab748 | |||
| 29ed19d600 | |||
| 0cc65513a5 | |||
| debc048659 | |||
| 92f5c918dd | |||
| 9519f1e8e2 | |||
| a8f874bf05 | |||
| 9d9917e45b | |||
| 91ee0a870d | |||
| 6cbbffc5a9 | |||
| 8f26fd34d1 | |||
| fda655f6d7 | |||
| a663d6509b | |||
| 9ec8839efa | |||
| a7a0350eb2 | |||
| 39a7a0d960 | |||
| 7740e1e131 | |||
| 9dce1ed47e | |||
| e84a00d3a5 | |||
| 88a944cb57 |
@@ -1,19 +1,46 @@
|
||||
<!-- 如果有的话,指定这个 PR 要解决的 ISSUE -->
|
||||
解决了 #XYZ
|
||||
<!-- 如果有的话,请指定此 PR 旨在解决的 ISSUE 编号。 -->
|
||||
<!-- If applicable, please specify the ISSUE number this PR aims to resolve. -->
|
||||
|
||||
### Motivation
|
||||
fixes #XYZ
|
||||
|
||||
<!--解释为什么要改动-->
|
||||
---
|
||||
|
||||
### Modifications
|
||||
### Motivation / 动机
|
||||
|
||||
<!--简单解释你的改动-->
|
||||
<!--请描述此项更改的动机:它解决了什么问题?(例如:修复了 XX 错误,添加了 YY 功能)-->
|
||||
<!--Please describe the motivation for this change: What problem does it solve? (e.g., Fixes XX bug, adds YY feature)-->
|
||||
|
||||
### Check
|
||||
### Modifications / 改动点
|
||||
|
||||
<!--如果分支被合并,您的代码将服务于数万名用户!在提交前,请核查一下几点内容-->
|
||||
<!--请总结你的改动:哪些核心文件被修改了?实现了什么功能?-->
|
||||
<!--Please summarize your changes: What core files were modified? What functionality was implemented?-->
|
||||
|
||||
- [ ] 😊 我的 Commit Message 符合良好的[规范](https://www.conventionalcommits.org/en/v1.0.0/#summary)
|
||||
- [ ] 👀 我的更改经过良好的测试
|
||||
- [ ] 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 `requirements.txt` 和 `pyproject.toml` 文件相应位置。
|
||||
- [ ] 😮 我的更改没有引入恶意代码
|
||||
### Verification Steps / 验证步骤
|
||||
|
||||
<!--请为审查者 (Reviewer) 提供清晰、可复现的验证步骤(例如:1. 导航到... 2. 点击...)。-->
|
||||
<!--Please provide clear and reproducible verification steps for the Reviewer (e.g., 1. Navigate to... 2. Click...).-->
|
||||
|
||||
### Screenshots or Test Results / 运行截图或测试结果
|
||||
|
||||
<!--请粘贴截图、GIF 或测试日志,作为执行“验证步骤”的证据,证明此改动有效。-->
|
||||
<!--Please paste screenshots, GIFs, or test logs here as evidence of executing the "Verification Steps" to prove this change is effective.-->
|
||||
|
||||
### Compatibility & Breaking Changes / 兼容性与破坏性变更
|
||||
|
||||
<!--请说明此变更的兼容性:哪些是破坏性变更?哪些地方做了向后兼容处理?是否提供了数据迁移方法?-->
|
||||
<!--Please explain the compatibility of this change: What are the breaking changes? What backward-compatible measures were taken? Are data migration paths provided?-->
|
||||
|
||||
- [ ] 这是一个破坏性变更 (Breaking Change)。/ This is a breaking change.
|
||||
- [ ] 这不是一个破坏性变更。/ This is NOT a breaking change.
|
||||
|
||||
---
|
||||
|
||||
### Checklist / 检查清单
|
||||
|
||||
<!--如果分支被合并,您的代码将服务于数万名用户!在提交前,请核查一下几点内容。-->
|
||||
<!--If merged, your code will serve tens of thousands of users! Please double-check the following items before submitting.-->
|
||||
|
||||
- [ ] 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。/ If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
|
||||
- [ ] 👀 我的更改经过了良好的测试,**并已在上方提供了“验证步骤”和“运行截图”**。/ My changes have been well-tested, **and "Verification Steps" and "Screenshots" have been provided above**.
|
||||
- [ ] 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 `requirements.txt` 和 `pyproject.toml` 文件相应位置。/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in `requirements.txt` and `pyproject.toml`.
|
||||
- [ ] 😮 我的更改没有引入恶意代码。/ My changes do not introduce malicious code.
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Code Format Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
push:
|
||||
branches: [ master ]
|
||||
|
||||
jobs:
|
||||
format-check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Install UV
|
||||
run: pip install uv
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync
|
||||
|
||||
- name: Check code formatting with ruff
|
||||
run: |
|
||||
uv run ruff format --check .
|
||||
|
||||
- name: Check code style with ruff
|
||||
run: |
|
||||
uv run ruff check .
|
||||
@@ -37,6 +37,7 @@ jobs:
|
||||
!dist/**/*.md
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: github.event_name == 'push'
|
||||
uses: ncipollo/release-action@v1
|
||||
with:
|
||||
tag: release-${{ github.sha }}
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
|
||||
<a href="https://github.com/Soulter/AstrBot/blob/master/README_en.md">English</a> |
|
||||
<a href="https://github.com/Soulter/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||
<a href="https://astrbot.app/">查看文档</a> |
|
||||
<a href="https://astrbot.app/">文档</a> |
|
||||
<a href="https://blog.astrbot.app/">Blog</a> |
|
||||
<a href="https://github.com/Soulter/AstrBot/issues">问题提交</a>
|
||||
</div>
|
||||
|
||||
@@ -110,7 +111,6 @@ uv run main.py
|
||||
|
||||
<a href="https://discord.gg/hAVk6tgV36"><img alt="Discord_community" src="https://img.shields.io/badge/Discord-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||
|
||||
|
||||
## ⚡ 消息平台支持情况
|
||||
|
||||
| 平台 | 支持性 |
|
||||
@@ -127,6 +127,8 @@ uv run main.py
|
||||
| Discord | ✔ |
|
||||
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | ✔ |
|
||||
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | ✔ |
|
||||
| Satori | ✔ |
|
||||
| Misskey | ✔ |
|
||||
|
||||
## ⚡ 提供商支持情况
|
||||
|
||||
@@ -172,7 +174,6 @@ pip install pre-commit
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
|
||||
## ❤️ Special Thanks
|
||||
|
||||
特别感谢所有 Contributors 和插件开发者对 AstrBot 的贡献 ❤️
|
||||
@@ -200,14 +201,11 @@ pre-commit install
|
||||
> 如果本项目对您的生活 / 工作产生了帮助,或者您关注本项目的未来发展,请给项目 Star,这是我维护这个开源项目的动力 <3
|
||||
|
||||
<div align="center">
|
||||
|
||||
|
||||
[](https://star-history.com/#soulter/astrbot&Date)
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
_私は、高性能ですから!_
|
||||
|
||||
|
||||
+22
-18
@@ -124,15 +124,17 @@ def build_plug_list(plugins_dir: Path) -> list:
|
||||
if metadata and all(
|
||||
k in metadata for k in ["name", "desc", "version", "author", "repo"]
|
||||
):
|
||||
result.append({
|
||||
"name": str(metadata.get("name", "")),
|
||||
"desc": str(metadata.get("desc", "")),
|
||||
"version": str(metadata.get("version", "")),
|
||||
"author": str(metadata.get("author", "")),
|
||||
"repo": str(metadata.get("repo", "")),
|
||||
"status": PluginStatus.INSTALLED,
|
||||
"local_path": str(plugin_dir),
|
||||
})
|
||||
result.append(
|
||||
{
|
||||
"name": str(metadata.get("name", "")),
|
||||
"desc": str(metadata.get("desc", "")),
|
||||
"version": str(metadata.get("version", "")),
|
||||
"author": str(metadata.get("author", "")),
|
||||
"repo": str(metadata.get("repo", "")),
|
||||
"status": PluginStatus.INSTALLED,
|
||||
"local_path": str(plugin_dir),
|
||||
}
|
||||
)
|
||||
|
||||
# 获取在线插件列表
|
||||
online_plugins = []
|
||||
@@ -142,15 +144,17 @@ def build_plug_list(plugins_dir: Path) -> list:
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
for plugin_id, plugin_info in data.items():
|
||||
online_plugins.append({
|
||||
"name": str(plugin_id),
|
||||
"desc": str(plugin_info.get("desc", "")),
|
||||
"version": str(plugin_info.get("version", "")),
|
||||
"author": str(plugin_info.get("author", "")),
|
||||
"repo": str(plugin_info.get("repo", "")),
|
||||
"status": PluginStatus.NOT_INSTALLED,
|
||||
"local_path": None,
|
||||
})
|
||||
online_plugins.append(
|
||||
{
|
||||
"name": str(plugin_id),
|
||||
"desc": str(plugin_info.get("desc", "")),
|
||||
"version": str(plugin_info.get("version", "")),
|
||||
"author": str(plugin_info.get("author", "")),
|
||||
"repo": str(plugin_info.get("repo", "")),
|
||||
"status": PluginStatus.NOT_INSTALLED,
|
||||
"local_path": None,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
click.echo(f"获取在线插件列表失败: {e}", err=True)
|
||||
|
||||
|
||||
@@ -9,5 +9,5 @@ from .hooks import BaseAgentRunHooks
|
||||
class Agent(Generic[TContext]):
|
||||
name: str
|
||||
instructions: str | None = None
|
||||
tools: list[str, FunctionTool] | None = None
|
||||
tools: list[str | FunctionTool] | None = None
|
||||
run_hooks: BaseAgentRunHooks[TContext] | None = None
|
||||
|
||||
@@ -92,7 +92,7 @@ class MCPClient:
|
||||
self.session: Optional[mcp.ClientSession] = None
|
||||
self.exit_stack = AsyncExitStack()
|
||||
|
||||
self.name = None
|
||||
self.name: str | None = None
|
||||
self.active: bool = True
|
||||
self.tools: list[mcp.Tool] = []
|
||||
self.server_errlogs: list[str] = []
|
||||
@@ -198,6 +198,8 @@ class MCPClient:
|
||||
|
||||
async def list_tools_and_save(self) -> mcp.ListToolsResult:
|
||||
"""List all tools from the server and save them to self.tools"""
|
||||
if not self.session:
|
||||
raise Exception("MCP Client is not initialized")
|
||||
response = await self.session.list_tools()
|
||||
self.tools = response.tools
|
||||
return response
|
||||
|
||||
@@ -2,6 +2,7 @@ from dataclasses import dataclass
|
||||
import typing as T
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
|
||||
|
||||
class AgentResponseData(T.TypedDict):
|
||||
chain: MessageChain
|
||||
|
||||
|
||||
@@ -14,4 +14,5 @@ class ContextWrapper(Generic[TContext]):
|
||||
context: TContext
|
||||
event: AstrMessageEvent
|
||||
|
||||
|
||||
NoContext = ContextWrapper[None]
|
||||
|
||||
@@ -258,7 +258,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
)
|
||||
yield MessageChain(
|
||||
type="tool_direct_result"
|
||||
).base64_image(res.content[0].data)
|
||||
).base64_image(resource.blob)
|
||||
else:
|
||||
tool_call_result_blocks.append(
|
||||
ToolCallMessageSegment(
|
||||
@@ -269,17 +269,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
)
|
||||
yield MessageChain().message("返回的数据类型不受支持。")
|
||||
|
||||
try:
|
||||
await self.agent_hooks.on_tool_end(
|
||||
self.run_context,
|
||||
func_tool_name,
|
||||
func_tool_args,
|
||||
resp,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error in on_tool_end hook: {e}", exc_info=True
|
||||
)
|
||||
elif resp is None:
|
||||
# Tool 直接请求发送消息给用户
|
||||
# 这里我们将直接结束 Agent Loop。
|
||||
@@ -289,27 +278,17 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
yield MessageChain(
|
||||
chain=res.chain, type="tool_direct_result"
|
||||
)
|
||||
try:
|
||||
await self.agent_hooks.on_tool_end(
|
||||
self.run_context, func_tool_name, func_tool_args, None
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error in on_tool_end hook: {e}", exc_info=True
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Tool 返回了不支持的类型: {type(resp)},将忽略。"
|
||||
)
|
||||
|
||||
try:
|
||||
await self.agent_hooks.on_tool_end(
|
||||
self.run_context, func_tool_name, func_tool_args, None
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error in on_tool_end hook: {e}", exc_info=True
|
||||
)
|
||||
try:
|
||||
await self.agent_hooks.on_tool_end(
|
||||
self.run_context, func_tool, func_tool_args, None
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in on_tool_end hook: {e}", exc_info=True)
|
||||
|
||||
self.run_context.event.clear_result()
|
||||
except Exception as e:
|
||||
|
||||
+28
-17
@@ -1,6 +1,6 @@
|
||||
from dataclasses import dataclass
|
||||
from deprecated import deprecated
|
||||
from typing import Awaitable, Literal, Any, Optional
|
||||
from typing import Awaitable, Callable, Literal, Any, Optional
|
||||
from .mcp_client import MCPClient
|
||||
|
||||
|
||||
@@ -8,10 +8,10 @@ from .mcp_client import MCPClient
|
||||
class FunctionTool:
|
||||
"""A class representing a function tool that can be used in function calling."""
|
||||
|
||||
name: str | None = None
|
||||
name: str
|
||||
parameters: dict | None = None
|
||||
description: str | None = None
|
||||
handler: Awaitable | None = None
|
||||
handler: Callable[..., Awaitable[Any]] | None = None
|
||||
"""处理函数, 当 origin 为 mcp 时,这个为空"""
|
||||
handler_module_path: str | None = None
|
||||
"""处理函数的模块路径,当 origin 为 mcp 时,这个为空
|
||||
@@ -51,7 +51,7 @@ class ToolSet:
|
||||
This class provides methods to add, remove, and retrieve tools, as well as
|
||||
convert the tools to different API formats (OpenAI, Anthropic, Google GenAI)."""
|
||||
|
||||
def __init__(self, tools: list[FunctionTool] = None):
|
||||
def __init__(self, tools: list[FunctionTool] | None = None):
|
||||
self.tools: list[FunctionTool] = tools or []
|
||||
|
||||
def empty(self) -> bool:
|
||||
@@ -79,7 +79,13 @@ class ToolSet:
|
||||
return None
|
||||
|
||||
@deprecated(reason="Use add_tool() instead", version="4.0.0")
|
||||
def add_func(self, name: str, func_args: list, desc: str, handler: Awaitable):
|
||||
def add_func(
|
||||
self,
|
||||
name: str,
|
||||
func_args: list,
|
||||
desc: str,
|
||||
handler: Callable[..., Awaitable[Any]],
|
||||
):
|
||||
"""Add a function tool to the set."""
|
||||
params = {
|
||||
"type": "object", # hard-coded here
|
||||
@@ -104,7 +110,7 @@ class ToolSet:
|
||||
self.remove_tool(name)
|
||||
|
||||
@deprecated(reason="Use get_tool() instead", version="4.0.0")
|
||||
def get_func(self, name: str) -> list[FunctionTool]:
|
||||
def get_func(self, name: str) -> FunctionTool | None:
|
||||
"""Get all function tools."""
|
||||
return self.get_tool(name)
|
||||
|
||||
@@ -125,7 +131,11 @@ class ToolSet:
|
||||
},
|
||||
}
|
||||
|
||||
if tool.parameters.get("properties") or not omit_empty_parameter_field:
|
||||
if (
|
||||
tool.parameters
|
||||
and tool.parameters.get("properties")
|
||||
or not omit_empty_parameter_field
|
||||
):
|
||||
func_def["function"]["parameters"] = tool.parameters
|
||||
|
||||
result.append(func_def)
|
||||
@@ -135,14 +145,14 @@ class ToolSet:
|
||||
"""Convert tools to Anthropic API format."""
|
||||
result = []
|
||||
for tool in self.tools:
|
||||
input_schema = {"type": "object"}
|
||||
if tool.parameters:
|
||||
input_schema["properties"] = tool.parameters.get("properties", {})
|
||||
input_schema["required"] = tool.parameters.get("required", [])
|
||||
tool_def = {
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": tool.parameters.get("properties", {}),
|
||||
"required": tool.parameters.get("required", []),
|
||||
},
|
||||
"input_schema": input_schema,
|
||||
}
|
||||
result.append(tool_def)
|
||||
return result
|
||||
@@ -210,14 +220,15 @@ class ToolSet:
|
||||
|
||||
return result
|
||||
|
||||
tools = [
|
||||
{
|
||||
tools = []
|
||||
for tool in self.tools:
|
||||
d = {
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"parameters": convert_schema(tool.parameters),
|
||||
}
|
||||
for tool in self.tools
|
||||
]
|
||||
if tool.parameters:
|
||||
d["parameters"] = convert_schema(tool.parameters)
|
||||
tools.append(d)
|
||||
|
||||
declarations = {}
|
||||
if tools:
|
||||
|
||||
@@ -6,7 +6,7 @@ import os
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
VERSION = "4.1.1"
|
||||
VERSION = "4.1.7"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||
|
||||
# 默认配置
|
||||
@@ -60,6 +60,7 @@ DEFAULT_CONFIG = {
|
||||
"web_search_link": False,
|
||||
"display_reasoning_text": False,
|
||||
"identifier": False,
|
||||
"group_name_display": False,
|
||||
"datetime_system_prompt": True,
|
||||
"default_personality": "default",
|
||||
"persona_pool": ["*"],
|
||||
@@ -235,6 +236,16 @@ CONFIG_METADATA_2 = {
|
||||
"discord_guild_id_for_debug": "",
|
||||
"discord_activity_name": "",
|
||||
},
|
||||
"Misskey": {
|
||||
"id": "misskey",
|
||||
"type": "misskey",
|
||||
"enable": False,
|
||||
"misskey_instance_url": "https://misskey.example",
|
||||
"misskey_token": "",
|
||||
"misskey_default_visibility": "public",
|
||||
"misskey_local_only": False,
|
||||
"misskey_enable_chat": True,
|
||||
},
|
||||
"Slack": {
|
||||
"id": "slack",
|
||||
"type": "slack",
|
||||
@@ -252,7 +263,7 @@ CONFIG_METADATA_2 = {
|
||||
"type": "satori",
|
||||
"enable": False,
|
||||
"satori_api_base_url": "http://localhost:5140/satori/v1",
|
||||
"satori_endpoint": "ws://127.0.0.1:5140/satori/v1/events",
|
||||
"satori_endpoint": "ws://localhost:5140/satori/v1/events",
|
||||
"satori_token": "",
|
||||
"satori_auto_reconnect": True,
|
||||
"satori_heartbeat_interval": 10,
|
||||
@@ -261,34 +272,34 @@ CONFIG_METADATA_2 = {
|
||||
},
|
||||
"items": {
|
||||
"satori_api_base_url": {
|
||||
"description": "Satori API Base URL",
|
||||
"description": "Satori API 终结点",
|
||||
"type": "string",
|
||||
"hint": "The base URL for the Satori API.",
|
||||
"hint": "Satori API 的基础地址。",
|
||||
},
|
||||
"satori_endpoint": {
|
||||
"description": "Satori WebSocket Endpoint",
|
||||
"description": "Satori WebSocket 终结点",
|
||||
"type": "string",
|
||||
"hint": "The WebSocket endpoint for Satori events.",
|
||||
"hint": "Satori 事件的 WebSocket 端点。",
|
||||
},
|
||||
"satori_token": {
|
||||
"description": "Satori Token",
|
||||
"description": "Satori 令牌",
|
||||
"type": "string",
|
||||
"hint": "The token used for authenticating with the Satori API.",
|
||||
"hint": "用于 Satori API 身份验证的令牌。",
|
||||
},
|
||||
"satori_auto_reconnect": {
|
||||
"description": "Enable Auto Reconnect",
|
||||
"description": "启用自动重连",
|
||||
"type": "bool",
|
||||
"hint": "Whether to automatically reconnect the WebSocket on disconnection.",
|
||||
"hint": "断开连接时是否自动重新连接 WebSocket。",
|
||||
},
|
||||
"satori_heartbeat_interval": {
|
||||
"description": "Satori Heartbeat Interval",
|
||||
"description": "Satori 心跳间隔",
|
||||
"type": "int",
|
||||
"hint": "The interval (in seconds) for sending heartbeat messages.",
|
||||
"hint": "发送心跳消息的间隔(秒)。",
|
||||
},
|
||||
"satori_reconnect_delay": {
|
||||
"description": "Satori Reconnect Delay",
|
||||
"description": "Satori 重连延迟",
|
||||
"type": "int",
|
||||
"hint": "The delay (in seconds) before attempting to reconnect.",
|
||||
"hint": "尝试重新连接前的延迟时间(秒)。",
|
||||
},
|
||||
"slack_connection_mode": {
|
||||
"description": "Slack Connection Mode",
|
||||
@@ -336,6 +347,32 @@ CONFIG_METADATA_2 = {
|
||||
"type": "string",
|
||||
"hint": "如果你的网络环境为中国大陆,请在 `其他配置` 处设置代理或更改 api_base。",
|
||||
},
|
||||
"misskey_instance_url": {
|
||||
"description": "Misskey 实例 URL",
|
||||
"type": "string",
|
||||
"hint": "例如 https://misskey.example,填写 Bot 账号所在的 Misskey 实例地址",
|
||||
},
|
||||
"misskey_token": {
|
||||
"description": "Misskey Access Token",
|
||||
"type": "string",
|
||||
"hint": "连接服务设置生成的 API 鉴权访问令牌(Access token)",
|
||||
},
|
||||
"misskey_default_visibility": {
|
||||
"description": "默认帖子可见性",
|
||||
"type": "string",
|
||||
"options": ["public", "home", "followers"],
|
||||
"hint": "机器人发帖时的默认可见性设置。public:公开,home:主页时间线,followers:仅关注者。",
|
||||
},
|
||||
"misskey_local_only": {
|
||||
"description": "仅限本站(不参与联合)",
|
||||
"type": "bool",
|
||||
"hint": "启用后,机器人发出的帖子将仅在本实例可见,不会联合到其他实例",
|
||||
},
|
||||
"misskey_enable_chat": {
|
||||
"description": "启用聊天消息响应",
|
||||
"type": "bool",
|
||||
"hint": "启用后,机器人将会监听和响应私信聊天消息",
|
||||
},
|
||||
"telegram_command_register": {
|
||||
"description": "Telegram 命令注册",
|
||||
"type": "bool",
|
||||
@@ -1724,6 +1761,9 @@ CONFIG_METADATA_2 = {
|
||||
"identifier": {
|
||||
"type": "bool",
|
||||
},
|
||||
"group_name_display": {
|
||||
"type": "bool",
|
||||
},
|
||||
"datetime_system_prompt": {
|
||||
"type": "bool",
|
||||
},
|
||||
@@ -1903,17 +1943,31 @@ CONFIG_METADATA_3 = {
|
||||
"_special": "select_provider",
|
||||
"hint": "留空代表不使用。可用于不支持视觉模态的聊天模型。",
|
||||
},
|
||||
"provider_stt_settings.enable": {
|
||||
"description": "默认启用语音转文本",
|
||||
"type": "bool",
|
||||
},
|
||||
"provider_stt_settings.provider_id": {
|
||||
"description": "语音转文本模型",
|
||||
"type": "string",
|
||||
"hint": "留空代表不使用。",
|
||||
"_special": "select_provider_stt",
|
||||
"condition": {
|
||||
"provider_stt_settings.enable": True,
|
||||
},
|
||||
},
|
||||
"provider_tts_settings.enable": {
|
||||
"description": "默认启用文本转语音",
|
||||
"type": "bool",
|
||||
},
|
||||
"provider_tts_settings.provider_id": {
|
||||
"description": "文本转语音模型",
|
||||
"type": "string",
|
||||
"hint": "留空代表不使用。",
|
||||
"_special": "select_provider_tts",
|
||||
"condition": {
|
||||
"provider_tts_settings.enable": True,
|
||||
},
|
||||
},
|
||||
"provider_settings.image_caption_prompt": {
|
||||
"description": "图片转述提示词",
|
||||
@@ -1983,6 +2037,11 @@ CONFIG_METADATA_3 = {
|
||||
"description": "用户识别",
|
||||
"type": "bool",
|
||||
},
|
||||
"provider_settings.group_name_display": {
|
||||
"description": "显示群名称",
|
||||
"type": "bool",
|
||||
"hint": "启用后,在支持的平台(aiocqhttp)上会在 prompt 中包含群名称信息。",
|
||||
},
|
||||
"provider_settings.datetime_system_prompt": {
|
||||
"description": "现实世界时间感知",
|
||||
"type": "bool",
|
||||
@@ -2130,41 +2189,41 @@ CONFIG_METADATA_3 = {
|
||||
"description": "内容安全",
|
||||
"type": "object",
|
||||
"items": {
|
||||
"platform_settings.content_safety.also_use_in_response": {
|
||||
"content_safety.also_use_in_response": {
|
||||
"description": "同时检查模型的响应内容",
|
||||
"type": "bool",
|
||||
},
|
||||
"platform_settings.content_safety.baidu_aip.enable": {
|
||||
"content_safety.baidu_aip.enable": {
|
||||
"description": "使用百度内容安全审核",
|
||||
"type": "bool",
|
||||
"hint": "您需要手动安装 baidu-aip 库。",
|
||||
},
|
||||
"platform_settings.content_safety.baidu_aip.app_id": {
|
||||
"content_safety.baidu_aip.app_id": {
|
||||
"description": "App ID",
|
||||
"type": "string",
|
||||
"condition": {
|
||||
"platform_settings.content_safety.baidu_aip.enable": True,
|
||||
"content_safety.baidu_aip.enable": True,
|
||||
},
|
||||
},
|
||||
"platform_settings.content_safety.baidu_aip.api_key": {
|
||||
"content_safety.baidu_aip.api_key": {
|
||||
"description": "API Key",
|
||||
"type": "string",
|
||||
"condition": {
|
||||
"platform_settings.content_safety.baidu_aip.enable": True,
|
||||
"content_safety.baidu_aip.enable": True,
|
||||
},
|
||||
},
|
||||
"platform_settings.content_safety.baidu_aip.secret_key": {
|
||||
"content_safety.baidu_aip.secret_key": {
|
||||
"description": "Secret Key",
|
||||
"type": "string",
|
||||
"condition": {
|
||||
"platform_settings.content_safety.baidu_aip.enable": True,
|
||||
"content_safety.baidu_aip.enable": True,
|
||||
},
|
||||
},
|
||||
"platform_settings.content_safety.internal_keywords.enable": {
|
||||
"content_safety.internal_keywords.enable": {
|
||||
"description": "关键词检查",
|
||||
"type": "bool",
|
||||
},
|
||||
"platform_settings.content_safety.internal_keywords.extra_keywords": {
|
||||
"content_safety.internal_keywords.extra_keywords": {
|
||||
"description": "额外关键词",
|
||||
"type": "list",
|
||||
"items": {"type": "string"},
|
||||
|
||||
@@ -53,7 +53,7 @@ async def do_migration_v4(
|
||||
await migration_webchat_data(db_helper, platform_id_map)
|
||||
|
||||
# 执行偏好设置迁移
|
||||
await migration_preferences(db_helper,platform_id_map)
|
||||
await migration_preferences(db_helper, platform_id_map)
|
||||
|
||||
# 执行平台统计表迁移
|
||||
await migration_platform_table(db_helper, platform_id_map)
|
||||
|
||||
@@ -5,6 +5,7 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
_VT = TypeVar("_VT")
|
||||
|
||||
|
||||
class SharedPreferences:
|
||||
def __init__(self, path=None):
|
||||
if path is None:
|
||||
@@ -42,4 +43,5 @@ class SharedPreferences:
|
||||
self._data.clear()
|
||||
self._save_preferences()
|
||||
|
||||
|
||||
sp = SharedPreferences()
|
||||
|
||||
@@ -4,6 +4,7 @@ from astrbot.core.db.po import Platform, Stats
|
||||
from typing import Tuple, List, Dict, Any
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Conversation:
|
||||
"""LLM 对话存储
|
||||
@@ -76,7 +77,7 @@ PRAGMA encoding = 'UTF-8';
|
||||
"""
|
||||
|
||||
|
||||
class SQLiteDatabase():
|
||||
class SQLiteDatabase:
|
||||
def __init__(self, db_path: str) -> None:
|
||||
super().__init__()
|
||||
self.db_path = db_path
|
||||
|
||||
@@ -18,6 +18,7 @@ from astrbot.core.db.po import (
|
||||
from sqlalchemy import select, update, delete, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy import or_
|
||||
|
||||
NOT_GIVEN = T.TypeVar("NOT_GIVEN")
|
||||
|
||||
@@ -153,8 +154,22 @@ class SQLiteDatabase(BaseDatabase):
|
||||
ConversationV2.platform_id.in_(platform_ids)
|
||||
)
|
||||
if search_query:
|
||||
search_query = search_query.encode("unicode_escape").decode("utf-8")
|
||||
base_query = base_query.where(
|
||||
ConversationV2.title.ilike(f"%{search_query}%")
|
||||
or_(
|
||||
ConversationV2.title.ilike(f"%{search_query}%"),
|
||||
ConversationV2.content.ilike(f"%{search_query}%"),
|
||||
ConversationV2.user_id.ilike(f"%{search_query}%"),
|
||||
)
|
||||
)
|
||||
if "message_types" in kwargs and len(kwargs["message_types"]) > 0:
|
||||
for msg_type in kwargs["message_types"]:
|
||||
base_query = base_query.where(
|
||||
ConversationV2.user_id.ilike(f"%:{msg_type}:%")
|
||||
)
|
||||
if "platforms" in kwargs and len(kwargs["platforms"]) > 0:
|
||||
base_query = base_query.where(
|
||||
ConversationV2.platform_id.in_(kwargs["platforms"])
|
||||
)
|
||||
|
||||
# Get total count matching the filters
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
from .vec_db import FaissVecDB
|
||||
|
||||
__all__ = ["FaissVecDB"]
|
||||
__all__ = ["FaissVecDB"]
|
||||
|
||||
@@ -113,7 +113,8 @@ class FaissVecDB(BaseVecDB):
|
||||
reranked_results, key=lambda x: x.relevance_score, reverse=True
|
||||
)
|
||||
top_k_results = [
|
||||
top_k_results[reranked_result.index] for reranked_result in reranked_results
|
||||
top_k_results[reranked_result.index]
|
||||
for reranked_result in reranked_results
|
||||
]
|
||||
|
||||
return top_k_results
|
||||
|
||||
@@ -19,7 +19,7 @@ class ContentSafetyCheckStage(Stage):
|
||||
self.strategy_selector = StrategySelector(config)
|
||||
|
||||
async def process(
|
||||
self, event: AstrMessageEvent, check_text: str = None
|
||||
self, event: AstrMessageEvent, check_text: str | None = None
|
||||
) -> Union[None, AsyncGenerator[None, None]]:
|
||||
"""检查内容安全"""
|
||||
text = check_text if check_text else event.get_message_str()
|
||||
|
||||
@@ -13,7 +13,7 @@ class BaiduAipStrategy(ContentSafetyStrategy):
|
||||
self.secret_key = sk
|
||||
self.client = AipContentCensor(self.app_id, self.api_key, self.secret_key)
|
||||
|
||||
def check(self, content: str):
|
||||
def check(self, content: str) -> tuple[bool, str]:
|
||||
res = self.client.textCensorUserDefined(content)
|
||||
if "conclusionType" not in res:
|
||||
return False, ""
|
||||
|
||||
@@ -16,7 +16,7 @@ class KeywordsStrategy(ContentSafetyStrategy):
|
||||
# json.loads(base64.b64decode(f.read()).decode("utf-8"))["keywords"]
|
||||
# )
|
||||
|
||||
def check(self, content: str) -> bool:
|
||||
def check(self, content: str) -> tuple[bool, str]:
|
||||
for keyword in self.keywords:
|
||||
if re.search(keyword, content):
|
||||
return False, "内容安全检查不通过,匹配到敏感词。"
|
||||
|
||||
@@ -10,7 +10,7 @@ from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
|
||||
async def call_handler(
|
||||
event: AstrMessageEvent,
|
||||
handler: T.Awaitable,
|
||||
handler: T.Callable[..., T.Awaitable[T.Any]],
|
||||
*args,
|
||||
**kwargs,
|
||||
) -> T.AsyncGenerator[T.Any, None]:
|
||||
@@ -36,6 +36,9 @@ async def call_handler(
|
||||
except TypeError:
|
||||
logger.error("处理函数参数不匹配,请检查 handler 的定义。", exc_info=True)
|
||||
|
||||
if not ready_to_call:
|
||||
return
|
||||
|
||||
if inspect.isasyncgen(ready_to_call):
|
||||
_has_yielded = False
|
||||
try:
|
||||
@@ -77,7 +80,7 @@ async def call_event_hook(
|
||||
|
||||
Returns:
|
||||
bool: 如果事件被终止,返回 True
|
||||
# """
|
||||
#"""
|
||||
handlers = star_handlers_registry.get_handlers_by_event_type(
|
||||
hook_type, plugins_name=event.plugins_name
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import copy
|
||||
import json
|
||||
import traceback
|
||||
from typing import AsyncGenerator, Union
|
||||
from astrbot.core.conversation_mgr import Conversation
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.message.components import Image
|
||||
from astrbot.core.message.message_event_result import (
|
||||
@@ -133,6 +134,15 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
|
||||
if agent_runner.done():
|
||||
llm_response = agent_runner.get_final_llm_resp()
|
||||
|
||||
if not llm_response:
|
||||
text_content = mcp.types.TextContent(
|
||||
type="text",
|
||||
text=f"error when deligate task to {tool.agent.name}",
|
||||
)
|
||||
yield mcp.types.CallToolResult(content=[text_content])
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
f"Agent {tool.agent.name} 任务完成, response: {llm_response.completion_text}"
|
||||
)
|
||||
@@ -148,7 +158,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
)
|
||||
yield mcp.types.CallToolResult(content=[text_content])
|
||||
else:
|
||||
yield mcp.types.TextContent(
|
||||
text_content = mcp.types.TextContent(
|
||||
type="text",
|
||||
text=f"error when deligate task to {tool.agent.name}",
|
||||
)
|
||||
@@ -200,7 +210,11 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
|
||||
):
|
||||
if not tool.mcp_client:
|
||||
raise ValueError("MCP client is not available for MCP function tools.")
|
||||
res = await tool.mcp_client.session.call_tool(
|
||||
|
||||
session = tool.mcp_client.session
|
||||
if not session:
|
||||
raise ValueError("MCP session is not available for MCP function tools.")
|
||||
res = await session.call_tool(
|
||||
name=tool.name,
|
||||
arguments=tool_args,
|
||||
)
|
||||
@@ -271,11 +285,11 @@ async def run_agent(
|
||||
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
astr_event.set_result(
|
||||
MessageEventResult().message(
|
||||
f"AstrBot 请求失败。\n错误类型: {type(e).__name__}\n错误信息: {str(e)}\n\n请在控制台查看和分享错误详情。\n"
|
||||
)
|
||||
)
|
||||
err_msg = f"\n\nAstrBot 请求失败。\n错误类型: {type(e).__name__}\n错误信息: {str(e)}\n\n请在控制台查看和分享错误详情。\n"
|
||||
if agent_runner.streaming:
|
||||
yield MessageChain().message(err_msg)
|
||||
else:
|
||||
astr_event.set_result(MessageEventResult().message(err_msg))
|
||||
return
|
||||
asyncio.create_task(
|
||||
Metric.upload(
|
||||
@@ -325,7 +339,7 @@ class LLMRequestSubStage(Stage):
|
||||
|
||||
return _ctx.get_using_provider(umo=event.unified_msg_origin)
|
||||
|
||||
async def _get_session_conv(self, event: AstrMessageEvent):
|
||||
async def _get_session_conv(self, event: AstrMessageEvent) -> Conversation:
|
||||
umo = event.unified_msg_origin
|
||||
conv_mgr = self.conv_manager
|
||||
|
||||
@@ -337,6 +351,8 @@ class LLMRequestSubStage(Stage):
|
||||
if not conversation:
|
||||
cid = await conv_mgr.new_conversation(umo, event.get_platform_id())
|
||||
conversation = await conv_mgr.get_conversation(umo, cid)
|
||||
if not conversation:
|
||||
raise RuntimeError("无法创建新的对话。")
|
||||
return conversation
|
||||
|
||||
async def process(
|
||||
@@ -444,7 +460,10 @@ class LLMRequestSubStage(Stage):
|
||||
if event.plugins_name is not None and req.func_tool:
|
||||
new_tool_set = ToolSet()
|
||||
for tool in req.func_tool.tools:
|
||||
plugin = star_map.get(tool.handler_module_path)
|
||||
mp = tool.handler_module_path
|
||||
if not mp:
|
||||
continue
|
||||
plugin = star_map.get(mp)
|
||||
if not plugin:
|
||||
continue
|
||||
if plugin.name in event.plugins_name or plugin.reserved:
|
||||
|
||||
@@ -34,12 +34,14 @@ class StarRequestSubStage(Stage):
|
||||
|
||||
for handler in activated_handlers:
|
||||
params = handlers_parsed_params.get(handler.handler_full_name, {})
|
||||
try:
|
||||
if handler.handler_module_path not in star_map:
|
||||
continue
|
||||
logger.debug(
|
||||
f"plugin -> {star_map.get(handler.handler_module_path).name} - {handler.handler_name}"
|
||||
md = star_map.get(handler.handler_module_path)
|
||||
if not md:
|
||||
logger.warning(
|
||||
f"Cannot find plugin for given handler module path: {handler.handler_module_path}"
|
||||
)
|
||||
continue
|
||||
logger.debug(f"plugin -> {md.name} - {handler.handler_name}")
|
||||
try:
|
||||
wrapper = call_handler(event, handler.handler, **params)
|
||||
async for ret in wrapper:
|
||||
yield ret
|
||||
@@ -49,7 +51,7 @@ class StarRequestSubStage(Stage):
|
||||
logger.error(f"Star {handler.handler_full_name} handle error: {e}")
|
||||
|
||||
if event.is_at_or_wake_command:
|
||||
ret = f":(\n\n在调用插件 {star_map.get(handler.handler_module_path).name} 的处理函数 {handler.handler_name} 时出现异常:{e}"
|
||||
ret = f":(\n\n在调用插件 {md.name} 的处理函数 {handler.handler_name} 时出现异常:{e}"
|
||||
event.set_result(MessageEventResult().message(ret))
|
||||
yield
|
||||
event.clear_result()
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import random
|
||||
import asyncio
|
||||
import math
|
||||
import traceback
|
||||
import astrbot.core.message.components as Comp
|
||||
from typing import Union, AsyncGenerator
|
||||
from ..stage import register_stage, Stage
|
||||
from ..context import PipelineContext
|
||||
from ..context import PipelineContext, call_event_hook
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.message.message_event_result import MessageChain, ResultContentType
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.message.message_event_result import BaseMessageComponent
|
||||
from astrbot.core.star.star_handler import star_handlers_registry, EventType
|
||||
from astrbot.core.star.star import star_map
|
||||
from astrbot.core.message.components import BaseMessageComponent, ComponentType
|
||||
from astrbot.core.star.star_handler import EventType
|
||||
from astrbot.core.utils.path_util import path_Mapping
|
||||
from astrbot.core.utils.session_lock import session_lock_manager
|
||||
|
||||
@@ -114,6 +112,43 @@ class RespondStage(Stage):
|
||||
# 如果所有组件都为空
|
||||
return True
|
||||
|
||||
def is_seg_reply_required(self, event: AstrMessageEvent) -> bool:
|
||||
"""检查是否需要分段回复"""
|
||||
if not self.enable_seg:
|
||||
return False
|
||||
|
||||
if self.only_llm_result and not event.get_result().is_llm_result():
|
||||
return False
|
||||
|
||||
if event.get_platform_name() in [
|
||||
"qq_official",
|
||||
"weixin_official_account",
|
||||
"dingtalk",
|
||||
]:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _extract_comp(
|
||||
self,
|
||||
raw_chain: list[BaseMessageComponent],
|
||||
extract_types: set[ComponentType],
|
||||
modify_raw_chain: bool = True,
|
||||
):
|
||||
extracted = []
|
||||
if modify_raw_chain:
|
||||
remaining = []
|
||||
for comp in raw_chain:
|
||||
if comp.type in extract_types:
|
||||
extracted.append(comp)
|
||||
else:
|
||||
remaining.append(comp)
|
||||
raw_chain[:] = remaining
|
||||
else:
|
||||
extracted = [comp for comp in raw_chain if comp.type in extract_types]
|
||||
|
||||
return extracted
|
||||
|
||||
async def process(
|
||||
self, event: AstrMessageEvent
|
||||
) -> Union[None, AsyncGenerator[None, None]]:
|
||||
@@ -123,7 +158,14 @@ class RespondStage(Stage):
|
||||
if result.result_content_type == ResultContentType.STREAMING_FINISH:
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"Prepare to send - {event.get_sender_name()}/{event.get_sender_id()}: {event._outline_chain(result.chain)}"
|
||||
)
|
||||
|
||||
if result.result_content_type == ResultContentType.STREAMING_RESULT:
|
||||
if result.async_stream is None:
|
||||
logger.warning("async_stream 为空,跳过发送。")
|
||||
return
|
||||
# 流式结果直接交付平台适配器处理
|
||||
use_fallback = self.config.get("provider_settings", {}).get(
|
||||
"streaming_segmented", False
|
||||
@@ -148,87 +190,71 @@ class RespondStage(Stage):
|
||||
except Exception as e:
|
||||
logger.warning(f"空内容检查异常: {e}")
|
||||
|
||||
record_comps = [c for c in result.chain if isinstance(c, Comp.Record)]
|
||||
non_record_comps = [
|
||||
c for c in result.chain if not isinstance(c, Comp.Record)
|
||||
]
|
||||
|
||||
if (
|
||||
self.enable_seg
|
||||
and (
|
||||
(self.only_llm_result and result.is_llm_result())
|
||||
or not self.only_llm_result
|
||||
# 发送消息链
|
||||
# Record 需要强制单独发送
|
||||
need_separately = {ComponentType.Record}
|
||||
if self.is_seg_reply_required(event):
|
||||
header_comps = self._extract_comp(
|
||||
result.chain,
|
||||
{ComponentType.Reply, ComponentType.At},
|
||||
modify_raw_chain=True,
|
||||
)
|
||||
and event.get_platform_name()
|
||||
not in ["qq_official", "weixin_official_account", "dingtalk"]
|
||||
):
|
||||
decorated_comps = []
|
||||
if self.reply_with_mention:
|
||||
for comp in result.chain:
|
||||
if isinstance(comp, Comp.At):
|
||||
decorated_comps.append(comp)
|
||||
result.chain.remove(comp)
|
||||
break
|
||||
if self.reply_with_quote:
|
||||
for comp in result.chain:
|
||||
if isinstance(comp, Comp.Reply):
|
||||
decorated_comps.append(comp)
|
||||
result.chain.remove(comp)
|
||||
break
|
||||
|
||||
# leverage lock to guarentee the order of message sending among different events
|
||||
if not result.chain or len(result.chain) == 0:
|
||||
# may fix #2670
|
||||
logger.warning(
|
||||
f"实际消息链为空, 跳过发送阶段。header_chain: {header_comps}, actual_chain: {result.chain}"
|
||||
)
|
||||
return
|
||||
async with session_lock_manager.acquire_lock(event.unified_msg_origin):
|
||||
for rcomp in record_comps:
|
||||
i = await self._calc_comp_interval(rcomp)
|
||||
await asyncio.sleep(i)
|
||||
try:
|
||||
await event.send(MessageChain([rcomp]))
|
||||
except Exception as e:
|
||||
logger.error(f"发送消息失败: {e} chain: {result.chain}")
|
||||
break
|
||||
# 分段回复
|
||||
for comp in non_record_comps:
|
||||
for comp in result.chain:
|
||||
i = await self._calc_comp_interval(comp)
|
||||
await asyncio.sleep(i)
|
||||
try:
|
||||
await event.send(MessageChain([*decorated_comps, comp]))
|
||||
decorated_comps = [] # 清空已发送的装饰组件
|
||||
if comp.type in need_separately:
|
||||
await event.send(MessageChain([comp]))
|
||||
else:
|
||||
await event.send(MessageChain([*header_comps, comp]))
|
||||
header_comps.clear()
|
||||
except Exception as e:
|
||||
logger.error(f"发送消息失败: {e} chain: {result.chain}")
|
||||
break
|
||||
logger.error(
|
||||
f"发送消息链失败: chain = {MessageChain([comp])}, error = {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
for rcomp in record_comps:
|
||||
if all(
|
||||
comp.type in {ComponentType.Reply, ComponentType.At}
|
||||
for comp in result.chain
|
||||
):
|
||||
# may fix #2670
|
||||
logger.warning(
|
||||
f"消息链全为 Reply 和 At 消息段, 跳过发送阶段。chain: {result.chain}"
|
||||
)
|
||||
return
|
||||
sep_comps = self._extract_comp(
|
||||
result.chain,
|
||||
need_separately,
|
||||
modify_raw_chain=True,
|
||||
)
|
||||
for comp in sep_comps:
|
||||
chain = MessageChain([comp])
|
||||
try:
|
||||
await event.send(MessageChain([rcomp]))
|
||||
await event.send(chain)
|
||||
except Exception as e:
|
||||
logger.error(f"发送消息失败: {e} chain: {result.chain}")
|
||||
logger.error(
|
||||
f"发送消息链失败: chain = {chain}, error = {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
chain = MessageChain(result.chain)
|
||||
if result.chain and len(result.chain) > 0:
|
||||
try:
|
||||
await event.send(chain)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"发送消息链失败: chain = {chain}, error = {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
try:
|
||||
await event.send(MessageChain(non_record_comps))
|
||||
except Exception as e:
|
||||
logger.error(traceback.format_exc())
|
||||
logger.error(f"发送消息失败: {e} chain: {result.chain}")
|
||||
|
||||
logger.info(
|
||||
f"AstrBot -> {event.get_sender_name()}/{event.get_sender_id()}: {event._outline_chain(result.chain)}"
|
||||
)
|
||||
|
||||
handlers = star_handlers_registry.get_handlers_by_event_type(
|
||||
EventType.OnAfterMessageSentEvent, plugins_name=event.plugins_name
|
||||
)
|
||||
for handler in handlers:
|
||||
try:
|
||||
logger.debug(
|
||||
f"hook(on_after_message_sent) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}"
|
||||
)
|
||||
await handler.handler(event)
|
||||
except BaseException:
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
if event.is_stopped():
|
||||
logger.info(
|
||||
f"{star_map[handler.handler_module_path].name} - {handler.handler_name} 终止了事件传播。"
|
||||
)
|
||||
return
|
||||
if await call_event_hook(event, EventType.OnAfterMessageSentEvent):
|
||||
return
|
||||
|
||||
event.clear_result()
|
||||
|
||||
@@ -24,7 +24,7 @@ from astrbot.core.provider.entities import ProviderRequest
|
||||
from astrbot.core.utils.metrics import Metric
|
||||
from .astrbot_message import AstrBotMessage, Group
|
||||
from .platform_metadata import PlatformMetadata
|
||||
from .message_session import MessageSession, MessageSesion # noqa
|
||||
from .message_session import MessageSession, MessageSesion # noqa
|
||||
|
||||
|
||||
class AstrMessageEvent(abc.ABC):
|
||||
|
||||
@@ -55,7 +55,7 @@ class AstrBotMessage:
|
||||
self_id: str # 机器人的识别id
|
||||
session_id: str # 会话id。取决于 unique_session 的设置。
|
||||
message_id: str # 消息id
|
||||
group_id: str = "" # 群组id,如果为私聊,则为空
|
||||
group: Group # 群组
|
||||
sender: MessageMember # 发送者
|
||||
message: List[BaseMessageComponent] # 消息链使用 Nakuru 的消息链格式
|
||||
message_str: str # 最直观的纯文本消息字符串
|
||||
@@ -64,6 +64,28 @@ class AstrBotMessage:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.timestamp = int(time.time())
|
||||
self.group = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self.__dict__)
|
||||
|
||||
@property
|
||||
def group_id(self) -> str:
|
||||
"""
|
||||
向后兼容的 group_id 属性
|
||||
群组id,如果为私聊,则为空
|
||||
"""
|
||||
if self.group:
|
||||
return self.group.group_id
|
||||
return ""
|
||||
|
||||
@group_id.setter
|
||||
def group_id(self, value: str):
|
||||
"""设置 group_id"""
|
||||
if value:
|
||||
if self.group:
|
||||
self.group.group_id = value
|
||||
else:
|
||||
self.group = Group(group_id=value)
|
||||
else:
|
||||
self.group = None
|
||||
|
||||
@@ -68,34 +68,38 @@ class PlatformManager:
|
||||
)
|
||||
case "lark":
|
||||
from .sources.lark.lark_adapter import (
|
||||
LarkPlatformAdapter,
|
||||
) # noqa: F401
|
||||
LarkPlatformAdapter, # noqa: F401
|
||||
)
|
||||
case "dingtalk":
|
||||
from .sources.dingtalk.dingtalk_adapter import (
|
||||
DingtalkPlatformAdapter, # noqa: F401
|
||||
)
|
||||
case "telegram":
|
||||
from .sources.telegram.tg_adapter import (
|
||||
TelegramPlatformAdapter,
|
||||
) # noqa: F401
|
||||
TelegramPlatformAdapter, # noqa: F401
|
||||
)
|
||||
case "wecom":
|
||||
from .sources.wecom.wecom_adapter import (
|
||||
WecomPlatformAdapter,
|
||||
) # noqa: F401
|
||||
WecomPlatformAdapter, # noqa: F401
|
||||
)
|
||||
case "weixin_official_account":
|
||||
from .sources.weixin_official_account.weixin_offacc_adapter import (
|
||||
WeixinOfficialAccountPlatformAdapter, # noqa
|
||||
WeixinOfficialAccountPlatformAdapter, # noqa: F401
|
||||
)
|
||||
case "discord":
|
||||
from .sources.discord.discord_platform_adapter import (
|
||||
DiscordPlatformAdapter, # noqa: F401
|
||||
)
|
||||
case "misskey":
|
||||
from .sources.misskey.misskey_adapter import (
|
||||
MisskeyPlatformAdapter, # noqa: F401
|
||||
)
|
||||
case "slack":
|
||||
from .sources.slack.slack_adapter import SlackAdapter # noqa: F401
|
||||
case "satori":
|
||||
from .sources.satori.satori_adapter import (
|
||||
SatoriPlatformAdapter,
|
||||
) # noqa: F401
|
||||
SatoriPlatformAdapter, # noqa: F401
|
||||
)
|
||||
except (ImportError, ModuleNotFoundError) as e:
|
||||
logger.error(
|
||||
f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->控制台->安装Pip库 中安装依赖库。"
|
||||
|
||||
@@ -182,11 +182,13 @@ class AiocqhttpAdapter(Platform):
|
||||
abm = AstrBotMessage()
|
||||
abm.self_id = str(event.self_id)
|
||||
abm.sender = MessageMember(
|
||||
str(event.sender["user_id"]), event.sender["nickname"]
|
||||
str(event.sender["user_id"]),
|
||||
event.sender.get("card") or event.sender.get("nickname", "N/A"),
|
||||
)
|
||||
if event["message_type"] == "group":
|
||||
abm.type = MessageType.GROUP_MESSAGE
|
||||
abm.group_id = str(event.group_id)
|
||||
abm.group.group_name = event.get("group_name", "N/A")
|
||||
elif event["message_type"] == "private":
|
||||
abm.type = MessageType.FRIEND_MESSAGE
|
||||
if self.unique_session and abm.type == MessageType.GROUP_MESSAGE:
|
||||
@@ -321,7 +323,9 @@ class AiocqhttpAdapter(Platform):
|
||||
user_id=int(m["data"]["qq"]),
|
||||
no_cache=False,
|
||||
)
|
||||
nickname = at_info.get("nick", "") or at_info.get("nickname", "")
|
||||
nickname = at_info.get("nick", "") or at_info.get(
|
||||
"nickname", ""
|
||||
)
|
||||
is_at_self = str(m["data"]["qq"]) in {abm.self_id, "all"}
|
||||
|
||||
abm.message.append(
|
||||
|
||||
@@ -54,9 +54,9 @@ class DingtalkMessageEvent(AstrMessageEvent):
|
||||
logger.debug(f"send image: {ret}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"钉钉图片处理失败: {e}")
|
||||
logger.warning(f"跳过图片发送: {image_path}")
|
||||
logger.warning(f"钉钉图片处理失败: {e}, 跳过图片发送")
|
||||
continue
|
||||
|
||||
async def send(self, message: MessageChain):
|
||||
await self.send_with_client(self.client, message)
|
||||
await super().send(message)
|
||||
|
||||
@@ -41,7 +41,8 @@ class DiscordBotClient(discord.Bot):
|
||||
await self.on_ready_once_callback()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[Discord] on_ready_once_callback 执行失败: {e}", exc_info=True)
|
||||
f"[Discord] on_ready_once_callback 执行失败: {e}", exc_info=True
|
||||
)
|
||||
|
||||
def _create_message_data(self, message: discord.Message) -> dict:
|
||||
"""从 discord.Message 创建数据字典"""
|
||||
@@ -90,7 +91,6 @@ class DiscordBotClient(discord.Bot):
|
||||
message_data = self._create_message_data(message)
|
||||
await self.on_message_received(message_data)
|
||||
|
||||
|
||||
def _extract_interaction_content(self, interaction: discord.Interaction) -> str:
|
||||
"""从交互中提取内容"""
|
||||
interaction_type = interaction.type
|
||||
|
||||
@@ -79,9 +79,12 @@ class DiscordButton(BaseMessageComponent):
|
||||
self.url = url
|
||||
self.disabled = disabled
|
||||
|
||||
|
||||
class DiscordReference(BaseMessageComponent):
|
||||
"""Discord引用组件"""
|
||||
|
||||
type: str = "discord_reference"
|
||||
|
||||
def __init__(self, message_id: str, channel_id: str):
|
||||
self.message_id = message_id
|
||||
self.channel_id = channel_id
|
||||
@@ -98,7 +101,6 @@ class DiscordView(BaseMessageComponent):
|
||||
self.components = components or []
|
||||
self.timeout = timeout
|
||||
|
||||
|
||||
def to_discord_view(self) -> discord.ui.View:
|
||||
"""转换为Discord View对象"""
|
||||
view = discord.ui.View(timeout=self.timeout)
|
||||
|
||||
@@ -53,7 +53,13 @@ class DiscordPlatformEvent(AstrMessageEvent):
|
||||
|
||||
# 解析消息链为 Discord 所需的对象
|
||||
try:
|
||||
content, files, view, embeds, reference_message_id = await self._parse_to_discord(message)
|
||||
(
|
||||
content,
|
||||
files,
|
||||
view,
|
||||
embeds,
|
||||
reference_message_id,
|
||||
) = await self._parse_to_discord(message)
|
||||
except Exception as e:
|
||||
logger.error(f"[Discord] 解析消息链时失败: {e}", exc_info=True)
|
||||
return
|
||||
@@ -206,8 +212,7 @@ class DiscordPlatformEvent(AstrMessageEvent):
|
||||
if await asyncio.to_thread(path.exists):
|
||||
file_bytes = await asyncio.to_thread(path.read_bytes)
|
||||
files.append(
|
||||
discord.File(BytesIO(file_bytes),
|
||||
filename=i.name)
|
||||
discord.File(BytesIO(file_bytes), filename=i.name)
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Dict, Any, Optional, Awaitable
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.api.platform import (
|
||||
AstrBotMessage,
|
||||
Platform,
|
||||
PlatformMetadata,
|
||||
register_platform_adapter,
|
||||
)
|
||||
from astrbot.core.platform.astr_message_event import MessageSession
|
||||
import astrbot.api.message_components as Comp
|
||||
|
||||
from .misskey_api import MisskeyAPI
|
||||
from .misskey_event import MisskeyPlatformEvent
|
||||
from .misskey_utils import (
|
||||
serialize_message_chain,
|
||||
resolve_message_visibility,
|
||||
is_valid_user_session_id,
|
||||
is_valid_room_session_id,
|
||||
add_at_mention_if_needed,
|
||||
process_files,
|
||||
extract_sender_info,
|
||||
create_base_message,
|
||||
process_at_mention,
|
||||
cache_user_info,
|
||||
cache_room_info,
|
||||
)
|
||||
|
||||
|
||||
@register_platform_adapter("misskey", "Misskey 平台适配器")
|
||||
class MisskeyPlatformAdapter(Platform):
|
||||
def __init__(
|
||||
self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue
|
||||
) -> None:
|
||||
super().__init__(event_queue)
|
||||
self.config = platform_config or {}
|
||||
self.settings = platform_settings or {}
|
||||
self.instance_url = self.config.get("misskey_instance_url", "")
|
||||
self.access_token = self.config.get("misskey_token", "")
|
||||
self.max_message_length = self.config.get("max_message_length", 3000)
|
||||
self.default_visibility = self.config.get(
|
||||
"misskey_default_visibility", "public"
|
||||
)
|
||||
self.local_only = self.config.get("misskey_local_only", False)
|
||||
self.enable_chat = self.config.get("misskey_enable_chat", True)
|
||||
|
||||
self.unique_session = platform_settings["unique_session"]
|
||||
|
||||
self.api: Optional[MisskeyAPI] = None
|
||||
self._running = False
|
||||
self.client_self_id = ""
|
||||
self._bot_username = ""
|
||||
self._user_cache = {}
|
||||
|
||||
def meta(self) -> PlatformMetadata:
|
||||
default_config = {
|
||||
"misskey_instance_url": "",
|
||||
"misskey_token": "",
|
||||
"max_message_length": 3000,
|
||||
"misskey_default_visibility": "public",
|
||||
"misskey_local_only": False,
|
||||
"misskey_enable_chat": True,
|
||||
}
|
||||
default_config.update(self.config)
|
||||
|
||||
return PlatformMetadata(
|
||||
name="misskey",
|
||||
description="Misskey 平台适配器",
|
||||
id=self.config.get("id", "misskey"),
|
||||
default_config_tmpl=default_config,
|
||||
)
|
||||
|
||||
async def run(self):
|
||||
if not self.instance_url or not self.access_token:
|
||||
logger.error("[Misskey] 配置不完整,无法启动")
|
||||
return
|
||||
|
||||
self.api = MisskeyAPI(self.instance_url, self.access_token)
|
||||
self._running = True
|
||||
|
||||
try:
|
||||
user_info = await self.api.get_current_user()
|
||||
self.client_self_id = str(user_info.get("id", ""))
|
||||
self._bot_username = user_info.get("username", "")
|
||||
logger.info(
|
||||
f"[Misskey] 已连接用户: {self._bot_username} (ID: {self.client_self_id})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[Misskey] 获取用户信息失败: {e}")
|
||||
self._running = False
|
||||
return
|
||||
|
||||
await self._start_websocket_connection()
|
||||
|
||||
async def _start_websocket_connection(self):
|
||||
backoff_delay = 1.0
|
||||
max_backoff = 300.0
|
||||
backoff_multiplier = 1.5
|
||||
connection_attempts = 0
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
connection_attempts += 1
|
||||
if not self.api:
|
||||
logger.error("[Misskey] API 客户端未初始化")
|
||||
break
|
||||
|
||||
streaming = self.api.get_streaming_client()
|
||||
streaming.add_message_handler("notification", self._handle_notification)
|
||||
if self.enable_chat:
|
||||
streaming.add_message_handler(
|
||||
"newChatMessage", self._handle_chat_message
|
||||
)
|
||||
streaming.add_message_handler("_debug", self._debug_handler)
|
||||
|
||||
if await streaming.connect():
|
||||
logger.info(
|
||||
f"[Misskey] WebSocket 已连接 (尝试 #{connection_attempts})"
|
||||
)
|
||||
connection_attempts = 0 # 重置计数器
|
||||
await streaming.subscribe_channel("main")
|
||||
if self.enable_chat:
|
||||
await streaming.subscribe_channel("messaging")
|
||||
await streaming.subscribe_channel("messagingIndex")
|
||||
logger.info("[Misskey] 聊天频道已订阅")
|
||||
|
||||
backoff_delay = 1.0 # 重置延迟
|
||||
await streaming.listen()
|
||||
else:
|
||||
logger.error(
|
||||
f"[Misskey] WebSocket 连接失败 (尝试 #{connection_attempts})"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[Misskey] WebSocket 异常 (尝试 #{connection_attempts}): {e}"
|
||||
)
|
||||
|
||||
if self._running:
|
||||
logger.info(
|
||||
f"[Misskey] {backoff_delay:.1f}秒后重连 (下次尝试 #{connection_attempts + 1})"
|
||||
)
|
||||
await asyncio.sleep(backoff_delay)
|
||||
backoff_delay = min(backoff_delay * backoff_multiplier, max_backoff)
|
||||
|
||||
async def _handle_notification(self, data: Dict[str, Any]):
|
||||
try:
|
||||
logger.debug(
|
||||
f"[Misskey] 收到通知事件:\n{json.dumps(data, indent=2, ensure_ascii=False)}"
|
||||
)
|
||||
notification_type = data.get("type")
|
||||
if notification_type in ["mention", "reply", "quote"]:
|
||||
note = data.get("note")
|
||||
if note and self._is_bot_mentioned(note):
|
||||
logger.info(
|
||||
f"[Misskey] 处理贴文提及: {note.get('text', '')[:50]}..."
|
||||
)
|
||||
message = await self.convert_message(note)
|
||||
event = MisskeyPlatformEvent(
|
||||
message_str=message.message_str,
|
||||
message_obj=message,
|
||||
platform_meta=self.meta(),
|
||||
session_id=message.session_id,
|
||||
client=self.api,
|
||||
)
|
||||
self.commit_event(event)
|
||||
except Exception as e:
|
||||
logger.error(f"[Misskey] 处理通知失败: {e}")
|
||||
|
||||
async def _handle_chat_message(self, data: Dict[str, Any]):
|
||||
try:
|
||||
logger.debug(
|
||||
f"[Misskey] 收到聊天事件数据:\n{json.dumps(data, indent=2, ensure_ascii=False)}"
|
||||
)
|
||||
|
||||
sender_id = str(
|
||||
data.get("fromUserId", "") or data.get("fromUser", {}).get("id", "")
|
||||
)
|
||||
if sender_id == self.client_self_id:
|
||||
return
|
||||
|
||||
room_id = data.get("toRoomId")
|
||||
if room_id:
|
||||
raw_text = data.get("text", "")
|
||||
logger.debug(
|
||||
f"[Misskey] 检查群聊消息: '{raw_text}', 机器人用户名: '{self._bot_username}'"
|
||||
)
|
||||
|
||||
message = await self.convert_room_message(data)
|
||||
logger.info(f"[Misskey] 处理群聊消息: {message.message_str[:50]}...")
|
||||
else:
|
||||
message = await self.convert_chat_message(data)
|
||||
logger.info(f"[Misskey] 处理私聊消息: {message.message_str[:50]}...")
|
||||
|
||||
event = MisskeyPlatformEvent(
|
||||
message_str=message.message_str,
|
||||
message_obj=message,
|
||||
platform_meta=self.meta(),
|
||||
session_id=message.session_id,
|
||||
client=self.api,
|
||||
)
|
||||
self.commit_event(event)
|
||||
except Exception as e:
|
||||
logger.error(f"[Misskey] 处理聊天消息失败: {e}")
|
||||
|
||||
async def _debug_handler(self, data: Dict[str, Any]):
|
||||
logger.debug(
|
||||
f"[Misskey] 收到未处理事件:\n{json.dumps(data, indent=2, ensure_ascii=False)}"
|
||||
)
|
||||
|
||||
def _is_bot_mentioned(self, note: Dict[str, Any]) -> bool:
|
||||
text = note.get("text", "")
|
||||
if not text:
|
||||
return False
|
||||
|
||||
mentions = note.get("mentions", [])
|
||||
if self._bot_username and f"@{self._bot_username}" in text:
|
||||
return True
|
||||
if self.client_self_id in [str(uid) for uid in mentions]:
|
||||
return True
|
||||
|
||||
reply = note.get("reply")
|
||||
if reply and isinstance(reply, dict):
|
||||
reply_user_id = str(reply.get("user", {}).get("id", ""))
|
||||
if reply_user_id == self.client_self_id:
|
||||
return bool(self._bot_username and f"@{self._bot_username}" in text)
|
||||
|
||||
return False
|
||||
|
||||
async def send_by_session(
|
||||
self, session: MessageSession, message_chain: MessageChain
|
||||
) -> Awaitable[Any]:
|
||||
if not self.api:
|
||||
logger.error("[Misskey] API 客户端未初始化")
|
||||
return await super().send_by_session(session, message_chain)
|
||||
|
||||
try:
|
||||
session_id = session.session_id
|
||||
text, has_at_user = serialize_message_chain(message_chain.chain)
|
||||
|
||||
if not has_at_user and session_id:
|
||||
user_info = self._user_cache.get(session_id)
|
||||
text = add_at_mention_if_needed(text, user_info, has_at_user)
|
||||
|
||||
if not text or not text.strip():
|
||||
logger.warning("[Misskey] 消息内容为空,跳过发送")
|
||||
return await super().send_by_session(session, message_chain)
|
||||
|
||||
if len(text) > self.max_message_length:
|
||||
text = text[: self.max_message_length] + "..."
|
||||
|
||||
if session_id and is_valid_user_session_id(session_id):
|
||||
from .misskey_utils import extract_user_id_from_session_id
|
||||
|
||||
user_id = extract_user_id_from_session_id(session_id)
|
||||
await self.api.send_message(user_id, text)
|
||||
elif session_id and is_valid_room_session_id(session_id):
|
||||
from .misskey_utils import extract_room_id_from_session_id
|
||||
|
||||
room_id = extract_room_id_from_session_id(session_id)
|
||||
await self.api.send_room_message(room_id, text)
|
||||
else:
|
||||
visibility, visible_user_ids = resolve_message_visibility(
|
||||
user_id=session_id,
|
||||
user_cache=self._user_cache,
|
||||
self_id=self.client_self_id,
|
||||
default_visibility=self.default_visibility,
|
||||
)
|
||||
|
||||
await self.api.create_note(
|
||||
text,
|
||||
visibility=visibility,
|
||||
visible_user_ids=visible_user_ids,
|
||||
local_only=self.local_only,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Misskey] 发送消息失败: {e}")
|
||||
|
||||
return await super().send_by_session(session, message_chain)
|
||||
|
||||
async def convert_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage:
|
||||
"""将 Misskey 贴文数据转换为 AstrBotMessage 对象"""
|
||||
sender_info = extract_sender_info(raw_data, is_chat=False)
|
||||
message = create_base_message(
|
||||
raw_data,
|
||||
sender_info,
|
||||
self.client_self_id,
|
||||
is_chat=False,
|
||||
unique_session=self.unique_session,
|
||||
)
|
||||
cache_user_info(
|
||||
self._user_cache, sender_info, raw_data, self.client_self_id, is_chat=False
|
||||
)
|
||||
|
||||
message_parts = []
|
||||
raw_text = raw_data.get("text", "")
|
||||
|
||||
if raw_text:
|
||||
text_parts, processed_text = process_at_mention(
|
||||
message, raw_text, self._bot_username, self.client_self_id
|
||||
)
|
||||
message_parts.extend(text_parts)
|
||||
|
||||
files = raw_data.get("files", [])
|
||||
file_parts = process_files(message, files)
|
||||
message_parts.extend(file_parts)
|
||||
|
||||
message.message_str = (
|
||||
" ".join(part for part in message_parts if part.strip())
|
||||
if message_parts
|
||||
else ""
|
||||
)
|
||||
return message
|
||||
|
||||
async def convert_chat_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage:
|
||||
"""将 Misskey 聊天消息数据转换为 AstrBotMessage 对象"""
|
||||
sender_info = extract_sender_info(raw_data, is_chat=True)
|
||||
message = create_base_message(
|
||||
raw_data,
|
||||
sender_info,
|
||||
self.client_self_id,
|
||||
is_chat=True,
|
||||
unique_session=self.unique_session,
|
||||
)
|
||||
cache_user_info(
|
||||
self._user_cache, sender_info, raw_data, self.client_self_id, is_chat=True
|
||||
)
|
||||
|
||||
raw_text = raw_data.get("text", "")
|
||||
if raw_text:
|
||||
message.message.append(Comp.Plain(raw_text))
|
||||
|
||||
files = raw_data.get("files", [])
|
||||
process_files(message, files, include_text_parts=False)
|
||||
|
||||
message.message_str = raw_text if raw_text else ""
|
||||
return message
|
||||
|
||||
async def convert_room_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage:
|
||||
"""将 Misskey 群聊消息数据转换为 AstrBotMessage 对象"""
|
||||
sender_info = extract_sender_info(raw_data, is_chat=True)
|
||||
room_id = raw_data.get("toRoomId", "")
|
||||
message = create_base_message(
|
||||
raw_data,
|
||||
sender_info,
|
||||
self.client_self_id,
|
||||
is_chat=False,
|
||||
room_id=room_id,
|
||||
unique_session=self.unique_session,
|
||||
)
|
||||
|
||||
cache_user_info(
|
||||
self._user_cache, sender_info, raw_data, self.client_self_id, is_chat=False
|
||||
)
|
||||
cache_room_info(self._user_cache, raw_data, self.client_self_id)
|
||||
|
||||
raw_text = raw_data.get("text", "")
|
||||
message_parts = []
|
||||
|
||||
if raw_text:
|
||||
if self._bot_username and f"@{self._bot_username}" in raw_text:
|
||||
text_parts, processed_text = process_at_mention(
|
||||
message, raw_text, self._bot_username, self.client_self_id
|
||||
)
|
||||
message_parts.extend(text_parts)
|
||||
else:
|
||||
message.message.append(Comp.Plain(raw_text))
|
||||
message_parts.append(raw_text)
|
||||
|
||||
files = raw_data.get("files", [])
|
||||
file_parts = process_files(message, files)
|
||||
message_parts.extend(file_parts)
|
||||
|
||||
message.message_str = (
|
||||
" ".join(part for part in message_parts if part.strip())
|
||||
if message_parts
|
||||
else ""
|
||||
)
|
||||
return message
|
||||
|
||||
async def terminate(self):
|
||||
self._running = False
|
||||
if self.api:
|
||||
await self.api.close()
|
||||
|
||||
def get_client(self) -> Any:
|
||||
return self.api
|
||||
@@ -0,0 +1,404 @@
|
||||
import json
|
||||
from typing import Any, Optional, Dict, List, Callable, Awaitable
|
||||
import uuid
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
import websockets
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"aiohttp and websockets are required for Misskey API. Please install them with: pip install aiohttp websockets"
|
||||
) from e
|
||||
|
||||
from astrbot.api import logger
|
||||
|
||||
# Constants
|
||||
API_MAX_RETRIES = 3
|
||||
HTTP_OK = 200
|
||||
|
||||
|
||||
class APIError(Exception):
|
||||
"""Misskey API 基础异常"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class APIConnectionError(APIError):
|
||||
"""网络连接异常"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class APIRateLimitError(APIError):
|
||||
"""API 频率限制异常"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AuthenticationError(APIError):
|
||||
"""认证失败异常"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class WebSocketError(APIError):
|
||||
"""WebSocket 连接异常"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class StreamingClient:
|
||||
def __init__(self, instance_url: str, access_token: str):
|
||||
self.instance_url = instance_url.rstrip("/")
|
||||
self.access_token = access_token
|
||||
self.websocket: Optional[Any] = None
|
||||
self.is_connected = False
|
||||
self.message_handlers: Dict[str, Callable] = {}
|
||||
self.channels: Dict[str, str] = {}
|
||||
self._running = False
|
||||
self._last_pong = None
|
||||
|
||||
async def connect(self) -> bool:
|
||||
try:
|
||||
ws_url = self.instance_url.replace("https://", "wss://").replace(
|
||||
"http://", "ws://"
|
||||
)
|
||||
ws_url += f"/streaming?i={self.access_token}"
|
||||
|
||||
self.websocket = await websockets.connect(
|
||||
ws_url, ping_interval=30, ping_timeout=10
|
||||
)
|
||||
self.is_connected = True
|
||||
self._running = True
|
||||
|
||||
logger.info("[Misskey WebSocket] 已连接")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Misskey WebSocket] 连接失败: {e}")
|
||||
self.is_connected = False
|
||||
return False
|
||||
|
||||
async def disconnect(self):
|
||||
self._running = False
|
||||
if self.websocket:
|
||||
await self.websocket.close()
|
||||
self.websocket = None
|
||||
self.is_connected = False
|
||||
logger.info("[Misskey WebSocket] 连接已断开")
|
||||
|
||||
async def subscribe_channel(
|
||||
self, channel_type: str, params: Optional[Dict] = None
|
||||
) -> str:
|
||||
if not self.is_connected or not self.websocket:
|
||||
raise WebSocketError("WebSocket 未连接")
|
||||
|
||||
channel_id = str(uuid.uuid4())
|
||||
message = {
|
||||
"type": "connect",
|
||||
"body": {"channel": channel_type, "id": channel_id, "params": params or {}},
|
||||
}
|
||||
|
||||
await self.websocket.send(json.dumps(message))
|
||||
self.channels[channel_id] = channel_type
|
||||
return channel_id
|
||||
|
||||
async def unsubscribe_channel(self, channel_id: str):
|
||||
if (
|
||||
not self.is_connected
|
||||
or not self.websocket
|
||||
or channel_id not in self.channels
|
||||
):
|
||||
return
|
||||
|
||||
message = {"type": "disconnect", "body": {"id": channel_id}}
|
||||
|
||||
await self.websocket.send(json.dumps(message))
|
||||
del self.channels[channel_id]
|
||||
|
||||
def add_message_handler(
|
||||
self, event_type: str, handler: Callable[[Dict], Awaitable[None]]
|
||||
):
|
||||
self.message_handlers[event_type] = handler
|
||||
|
||||
async def listen(self):
|
||||
if not self.is_connected or not self.websocket:
|
||||
raise WebSocketError("WebSocket 未连接")
|
||||
|
||||
try:
|
||||
async for message in self.websocket:
|
||||
if not self._running:
|
||||
break
|
||||
|
||||
try:
|
||||
data = json.loads(message)
|
||||
await self._handle_message(data)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"[Misskey WebSocket] 无法解析消息: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"[Misskey WebSocket] 处理消息失败: {e}")
|
||||
|
||||
except websockets.exceptions.ConnectionClosedError as e:
|
||||
logger.warning(f"[Misskey WebSocket] 连接意外关闭: {e}")
|
||||
self.is_connected = False
|
||||
except websockets.exceptions.ConnectionClosed as e:
|
||||
logger.warning(
|
||||
f"[Misskey WebSocket] 连接已关闭 (代码: {e.code}, 原因: {e.reason})"
|
||||
)
|
||||
self.is_connected = False
|
||||
except websockets.exceptions.InvalidHandshake as e:
|
||||
logger.error(f"[Misskey WebSocket] 握手失败: {e}")
|
||||
self.is_connected = False
|
||||
except Exception as e:
|
||||
logger.error(f"[Misskey WebSocket] 监听消息失败: {e}")
|
||||
self.is_connected = False
|
||||
|
||||
async def _handle_message(self, data: Dict[str, Any]):
|
||||
message_type = data.get("type")
|
||||
body = data.get("body", {})
|
||||
|
||||
logger.debug(
|
||||
f"[Misskey WebSocket] 收到消息类型: {message_type}\n数据: {json.dumps(data, indent=2, ensure_ascii=False)}"
|
||||
)
|
||||
|
||||
if message_type == "channel":
|
||||
channel_id = body.get("id")
|
||||
event_type = body.get("type")
|
||||
event_body = body.get("body", {})
|
||||
|
||||
logger.debug(
|
||||
f"[Misskey WebSocket] 频道消息: {channel_id}, 事件类型: {event_type}"
|
||||
)
|
||||
|
||||
if channel_id in self.channels:
|
||||
channel_type = self.channels[channel_id]
|
||||
handler_key = f"{channel_type}:{event_type}"
|
||||
|
||||
if handler_key in self.message_handlers:
|
||||
logger.debug(f"[Misskey WebSocket] 使用处理器: {handler_key}")
|
||||
await self.message_handlers[handler_key](event_body)
|
||||
elif event_type in self.message_handlers:
|
||||
logger.debug(f"[Misskey WebSocket] 使用事件处理器: {event_type}")
|
||||
await self.message_handlers[event_type](event_body)
|
||||
else:
|
||||
logger.debug(
|
||||
f"[Misskey WebSocket] 未找到处理器: {handler_key} 或 {event_type}"
|
||||
)
|
||||
if "_debug" in self.message_handlers:
|
||||
await self.message_handlers["_debug"](
|
||||
{
|
||||
"type": event_type,
|
||||
"body": event_body,
|
||||
"channel": channel_type,
|
||||
}
|
||||
)
|
||||
|
||||
elif message_type in self.message_handlers:
|
||||
logger.debug(f"[Misskey WebSocket] 直接消息处理器: {message_type}")
|
||||
await self.message_handlers[message_type](body)
|
||||
else:
|
||||
logger.debug(f"[Misskey WebSocket] 未处理的消息类型: {message_type}")
|
||||
if "_debug" in self.message_handlers:
|
||||
await self.message_handlers["_debug"](data)
|
||||
|
||||
|
||||
def retry_async(max_retries: int = 3, retryable_exceptions: tuple = ()):
|
||||
def decorator(func):
|
||||
async def wrapper(*args, **kwargs):
|
||||
last_exc = None
|
||||
for _ in range(max_retries):
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except retryable_exceptions as e:
|
||||
last_exc = e
|
||||
continue
|
||||
if last_exc:
|
||||
raise last_exc
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
class MisskeyAPI:
|
||||
def __init__(self, instance_url: str, access_token: str):
|
||||
self.instance_url = instance_url.rstrip("/")
|
||||
self.access_token = access_token
|
||||
self._session: Optional[aiohttp.ClientSession] = None
|
||||
self.streaming: Optional[StreamingClient] = None
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
await self.close()
|
||||
return False
|
||||
|
||||
async def close(self) -> None:
|
||||
if self.streaming:
|
||||
await self.streaming.disconnect()
|
||||
self.streaming = None
|
||||
if self._session:
|
||||
await self._session.close()
|
||||
self._session = None
|
||||
logger.debug("[Misskey API] 客户端已关闭")
|
||||
|
||||
def get_streaming_client(self) -> StreamingClient:
|
||||
if not self.streaming:
|
||||
self.streaming = StreamingClient(self.instance_url, self.access_token)
|
||||
return self.streaming
|
||||
|
||||
@property
|
||||
def session(self) -> aiohttp.ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
headers = {"Authorization": f"Bearer {self.access_token}"}
|
||||
self._session = aiohttp.ClientSession(headers=headers)
|
||||
return self._session
|
||||
|
||||
def _handle_response_status(self, status: int, endpoint: str):
|
||||
"""处理 HTTP 响应状态码"""
|
||||
if status == 400:
|
||||
logger.error(f"API 请求错误: {endpoint} (状态码: {status})")
|
||||
raise APIError(f"Bad request for {endpoint}")
|
||||
elif status in (401, 403):
|
||||
logger.error(f"API 认证失败: {endpoint} (状态码: {status})")
|
||||
raise AuthenticationError(f"Authentication failed for {endpoint}")
|
||||
elif status == 429:
|
||||
logger.warning(f"API 频率限制: {endpoint} (状态码: {status})")
|
||||
raise APIRateLimitError(f"Rate limit exceeded for {endpoint}")
|
||||
else:
|
||||
logger.error(f"API 请求失败: {endpoint} (状态码: {status})")
|
||||
raise APIConnectionError(f"HTTP {status} for {endpoint}")
|
||||
|
||||
async def _process_response(
|
||||
self, response: aiohttp.ClientResponse, endpoint: str
|
||||
) -> Any:
|
||||
"""处理 API 响应"""
|
||||
if response.status == HTTP_OK:
|
||||
try:
|
||||
result = await response.json()
|
||||
if endpoint == "i/notifications":
|
||||
notifications_data = (
|
||||
result
|
||||
if isinstance(result, list)
|
||||
else result.get("notifications", [])
|
||||
if isinstance(result, dict)
|
||||
else []
|
||||
)
|
||||
if notifications_data:
|
||||
logger.debug(f"获取到 {len(notifications_data)} 条新通知")
|
||||
else:
|
||||
logger.debug(f"API 请求成功: {endpoint}")
|
||||
return result
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"响应不是有效的 JSON 格式: {e}")
|
||||
raise APIConnectionError("Invalid JSON response") from e
|
||||
else:
|
||||
try:
|
||||
error_text = await response.text()
|
||||
logger.error(
|
||||
f"API 请求失败: {endpoint} - 状态码: {response.status}, 响应: {error_text}"
|
||||
)
|
||||
except Exception:
|
||||
logger.error(f"API 请求失败: {endpoint} - 状态码: {response.status}")
|
||||
|
||||
self._handle_response_status(response.status, endpoint)
|
||||
raise APIConnectionError(f"Request failed for {endpoint}")
|
||||
|
||||
@retry_async(
|
||||
max_retries=API_MAX_RETRIES,
|
||||
retryable_exceptions=(APIConnectionError, APIRateLimitError),
|
||||
)
|
||||
async def _make_request(
|
||||
self, endpoint: str, data: Optional[Dict[str, Any]] = None
|
||||
) -> Any:
|
||||
url = f"{self.instance_url}/api/{endpoint}"
|
||||
payload = {"i": self.access_token}
|
||||
if data:
|
||||
payload.update(data)
|
||||
|
||||
try:
|
||||
async with self.session.post(url, json=payload) as response:
|
||||
return await self._process_response(response, endpoint)
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"HTTP 请求错误: {e}")
|
||||
raise APIConnectionError(f"HTTP request failed: {e}") from e
|
||||
|
||||
async def create_note(
|
||||
self,
|
||||
text: str,
|
||||
visibility: str = "public",
|
||||
reply_id: Optional[str] = None,
|
||||
visible_user_ids: Optional[List[str]] = None,
|
||||
local_only: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""创建新贴文"""
|
||||
data: Dict[str, Any] = {
|
||||
"text": text,
|
||||
"visibility": visibility,
|
||||
"localOnly": local_only,
|
||||
}
|
||||
if reply_id:
|
||||
data["replyId"] = reply_id
|
||||
if visible_user_ids and visibility == "specified":
|
||||
data["visibleUserIds"] = visible_user_ids
|
||||
|
||||
result = await self._make_request("notes/create", data)
|
||||
note_id = result.get("createdNote", {}).get("id", "unknown")
|
||||
logger.debug(f"发帖成功,note_id: {note_id}")
|
||||
return result
|
||||
|
||||
async def get_current_user(self) -> Dict[str, Any]:
|
||||
"""获取当前用户信息"""
|
||||
return await self._make_request("i", {})
|
||||
|
||||
async def send_message(self, user_id: str, text: str) -> Dict[str, Any]:
|
||||
"""发送聊天消息"""
|
||||
result = await self._make_request(
|
||||
"chat/messages/create-to-user", {"toUserId": user_id, "text": text}
|
||||
)
|
||||
message_id = result.get("id", "unknown")
|
||||
logger.debug(f"聊天发送成功,message_id: {message_id}")
|
||||
return result
|
||||
|
||||
async def send_room_message(self, room_id: str, text: str) -> Dict[str, Any]:
|
||||
"""发送房间消息"""
|
||||
result = await self._make_request(
|
||||
"chat/messages/create-to-room", {"toRoomId": room_id, "text": text}
|
||||
)
|
||||
message_id = result.get("id", "unknown")
|
||||
logger.debug(f"房间消息发送成功,message_id: {message_id}")
|
||||
return result
|
||||
|
||||
async def get_messages(
|
||||
self, user_id: str, limit: int = 10, since_id: Optional[str] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""获取聊天消息历史"""
|
||||
data: Dict[str, Any] = {"userId": user_id, "limit": limit}
|
||||
if since_id:
|
||||
data["sinceId"] = since_id
|
||||
|
||||
result = await self._make_request("chat/messages/user-timeline", data)
|
||||
if isinstance(result, list):
|
||||
return result
|
||||
else:
|
||||
logger.warning(f"获取聊天消息响应格式异常: {type(result)}")
|
||||
return []
|
||||
|
||||
async def get_mentions(
|
||||
self, limit: int = 10, since_id: Optional[str] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""获取提及通知"""
|
||||
data: Dict[str, Any] = {"limit": limit}
|
||||
if since_id:
|
||||
data["sinceId"] = since_id
|
||||
data["includeTypes"] = ["mention", "reply", "quote"]
|
||||
|
||||
result = await self._make_request("i/notifications", data)
|
||||
if isinstance(result, list):
|
||||
return result
|
||||
elif isinstance(result, dict) and "notifications" in result:
|
||||
return result["notifications"]
|
||||
else:
|
||||
logger.warning(f"获取提及通知响应格式异常: {type(result)}")
|
||||
return []
|
||||
@@ -0,0 +1,123 @@
|
||||
import asyncio
|
||||
import re
|
||||
from typing import AsyncGenerator
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.platform import PlatformMetadata, AstrBotMessage
|
||||
from astrbot.api.message_components import Plain
|
||||
|
||||
from .misskey_utils import (
|
||||
serialize_message_chain,
|
||||
resolve_visibility_from_raw_message,
|
||||
is_valid_user_session_id,
|
||||
is_valid_room_session_id,
|
||||
add_at_mention_if_needed,
|
||||
extract_user_id_from_session_id,
|
||||
extract_room_id_from_session_id,
|
||||
)
|
||||
|
||||
|
||||
class MisskeyPlatformEvent(AstrMessageEvent):
|
||||
def __init__(
|
||||
self,
|
||||
message_str: str,
|
||||
message_obj: AstrBotMessage,
|
||||
platform_meta: PlatformMetadata,
|
||||
session_id: str,
|
||||
client,
|
||||
):
|
||||
super().__init__(message_str, message_obj, platform_meta, session_id)
|
||||
self.client = client
|
||||
|
||||
def _is_system_command(self, message_str: str) -> bool:
|
||||
"""检测是否为系统指令"""
|
||||
if not message_str or not message_str.strip():
|
||||
return False
|
||||
|
||||
system_prefixes = ["/", "!", "#", ".", "^"]
|
||||
message_trimmed = message_str.strip()
|
||||
|
||||
return any(message_trimmed.startswith(prefix) for prefix in system_prefixes)
|
||||
|
||||
async def send(self, message: MessageChain):
|
||||
content, has_at = serialize_message_chain(message.chain)
|
||||
|
||||
if not content:
|
||||
logger.debug("[MisskeyEvent] 内容为空,跳过发送")
|
||||
return
|
||||
|
||||
try:
|
||||
original_message_id = getattr(self.message_obj, "message_id", None)
|
||||
raw_message = getattr(self.message_obj, "raw_message", {})
|
||||
|
||||
if raw_message and not has_at:
|
||||
user_data = raw_message.get("user", {})
|
||||
user_info = {
|
||||
"username": user_data.get("username", ""),
|
||||
"nickname": user_data.get("name", user_data.get("username", "")),
|
||||
}
|
||||
content = add_at_mention_if_needed(content, user_info, has_at)
|
||||
|
||||
# 根据会话类型选择发送方式
|
||||
if hasattr(self.client, "send_message") and is_valid_user_session_id(
|
||||
self.session_id
|
||||
):
|
||||
user_id = extract_user_id_from_session_id(self.session_id)
|
||||
await self.client.send_message(user_id, content)
|
||||
elif hasattr(self.client, "send_room_message") and is_valid_room_session_id(
|
||||
self.session_id
|
||||
):
|
||||
room_id = extract_room_id_from_session_id(self.session_id)
|
||||
await self.client.send_room_message(room_id, content)
|
||||
elif original_message_id and hasattr(self.client, "create_note"):
|
||||
visibility, visible_user_ids = resolve_visibility_from_raw_message(
|
||||
raw_message
|
||||
)
|
||||
await self.client.create_note(
|
||||
content,
|
||||
reply_id=original_message_id,
|
||||
visibility=visibility,
|
||||
visible_user_ids=visible_user_ids,
|
||||
)
|
||||
elif hasattr(self.client, "create_note"):
|
||||
logger.debug("[MisskeyEvent] 创建新帖子")
|
||||
await self.client.create_note(content)
|
||||
|
||||
await super().send(message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[MisskeyEvent] 发送失败: {e}")
|
||||
|
||||
async def send_streaming(
|
||||
self, generator: AsyncGenerator[MessageChain, None], use_fallback: bool = False
|
||||
):
|
||||
if not use_fallback:
|
||||
buffer = None
|
||||
async for chain in generator:
|
||||
if not buffer:
|
||||
buffer = chain
|
||||
else:
|
||||
buffer.chain.extend(chain.chain)
|
||||
if not buffer:
|
||||
return
|
||||
buffer.squash_plain()
|
||||
await self.send(buffer)
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
|
||||
buffer = ""
|
||||
pattern = re.compile(r"[^。?!~…]+[。?!~…]+")
|
||||
|
||||
async for chain in generator:
|
||||
if isinstance(chain, MessageChain):
|
||||
for comp in chain.chain:
|
||||
if isinstance(comp, Plain):
|
||||
buffer += comp.text
|
||||
if any(p in buffer for p in "。?!~…"):
|
||||
buffer = await self.process_buffer(buffer, pattern)
|
||||
else:
|
||||
await self.send(MessageChain(chain=[comp]))
|
||||
await asyncio.sleep(1.5) # 限速
|
||||
|
||||
if buffer.strip():
|
||||
await self.send(MessageChain([Plain(buffer)]))
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
@@ -0,0 +1,327 @@
|
||||
"""Misskey 平台适配器通用工具函数"""
|
||||
|
||||
from typing import Dict, Any, List, Tuple, Optional, Union
|
||||
import astrbot.api.message_components as Comp
|
||||
from astrbot.api.platform import AstrBotMessage, MessageMember, MessageType
|
||||
|
||||
|
||||
def serialize_message_chain(chain: List[Any]) -> Tuple[str, bool]:
|
||||
"""将消息链序列化为文本字符串"""
|
||||
text_parts = []
|
||||
has_at = False
|
||||
|
||||
def process_component(component):
|
||||
nonlocal has_at
|
||||
if isinstance(component, Comp.Plain):
|
||||
return component.text
|
||||
elif isinstance(component, Comp.File):
|
||||
file_name = getattr(component, "name", "文件")
|
||||
return f"[文件: {file_name}]"
|
||||
elif isinstance(component, Comp.At):
|
||||
has_at = True
|
||||
return f"@{component.qq}"
|
||||
elif hasattr(component, "text"):
|
||||
text = getattr(component, "text", "")
|
||||
if "@" in text:
|
||||
has_at = True
|
||||
return text
|
||||
else:
|
||||
return str(component)
|
||||
|
||||
for component in chain:
|
||||
if isinstance(component, Comp.Node) and component.content:
|
||||
for node_comp in component.content:
|
||||
result = process_component(node_comp)
|
||||
if result:
|
||||
text_parts.append(result)
|
||||
else:
|
||||
result = process_component(component)
|
||||
if result:
|
||||
text_parts.append(result)
|
||||
|
||||
return "".join(text_parts), has_at
|
||||
|
||||
|
||||
def resolve_message_visibility(
|
||||
user_id: Optional[str],
|
||||
user_cache: Dict[str, Any],
|
||||
self_id: Optional[str],
|
||||
default_visibility: str = "public",
|
||||
) -> Tuple[str, Optional[List[str]]]:
|
||||
"""解析 Misskey 消息的可见性设置"""
|
||||
visibility = default_visibility
|
||||
visible_user_ids = None
|
||||
|
||||
if user_id and user_cache:
|
||||
user_info = user_cache.get(user_id)
|
||||
if user_info:
|
||||
original_visibility = user_info.get("visibility", default_visibility)
|
||||
if original_visibility == "specified":
|
||||
visibility = "specified"
|
||||
original_visible_users = user_info.get("visible_user_ids", [])
|
||||
users_to_include = [user_id]
|
||||
if self_id:
|
||||
users_to_include.append(self_id)
|
||||
visible_user_ids = list(set(original_visible_users + users_to_include))
|
||||
visible_user_ids = [uid for uid in visible_user_ids if uid]
|
||||
else:
|
||||
visibility = original_visibility
|
||||
|
||||
return visibility, visible_user_ids
|
||||
|
||||
|
||||
def resolve_visibility_from_raw_message(
|
||||
raw_message: Dict[str, Any], self_id: Optional[str] = None
|
||||
) -> Tuple[str, Optional[List[str]]]:
|
||||
"""从原始消息数据中解析可见性设置"""
|
||||
visibility = "public"
|
||||
visible_user_ids = None
|
||||
|
||||
if not raw_message:
|
||||
return visibility, visible_user_ids
|
||||
|
||||
original_visibility = raw_message.get("visibility", "public")
|
||||
if original_visibility == "specified":
|
||||
visibility = "specified"
|
||||
original_visible_users = raw_message.get("visibleUserIds", [])
|
||||
sender_id = raw_message.get("userId", "")
|
||||
|
||||
users_to_include = []
|
||||
if sender_id:
|
||||
users_to_include.append(sender_id)
|
||||
if self_id:
|
||||
users_to_include.append(self_id)
|
||||
|
||||
visible_user_ids = list(set(original_visible_users + users_to_include))
|
||||
visible_user_ids = [uid for uid in visible_user_ids if uid]
|
||||
else:
|
||||
visibility = original_visibility
|
||||
|
||||
return visibility, visible_user_ids
|
||||
|
||||
|
||||
def is_valid_user_session_id(session_id: Union[str, Any]) -> bool:
|
||||
"""检查 session_id 是否是有效的聊天用户 session_id (仅限chat%前缀)"""
|
||||
if not isinstance(session_id, str) or "%" not in session_id:
|
||||
return False
|
||||
|
||||
parts = session_id.split("%")
|
||||
return (
|
||||
len(parts) == 2
|
||||
and parts[0] == "chat"
|
||||
and bool(parts[1])
|
||||
and parts[1] != "unknown"
|
||||
)
|
||||
|
||||
|
||||
def is_valid_room_session_id(session_id: Union[str, Any]) -> bool:
|
||||
"""检查 session_id 是否是有效的房间 session_id (仅限room%前缀)"""
|
||||
if not isinstance(session_id, str) or "%" not in session_id:
|
||||
return False
|
||||
|
||||
parts = session_id.split("%")
|
||||
return (
|
||||
len(parts) == 2
|
||||
and parts[0] == "room"
|
||||
and bool(parts[1])
|
||||
and parts[1] != "unknown"
|
||||
)
|
||||
|
||||
|
||||
def extract_user_id_from_session_id(session_id: str) -> str:
|
||||
"""从 session_id 中提取用户 ID"""
|
||||
if "%" in session_id:
|
||||
parts = session_id.split("%")
|
||||
if len(parts) >= 2:
|
||||
return parts[1]
|
||||
return session_id
|
||||
|
||||
|
||||
def extract_room_id_from_session_id(session_id: str) -> str:
|
||||
"""从 session_id 中提取房间 ID"""
|
||||
if "%" in session_id:
|
||||
parts = session_id.split("%")
|
||||
if len(parts) >= 2 and parts[0] == "room":
|
||||
return parts[1]
|
||||
return session_id
|
||||
|
||||
|
||||
def add_at_mention_if_needed(
|
||||
text: str, user_info: Optional[Dict[str, Any]], has_at: bool = False
|
||||
) -> str:
|
||||
"""如果需要且没有@用户,则添加@用户"""
|
||||
if has_at or not user_info:
|
||||
return text
|
||||
|
||||
username = user_info.get("username")
|
||||
nickname = user_info.get("nickname")
|
||||
|
||||
if username:
|
||||
mention = f"@{username}"
|
||||
if not text.startswith(mention):
|
||||
text = f"{mention}\n{text}".strip()
|
||||
elif nickname:
|
||||
mention = f"@{nickname}"
|
||||
if not text.startswith(mention):
|
||||
text = f"{mention}\n{text}".strip()
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def create_file_component(file_info: Dict[str, Any]) -> Tuple[Any, str]:
|
||||
"""创建文件组件和描述文本"""
|
||||
file_url = file_info.get("url", "")
|
||||
file_name = file_info.get("name", "未知文件")
|
||||
file_type = file_info.get("type", "")
|
||||
|
||||
if file_type.startswith("image/"):
|
||||
return Comp.Image(url=file_url, file=file_name), f"图片[{file_name}]"
|
||||
elif file_type.startswith("audio/"):
|
||||
return Comp.Record(url=file_url, file=file_name), f"音频[{file_name}]"
|
||||
elif file_type.startswith("video/"):
|
||||
return Comp.Video(url=file_url, file=file_name), f"视频[{file_name}]"
|
||||
else:
|
||||
return Comp.File(name=file_name, url=file_url), f"文件[{file_name}]"
|
||||
|
||||
|
||||
def process_files(
|
||||
message: AstrBotMessage, files: list, include_text_parts: bool = True
|
||||
) -> list:
|
||||
"""处理文件列表,添加到消息组件中并返回文本描述"""
|
||||
file_parts = []
|
||||
for file_info in files:
|
||||
component, part_text = create_file_component(file_info)
|
||||
message.message.append(component)
|
||||
if include_text_parts:
|
||||
file_parts.append(part_text)
|
||||
return file_parts
|
||||
|
||||
|
||||
def extract_sender_info(
|
||||
raw_data: Dict[str, Any], is_chat: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
"""提取发送者信息"""
|
||||
if is_chat:
|
||||
sender = raw_data.get("fromUser", {})
|
||||
sender_id = str(sender.get("id", "") or raw_data.get("fromUserId", ""))
|
||||
else:
|
||||
sender = raw_data.get("user", {})
|
||||
sender_id = str(sender.get("id", ""))
|
||||
|
||||
return {
|
||||
"sender": sender,
|
||||
"sender_id": sender_id,
|
||||
"nickname": sender.get("name", sender.get("username", "")),
|
||||
"username": sender.get("username", ""),
|
||||
}
|
||||
|
||||
|
||||
def create_base_message(
|
||||
raw_data: Dict[str, Any],
|
||||
sender_info: Dict[str, Any],
|
||||
client_self_id: str,
|
||||
is_chat: bool = False,
|
||||
room_id: Optional[str] = None,
|
||||
unique_session: bool = False,
|
||||
) -> AstrBotMessage:
|
||||
"""创建基础消息对象"""
|
||||
message = AstrBotMessage()
|
||||
message.raw_message = raw_data
|
||||
message.message = []
|
||||
|
||||
message.sender = MessageMember(
|
||||
user_id=sender_info["sender_id"],
|
||||
nickname=sender_info["nickname"],
|
||||
)
|
||||
|
||||
if room_id:
|
||||
session_prefix = "room"
|
||||
session_id = f"{session_prefix}%{room_id}"
|
||||
if unique_session:
|
||||
session_id += f"_{sender_info['sender_id']}"
|
||||
message.type = MessageType.GROUP_MESSAGE
|
||||
message.group_id = room_id
|
||||
elif is_chat:
|
||||
session_prefix = "chat"
|
||||
session_id = f"{session_prefix}%{sender_info['sender_id']}"
|
||||
message.type = MessageType.FRIEND_MESSAGE
|
||||
else:
|
||||
session_prefix = "note"
|
||||
session_id = f"{session_prefix}%{sender_info['sender_id']}"
|
||||
message.type = MessageType.FRIEND_MESSAGE
|
||||
|
||||
message.session_id = (
|
||||
session_id if sender_info["sender_id"] else f"{session_prefix}%unknown"
|
||||
)
|
||||
message.message_id = str(raw_data.get("id", ""))
|
||||
message.self_id = client_self_id
|
||||
|
||||
return message
|
||||
|
||||
|
||||
def process_at_mention(
|
||||
message: AstrBotMessage, raw_text: str, bot_username: str, client_self_id: str
|
||||
) -> Tuple[List[str], str]:
|
||||
"""处理@提及逻辑,返回消息部分列表和处理后的文本"""
|
||||
message_parts = []
|
||||
|
||||
if not raw_text:
|
||||
return message_parts, ""
|
||||
|
||||
if bot_username and raw_text.startswith(f"@{bot_username}"):
|
||||
at_mention = f"@{bot_username}"
|
||||
message.message.append(Comp.At(qq=client_self_id))
|
||||
remaining_text = raw_text[len(at_mention) :].strip()
|
||||
if remaining_text:
|
||||
message.message.append(Comp.Plain(remaining_text))
|
||||
message_parts.append(remaining_text)
|
||||
return message_parts, remaining_text
|
||||
else:
|
||||
message.message.append(Comp.Plain(raw_text))
|
||||
message_parts.append(raw_text)
|
||||
return message_parts, raw_text
|
||||
|
||||
|
||||
def cache_user_info(
|
||||
user_cache: Dict[str, Any],
|
||||
sender_info: Dict[str, Any],
|
||||
raw_data: Dict[str, Any],
|
||||
client_self_id: str,
|
||||
is_chat: bool = False,
|
||||
):
|
||||
"""缓存用户信息"""
|
||||
if is_chat:
|
||||
user_cache_data = {
|
||||
"username": sender_info["username"],
|
||||
"nickname": sender_info["nickname"],
|
||||
"visibility": "specified",
|
||||
"visible_user_ids": [client_self_id, sender_info["sender_id"]],
|
||||
}
|
||||
else:
|
||||
user_cache_data = {
|
||||
"username": sender_info["username"],
|
||||
"nickname": sender_info["nickname"],
|
||||
"visibility": raw_data.get("visibility", "public"),
|
||||
"visible_user_ids": raw_data.get("visibleUserIds", []),
|
||||
}
|
||||
|
||||
user_cache[sender_info["sender_id"]] = user_cache_data
|
||||
|
||||
|
||||
def cache_room_info(
|
||||
user_cache: Dict[str, Any], raw_data: Dict[str, Any], client_self_id: str
|
||||
):
|
||||
"""缓存房间信息"""
|
||||
room_data = raw_data.get("toRoom")
|
||||
room_id = raw_data.get("toRoomId")
|
||||
|
||||
if room_data and room_id:
|
||||
room_cache_key = f"room:{room_id}"
|
||||
user_cache[room_cache_key] = {
|
||||
"room_id": room_id,
|
||||
"room_name": room_data.get("name", ""),
|
||||
"room_description": room_data.get("description", ""),
|
||||
"owner_id": room_data.get("ownerId", ""),
|
||||
"visibility": "specified",
|
||||
"visible_user_ids": [client_self_id],
|
||||
}
|
||||
@@ -94,10 +94,15 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
plain_text,
|
||||
image_base64,
|
||||
image_path,
|
||||
record_file_path
|
||||
record_file_path,
|
||||
) = await QQOfficialMessageEvent._parse_to_qqofficial(self.send_buffer)
|
||||
|
||||
if not plain_text and not image_base64 and not image_path and not record_file_path:
|
||||
if (
|
||||
not plain_text
|
||||
and not image_base64
|
||||
and not image_path
|
||||
and not record_file_path
|
||||
):
|
||||
return
|
||||
|
||||
payload = {
|
||||
@@ -118,7 +123,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
if record_file_path: # group record msg
|
||||
if record_file_path: # group record msg
|
||||
media = await self.upload_group_and_c2c_record(
|
||||
record_file_path, 3, group_openid=source.group_openid
|
||||
)
|
||||
@@ -134,9 +139,9 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
if record_file_path: # c2c record
|
||||
if record_file_path: # c2c record
|
||||
media = await self.upload_group_and_c2c_record(
|
||||
record_file_path, 3, openid = source.author.user_openid
|
||||
record_file_path, 3, openid=source.author.user_openid
|
||||
)
|
||||
payload["media"] = media
|
||||
payload["msg_type"] = 7
|
||||
@@ -190,58 +195,55 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
return await self.bot.api._http.request(route, json=payload)
|
||||
|
||||
async def upload_group_and_c2c_record(
|
||||
self,
|
||||
file_source: str,
|
||||
file_type: int,
|
||||
srv_send_msg: bool = False,
|
||||
**kwargs
|
||||
self, file_source: str, file_type: int, srv_send_msg: bool = False, **kwargs
|
||||
) -> Optional[Media]:
|
||||
"""
|
||||
上传媒体文件
|
||||
"""
|
||||
# 构建基础payload
|
||||
payload = {
|
||||
"file_type": file_type,
|
||||
"srv_send_msg": srv_send_msg
|
||||
}
|
||||
|
||||
payload = {"file_type": file_type, "srv_send_msg": srv_send_msg}
|
||||
|
||||
# 处理文件数据
|
||||
if os.path.exists(file_source):
|
||||
# 读取本地文件
|
||||
async with aiofiles.open(file_source, 'rb') as f:
|
||||
async with aiofiles.open(file_source, "rb") as f:
|
||||
file_content = await f.read()
|
||||
# use base64 encode
|
||||
payload["file_data"] = base64.b64encode(file_content).decode('utf-8')
|
||||
payload["file_data"] = base64.b64encode(file_content).decode("utf-8")
|
||||
else:
|
||||
# 使用URL
|
||||
payload["url"] = file_source
|
||||
|
||||
|
||||
# 添加接收者信息和确定路由
|
||||
if "openid" in kwargs:
|
||||
payload["openid"] = kwargs["openid"]
|
||||
route = Route("POST", "/v2/users/{openid}/files", openid=kwargs["openid"])
|
||||
elif "group_openid" in kwargs:
|
||||
payload["group_openid"] =kwargs["group_openid"]
|
||||
route = Route("POST", "/v2/groups/{group_openid}/files", group_openid=kwargs["group_openid"])
|
||||
payload["group_openid"] = kwargs["group_openid"]
|
||||
route = Route(
|
||||
"POST",
|
||||
"/v2/groups/{group_openid}/files",
|
||||
group_openid=kwargs["group_openid"],
|
||||
)
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
try:
|
||||
# 使用底层HTTP请求
|
||||
result = await self.bot.api._http.request(route, json=payload)
|
||||
|
||||
|
||||
if result:
|
||||
return Media(
|
||||
file_uuid=result.get("file_uuid"),
|
||||
file_info=result.get("file_info"),
|
||||
ttl=result.get("ttl", 0),
|
||||
file_id=result.get("id", "")
|
||||
file_id=result.get("id", ""),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"上传请求错误: {e}")
|
||||
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def post_c2c_message(
|
||||
self,
|
||||
openid: str,
|
||||
@@ -286,19 +288,23 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
image_base64 = image_base64.removeprefix("base64://")
|
||||
elif isinstance(i, Record):
|
||||
if i.file:
|
||||
record_wav_path = await i.convert_to_file_path() # wav 路径
|
||||
record_wav_path = await i.convert_to_file_path() # wav 路径
|
||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||
record_tecent_silk_path = os.path.join(temp_dir, f"{uuid.uuid4()}.silk")
|
||||
record_tecent_silk_path = os.path.join(
|
||||
temp_dir, f"{uuid.uuid4()}.silk"
|
||||
)
|
||||
try:
|
||||
duration = await wav_to_tencent_silk(record_wav_path, record_tecent_silk_path)
|
||||
duration = await wav_to_tencent_silk(
|
||||
record_wav_path, record_tecent_silk_path
|
||||
)
|
||||
if duration > 0:
|
||||
record_file_path = record_tecent_silk_path
|
||||
else:
|
||||
record_file_path = None
|
||||
record_file_path = None
|
||||
logger.error("转换音频格式时出错:音频时长不大于0")
|
||||
except Exception as e:
|
||||
logger.error(f"处理语音时出错: {e}")
|
||||
record_file_path = None
|
||||
record_file_path = None
|
||||
else:
|
||||
logger.debug(f"qq_official 忽略 {i.type}")
|
||||
return plain_text, image_base64, image_file_path, record_file_path
|
||||
|
||||
@@ -17,7 +17,14 @@ from astrbot.api.platform import (
|
||||
register_platform_adapter,
|
||||
)
|
||||
from astrbot.core.platform.astr_message_event import MessageSession
|
||||
from astrbot.api.message_components import Plain, Image, At, File, Record
|
||||
from astrbot.api.message_components import (
|
||||
Plain,
|
||||
Image,
|
||||
At,
|
||||
File,
|
||||
Record,
|
||||
Reply,
|
||||
)
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
|
||||
@@ -38,12 +45,18 @@ class SatoriPlatformAdapter(Platform):
|
||||
)
|
||||
self.token = self.config.get("satori_token", "")
|
||||
self.endpoint = self.config.get(
|
||||
"satori_endpoint", "ws://127.0.0.1:5140/satori/v1/events"
|
||||
"satori_endpoint", "ws://localhost:5140/satori/v1/events"
|
||||
)
|
||||
self.auto_reconnect = self.config.get("satori_auto_reconnect", True)
|
||||
self.heartbeat_interval = self.config.get("satori_heartbeat_interval", 10)
|
||||
self.reconnect_delay = self.config.get("satori_reconnect_delay", 5)
|
||||
|
||||
self.metadata = PlatformMetadata(
|
||||
name="satori",
|
||||
description="Satori 通用协议适配器",
|
||||
id=self.config["id"],
|
||||
)
|
||||
|
||||
self.ws: Optional[ClientConnection] = None
|
||||
self.session: Optional[ClientSession] = None
|
||||
self.sequence = 0
|
||||
@@ -63,7 +76,7 @@ class SatoriPlatformAdapter(Platform):
|
||||
await super().send_by_session(session, message_chain)
|
||||
|
||||
def meta(self) -> PlatformMetadata:
|
||||
return PlatformMetadata(name="satori", description="Satori 通用协议适配器")
|
||||
return self.metadata
|
||||
|
||||
def _is_websocket_closed(self, ws) -> bool:
|
||||
"""检查WebSocket连接是否已关闭"""
|
||||
@@ -312,12 +325,52 @@ class SatoriPlatformAdapter(Platform):
|
||||
|
||||
abm.self_id = login.get("user", {}).get("id", "")
|
||||
|
||||
content = message.get("content", "")
|
||||
abm.message = await self.parse_satori_elements(content)
|
||||
# 消息链
|
||||
abm.message = []
|
||||
|
||||
content = message.get("content", "")
|
||||
|
||||
quote = message.get("quote")
|
||||
content_for_parsing = content # 副本
|
||||
|
||||
# 提取<quote>标签
|
||||
if "<quote" in content:
|
||||
try:
|
||||
quote_info = await self._extract_quote_element(content)
|
||||
if quote_info:
|
||||
quote = quote_info["quote"]
|
||||
content_for_parsing = quote_info["content_without_quote"]
|
||||
except Exception as e:
|
||||
logger.error(f"解析<quote>标签时发生错误: {e}, 错误内容: {content}")
|
||||
|
||||
if quote:
|
||||
# 引用消息
|
||||
quote_abm = await self._convert_quote_message(quote)
|
||||
if quote_abm:
|
||||
sender_id = quote_abm.sender.user_id
|
||||
if isinstance(sender_id, str) and sender_id.isdigit():
|
||||
sender_id = int(sender_id)
|
||||
elif not isinstance(sender_id, int):
|
||||
sender_id = 0 # 默认值
|
||||
|
||||
reply_component = Reply(
|
||||
id=quote_abm.message_id,
|
||||
chain=quote_abm.message,
|
||||
sender_id=quote_abm.sender.user_id,
|
||||
sender_nickname=quote_abm.sender.nickname,
|
||||
time=quote_abm.timestamp,
|
||||
message_str=quote_abm.message_str,
|
||||
text=quote_abm.message_str,
|
||||
qq=sender_id,
|
||||
)
|
||||
abm.message.append(reply_component)
|
||||
|
||||
# 解析消息内容
|
||||
content_elements = await self.parse_satori_elements(content_for_parsing)
|
||||
abm.message.extend(content_elements)
|
||||
|
||||
# parse message_str
|
||||
abm.message_str = ""
|
||||
for comp in abm.message:
|
||||
for comp in content_elements:
|
||||
if isinstance(comp, Plain):
|
||||
abm.message_str += comp.text
|
||||
|
||||
@@ -333,6 +386,163 @@ class SatoriPlatformAdapter(Platform):
|
||||
logger.error(f"转换 Satori 消息失败: {e}")
|
||||
return None
|
||||
|
||||
def _extract_namespace_prefixes(self, content: str) -> set:
|
||||
"""提取XML内容中的命名空间前缀"""
|
||||
prefixes = set()
|
||||
|
||||
# 查找所有标签
|
||||
i = 0
|
||||
while i < len(content):
|
||||
# 查找开始标签
|
||||
if content[i] == "<" and i + 1 < len(content) and content[i + 1] != "/":
|
||||
# 找到标签结束位置
|
||||
tag_end = content.find(">", i)
|
||||
if tag_end != -1:
|
||||
# 提取标签内容
|
||||
tag_content = content[i + 1 : tag_end]
|
||||
# 检查是否有命名空间前缀
|
||||
if ":" in tag_content and "xmlns:" not in tag_content:
|
||||
# 分割标签名
|
||||
parts = tag_content.split()
|
||||
if parts:
|
||||
tag_name = parts[0]
|
||||
if ":" in tag_name:
|
||||
prefix = tag_name.split(":")[0]
|
||||
# 确保是有效的命名空间前缀
|
||||
if (
|
||||
prefix.isalnum()
|
||||
or prefix.replace("_", "").isalnum()
|
||||
):
|
||||
prefixes.add(prefix)
|
||||
i = tag_end + 1
|
||||
else:
|
||||
i += 1
|
||||
# 查找结束标签
|
||||
elif content[i] == "<" and i + 1 < len(content) and content[i + 1] == "/":
|
||||
# 找到标签结束位置
|
||||
tag_end = content.find(">", i)
|
||||
if tag_end != -1:
|
||||
# 提取标签内容
|
||||
tag_content = content[i + 2 : tag_end]
|
||||
# 检查是否有命名空间前缀
|
||||
if ":" in tag_content:
|
||||
prefix = tag_content.split(":")[0]
|
||||
# 确保是有效的命名空间前缀
|
||||
if prefix.isalnum() or prefix.replace("_", "").isalnum():
|
||||
prefixes.add(prefix)
|
||||
i = tag_end + 1
|
||||
else:
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
return prefixes
|
||||
|
||||
async def _extract_quote_element(self, content: str) -> Optional[dict]:
|
||||
"""提取<quote>标签信息"""
|
||||
try:
|
||||
# 处理命名空间前缀问题
|
||||
processed_content = content
|
||||
if ":" in content and not content.startswith("<root"):
|
||||
prefixes = self._extract_namespace_prefixes(content)
|
||||
|
||||
# 构建命名空间声明
|
||||
ns_declarations = " ".join(
|
||||
[
|
||||
f'xmlns:{prefix}="http://temp.uri/{prefix}"'
|
||||
for prefix in prefixes
|
||||
]
|
||||
)
|
||||
|
||||
# 包装内容
|
||||
processed_content = f"<root {ns_declarations}>{content}</root>"
|
||||
elif not content.startswith("<root"):
|
||||
processed_content = f"<root>{content}</root>"
|
||||
else:
|
||||
processed_content = content
|
||||
|
||||
root = ET.fromstring(processed_content)
|
||||
|
||||
# 查找<quote>标签
|
||||
quote_element = None
|
||||
for elem in root.iter():
|
||||
tag_name = elem.tag
|
||||
if "}" in tag_name:
|
||||
tag_name = tag_name.split("}")[1]
|
||||
if tag_name.lower() == "quote":
|
||||
quote_element = elem
|
||||
break
|
||||
|
||||
if quote_element is not None:
|
||||
# 提取quote标签的属性
|
||||
quote_id = quote_element.get("id", "")
|
||||
|
||||
# 提取<quote>标签内部的内容
|
||||
inner_content = ""
|
||||
if quote_element.text:
|
||||
inner_content += quote_element.text
|
||||
for child in quote_element:
|
||||
inner_content += ET.tostring(
|
||||
child, encoding="unicode", method="xml"
|
||||
)
|
||||
if child.tail:
|
||||
inner_content += child.tail
|
||||
|
||||
# 构造移除了<quote>标签的内容
|
||||
content_without_quote = content.replace(
|
||||
ET.tostring(quote_element, encoding="unicode", method="xml"), ""
|
||||
)
|
||||
|
||||
return {
|
||||
"quote": {"id": quote_id, "content": inner_content},
|
||||
"content_without_quote": content_without_quote,
|
||||
}
|
||||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"提取<quote>标签时发生错误: {e}")
|
||||
return None
|
||||
|
||||
async def _convert_quote_message(self, quote: dict) -> Optional[AstrBotMessage]:
|
||||
"""转换引用消息"""
|
||||
try:
|
||||
quote_abm = AstrBotMessage()
|
||||
quote_abm.message_id = quote.get("id", "")
|
||||
|
||||
# 解析引用消息的发送者
|
||||
quote_author = quote.get("author", {})
|
||||
if quote_author:
|
||||
quote_abm.sender = MessageMember(
|
||||
user_id=quote_author.get("id", ""),
|
||||
nickname=quote_author.get("nick", quote_author.get("name", "")),
|
||||
)
|
||||
else:
|
||||
# 如果没有作者信息,使用默认值
|
||||
quote_abm.sender = MessageMember(
|
||||
user_id=quote.get("user_id", ""),
|
||||
nickname="内容",
|
||||
)
|
||||
|
||||
# 解析引用消息内容
|
||||
quote_content = quote.get("content", "")
|
||||
quote_abm.message = await self.parse_satori_elements(quote_content)
|
||||
|
||||
quote_abm.message_str = ""
|
||||
for comp in quote_abm.message:
|
||||
if isinstance(comp, Plain):
|
||||
quote_abm.message_str += comp.text
|
||||
|
||||
quote_abm.timestamp = int(quote.get("timestamp", time.time()))
|
||||
|
||||
# 如果没有任何内容,使用默认文本
|
||||
if not quote_abm.message_str.strip():
|
||||
quote_abm.message_str = "[引用消息]"
|
||||
|
||||
return quote_abm
|
||||
except Exception as e:
|
||||
logger.error(f"转换引用消息失败: {e}")
|
||||
return None
|
||||
|
||||
async def parse_satori_elements(self, content: str) -> list:
|
||||
"""解析 Satori 消息元素"""
|
||||
elements = []
|
||||
@@ -341,12 +551,35 @@ class SatoriPlatformAdapter(Platform):
|
||||
return elements
|
||||
|
||||
try:
|
||||
wrapped_content = f"<root>{content}</root>"
|
||||
root = ET.fromstring(wrapped_content)
|
||||
# 处理命名空间前缀问题
|
||||
processed_content = content
|
||||
if ":" in content and not content.startswith("<root"):
|
||||
prefixes = self._extract_namespace_prefixes(content)
|
||||
|
||||
# 构建命名空间声明
|
||||
ns_declarations = " ".join(
|
||||
[
|
||||
f'xmlns:{prefix}="http://temp.uri/{prefix}"'
|
||||
for prefix in prefixes
|
||||
]
|
||||
)
|
||||
|
||||
# 包装内容
|
||||
processed_content = f"<root {ns_declarations}>{content}</root>"
|
||||
elif not content.startswith("<root"):
|
||||
processed_content = f"<root>{content}</root>"
|
||||
else:
|
||||
processed_content = content
|
||||
|
||||
root = ET.fromstring(processed_content)
|
||||
await self._parse_xml_node(root, elements)
|
||||
except ET.ParseError as e:
|
||||
raise ValueError(f"解析 Satori 元素时发生解析错误: {e}")
|
||||
logger.error(f"解析 Satori 元素时发生解析错误: {e}, 错误内容: {content}")
|
||||
# 如果解析失败,将整个内容当作纯文本
|
||||
if content.strip():
|
||||
elements.append(Plain(text=content))
|
||||
except Exception as e:
|
||||
logger.error(f"解析 Satori 元素时发生未知错误: {e}")
|
||||
raise e
|
||||
|
||||
# 如果没有解析到任何元素,将整个内容当作纯文本
|
||||
@@ -361,7 +594,12 @@ class SatoriPlatformAdapter(Platform):
|
||||
elements.append(Plain(text=node.text))
|
||||
|
||||
for child in node:
|
||||
tag_name = child.tag.lower()
|
||||
# 获取标签名,去除命名空间前缀
|
||||
tag_name = child.tag
|
||||
if "}" in tag_name:
|
||||
tag_name = tag_name.split("}")[1]
|
||||
tag_name = tag_name.lower()
|
||||
|
||||
attrs = child.attrib
|
||||
|
||||
if tag_name == "at":
|
||||
@@ -372,31 +610,59 @@ class SatoriPlatformAdapter(Platform):
|
||||
src = attrs.get("src", "")
|
||||
if not src:
|
||||
continue
|
||||
if src.startswith("data:image/"):
|
||||
src = src.split(",")[1]
|
||||
elements.append(Image.fromBase64(src))
|
||||
elif src.startswith("http"):
|
||||
elements.append(Image.fromURL(src))
|
||||
else:
|
||||
logger.error(f"未知的图片 src 格式: {str(src)[:16]}")
|
||||
elements.append(Image(file=src))
|
||||
|
||||
elif tag_name == "file":
|
||||
src = attrs.get("src", "")
|
||||
name = attrs.get("name", "文件")
|
||||
if src:
|
||||
elements.append(File(file=src, name=name))
|
||||
elements.append(File(name=name, file=src))
|
||||
|
||||
elif tag_name in ("audio", "record"):
|
||||
src = attrs.get("src", "")
|
||||
if not src:
|
||||
continue
|
||||
if src.startswith("data:audio/"):
|
||||
src = src.split(",")[1]
|
||||
elements.append(Record.fromBase64(src))
|
||||
elif src.startswith("http"):
|
||||
elements.append(Record.fromURL(src))
|
||||
elements.append(Record(file=src))
|
||||
|
||||
elif tag_name == "quote":
|
||||
# quote标签已经被特殊处理
|
||||
pass
|
||||
|
||||
elif tag_name == "face":
|
||||
face_id = attrs.get("id", "")
|
||||
face_name = attrs.get("name", "")
|
||||
face_type = attrs.get("type", "")
|
||||
|
||||
if face_name:
|
||||
elements.append(Plain(text=f"[表情:{face_name}]"))
|
||||
elif face_id and face_type:
|
||||
elements.append(Plain(text=f"[表情ID:{face_id},类型:{face_type}]"))
|
||||
elif face_id:
|
||||
elements.append(Plain(text=f"[表情ID:{face_id}]"))
|
||||
else:
|
||||
logger.error(f"未知的音频 src 格式: {str(src)[:16]}")
|
||||
elements.append(Plain(text="[表情]"))
|
||||
|
||||
elif tag_name == "ark":
|
||||
# 作为纯文本添加到消息链中
|
||||
data = attrs.get("data", "")
|
||||
if data:
|
||||
import html
|
||||
|
||||
decoded_data = html.unescape(data)
|
||||
elements.append(Plain(text=f"[ARK卡片数据: {decoded_data}]"))
|
||||
else:
|
||||
elements.append(Plain(text="[ARK卡片]"))
|
||||
|
||||
elif tag_name == "json":
|
||||
# JSON标签 视为ARK卡片消息
|
||||
data = attrs.get("data", "")
|
||||
if data:
|
||||
import html
|
||||
|
||||
decoded_data = html.unescape(data)
|
||||
elements.append(Plain(text=f"[ARK卡片数据: {decoded_data}]"))
|
||||
else:
|
||||
elements.append(Plain(text="[JSON卡片]"))
|
||||
|
||||
else:
|
||||
# 未知标签,递归处理其内容
|
||||
|
||||
@@ -17,6 +17,15 @@ class SatoriPlatformEvent(AstrMessageEvent):
|
||||
session_id: str,
|
||||
adapter: "SatoriPlatformAdapter",
|
||||
):
|
||||
# 更新平台元数据
|
||||
if adapter and hasattr(adapter, "logins") and adapter.logins:
|
||||
current_login = adapter.logins[0]
|
||||
platform_name = current_login.get("platform", "satori")
|
||||
user = current_login.get("user", {})
|
||||
user_id = user.get("id", "") if user else ""
|
||||
if not platform_meta.id and user_id:
|
||||
platform_meta.id = f"{platform_name}({user_id})"
|
||||
|
||||
super().__init__(message_str, message_obj, platform_meta, session_id)
|
||||
self.adapter = adapter
|
||||
self.platform = None
|
||||
|
||||
@@ -308,7 +308,9 @@ class SlackAdapter(Platform):
|
||||
base64_content = base64.b64encode(content).decode("utf-8")
|
||||
return base64_content
|
||||
else:
|
||||
logger.error(f"Failed to download slack file: {resp.status} {await resp.text()}")
|
||||
logger.error(
|
||||
f"Failed to download slack file: {resp.status} {await resp.text()}"
|
||||
)
|
||||
raise Exception(f"下载文件失败: {resp.status}")
|
||||
|
||||
async def run(self) -> Awaitable[Any]:
|
||||
|
||||
@@ -75,7 +75,13 @@ class SlackMessageEvent(AstrMessageEvent):
|
||||
"text": {"type": "mrkdwn", "text": "文件上传失败"},
|
||||
}
|
||||
file_url = response["files"][0]["permalink"]
|
||||
return {"type": "section", "text": {"type": "mrkdwn", "text": f"文件: <{file_url}|{segment.name or '文件'}>"}}
|
||||
return {
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": f"文件: <{file_url}|{segment.name or '文件'}>",
|
||||
},
|
||||
}
|
||||
else:
|
||||
return {"type": "section", "text": {"type": "mrkdwn", "text": str(segment)}}
|
||||
|
||||
|
||||
@@ -66,7 +66,9 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
return chunks
|
||||
|
||||
@classmethod
|
||||
async def send_with_client(cls, client: ExtBot, message: MessageChain, user_name: str):
|
||||
async def send_with_client(
|
||||
cls, client: ExtBot, message: MessageChain, user_name: str
|
||||
):
|
||||
image_path = None
|
||||
|
||||
has_reply = False
|
||||
@@ -216,7 +218,6 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
try:
|
||||
msg = await self.client.send_message(text=delta, **payload)
|
||||
current_content = delta
|
||||
delta = ""
|
||||
except Exception as e:
|
||||
logger.warning(f"发送消息失败(streaming): {e!s}")
|
||||
message_id = msg.message_id
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
|
||||
|
||||
class WebChatQueueMgr:
|
||||
def __init__(self) -> None:
|
||||
self.queues = {}
|
||||
@@ -30,4 +31,5 @@ class WebChatQueueMgr:
|
||||
"""Check if a queue exists for the given conversation ID"""
|
||||
return conversation_id in self.queues
|
||||
|
||||
|
||||
webchat_queue_mgr = WebChatQueueMgr()
|
||||
|
||||
@@ -213,10 +213,10 @@ class WeChatPadProAdapter(Platform):
|
||||
def _extract_auth_key(self, data):
|
||||
"""Helper method to extract auth_key from response data."""
|
||||
if isinstance(data, dict):
|
||||
auth_keys = data.get("authKeys") # 新接口
|
||||
auth_keys = data.get("authKeys") # 新接口
|
||||
if isinstance(auth_keys, list) and auth_keys:
|
||||
return auth_keys[0]
|
||||
elif isinstance(data, list) and data: # 旧接口
|
||||
elif isinstance(data, list) and data: # 旧接口
|
||||
return data[0]
|
||||
return None
|
||||
|
||||
@@ -234,7 +234,9 @@ class WeChatPadProAdapter(Platform):
|
||||
try:
|
||||
async with session.post(url, params=params, json=payload) as response:
|
||||
if response.status != 200:
|
||||
logger.error(f"生成授权码失败: {response.status}, {await response.text()}")
|
||||
logger.error(
|
||||
f"生成授权码失败: {response.status}, {await response.text()}"
|
||||
)
|
||||
return
|
||||
|
||||
response_data = await response.json()
|
||||
@@ -245,7 +247,9 @@ class WeChatPadProAdapter(Platform):
|
||||
if self.auth_key:
|
||||
logger.info("成功获取授权码")
|
||||
else:
|
||||
logger.error(f"生成授权码成功但未找到授权码: {response_data}")
|
||||
logger.error(
|
||||
f"生成授权码成功但未找到授权码: {response_data}"
|
||||
)
|
||||
else:
|
||||
logger.error(f"生成授权码失败: {response_data}")
|
||||
except aiohttp.ClientConnectorError as e:
|
||||
|
||||
@@ -48,7 +48,12 @@ class WeChatKF(BaseWeChatAPI):
|
||||
注意:可能会出现返回条数少于limit的情况,需结合返回的has_more字段判断是否继续请求。
|
||||
:return: 接口调用结果
|
||||
"""
|
||||
data = {"token": token, "cursor": cursor, "limit": limit, "open_kfid": open_kfid}
|
||||
data = {
|
||||
"token": token,
|
||||
"cursor": cursor,
|
||||
"limit": limit,
|
||||
"open_kfid": open_kfid,
|
||||
}
|
||||
return self._post("kf/sync_msg", data=data)
|
||||
|
||||
def get_service_state(self, open_kfid, external_userid):
|
||||
@@ -72,7 +77,9 @@ class WeChatKF(BaseWeChatAPI):
|
||||
}
|
||||
return self._post("kf/service_state/get", data=data)
|
||||
|
||||
def trans_service_state(self, open_kfid, external_userid, service_state, servicer_userid=""):
|
||||
def trans_service_state(
|
||||
self, open_kfid, external_userid, service_state, servicer_userid=""
|
||||
):
|
||||
"""
|
||||
变更会话状态
|
||||
|
||||
@@ -180,7 +187,9 @@ class WeChatKF(BaseWeChatAPI):
|
||||
"""
|
||||
return self._get("kf/customer/get_upgrade_service_config")
|
||||
|
||||
def upgrade_service(self, open_kfid, external_userid, service_type, member=None, groupchat=None):
|
||||
def upgrade_service(
|
||||
self, open_kfid, external_userid, service_type, member=None, groupchat=None
|
||||
):
|
||||
"""
|
||||
为客户升级为专员或客户群服务
|
||||
|
||||
@@ -246,7 +255,9 @@ class WeChatKF(BaseWeChatAPI):
|
||||
data = {"open_kfid": open_kfid, "start_time": start_time, "end_time": end_time}
|
||||
return self._post("kf/get_corp_statistic", data=data)
|
||||
|
||||
def get_servicer_statistic(self, start_time, end_time, open_kfid=None, servicer_userid=None):
|
||||
def get_servicer_statistic(
|
||||
self, start_time, end_time, open_kfid=None, servicer_userid=None
|
||||
):
|
||||
"""
|
||||
获取「客户数据统计」接待人员明细数据
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from optionaldict import optionaldict
|
||||
|
||||
from wechatpy.client.api.base import BaseWeChatAPI
|
||||
|
||||
|
||||
class WeChatKFMessage(BaseWeChatAPI):
|
||||
"""
|
||||
发送微信客服消息
|
||||
@@ -125,35 +126,55 @@ class WeChatKFMessage(BaseWeChatAPI):
|
||||
msg={"msgtype": "news", "link": {"link": articles_data}},
|
||||
)
|
||||
|
||||
def send_msgmenu(self, user_id, open_kfid, head_content, menu_list, tail_content, msgid=""):
|
||||
def send_msgmenu(
|
||||
self, user_id, open_kfid, head_content, menu_list, tail_content, msgid=""
|
||||
):
|
||||
return self.send(
|
||||
user_id,
|
||||
open_kfid,
|
||||
msgid,
|
||||
msg={
|
||||
"msgtype": "msgmenu",
|
||||
"msgmenu": {"head_content": head_content, "list": menu_list, "tail_content": tail_content},
|
||||
"msgmenu": {
|
||||
"head_content": head_content,
|
||||
"list": menu_list,
|
||||
"tail_content": tail_content,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def send_location(self, user_id, open_kfid, name, address, latitude, longitude, msgid=""):
|
||||
def send_location(
|
||||
self, user_id, open_kfid, name, address, latitude, longitude, msgid=""
|
||||
):
|
||||
return self.send(
|
||||
user_id,
|
||||
open_kfid,
|
||||
msgid,
|
||||
msg={
|
||||
"msgtype": "location",
|
||||
"msgmenu": {"name": name, "address": address, "latitude": latitude, "longitude": longitude},
|
||||
"msgmenu": {
|
||||
"name": name,
|
||||
"address": address,
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def send_miniprogram(self, user_id, open_kfid, appid, title, thumb_media_id, pagepath, msgid=""):
|
||||
def send_miniprogram(
|
||||
self, user_id, open_kfid, appid, title, thumb_media_id, pagepath, msgid=""
|
||||
):
|
||||
return self.send(
|
||||
user_id,
|
||||
open_kfid,
|
||||
msgid,
|
||||
msg={
|
||||
"msgtype": "miniprogram",
|
||||
"msgmenu": {"appid": appid, "title": title, "thumb_media_id": thumb_media_id, "pagepath": pagepath},
|
||||
"msgmenu": {
|
||||
"appid": appid,
|
||||
"title": title,
|
||||
"thumb_media_id": thumb_media_id,
|
||||
"pagepath": pagepath,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -160,7 +160,9 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
|
||||
self.wexin_event_workers[msg.id] = future
|
||||
await self.convert_message(msg, future)
|
||||
# I love shield so much!
|
||||
result = await asyncio.wait_for(asyncio.shield(future), 60) # wait for 60s
|
||||
result = await asyncio.wait_for(
|
||||
asyncio.shield(future), 60
|
||||
) # wait for 60s
|
||||
logger.debug(f"Got future result: {result}")
|
||||
self.wexin_event_workers.pop(msg.id, None)
|
||||
return result # xml. see weixin_offacc_event.py
|
||||
|
||||
@@ -150,7 +150,6 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
|
||||
return
|
||||
logger.info(f"微信公众平台上传语音返回: {response}")
|
||||
|
||||
|
||||
if active_send_mode:
|
||||
self.client.message.send_voice(
|
||||
message_obj.sender.user_id,
|
||||
|
||||
@@ -65,13 +65,16 @@ class AssistantMessageSegment:
|
||||
role: str = "assistant"
|
||||
|
||||
def to_dict(self):
|
||||
ret = {
|
||||
ret: dict[str, str | list[dict]] = {
|
||||
"role": self.role,
|
||||
}
|
||||
if self.content:
|
||||
ret["content"] = self.content
|
||||
if self.tool_calls:
|
||||
ret["tool_calls"] = self.tool_calls
|
||||
tool_calls_dict = [
|
||||
tc if isinstance(tc, dict) else tc.to_dict() for tc in self.tool_calls
|
||||
]
|
||||
ret["tool_calls"] = tool_calls_dict
|
||||
return ret
|
||||
|
||||
|
||||
@@ -117,7 +120,14 @@ class ProviderRequest:
|
||||
"""模型名称,为 None 时使用提供商的默认模型"""
|
||||
|
||||
def __repr__(self):
|
||||
return f"ProviderRequest(prompt={self.prompt}, session_id={self.session_id}, image_urls={self.image_urls}, func_tool={self.func_tool}, contexts={self._print_friendly_context()}, system_prompt={self.system_prompt.strip()}, tool_calls_result={self.tool_calls_result})"
|
||||
return (
|
||||
f"ProviderRequest(prompt={self.prompt}, session_id={self.session_id}, "
|
||||
f"image_count={len(self.image_urls or [])}, "
|
||||
f"func_tool={self.func_tool}, "
|
||||
f"contexts={self._print_friendly_context()}, "
|
||||
f"system_prompt={self.system_prompt}, "
|
||||
f"conversation_id={self.conversation.cid if self.conversation else 'N/A'}, "
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.__repr__()
|
||||
@@ -297,6 +307,7 @@ class LLMResponse:
|
||||
)
|
||||
return ret
|
||||
|
||||
|
||||
@dataclass
|
||||
class RerankResult:
|
||||
index: int
|
||||
|
||||
@@ -4,7 +4,7 @@ import os
|
||||
import asyncio
|
||||
import aiohttp
|
||||
|
||||
from typing import Dict, List, Awaitable
|
||||
from typing import Dict, List, Awaitable, Callable, Any
|
||||
from astrbot import logger
|
||||
from astrbot.core import sp
|
||||
|
||||
@@ -109,7 +109,7 @@ class FunctionToolManager:
|
||||
name: str,
|
||||
func_args: list,
|
||||
desc: str,
|
||||
handler: Awaitable,
|
||||
handler: Callable[..., Awaitable[Any]],
|
||||
) -> FuncTool:
|
||||
params = {
|
||||
"type": "object", # hard-coded here
|
||||
@@ -132,7 +132,7 @@ class FunctionToolManager:
|
||||
name: str,
|
||||
func_args: list,
|
||||
desc: str,
|
||||
handler: Awaitable,
|
||||
handler: Callable[..., Awaitable[Any]],
|
||||
) -> None:
|
||||
"""添加函数调用工具
|
||||
|
||||
@@ -220,7 +220,7 @@ class FunctionToolManager:
|
||||
name: str,
|
||||
cfg: dict,
|
||||
event: asyncio.Event,
|
||||
ready_future: asyncio.Future = None,
|
||||
ready_future: asyncio.Future | None = None,
|
||||
) -> None:
|
||||
"""初始化 MCP 客户端的包装函数,用于捕获异常"""
|
||||
try:
|
||||
|
||||
@@ -7,7 +7,13 @@ from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
|
||||
from astrbot.core.db import BaseDatabase
|
||||
|
||||
from .entities import ProviderType
|
||||
from .provider import Provider, STTProvider, TTSProvider, EmbeddingProvider
|
||||
from .provider import (
|
||||
Provider,
|
||||
STTProvider,
|
||||
TTSProvider,
|
||||
EmbeddingProvider,
|
||||
RerankProvider,
|
||||
)
|
||||
from .register import llm_tools, provider_cls_map
|
||||
from ..persona_mgr import PersonaManager
|
||||
|
||||
@@ -38,7 +44,12 @@ class ProviderManager:
|
||||
"""加载的 Text To Speech Provider 的实例"""
|
||||
self.embedding_provider_insts: List[EmbeddingProvider] = []
|
||||
"""加载的 Embedding Provider 的实例"""
|
||||
self.inst_map: dict[str, Provider] = {}
|
||||
self.rerank_provider_insts: List[RerankProvider] = []
|
||||
"""加载的 Rerank Provider 的实例"""
|
||||
self.inst_map: dict[
|
||||
str,
|
||||
Provider | STTProvider | TTSProvider | EmbeddingProvider | RerankProvider,
|
||||
] = {}
|
||||
"""Provider 实例映射. key: provider_id, value: Provider 实例"""
|
||||
self.llm_tools = llm_tools
|
||||
|
||||
@@ -87,19 +98,31 @@ class ProviderManager:
|
||||
)
|
||||
return
|
||||
# 不启用提供商会话隔离模式的情况
|
||||
self.curr_provider_inst = self.inst_map[provider_id]
|
||||
if provider_type == ProviderType.TEXT_TO_SPEECH:
|
||||
|
||||
prov = self.inst_map[provider_id]
|
||||
if provider_type == ProviderType.TEXT_TO_SPEECH and isinstance(
|
||||
prov, TTSProvider
|
||||
):
|
||||
self.curr_tts_provider_inst = prov
|
||||
sp.put("curr_provider_tts", provider_id, scope="global", scope_id="global")
|
||||
elif provider_type == ProviderType.SPEECH_TO_TEXT:
|
||||
elif provider_type == ProviderType.SPEECH_TO_TEXT and isinstance(
|
||||
prov, STTProvider
|
||||
):
|
||||
self.curr_stt_provider_inst = prov
|
||||
sp.put("curr_provider_stt", provider_id, scope="global", scope_id="global")
|
||||
elif provider_type == ProviderType.CHAT_COMPLETION:
|
||||
elif provider_type == ProviderType.CHAT_COMPLETION and isinstance(
|
||||
prov, Provider
|
||||
):
|
||||
self.curr_provider_inst = prov
|
||||
sp.put("curr_provider", provider_id, scope="global", scope_id="global")
|
||||
|
||||
async def get_provider_by_id(self, provider_id: str) -> Provider | None:
|
||||
"""根据提供商 ID 获取提供商实例"""
|
||||
return self.inst_map.get(provider_id)
|
||||
|
||||
def get_using_provider(self, provider_type: ProviderType, umo=None):
|
||||
def get_using_provider(
|
||||
self, provider_type: ProviderType, umo=None
|
||||
) -> Provider | STTProvider | TTSProvider | None:
|
||||
"""获取正在使用的提供商实例。
|
||||
|
||||
Args:
|
||||
@@ -303,12 +326,14 @@ class ProviderManager:
|
||||
provider_metadata = provider_cls_map[provider_config["type"]]
|
||||
try:
|
||||
# 按任务实例化提供商
|
||||
cls_type = provider_metadata.cls_type
|
||||
if not cls_type:
|
||||
logger.error(f"无法找到 {provider_metadata.type} 的类")
|
||||
return
|
||||
|
||||
if provider_metadata.provider_type == ProviderType.SPEECH_TO_TEXT:
|
||||
# STT 任务
|
||||
inst = provider_metadata.cls_type(
|
||||
provider_config, self.provider_settings
|
||||
)
|
||||
inst = cls_type(provider_config, self.provider_settings)
|
||||
|
||||
if getattr(inst, "initialize", None):
|
||||
await inst.initialize()
|
||||
@@ -327,9 +352,7 @@ class ProviderManager:
|
||||
|
||||
elif provider_metadata.provider_type == ProviderType.TEXT_TO_SPEECH:
|
||||
# TTS 任务
|
||||
inst = provider_metadata.cls_type(
|
||||
provider_config, self.provider_settings
|
||||
)
|
||||
inst = cls_type(provider_config, self.provider_settings)
|
||||
|
||||
if getattr(inst, "initialize", None):
|
||||
await inst.initialize()
|
||||
@@ -345,7 +368,7 @@ class ProviderManager:
|
||||
|
||||
elif provider_metadata.provider_type == ProviderType.CHAT_COMPLETION:
|
||||
# 文本生成任务
|
||||
inst = provider_metadata.cls_type(
|
||||
inst = cls_type(
|
||||
provider_config,
|
||||
self.provider_settings,
|
||||
self.selected_default_persona,
|
||||
@@ -366,13 +389,16 @@ class ProviderManager:
|
||||
if not self.curr_provider_inst:
|
||||
self.curr_provider_inst = inst
|
||||
|
||||
elif provider_metadata.provider_type in [ProviderType.EMBEDDING, ProviderType.RERANK]:
|
||||
inst = provider_metadata.cls_type(
|
||||
provider_config, self.provider_settings
|
||||
)
|
||||
elif provider_metadata.provider_type == ProviderType.EMBEDDING:
|
||||
inst = cls_type(provider_config, self.provider_settings)
|
||||
if getattr(inst, "initialize", None):
|
||||
await inst.initialize()
|
||||
self.embedding_provider_insts.append(inst)
|
||||
elif provider_metadata.provider_type == ProviderType.RERANK:
|
||||
inst = cls_type(provider_config, self.provider_settings)
|
||||
if getattr(inst, "initialize", None):
|
||||
await inst.initialize()
|
||||
self.rerank_provider_insts.append(inst)
|
||||
|
||||
self.inst_map[provider_config["id"]] = inst
|
||||
except Exception as e:
|
||||
@@ -388,6 +414,7 @@ class ProviderManager:
|
||||
|
||||
# 和配置文件保持同步
|
||||
config_ids = [provider["id"] for provider in self.providers_config]
|
||||
logger.debug(f"providers in user's config: {config_ids}")
|
||||
for key in list(self.inst_map.keys()):
|
||||
if key not in config_ids:
|
||||
await self.terminate_provider(key)
|
||||
@@ -426,11 +453,17 @@ class ProviderManager:
|
||||
)
|
||||
|
||||
if self.inst_map[provider_id] in self.provider_insts:
|
||||
self.provider_insts.remove(self.inst_map[provider_id])
|
||||
prov_inst = self.inst_map[provider_id]
|
||||
if isinstance(prov_inst, Provider):
|
||||
self.provider_insts.remove(prov_inst)
|
||||
if self.inst_map[provider_id] in self.stt_provider_insts:
|
||||
self.stt_provider_insts.remove(self.inst_map[provider_id])
|
||||
prov_inst = self.inst_map[provider_id]
|
||||
if isinstance(prov_inst, STTProvider):
|
||||
self.stt_provider_insts.remove(prov_inst)
|
||||
if self.inst_map[provider_id] in self.tts_provider_insts:
|
||||
self.tts_provider_insts.remove(self.inst_map[provider_id])
|
||||
prov_inst = self.inst_map[provider_id]
|
||||
if isinstance(prov_inst, TTSProvider):
|
||||
self.tts_provider_insts.remove(prov_inst)
|
||||
|
||||
if self.inst_map[provider_id] == self.curr_provider_inst:
|
||||
self.curr_provider_inst = None
|
||||
|
||||
@@ -98,7 +98,7 @@ class ProviderFishAudioTTSAPI(TTSProvider):
|
||||
|
||||
# FishAudio的reference_id通常是32位十六进制字符串
|
||||
# 例如: 626bb6d3f3364c9cbc3aa6a67300a664
|
||||
pattern = r'^[a-fA-F0-9]{32}$'
|
||||
pattern = r"^[a-fA-F0-9]{32}$"
|
||||
return bool(re.match(pattern, reference_id.strip()))
|
||||
|
||||
async def _generate_request(self, text: str) -> dict:
|
||||
|
||||
@@ -6,6 +6,7 @@ from astrbot.core.provider.provider import (
|
||||
TTSProvider,
|
||||
STTProvider,
|
||||
EmbeddingProvider,
|
||||
RerankProvider,
|
||||
)
|
||||
from astrbot.core.provider.entities import ProviderType
|
||||
from astrbot.core.db import BaseDatabase
|
||||
@@ -23,7 +24,7 @@ from .star import star_registry, StarMetadata, star_map
|
||||
from .star_handler import star_handlers_registry, StarHandlerMetadata, EventType
|
||||
from .filter.command import CommandFilter
|
||||
from .filter.regex import RegexFilter
|
||||
from typing import Awaitable
|
||||
from typing import Awaitable, Any, Callable
|
||||
from astrbot.core.conversation_mgr import ConversationManager
|
||||
from astrbot.core.star.filter.platform_adapter_type import (
|
||||
PlatformAdapterType,
|
||||
@@ -103,9 +104,14 @@ class Context:
|
||||
"""
|
||||
self.provider_manager.provider_insts.append(provider)
|
||||
|
||||
def get_provider_by_id(self, provider_id: str) -> Provider | None:
|
||||
"""通过 ID 获取对应的 LLM Provider(Chat_Completion 类型)。"""
|
||||
return self.provider_manager.inst_map.get(provider_id)
|
||||
def get_provider_by_id(
|
||||
self, provider_id: str
|
||||
) -> (
|
||||
Provider | TTSProvider | STTProvider | EmbeddingProvider | RerankProvider | None
|
||||
):
|
||||
"""通过 ID 获取对应的 LLM Provider。"""
|
||||
prov = self.provider_manager.inst_map.get(provider_id)
|
||||
return prov
|
||||
|
||||
def get_all_providers(self) -> List[Provider]:
|
||||
"""获取所有用于文本生成任务的 LLM Provider(Chat_Completion 类型)。"""
|
||||
@@ -130,34 +136,43 @@ class Context:
|
||||
Args:
|
||||
umo(str): unified_message_origin 值,如果传入并且用户启用了提供商会话隔离,则使用该会话偏好的提供商。
|
||||
"""
|
||||
return self.provider_manager.get_using_provider(
|
||||
prov = self.provider_manager.get_using_provider(
|
||||
provider_type=ProviderType.CHAT_COMPLETION,
|
||||
umo=umo,
|
||||
)
|
||||
if prov and not isinstance(prov, Provider):
|
||||
raise ValueError("返回的 Provider 不是 Provider 类型")
|
||||
return prov
|
||||
|
||||
def get_using_tts_provider(self, umo: str | None = None) -> TTSProvider:
|
||||
def get_using_tts_provider(self, umo: str | None = None) -> TTSProvider | None:
|
||||
"""
|
||||
获取当前使用的用于 TTS 任务的 Provider。
|
||||
|
||||
Args:
|
||||
umo(str): unified_message_origin 值,如果传入,则使用该会话偏好的提供商。
|
||||
"""
|
||||
return self.provider_manager.get_using_provider(
|
||||
prov = self.provider_manager.get_using_provider(
|
||||
provider_type=ProviderType.TEXT_TO_SPEECH,
|
||||
umo=umo,
|
||||
)
|
||||
if prov and not isinstance(prov, TTSProvider):
|
||||
raise ValueError("返回的 Provider 不是 TTSProvider 类型")
|
||||
return prov
|
||||
|
||||
def get_using_stt_provider(self, umo: str | None = None) -> STTProvider:
|
||||
def get_using_stt_provider(self, umo: str | None = None) -> STTProvider | None:
|
||||
"""
|
||||
获取当前使用的用于 STT 任务的 Provider。
|
||||
|
||||
Args:
|
||||
umo(str): unified_message_origin 值,如果传入,则使用该会话偏好的提供商。
|
||||
"""
|
||||
return self.provider_manager.get_using_provider(
|
||||
prov = self.provider_manager.get_using_provider(
|
||||
provider_type=ProviderType.SPEECH_TO_TEXT,
|
||||
umo=umo,
|
||||
)
|
||||
if prov and not isinstance(prov, STTProvider):
|
||||
raise ValueError("返回的 Provider 不是 STTProvider 类型")
|
||||
return prov
|
||||
|
||||
def get_config(self, umo: str | None = None) -> AstrBotConfig:
|
||||
"""获取 AstrBot 的配置。"""
|
||||
@@ -245,7 +260,11 @@ class Context:
|
||||
"""
|
||||
|
||||
def register_llm_tool(
|
||||
self, name: str, func_args: list, desc: str, func_obj: Awaitable
|
||||
self,
|
||||
name: str,
|
||||
func_args: list,
|
||||
desc: str,
|
||||
func_obj: Callable[..., Awaitable[Any]],
|
||||
) -> None:
|
||||
"""
|
||||
为函数调用(function-calling / tools-use)添加工具。
|
||||
@@ -267,9 +286,7 @@ class Context:
|
||||
desc=desc,
|
||||
)
|
||||
star_handlers_registry.append(md)
|
||||
self.provider_manager.llm_tools.add_func(
|
||||
name, func_args, desc, func_obj, func_obj
|
||||
)
|
||||
self.provider_manager.llm_tools.add_func(name, func_args, desc, func_obj)
|
||||
|
||||
def unregister_llm_tool(self, name: str) -> None:
|
||||
"""删除一个函数调用工具。如果再要启用,需要重新注册。"""
|
||||
@@ -281,7 +298,7 @@ class Context:
|
||||
command_name: str,
|
||||
desc: str,
|
||||
priority: int,
|
||||
awaitable: Awaitable,
|
||||
awaitable: Callable[..., Awaitable[Any]],
|
||||
use_regex=False,
|
||||
ignore_prefix=False,
|
||||
):
|
||||
|
||||
@@ -7,6 +7,7 @@ from astrbot.core.config import AstrBotConfig
|
||||
from .custom_filter import CustomFilter
|
||||
from ..star_handler import StarHandlerMetadata
|
||||
|
||||
|
||||
class GreedyStr(str):
|
||||
"""标记指令完成其他参数接收后的所有剩余文本。"""
|
||||
|
||||
@@ -152,17 +153,10 @@ class CommandFilter(HandlerFilter):
|
||||
_full = f"{parent_command_name} {candidate}"
|
||||
else:
|
||||
_full = candidate
|
||||
if message_str == _full:
|
||||
# 完全等于命令名 → 没参数
|
||||
message_str = ""
|
||||
if message_str.startswith(f"{_full} ") or message_str == _full:
|
||||
message_str = message_str[len(_full) :].strip()
|
||||
ok = True
|
||||
break
|
||||
elif message_str.startswith(_full):
|
||||
# 命令名后面无论是空格还是直接连参数都可以
|
||||
message_str = message_str[len(_full):].lstrip()
|
||||
ok = True
|
||||
break
|
||||
|
||||
if not ok:
|
||||
return False
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ class CommandGroupFilter(HandlerFilter):
|
||||
def __init__(
|
||||
self,
|
||||
group_name: str,
|
||||
alias: set = None,
|
||||
parent_group: CommandGroupFilter = None,
|
||||
alias: set | None = None,
|
||||
parent_group: CommandGroupFilter | None = None,
|
||||
):
|
||||
self.group_name = group_name
|
||||
self.alias = alias if alias else set()
|
||||
@@ -54,8 +54,8 @@ class CommandGroupFilter(HandlerFilter):
|
||||
self,
|
||||
sub_command_filters: List[Union[CommandFilter, CommandGroupFilter]],
|
||||
prefix: str = "",
|
||||
event: AstrMessageEvent = None,
|
||||
cfg: AstrBotConfig = None,
|
||||
event: AstrMessageEvent | None = None,
|
||||
cfg: AstrBotConfig | None = None,
|
||||
) -> str:
|
||||
result = ""
|
||||
for sub_filter in sub_command_filters:
|
||||
@@ -113,8 +113,7 @@ class CommandGroupFilter(HandlerFilter):
|
||||
+ self.print_cmd_tree(self.sub_command_filters, event=event, cfg=cfg)
|
||||
)
|
||||
raise ValueError(
|
||||
f"参数不足。{self.group_name} 指令组下有如下指令,请参考:\n"
|
||||
+ tree
|
||||
f"参数不足。{self.group_name} 指令组下有如下指令,请参考:\n" + tree
|
||||
)
|
||||
|
||||
# complete_command_names = [name + " " for name in complete_command_names]
|
||||
|
||||
@@ -2,7 +2,6 @@ import enum
|
||||
from . import HandlerFilter
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.config import AstrBotConfig
|
||||
from typing import Union
|
||||
|
||||
|
||||
class PlatformAdapterType(enum.Flag):
|
||||
@@ -19,6 +18,7 @@ class PlatformAdapterType(enum.Flag):
|
||||
VOCECHAT = enum.auto()
|
||||
WEIXIN_OFFICIAL_ACCOUNT = enum.auto()
|
||||
SATORI = enum.auto()
|
||||
MISSKEY = enum.auto()
|
||||
ALL = (
|
||||
AIOCQHTTP
|
||||
| QQOFFICIAL
|
||||
@@ -33,6 +33,7 @@ class PlatformAdapterType(enum.Flag):
|
||||
| VOCECHAT
|
||||
| WEIXIN_OFFICIAL_ACCOUNT
|
||||
| SATORI
|
||||
| MISSKEY
|
||||
)
|
||||
|
||||
|
||||
@@ -50,15 +51,19 @@ ADAPTER_NAME_2_TYPE = {
|
||||
"vocechat": PlatformAdapterType.VOCECHAT,
|
||||
"weixin_official_account": PlatformAdapterType.WEIXIN_OFFICIAL_ACCOUNT,
|
||||
"satori": PlatformAdapterType.SATORI,
|
||||
"misskey": PlatformAdapterType.MISSKEY,
|
||||
}
|
||||
|
||||
|
||||
class PlatformAdapterTypeFilter(HandlerFilter):
|
||||
def __init__(self, platform_adapter_type_or_str: Union[PlatformAdapterType, str]):
|
||||
self.type_or_str = platform_adapter_type_or_str
|
||||
def __init__(self, platform_adapter_type_or_str: PlatformAdapterType | str):
|
||||
if isinstance(platform_adapter_type_or_str, str):
|
||||
self.platform_type = ADAPTER_NAME_2_TYPE.get(platform_adapter_type_or_str)
|
||||
else:
|
||||
self.platform_type = platform_adapter_type_or_str
|
||||
|
||||
def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
|
||||
adapter_name = event.get_platform_name()
|
||||
if adapter_name in ADAPTER_NAME_2_TYPE:
|
||||
return ADAPTER_NAME_2_TYPE[adapter_name] & self.type_or_str
|
||||
if adapter_name in ADAPTER_NAME_2_TYPE and self.platform_type is not None:
|
||||
return bool(ADAPTER_NAME_2_TYPE[adapter_name] & self.platform_type)
|
||||
return False
|
||||
|
||||
@@ -5,7 +5,9 @@ from astrbot.core.star import StarMetadata, star_map
|
||||
_warned_register_star = False
|
||||
|
||||
|
||||
def register_star(name: str, author: str, desc: str, version: str, repo: str = None):
|
||||
def register_star(
|
||||
name: str, author: str, desc: str, version: str, repo: str | None = None
|
||||
):
|
||||
"""注册一个插件(Star)。
|
||||
|
||||
[DEPRECATED] 该装饰器已废弃,将在未来版本中移除。
|
||||
|
||||
@@ -12,7 +12,7 @@ from ..filter.platform_adapter_type import (
|
||||
from ..filter.permission import PermissionTypeFilter, PermissionType
|
||||
from ..filter.custom_filter import CustomFilterAnd, CustomFilterOr
|
||||
from ..filter.regex import RegexFilter
|
||||
from typing import Awaitable
|
||||
from typing import Awaitable, Any, Callable
|
||||
from astrbot.core.provider.func_tool_manager import SUPPORTED_TYPES
|
||||
from astrbot.core.provider.register import llm_tools
|
||||
from astrbot.core.agent.agent import Agent
|
||||
@@ -20,15 +20,19 @@ from astrbot.core.agent.tool import FunctionTool
|
||||
from astrbot.core.agent.handoff import HandoffTool
|
||||
from astrbot.core.agent.hooks import BaseAgentRunHooks
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
from astrbot.core import logger
|
||||
|
||||
|
||||
def get_handler_full_name(awaitable: Awaitable) -> str:
|
||||
def get_handler_full_name(awaitable: Callable[..., Awaitable[Any]]) -> str:
|
||||
"""获取 Handler 的全名"""
|
||||
return f"{awaitable.__module__}_{awaitable.__name__}"
|
||||
|
||||
|
||||
def get_handler_or_create(
|
||||
handler: Awaitable, event_type: EventType, dont_add=False, **kwargs
|
||||
handler: Callable[..., Awaitable[Any]],
|
||||
event_type: EventType,
|
||||
dont_add=False,
|
||||
**kwargs,
|
||||
) -> StarHandlerMetadata:
|
||||
"""获取 Handler 或者创建一个新的 Handler"""
|
||||
handler_full_name = get_handler_full_name(handler)
|
||||
@@ -59,22 +63,35 @@ def get_handler_or_create(
|
||||
|
||||
|
||||
def register_command(
|
||||
command_name: str = None, sub_command: str = None, alias: set = None, **kwargs
|
||||
command_name: str | None = None,
|
||||
sub_command: str | None = None,
|
||||
alias: set | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""注册一个 Command."""
|
||||
new_command = None
|
||||
add_to_event_filters = False
|
||||
if isinstance(command_name, RegisteringCommandable):
|
||||
# 子指令
|
||||
parent_command_names = command_name.parent_group.get_complete_command_names()
|
||||
new_command = CommandFilter(
|
||||
sub_command, alias, None, parent_command_names=parent_command_names
|
||||
)
|
||||
command_name.parent_group.add_sub_command_filter(new_command)
|
||||
if sub_command is not None:
|
||||
parent_command_names = (
|
||||
command_name.parent_group.get_complete_command_names()
|
||||
)
|
||||
new_command = CommandFilter(
|
||||
sub_command, alias, None, parent_command_names=parent_command_names
|
||||
)
|
||||
command_name.parent_group.add_sub_command_filter(new_command)
|
||||
else:
|
||||
logger.warning(
|
||||
f"注册指令{command_name} 的子指令时未提供 sub_command 参数。"
|
||||
)
|
||||
else:
|
||||
# 裸指令
|
||||
new_command = CommandFilter(command_name, alias, None)
|
||||
add_to_event_filters = True
|
||||
if command_name is None:
|
||||
logger.warning("注册裸指令时未提供 command_name 参数。")
|
||||
else:
|
||||
new_command = CommandFilter(command_name, alias, None)
|
||||
add_to_event_filters = True
|
||||
|
||||
def decorator(awaitable):
|
||||
if not add_to_event_filters:
|
||||
@@ -84,8 +101,9 @@ def register_command(
|
||||
handler_md = get_handler_or_create(
|
||||
awaitable, EventType.AdapterMessageEvent, **kwargs
|
||||
)
|
||||
new_command.init_handler_md(handler_md)
|
||||
handler_md.event_filters.append(new_command)
|
||||
if new_command:
|
||||
new_command.init_handler_md(handler_md)
|
||||
handler_md.event_filters.append(new_command)
|
||||
return awaitable
|
||||
|
||||
return decorator
|
||||
@@ -163,26 +181,38 @@ def register_custom_filter(custom_type_filter, *args, **kwargs):
|
||||
|
||||
|
||||
def register_command_group(
|
||||
command_group_name: str = None, sub_command: str = None, alias: set = None, **kwargs
|
||||
command_group_name: str | None = None,
|
||||
sub_command: str | None = None,
|
||||
alias: set | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""注册一个 CommandGroup"""
|
||||
new_group = None
|
||||
if isinstance(command_group_name, RegisteringCommandable):
|
||||
# 子指令组
|
||||
new_group = CommandGroupFilter(
|
||||
sub_command, alias, parent_group=command_group_name.parent_group
|
||||
)
|
||||
command_group_name.parent_group.add_sub_command_filter(new_group)
|
||||
if sub_command is None:
|
||||
logger.warning(f"{command_group_name} 指令组的子指令组 sub_command 未指定")
|
||||
else:
|
||||
new_group = CommandGroupFilter(
|
||||
sub_command, alias, parent_group=command_group_name.parent_group
|
||||
)
|
||||
command_group_name.parent_group.add_sub_command_filter(new_group)
|
||||
else:
|
||||
# 根指令组
|
||||
new_group = CommandGroupFilter(command_group_name, alias)
|
||||
if command_group_name is None:
|
||||
logger.warning("根指令组的名称未指定")
|
||||
else:
|
||||
new_group = CommandGroupFilter(command_group_name, alias)
|
||||
|
||||
def decorator(obj):
|
||||
# 根指令组
|
||||
handler_md = get_handler_or_create(obj, EventType.AdapterMessageEvent, **kwargs)
|
||||
handler_md.event_filters.append(new_group)
|
||||
if new_group:
|
||||
handler_md = get_handler_or_create(
|
||||
obj, EventType.AdapterMessageEvent, **kwargs
|
||||
)
|
||||
handler_md.event_filters.append(new_group)
|
||||
|
||||
return RegisteringCommandable(new_group)
|
||||
return RegisteringCommandable(new_group)
|
||||
|
||||
return decorator
|
||||
|
||||
@@ -323,7 +353,7 @@ def register_on_llm_response(**kwargs):
|
||||
return decorator
|
||||
|
||||
|
||||
def register_llm_tool(name: str = None, **kwargs):
|
||||
def register_llm_tool(name: str | None = None, **kwargs):
|
||||
"""为函数调用(function-calling / tools-use)添加工具。
|
||||
|
||||
请务必按照以下格式编写一个工具(包括函数注释,AstrBot 会尝试解析该函数注释)
|
||||
@@ -361,9 +391,10 @@ def register_llm_tool(name: str = None, **kwargs):
|
||||
if kwargs.get("registering_agent"):
|
||||
registering_agent = kwargs["registering_agent"]
|
||||
|
||||
def decorator(awaitable: Awaitable):
|
||||
def decorator(awaitable: Callable[..., Awaitable[Any]]):
|
||||
llm_tool_name = name_ if name_ else awaitable.__name__
|
||||
docstring = docstring_parser.parse(awaitable.__doc__)
|
||||
func_doc = awaitable.__doc__ or ""
|
||||
docstring = docstring_parser.parse(func_doc)
|
||||
args = []
|
||||
for arg in docstring.params:
|
||||
if arg.type_name not in SUPPORTED_TYPES:
|
||||
@@ -379,20 +410,18 @@ def register_llm_tool(name: str = None, **kwargs):
|
||||
)
|
||||
# print(llm_tool_name, registering_agent)
|
||||
if not registering_agent:
|
||||
doc_desc = docstring.description.strip() if docstring.description else ""
|
||||
md = get_handler_or_create(awaitable, EventType.OnCallingFuncToolEvent)
|
||||
llm_tools.add_func(
|
||||
llm_tool_name, args, docstring.description.strip(), md.handler
|
||||
)
|
||||
llm_tools.add_func(llm_tool_name, args, doc_desc, md.handler)
|
||||
else:
|
||||
assert isinstance(registering_agent, RegisteringAgent)
|
||||
# print(f"Registering tool {llm_tool_name} for agent", registering_agent._agent.name)
|
||||
if registering_agent._agent.tools is None:
|
||||
registering_agent._agent.tools = []
|
||||
registering_agent._agent.tools.append(
|
||||
llm_tools.spec_to_func(
|
||||
llm_tool_name, args, docstring.description.strip(), awaitable
|
||||
)
|
||||
)
|
||||
|
||||
desc = docstring.description.strip() if docstring.description else ""
|
||||
tool = llm_tools.spec_to_func(llm_tool_name, args, desc, awaitable)
|
||||
registering_agent._agent.tools.append(tool)
|
||||
|
||||
return awaitable
|
||||
|
||||
@@ -413,8 +442,8 @@ class RegisteringAgent:
|
||||
def register_agent(
|
||||
name: str,
|
||||
instruction: str,
|
||||
tools: list[str | FunctionTool] = None,
|
||||
run_hooks: BaseAgentRunHooks[AstrAgentContext] = None,
|
||||
tools: list[str | FunctionTool] | None = None,
|
||||
run_hooks: BaseAgentRunHooks[AstrAgentContext] | None = None,
|
||||
):
|
||||
"""注册一个 Agent
|
||||
|
||||
@@ -426,7 +455,7 @@ def register_agent(
|
||||
"""
|
||||
tools_ = tools or []
|
||||
|
||||
def decorator(awaitable: Awaitable):
|
||||
def decorator(awaitable: Callable[..., Awaitable[Any]]):
|
||||
AstrAgent = Agent[AstrAgentContext]
|
||||
agent = AstrAgent(
|
||||
name=name,
|
||||
|
||||
@@ -84,7 +84,10 @@ class SessionPluginManager:
|
||||
session_config["disabled_plugins"] = disabled_plugins
|
||||
session_plugin_config[session_id] = session_config
|
||||
sp.put(
|
||||
"session_plugin_config", session_plugin_config, scope="umo", scope_id=session_id
|
||||
"session_plugin_config",
|
||||
session_plugin_config,
|
||||
scope="umo",
|
||||
scope_id=session_id,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
@@ -137,6 +140,9 @@ class SessionPluginManager:
|
||||
filtered_handlers.append(handler)
|
||||
continue
|
||||
|
||||
if plugin.name is None:
|
||||
continue
|
||||
|
||||
# 检查插件是否在当前会话中启用
|
||||
if SessionPluginManager.is_plugin_enabled_for_session(
|
||||
session_id, plugin.name
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
import enum
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Awaitable, List, Dict, TypeVar, Generic
|
||||
from typing import Callable, Awaitable, Any, List, Dict, TypeVar, Generic
|
||||
from .filter import HandlerFilter
|
||||
from .star import star_map
|
||||
|
||||
@@ -60,7 +60,7 @@ class StarHandlerRegistry(Generic[T]):
|
||||
handlers.append(handler)
|
||||
return handlers
|
||||
|
||||
def get_handler_by_full_name(self, full_name: str) -> StarHandlerMetadata:
|
||||
def get_handler_by_full_name(self, full_name: str) -> StarHandlerMetadata | None:
|
||||
return self.star_handlers_map.get(full_name, None)
|
||||
|
||||
def get_handlers_by_module_name(
|
||||
@@ -87,7 +87,7 @@ class StarHandlerRegistry(Generic[T]):
|
||||
return len(self._handlers)
|
||||
|
||||
|
||||
star_handlers_registry = StarHandlerRegistry()
|
||||
star_handlers_registry = StarHandlerRegistry() # type: ignore
|
||||
|
||||
|
||||
class EventType(enum.Enum):
|
||||
@@ -123,7 +123,7 @@ class StarHandlerMetadata:
|
||||
handler_module_path: str
|
||||
"""Handler 所在的模块路径。"""
|
||||
|
||||
handler: Awaitable
|
||||
handler: Callable[..., Awaitable[Any]]
|
||||
"""Handler 的函数对象,应当是一个异步函数"""
|
||||
|
||||
event_filters: List[HandlerFilter]
|
||||
|
||||
@@ -43,7 +43,7 @@ class PluginManager:
|
||||
self.updator = PluginUpdator()
|
||||
|
||||
self.context = context
|
||||
self.context._star_manager = self
|
||||
self.context._star_manager = self # type: ignore
|
||||
|
||||
self.config = config
|
||||
self.plugin_store_path = get_astrbot_plugin_path()
|
||||
@@ -478,9 +478,10 @@ class PluginManager:
|
||||
if isinstance(func_tool, HandoffTool):
|
||||
need_apply = []
|
||||
sub_tools = func_tool.agent.tools
|
||||
for sub_tool in sub_tools:
|
||||
if isinstance(sub_tool, FunctionTool):
|
||||
need_apply.append(sub_tool)
|
||||
if sub_tools:
|
||||
for sub_tool in sub_tools:
|
||||
if isinstance(sub_tool, FunctionTool):
|
||||
need_apply.append(sub_tool)
|
||||
else:
|
||||
need_apply = [func_tool]
|
||||
|
||||
@@ -686,6 +687,9 @@ class PluginManager:
|
||||
)
|
||||
|
||||
# 从 star_registry 和 star_map 中删除
|
||||
if plugin.module_path is None or root_dir_name is None:
|
||||
raise Exception(f"插件 {plugin_name} 数据不完整,无法卸载。")
|
||||
|
||||
await self._unbind_plugin(plugin_name, plugin.module_path)
|
||||
|
||||
try:
|
||||
@@ -791,15 +795,17 @@ class PluginManager:
|
||||
if star_metadata.star_cls is None:
|
||||
return
|
||||
|
||||
if '__del__' in star_metadata.star_cls_type.__dict__:
|
||||
if "__del__" in star_metadata.star_cls_type.__dict__:
|
||||
asyncio.get_event_loop().run_in_executor(
|
||||
None, star_metadata.star_cls.__del__
|
||||
)
|
||||
elif 'terminate' in star_metadata.star_cls_type.__dict__:
|
||||
elif "terminate" in star_metadata.star_cls_type.__dict__:
|
||||
await star_metadata.star_cls.terminate()
|
||||
|
||||
async def turn_on_plugin(self, plugin_name: str):
|
||||
plugin = self.context.get_registered_star(plugin_name)
|
||||
if plugin is None:
|
||||
raise Exception(f"插件 {plugin_name} 不存在。")
|
||||
inactivated_plugins: list = await sp.global_get("inactivated_plugins", [])
|
||||
inactivated_llm_tools: list = await sp.global_get("inactivated_llm_tools", [])
|
||||
if plugin.module_path in inactivated_plugins:
|
||||
|
||||
@@ -22,7 +22,7 @@ import inspect
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Union, Awaitable, List, Optional, ClassVar
|
||||
from typing import Union, Awaitable, Callable, Any, List, Optional, ClassVar
|
||||
from astrbot.core.message.components import BaseMessageComponent
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.api.platform import MessageMember, AstrBotMessage, MessageType
|
||||
@@ -30,8 +30,13 @@ from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
from astrbot.core.star.context import Context
|
||||
from astrbot.core.star.star import star_map
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_message_event import AiocqhttpMessageEvent
|
||||
from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_platform_adapter import AiocqhttpAdapter
|
||||
from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_message_event import (
|
||||
AiocqhttpMessageEvent,
|
||||
)
|
||||
from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_platform_adapter import (
|
||||
AiocqhttpAdapter,
|
||||
)
|
||||
|
||||
|
||||
class StarTools:
|
||||
"""
|
||||
@@ -77,7 +82,11 @@ class StarTools:
|
||||
|
||||
@classmethod
|
||||
async def send_message_by_id(
|
||||
cls, type: str, id: str, message_chain: MessageChain, platform: str = "aiocqhttp"
|
||||
cls,
|
||||
type: str,
|
||||
id: str,
|
||||
message_chain: MessageChain,
|
||||
platform: str = "aiocqhttp",
|
||||
):
|
||||
"""
|
||||
根据 id(例如qq号, 群号等) 直接, 主动地发送消息
|
||||
@@ -92,7 +101,9 @@ class StarTools:
|
||||
raise ValueError("StarTools not initialized")
|
||||
platforms = cls._context.platform_manager.get_insts()
|
||||
if platform == "aiocqhttp":
|
||||
adapter = next((p for p in platforms if isinstance(p, AiocqhttpAdapter)), None)
|
||||
adapter = next(
|
||||
(p for p in platforms if isinstance(p, AiocqhttpAdapter)), None
|
||||
)
|
||||
if adapter is None:
|
||||
raise ValueError("未找到适配器: AiocqhttpAdapter")
|
||||
await AiocqhttpMessageEvent.send_message(
|
||||
@@ -115,7 +126,7 @@ class StarTools:
|
||||
message_str: str,
|
||||
message_id: str = "",
|
||||
raw_message: object = None,
|
||||
group_id: str = ""
|
||||
group_id: str = "",
|
||||
) -> AstrBotMessage:
|
||||
"""
|
||||
创建一个AstrBot消息对象
|
||||
@@ -152,7 +163,6 @@ class StarTools:
|
||||
@classmethod
|
||||
async def create_event(
|
||||
cls, abm: AstrBotMessage, platform: str = "aiocqhttp", is_wake: bool = True
|
||||
|
||||
) -> None:
|
||||
"""
|
||||
创建并提交事件到指定平台
|
||||
@@ -167,7 +177,9 @@ class StarTools:
|
||||
raise ValueError("StarTools not initialized")
|
||||
platforms = cls._context.platform_manager.get_insts()
|
||||
if platform == "aiocqhttp":
|
||||
adapter = next((p for p in platforms if isinstance(p, AiocqhttpAdapter)), None)
|
||||
adapter = next(
|
||||
(p for p in platforms if isinstance(p, AiocqhttpAdapter)), None
|
||||
)
|
||||
if adapter is None:
|
||||
raise ValueError("未找到适配器: AiocqhttpAdapter")
|
||||
event = AiocqhttpMessageEvent(
|
||||
@@ -209,7 +221,11 @@ class StarTools:
|
||||
|
||||
@classmethod
|
||||
def register_llm_tool(
|
||||
cls, name: str, func_args: list, desc: str, func_obj: Awaitable
|
||||
cls,
|
||||
name: str,
|
||||
func_args: list,
|
||||
desc: str,
|
||||
func_obj: Callable[..., Awaitable[Any]],
|
||||
) -> None:
|
||||
"""
|
||||
为函数调用(function-calling/tools-use)添加工具
|
||||
@@ -277,7 +293,9 @@ class StarTools:
|
||||
if not plugin_name:
|
||||
raise ValueError("无法获取插件名称")
|
||||
|
||||
data_dir = Path(os.path.join(get_astrbot_data_path(), "plugin_data", plugin_name))
|
||||
data_dir = Path(
|
||||
os.path.join(get_astrbot_data_path(), "plugin_data", plugin_name)
|
||||
)
|
||||
|
||||
try:
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -32,6 +32,9 @@ class PluginUpdator(RepoZipUpdator):
|
||||
if not repo_url:
|
||||
raise Exception(f"插件 {plugin.name} 没有指定仓库地址。")
|
||||
|
||||
if not plugin.root_dir_name:
|
||||
raise Exception(f"插件 {plugin.name} 的根目录名未指定。")
|
||||
|
||||
plugin_path = os.path.join(self.plugin_store_path, plugin.root_dir_name)
|
||||
|
||||
logger.info(f"正在更新插件,路径: {plugin_path},仓库地址: {repo_url}")
|
||||
|
||||
@@ -227,9 +227,11 @@ async def download_dashboard(
|
||||
path = os.path.join(get_astrbot_data_path(), "dashboard.zip")
|
||||
|
||||
if latest or len(str(version)) != 40:
|
||||
logger.info(f"准备下载 {version} 发行版本的 AstrBot WebUI 文件")
|
||||
ver_name = "latest" if latest else version
|
||||
dashboard_release_url = f"https://astrbot-registry.soulter.top/download/astrbot-dashboard/{ver_name}/dist.zip"
|
||||
logger.info(
|
||||
f"准备下载指定发行版本的 AstrBot WebUI 文件: {dashboard_release_url}"
|
||||
)
|
||||
try:
|
||||
await download_file(dashboard_release_url, path, show_progress=True)
|
||||
except BaseException as _:
|
||||
@@ -241,24 +243,10 @@ async def download_dashboard(
|
||||
dashboard_release_url = f"{proxy}/{dashboard_release_url}"
|
||||
await download_file(dashboard_release_url, path, show_progress=True)
|
||||
else:
|
||||
logger.info(f"准备下载指定版本的 AstrBot WebUI: {version}")
|
||||
|
||||
url = (
|
||||
"https://api.github.com/repos/AstrBotDevs/astrbot-release-harbour/releases"
|
||||
)
|
||||
url = f"https://github.com/AstrBotDevs/astrbot-release-harbour/releases/download/release-{version}/dist.zip"
|
||||
logger.info(f"准备下载指定版本的 AstrBot WebUI: {url}")
|
||||
if proxy:
|
||||
url = f"{proxy}/{url}"
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.get(url) as resp:
|
||||
if resp.status == 200:
|
||||
releases = await resp.json()
|
||||
for release in releases:
|
||||
if version in release["tag_name"]:
|
||||
download_url = release["assets"][0]["browser_download_url"]
|
||||
await download_file(download_url, path, show_progress=True)
|
||||
else:
|
||||
logger.warning(f"未找到指定的版本的 Dashboard 构建文件: {version}")
|
||||
return
|
||||
|
||||
await download_file(url, path, show_progress=True)
|
||||
with zipfile.ZipFile(path, "r") as z:
|
||||
z.extractall(extract_path)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import typing
|
||||
import traceback
|
||||
import os
|
||||
import copy
|
||||
from .route import Route, Response, RouteContext
|
||||
from astrbot.core.provider.entities import ProviderType
|
||||
from quart import request
|
||||
@@ -52,24 +51,6 @@ def validate_config(
|
||||
def validate(data: dict, metadata: dict = schema, path=""):
|
||||
for key, value in data.items():
|
||||
if key not in metadata:
|
||||
# 无 schema 的配置项,执行类型猜测
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
data[key] = int(value)
|
||||
continue
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
try:
|
||||
data[key] = float(value)
|
||||
continue
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if value.lower() == "true":
|
||||
data[key] = True
|
||||
elif value.lower() == "false":
|
||||
data[key] = False
|
||||
continue
|
||||
meta = metadata[key]
|
||||
if "type" not in meta:
|
||||
@@ -128,12 +109,12 @@ def validate_config(
|
||||
)
|
||||
|
||||
if is_core:
|
||||
for key, group in schema.items():
|
||||
group_meta = group.get("metadata")
|
||||
if not group_meta:
|
||||
continue
|
||||
# logger.info(f"验证配置: 组 {key} ...")
|
||||
validate(data, group_meta, path=f"{key}.")
|
||||
meta_all = {
|
||||
**schema["platform_group"]["metadata"],
|
||||
**schema["provider_group"]["metadata"],
|
||||
**schema["misc_config_group"]["metadata"],
|
||||
}
|
||||
validate(data, meta_all)
|
||||
else:
|
||||
validate(data, schema)
|
||||
|
||||
@@ -143,6 +124,7 @@ def validate_config(
|
||||
def save_config(post_config: dict, config: AstrBotConfig, is_core: bool = False):
|
||||
"""验证并保存配置"""
|
||||
errors = None
|
||||
logger.info(f"Saving config, is_core={is_core}")
|
||||
try:
|
||||
if is_core:
|
||||
errors, post_config = validate_config(
|
||||
|
||||
@@ -169,15 +169,65 @@ class ConversationRoute(Route):
|
||||
"""删除对话"""
|
||||
try:
|
||||
data = await request.get_json()
|
||||
user_id = data.get("user_id")
|
||||
cid = data.get("cid")
|
||||
|
||||
if not user_id or not cid:
|
||||
return Response().error("缺少必要参数: user_id 和 cid").__dict__
|
||||
await self.core_lifecycle.conversation_manager.delete_conversation(
|
||||
unified_msg_origin=user_id, conversation_id=cid
|
||||
)
|
||||
return Response().ok({"message": "对话删除成功"}).__dict__
|
||||
# 检查是否是批量删除
|
||||
if "conversations" in data:
|
||||
# 批量删除
|
||||
conversations = data.get("conversations", [])
|
||||
if not conversations:
|
||||
return (
|
||||
Response().error("批量删除时conversations参数不能为空").__dict__
|
||||
)
|
||||
|
||||
deleted_count = 0
|
||||
failed_items = []
|
||||
|
||||
for conv in conversations:
|
||||
user_id = conv.get("user_id")
|
||||
cid = conv.get("cid")
|
||||
|
||||
if not user_id or not cid:
|
||||
failed_items.append(
|
||||
f"user_id:{user_id}, cid:{cid} - 缺少必要参数"
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
await self.core_lifecycle.conversation_manager.delete_conversation(
|
||||
unified_msg_origin=user_id, conversation_id=cid
|
||||
)
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
failed_items.append(f"user_id:{user_id}, cid:{cid} - {str(e)}")
|
||||
|
||||
message = f"成功删除 {deleted_count} 个对话"
|
||||
if failed_items:
|
||||
message += f",失败 {len(failed_items)} 个"
|
||||
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
{
|
||||
"message": message,
|
||||
"deleted_count": deleted_count,
|
||||
"failed_count": len(failed_items),
|
||||
"failed_items": failed_items,
|
||||
}
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
else:
|
||||
# 单个删除
|
||||
user_id = data.get("user_id")
|
||||
cid = data.get("cid")
|
||||
|
||||
if not user_id or not cid:
|
||||
return Response().error("缺少必要参数: user_id 和 cid").__dict__
|
||||
|
||||
await self.core_lifecycle.conversation_manager.delete_conversation(
|
||||
unified_msg_origin=user_id, conversation_id=cid
|
||||
)
|
||||
return Response().ok({"message": "对话删除成功"}).__dict__
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"删除对话失败: {str(e)}\n{traceback.format_exc()}")
|
||||
|
||||
@@ -10,7 +10,9 @@ class LogRoute(Route):
|
||||
super().__init__(context)
|
||||
self.log_broker = log_broker
|
||||
self.app.add_url_rule("/api/live-log", view_func=self.log, methods=["GET"])
|
||||
self.app.add_url_rule("/api/log-history", view_func=self.log_history, methods=["GET"])
|
||||
self.app.add_url_rule(
|
||||
"/api/log-history", view_func=self.log_history, methods=["GET"]
|
||||
)
|
||||
|
||||
async def log(self):
|
||||
async def stream():
|
||||
@@ -48,9 +50,15 @@ class LogRoute(Route):
|
||||
"""获取日志历史"""
|
||||
try:
|
||||
logs = list(self.log_broker.log_cache)
|
||||
return Response().ok(data={
|
||||
"logs": logs,
|
||||
}).__dict__
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
data={
|
||||
"logs": logs,
|
||||
}
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
except BaseException as e:
|
||||
logger.error(f"获取日志历史失败: {e}")
|
||||
return Response().error(f"获取日志历史失败: {e}").__dict__
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.config.astrbot_config import AstrBotConfig
|
||||
from dataclasses import dataclass
|
||||
from quart import Quart
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import traceback
|
||||
|
||||
import aiohttp
|
||||
from quart import request
|
||||
|
||||
from astrbot.core import logger
|
||||
|
||||
@@ -3,15 +3,3 @@
|
||||
修复了 v4.1.0 `model referenced before assignment` 的错误。
|
||||
|
||||
> 如果已经使用自定义文转图模板,此次升级之后将会被覆盖,请提前备份。路径在 `astrbot/core/utils/t2i/template` 目录下。
|
||||
|
||||
0. ‼️‼️‼️ 修复 LLM 仍会调用已禁用的工具的问题 ([#2729](https://github.com/Soulter/AstrBot/issues/2729))
|
||||
1. ‼️ 修复 WebChat 下,Agent 长时任务时,SSE 连接自动断开的问题
|
||||
2. ‼️ 修复自定义文转图模板更新版本后会被覆盖的问题 ([#2677](https://github.com/Soulter/AstrBot/issues/2677))
|
||||
3. 修复 Satori 适配器教程链接 ([#2668](https://github.com/Soulter/AstrBot/issues/2668))
|
||||
4. 修复插件页表格视图中,点击状态字段表头排序不起作用的问题 ([#2714](https://github.com/Soulter/AstrBot/issues/2714))
|
||||
5. 修复工具调用时的 content 内容在重新加载后没有显示在 webchat 的问题 ([#2727](https://github.com/Soulter/AstrBot/issues/2727))
|
||||
6. 允许添加多个 tavily API Key 进行轮询 ([#2725](https://github.com/Soulter/AstrBot/issues/2725))
|
||||
7. 添加 --webui-dir 启动参数以支持指定 WebUI 构建文件目录 ([#2680](https://github.com/Soulter/AstrBot/issues/2680))
|
||||
8. 兼容指令名和第一个参数之间没有空格的情况 ([#2650](https://github.com/Soulter/AstrBot/issues/2650))
|
||||
9. 支持在 WebUI 自定义 OpenAI API extra_body 参数 ([#2719](https://github.com/Soulter/AstrBot/issues/2719))
|
||||
10. 增加 on_platform_loaded 钩子以在消息平台适配器实例化完成后触发 ([#2651](https://github.com/Soulter/AstrBot/issues/2651))
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# What's Changed
|
||||
|
||||
0. ‼️‼️‼️ fix: 修复 4.1.1 版本下,指令调用异常的问题
|
||||
1. ‼️‼️ fix: 修复多配置文件配置的不同人格无法生效的问题 ([#2739](https://github.com/AstrBotDevs/AstrBot/issues/2739))
|
||||
2. ‼️‼️ fix: 修复人格所选择的工具无法应用的问题 ([#2739](https://github.com/AstrBotDevs/AstrBot/issues/2739))
|
||||
3. ‼️‼️ fix: 修复平台配置下的「内容安全」组无法生效 ([#2751](https://github.com/AstrBotDevs/AstrBot/issues/2751))
|
||||
4. perf: 检查服务提供商可用性时跳过未启用的提供商,解决部分 `provider with id xxx not found` 的问题
|
||||
|
||||
fixes: [#2724](https://github.com/AstrBotDevs/AstrBot/issues/2724)
|
||||
@@ -0,0 +1,8 @@
|
||||
# What's Changed
|
||||
|
||||
0. ‼️ fix: 修复 4.0.0 版本之后,配置默认 TTS 或者 STT 模型之后仍无法生效的问题 ([#2758](https://github.com/Soulter/AstrBot/issues/2758))
|
||||
1. ‼️ fix: 修复分段回复时,引用消息单独发送导致第一条消息内容为空的问题 ([#2757](https://github.com/Soulter/AstrBot/issues/2757))
|
||||
2. feat: 支持在 WebUI 复制提供商配置以简化操作 ([#2767](https://github.com/Soulter/AstrBot/issues/2767))
|
||||
3. fix: handle image value correctly for mcp BlobResourceContents ([#2753](https://github.com/Soulter/AstrBot/issues/2753))
|
||||
4. feat: 增加 QQ 群名称识别到 system prompt, 并提供相应的配置 ([#2770](https://github.com/Soulter/AstrBot/issues/2770))
|
||||
5. fix: parameter type/default handling in CommandFilter
|
||||
@@ -0,0 +1,10 @@
|
||||
# What's Changed
|
||||
|
||||
0. ‼️ fix: 修复 4.0.0 版本之后,配置默认 TTS 或者 STT 模型之后仍无法生效的问题 ([#2758](https://github.com/Soulter/AstrBot/issues/2758))
|
||||
1. ‼️ fix: 修复分段回复时,引用消息单独发送导致第一条消息内容为空的问题 ([#2757](https://github.com/Soulter/AstrBot/issues/2757))
|
||||
2. feat: 支持在 WebUI 复制提供商配置以简化操作 ([#2767](https://github.com/Soulter/AstrBot/issues/2767))
|
||||
3. fix: handle image value correctly for mcp BlobResourceContents ([#2753](https://github.com/Soulter/AstrBot/issues/2753))
|
||||
4. feat: 增加 QQ 群名称识别到 system prompt, 并提供相应的配置 ([#2770](https://github.com/Soulter/AstrBot/issues/2770))
|
||||
5. fix: 修复 4.1.3 的异常问题
|
||||
|
||||
**总之上个版本有很严重的 bug 赶快更新!**
|
||||
@@ -0,0 +1,11 @@
|
||||
# What's Changed
|
||||
|
||||
0. feat: 新增 Misskey 平台适配器 ([#2774](https://github.com/AstrBotDevs/AstrBot/issues/2774))
|
||||
1. fix: 修复aiocqhttp适配器at会获取群昵称而消息不会获取的逻辑不一致 ([#2769](https://github.com/AstrBotDevs/AstrBot/issues/2769))
|
||||
2. fix: 修复「对话管理」页面的关键词搜索功能失效的问题并优化一些 UI 样式 ([#2837](https://github.com/AstrBotDevs/AstrBot/issues/2837))
|
||||
3. fix: 识别「引用消息」的图片时优先使用默认图片转述提供商 ([#2836](https://github.com/AstrBotDevs/AstrBot/issues/2836))
|
||||
5. fix: 修复 Telegram 下流式传输时,第一次输出的内容会被覆盖掉的问题
|
||||
6. perf: 优化统计页内存占用和消息数据趋势的样式 ([#2826](https://github.com/AstrBotDevs/AstrBot/issues/2826))
|
||||
7. perf: 优化 「插件页」、「对话管理页」、「会话管理页」的样式
|
||||
8. fix: on_tool_end hook unavailable
|
||||
9. feat: add audioop-lts dependencies ([#2809](https://github.com/AstrBotDevs/AstrBot/issues/2809))
|
||||
@@ -0,0 +1,3 @@
|
||||
# What's Changed
|
||||
|
||||
1. fix: 修复在某些情况下,出现 「返回的 Provider 不是 Provider 类型的错误」
|
||||
@@ -0,0 +1,8 @@
|
||||
# What's Changed
|
||||
|
||||
1. perf: 优化 WebChat 等组件的 UI 风格
|
||||
2. fix: 修复 4.1.6 版本可能无法点击更新按钮的问题
|
||||
3. fix: 修复更新开发版的时候,可能无法同时更新 WebUI 的问题
|
||||
4. feat: 支持在「对话数据」页批量删除对话
|
||||
5. fix: 修复部分错误地显示「格式校验未通过」的问题
|
||||
6. perf: WebChat 支持手动填写模型名称
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,775 @@
|
||||
<template>
|
||||
<div class="messages-container" ref="messageContainer">
|
||||
<!-- 聊天消息列表 -->
|
||||
<div class="message-list">
|
||||
<div class="message-item fade-in" v-for="(msg, index) in messages" :key="index">
|
||||
<!-- 用户消息 -->
|
||||
<div v-if="msg.content.type == 'user'" class="user-message">
|
||||
<div class="message-bubble user-bubble" :class="{ 'has-audio': msg.content.audio_url }"
|
||||
:style="{ backgroundColor: isDark ? '#2d2e30' : '#e7ebf4' }">
|
||||
<pre
|
||||
style="font-family: inherit; white-space: pre-wrap; word-wrap: break-word;">{{ msg.content.message }}</pre>
|
||||
|
||||
<!-- 图片附件 -->
|
||||
<div class="image-attachments" v-if="msg.content.image_url && msg.content.image_url.length > 0">
|
||||
<div v-for="(img, index) in msg.content.image_url" :key="index" class="image-attachment">
|
||||
<img :src="img" class="attached-image" @click="$emit('openImagePreview', img)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 音频附件 -->
|
||||
<div class="audio-attachment" v-if="msg.content.audio_url && msg.content.audio_url.length > 0">
|
||||
<audio controls class="audio-player">
|
||||
<source :src="msg.content.audio_url" type="audio/wav">
|
||||
{{ t('messages.errors.browser.audioNotSupported') }}
|
||||
</audio>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bot Messages -->
|
||||
<div v-else class="bot-message">
|
||||
<div v-if="isStreaming && index === messages.length - 1" style="width: 36px; height: 36px;">
|
||||
<v-progress-circular indeterminate size="28" width="2"
|
||||
style="margin-top: 12px;"></v-progress-circular>
|
||||
</div>
|
||||
<v-avatar v-else class="bot-avatar" size="36">
|
||||
<span class="text-h2">✨</span>
|
||||
</v-avatar>
|
||||
<div class="bot-message-content">
|
||||
<div class="message-bubble bot-bubble">
|
||||
<!-- Text -->
|
||||
<div v-if="msg.content.message && msg.content.message.trim()"
|
||||
v-html="md.render(msg.content.message)" class="markdown-content"></div>
|
||||
|
||||
<!-- Image -->
|
||||
<div class="embedded-images"
|
||||
v-if="msg.content.embedded_images && msg.content.embedded_images.length > 0">
|
||||
<div v-for="(img, imgIndex) in msg.content.embedded_images" :key="imgIndex"
|
||||
class="embedded-image">
|
||||
<img :src="img" class="bot-embedded-image"
|
||||
@click="$emit('openImagePreview', img)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audio -->
|
||||
<div class="embedded-audio" v-if="msg.content.embedded_audio">
|
||||
<audio controls class="audio-player">
|
||||
<source :src="msg.content.embedded_audio" type="audio/wav">
|
||||
{{ t('messages.errors.browser.audioNotSupported') }}
|
||||
</audio>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-actions">
|
||||
<v-btn :icon="getCopyIcon(index)" size="small" variant="text" class="copy-message-btn"
|
||||
:class="{ 'copy-success': isCopySuccess(index) }"
|
||||
@click="copyBotMessage(msg.content.message, index)" :title="t('core.common.copy')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import hljs from 'highlight.js';
|
||||
import 'highlight.js/styles/github.css';
|
||||
|
||||
const md = new MarkdownIt({
|
||||
html: false,
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
highlight: function (code, lang) {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
return hljs.highlight(code, { language: lang }).value;
|
||||
} catch (err) {
|
||||
console.error('Highlight error:', err);
|
||||
}
|
||||
}
|
||||
return hljs.highlightAuto(code).value;
|
||||
}
|
||||
});
|
||||
|
||||
export default {
|
||||
name: 'MessageList',
|
||||
props: {
|
||||
messages: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
isDark: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isStreaming: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['openImagePreview'],
|
||||
setup() {
|
||||
const { t } = useI18n();
|
||||
const { tm } = useModuleI18n('features/chat');
|
||||
|
||||
return {
|
||||
t,
|
||||
tm,
|
||||
md
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
copiedMessages: new Set(),
|
||||
isUserNearBottom: true,
|
||||
scrollThreshold: 1,
|
||||
scrollTimer: null
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.initCodeCopyButtons();
|
||||
this.initImageClickEvents();
|
||||
this.addScrollListener();
|
||||
this.scrollToBottom();
|
||||
},
|
||||
updated() {
|
||||
this.initCodeCopyButtons();
|
||||
this.initImageClickEvents();
|
||||
if (this.isUserNearBottom) {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 复制代码到剪贴板
|
||||
copyCodeToClipboard(code) {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
console.log('代码已复制到剪贴板');
|
||||
}).catch(err => {
|
||||
console.error('复制失败:', err);
|
||||
// 如果现代API失败,使用传统方法
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = code;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
console.log('代码已复制到剪贴板 (fallback)');
|
||||
} catch (fallbackErr) {
|
||||
console.error('复制失败 (fallback):', fallbackErr);
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
});
|
||||
},
|
||||
|
||||
// 复制bot消息到剪贴板
|
||||
copyBotMessage(message, messageIndex) {
|
||||
// 获取对应的消息对象
|
||||
const msgObj = this.messages[messageIndex].content;
|
||||
let textToCopy = '';
|
||||
|
||||
// 如果有文本消息,添加到复制内容中
|
||||
if (message && message.trim()) {
|
||||
// 移除HTML标签,获取纯文本
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = message;
|
||||
textToCopy = tempDiv.textContent || tempDiv.innerText || message;
|
||||
}
|
||||
|
||||
// 如果有内嵌图片,添加说明
|
||||
if (msgObj && msgObj.embedded_images && msgObj.embedded_images.length > 0) {
|
||||
if (textToCopy) textToCopy += '\n\n';
|
||||
textToCopy += `[包含 ${msgObj.embedded_images.length} 张图片]`;
|
||||
}
|
||||
|
||||
// 如果有内嵌音频,添加说明
|
||||
if (msgObj && msgObj.embedded_audio) {
|
||||
if (textToCopy) textToCopy += '\n\n';
|
||||
textToCopy += '[包含音频内容]';
|
||||
}
|
||||
|
||||
// 如果没有任何内容,使用默认文本
|
||||
if (!textToCopy.trim()) {
|
||||
textToCopy = '[媒体内容]';
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||
console.log('消息已复制到剪贴板');
|
||||
this.showCopySuccess(messageIndex);
|
||||
}).catch(err => {
|
||||
console.error('复制失败:', err);
|
||||
// 如果现代API失败,使用传统方法
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = textToCopy;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
console.log('消息已复制到剪贴板 (fallback)');
|
||||
this.showCopySuccess(messageIndex);
|
||||
} catch (fallbackErr) {
|
||||
console.error('复制失败 (fallback):', fallbackErr);
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
});
|
||||
},
|
||||
|
||||
// 显示复制成功提示
|
||||
showCopySuccess(messageIndex) {
|
||||
this.copiedMessages.add(messageIndex);
|
||||
|
||||
// 2秒后移除成功状态
|
||||
setTimeout(() => {
|
||||
this.copiedMessages.delete(messageIndex);
|
||||
}, 2000);
|
||||
},
|
||||
|
||||
// 获取复制按钮图标
|
||||
getCopyIcon(messageIndex) {
|
||||
return this.copiedMessages.has(messageIndex) ? 'mdi-check' : 'mdi-content-copy';
|
||||
},
|
||||
|
||||
// 检查是否为复制成功状态
|
||||
isCopySuccess(messageIndex) {
|
||||
return this.copiedMessages.has(messageIndex);
|
||||
},
|
||||
|
||||
// 获取复制图标SVG
|
||||
getCopyIconSvg() {
|
||||
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
|
||||
},
|
||||
|
||||
// 获取成功图标SVG
|
||||
getSuccessIconSvg() {
|
||||
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20,6 9,17 4,12"></polyline></svg>';
|
||||
},
|
||||
|
||||
// 初始化代码块复制按钮
|
||||
initCodeCopyButtons() {
|
||||
this.$nextTick(() => {
|
||||
const codeBlocks = this.$refs.messageContainer?.querySelectorAll('pre code') || [];
|
||||
codeBlocks.forEach((codeBlock, index) => {
|
||||
const pre = codeBlock.parentElement;
|
||||
if (pre && !pre.querySelector('.copy-code-btn')) {
|
||||
const button = document.createElement('button');
|
||||
button.className = 'copy-code-btn';
|
||||
button.innerHTML = this.getCopyIconSvg();
|
||||
button.title = '复制代码';
|
||||
button.addEventListener('click', () => {
|
||||
this.copyCodeToClipboard(codeBlock.textContent);
|
||||
// 显示复制成功提示
|
||||
button.innerHTML = this.getSuccessIconSvg();
|
||||
button.style.color = '#4caf50';
|
||||
setTimeout(() => {
|
||||
button.innerHTML = this.getCopyIconSvg();
|
||||
button.style.color = '';
|
||||
}, 2000);
|
||||
});
|
||||
pre.style.position = 'relative';
|
||||
pre.appendChild(button);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
initImageClickEvents() {
|
||||
this.$nextTick(() => {
|
||||
// 查找所有动态生成的图片(在markdown-content中)
|
||||
const images = document.querySelectorAll('.markdown-content img');
|
||||
images.forEach((img) => {
|
||||
if (!img.hasAttribute('data-click-enabled')) {
|
||||
img.style.cursor = 'pointer';
|
||||
img.setAttribute('data-click-enabled', 'true');
|
||||
img.onclick = () => this.$emit('openImagePreview', img.src);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
scrollToBottom() {
|
||||
this.$nextTick(() => {
|
||||
const container = this.$refs.messageContainer;
|
||||
if (container) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
this.isUserNearBottom = true; // 程序滚动到底部后标记用户在底部
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 添加滚动事件监听器
|
||||
addScrollListener() {
|
||||
const container = this.$refs.messageContainer;
|
||||
if (container) {
|
||||
container.addEventListener('scroll', this.throttledHandleScroll);
|
||||
}
|
||||
},
|
||||
|
||||
// 节流处理滚动事件
|
||||
throttledHandleScroll() {
|
||||
if (this.scrollTimer) return;
|
||||
|
||||
this.scrollTimer = setTimeout(() => {
|
||||
this.handleScroll();
|
||||
this.scrollTimer = null;
|
||||
}, 50); // 50ms 节流
|
||||
},
|
||||
|
||||
// 处理滚动事件
|
||||
handleScroll() {
|
||||
const container = this.$refs.messageContainer;
|
||||
if (container) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
const distanceFromBottom = scrollHeight - (scrollTop + clientHeight);
|
||||
|
||||
// 判断用户是否在底部附近
|
||||
this.isUserNearBottom = distanceFromBottom <= this.scrollThreshold;
|
||||
}
|
||||
},
|
||||
|
||||
// 组件销毁时移除监听器
|
||||
beforeUnmount() {
|
||||
const container = this.$refs.messageContainer;
|
||||
if (container) {
|
||||
container.removeEventListener('scroll', this.throttledHandleScroll);
|
||||
}
|
||||
// 清理定时器
|
||||
if (this.scrollTimer) {
|
||||
clearTimeout(this.scrollTimer);
|
||||
this.scrollTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 基础动画 */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 消息列表样式 */
|
||||
.message-list {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
margin-bottom: 24px;
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.user-message {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bot-message {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bot-message-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
max-width: 80%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.bot-message:hover .message-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.copy-message-btn {
|
||||
opacity: 0.6;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--v-theme-secondary);
|
||||
}
|
||||
|
||||
.copy-message-btn:hover {
|
||||
opacity: 1;
|
||||
background-color: rgba(103, 58, 183, 0.1);
|
||||
}
|
||||
|
||||
.copy-message-btn.copy-success {
|
||||
color: #4caf50;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.copy-message-btn.copy-success:hover {
|
||||
color: #4caf50;
|
||||
background-color: rgba(76, 175, 80, 0.1);
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
padding: 2px 16px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.user-bubble {
|
||||
color: var(--v-theme-primaryText);
|
||||
padding: 12px 18px;
|
||||
font-size: 15px;
|
||||
max-width: 60%;
|
||||
border-radius: 1.5rem;
|
||||
}
|
||||
|
||||
.bot-bubble {
|
||||
border: 1px solid var(--v-theme-border);
|
||||
color: var(--v-theme-primaryText);
|
||||
font-size: 15px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.user-avatar,
|
||||
.bot-avatar {
|
||||
align-self: flex-start;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* 附件样式 */
|
||||
.image-attachments {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.image-attachment {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.attached-image {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.audio-attachment {
|
||||
margin-top: 8px;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
/* 包含音频的消息气泡最小宽度 */
|
||||
.message-bubble.has-audio {
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.audio-player {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.embedded-images {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.embedded-image {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.bot-embedded-image {
|
||||
max-width: 80%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.bot-embedded-image:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.embedded-audio {
|
||||
width: 300px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.embedded-audio .audio-player {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
/* 动画类 */
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* Markdown内容样式 - 需要全局样式 */
|
||||
.markdown-content {
|
||||
font-family: inherit;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.markdown-content h1,
|
||||
.markdown-content h2,
|
||||
.markdown-content h3,
|
||||
.markdown-content h4,
|
||||
.markdown-content h5,
|
||||
.markdown-content h6 {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--v-theme-primaryText);
|
||||
}
|
||||
|
||||
.markdown-content h1 {
|
||||
font-size: 1.8em;
|
||||
border-bottom: 1px solid var(--v-theme-border);
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.markdown-content h2 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.markdown-content h3 {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
.markdown-content li {
|
||||
margin-left: 16px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.markdown-content p {
|
||||
margin-top: .5rem;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
.markdown-content pre {
|
||||
background-color: var(--v-theme-surface);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 12px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.markdown-content code {
|
||||
background-color: rgb(var(--v-theme-codeBg));
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Fira Code', monospace;
|
||||
font-size: 0.9em;
|
||||
color: var(--v-theme-code);
|
||||
}
|
||||
|
||||
/* 代码块中的code标签样式 */
|
||||
.markdown-content pre code {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
font-family: 'Fira Code', 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 0.85em;
|
||||
color: inherit;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 自定义代码高亮样式 */
|
||||
.markdown-content pre {
|
||||
border: 1px solid var(--v-theme-border);
|
||||
background-color: rgb(var(--v-theme-preBg));
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* 确保highlight.js的样式正确应用 */
|
||||
.markdown-content pre code.hljs {
|
||||
background: transparent !important;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* 亮色主题下的代码高亮 */
|
||||
.v-theme--light .markdown-content pre {
|
||||
background-color: #f6f8fa;
|
||||
}
|
||||
|
||||
/* 暗色主题下的代码块样式 */
|
||||
.v-theme--dark .markdown-content pre {
|
||||
background-color: #0d1117 !important;
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.v-theme--dark .markdown-content pre code {
|
||||
color: #e6edf3 !important;
|
||||
}
|
||||
|
||||
/* 暗色主题下的highlight.js样式覆盖 */
|
||||
.v-theme--dark .hljs {
|
||||
background: #0d1117 !important;
|
||||
color: #e6edf3 !important;
|
||||
}
|
||||
|
||||
.v-theme--dark .hljs-keyword,
|
||||
.v-theme--dark .hljs-selector-tag,
|
||||
.v-theme--dark .hljs-built_in,
|
||||
.v-theme--dark .hljs-name,
|
||||
.v-theme--dark .hljs-tag {
|
||||
color: #ff7b72 !important;
|
||||
}
|
||||
|
||||
.v-theme--dark .hljs-string,
|
||||
.v-theme--dark .hljs-title,
|
||||
.v-theme--dark .hljs-section,
|
||||
.v-theme--dark .hljs-attribute,
|
||||
.v-theme--dark .hljs-literal,
|
||||
.v-theme--dark .hljs-template-tag,
|
||||
.v-theme--dark .hljs-template-variable,
|
||||
.v-theme--dark .hljs-type,
|
||||
.v-theme--dark .hljs-addition {
|
||||
color: #a5d6ff !important;
|
||||
}
|
||||
|
||||
.v-theme--dark .hljs-comment,
|
||||
.v-theme--dark .hljs-quote,
|
||||
.v-theme--dark .hljs-deletion,
|
||||
.v-theme--dark .hljs-meta {
|
||||
color: #8b949e !important;
|
||||
}
|
||||
|
||||
.v-theme--dark .hljs-number,
|
||||
.v-theme--dark .hljs-regexp,
|
||||
.v-theme--dark .hljs-symbol,
|
||||
.v-theme--dark .hljs-variable,
|
||||
.v-theme--dark .hljs-template-variable,
|
||||
.v-theme--dark .hljs-link,
|
||||
.v-theme--dark .hljs-selector-attr,
|
||||
.v-theme--dark .hljs-selector-pseudo {
|
||||
color: #79c0ff !important;
|
||||
}
|
||||
|
||||
.v-theme--dark .hljs-function,
|
||||
.v-theme--dark .hljs-class,
|
||||
.v-theme--dark .hljs-title.class_ {
|
||||
color: #d2a8ff !important;
|
||||
}
|
||||
|
||||
/* 复制按钮样式 */
|
||||
.copy-code-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 4px;
|
||||
padding: 6px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.copy-code-btn:hover {
|
||||
background: rgba(255, 255, 255, 1);
|
||||
color: #333;
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.copy-code-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.markdown-content pre:hover .copy-code-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.v-theme--dark .copy-code-btn {
|
||||
background: rgba(45, 45, 45, 0.9);
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.v-theme--dark .copy-code-btn:hover {
|
||||
background: rgba(45, 45, 45, 1);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.markdown-content img {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.markdown-content blockquote {
|
||||
border-left: 4px solid var(--v-theme-secondary);
|
||||
padding-left: 16px;
|
||||
color: var(--v-theme-secondaryText);
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.markdown-content table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.markdown-content th,
|
||||
.markdown-content td {
|
||||
border: 1px solid var(--v-theme-background);
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-content th {
|
||||
background-color: var(--v-theme-containerBg);
|
||||
}
|
||||
</style>
|
||||
@@ -1,21 +1,11 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 选择提供商和模型按钮 -->
|
||||
<v-btn
|
||||
class="text-none"
|
||||
variant="tonal"
|
||||
rounded="xl"
|
||||
size="small"
|
||||
v-if="selectedProviderId && selectedModelName"
|
||||
@click="showDialog = true">
|
||||
<v-btn class="text-none" variant="tonal" rounded="xl" size="small"
|
||||
v-if="selectedProviderId && selectedModelName" @click="openDialog">
|
||||
{{ selectedProviderId }} / {{ selectedModelName }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="tonal"
|
||||
rounded="xl"
|
||||
size="small"
|
||||
v-else
|
||||
@click="showDialog = true">
|
||||
<v-btn variant="tonal" rounded="xl" size="small" v-else @click="openDialog">
|
||||
选择模型
|
||||
</v-btn>
|
||||
|
||||
@@ -33,16 +23,12 @@
|
||||
<h4>提供商</h4>
|
||||
</div>
|
||||
<v-list density="compact" nav class="provider-list">
|
||||
<v-list-item
|
||||
v-for="provider in providerConfigs"
|
||||
:key="provider.id"
|
||||
:value="provider.id"
|
||||
@click="selectProvider(provider)"
|
||||
:active="selectedProviderId === provider.id"
|
||||
rounded="lg"
|
||||
class="provider-item">
|
||||
<v-list-item v-for="provider in providerConfigs" :key="provider.id" :value="provider.id"
|
||||
@click="selectProvider(provider)" :active="tempSelectedProviderId === provider.id"
|
||||
rounded="lg" class="provider-item">
|
||||
<v-list-item-title>{{ provider.id }}</v-list-item-title>
|
||||
<v-list-item-subtitle v-if="provider.api_base">{{ provider.api_base }}</v-list-item-subtitle>
|
||||
<v-list-item-subtitle v-if="provider.api_base">{{ provider.api_base
|
||||
}}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<div v-if="providerConfigs.length === 0" class="empty-state">
|
||||
@@ -55,33 +41,28 @@
|
||||
<div class="model-list-panel">
|
||||
<div class="panel-header">
|
||||
<h4>模型</h4>
|
||||
<v-btn
|
||||
v-if="selectedProviderId"
|
||||
icon="mdi-refresh"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="refreshModels"
|
||||
:loading="loadingModels">
|
||||
<v-btn v-if="tempSelectedProviderId" icon="mdi-refresh" size="small" variant="text"
|
||||
@click="refreshModels" :loading="loadingModels">
|
||||
</v-btn>
|
||||
</div>
|
||||
<v-list density="compact" nav class="model-list" v-if="selectedProviderId">
|
||||
<v-list-item
|
||||
v-for="model in modelList"
|
||||
:key="model"
|
||||
:value="model"
|
||||
@click="selectModel(model)"
|
||||
:active="selectedModelName === model"
|
||||
rounded="lg"
|
||||
<v-list density="compact" nav class="model-list" v-if="tempSelectedProviderId">
|
||||
|
||||
<v-text-field v-model="tempSelectedModelName" placeholder="自定义模型" hide-details solo variant="outlined" density="compact" class="mb-2 mx-2"></v-text-field>
|
||||
|
||||
<v-list-item v-for="model in modelList" :key="model" :value="model"
|
||||
@click="selectModel(model)" :active="tempSelectedModelName === model" rounded="lg"
|
||||
class="model-item">
|
||||
<v-list-item-title>{{ model }}</v-list-item-title>
|
||||
<v-list-item-subtitle v-if="model.description">{{ model.description }}</v-list-item-subtitle>
|
||||
<v-list-item-subtitle v-if="model.description">{{ model.description
|
||||
}}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<div v-else class="empty-state">
|
||||
<v-icon icon="mdi-robot-outline" size="large" color="grey-lighten-1"></v-icon>
|
||||
<div class="empty-text">请先选择提供商</div>
|
||||
</div>
|
||||
<div v-if="selectedProviderId && modelList.length === 0 && !loadingModels" class="empty-state">
|
||||
<div v-if="tempSelectedProviderId && modelList.length === 0 && !loadingModels"
|
||||
class="empty-state">
|
||||
<v-icon icon="mdi-robot-off-outline" size="large" color="grey-lighten-1"></v-icon>
|
||||
<div class="empty-text">该提供商暂无可用模型</div>
|
||||
</div>
|
||||
@@ -91,11 +72,8 @@
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn text @click="closeDialog" color="grey-darken-1">取消</v-btn>
|
||||
<v-btn
|
||||
text
|
||||
@click="confirmSelection"
|
||||
color="primary"
|
||||
:disabled="!selectedProviderId || !selectedModelName">
|
||||
<v-btn text @click="confirmSelection" color="primary"
|
||||
:disabled="!tempSelectedProviderId || !tempSelectedModelName">
|
||||
确认选择
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
@@ -127,12 +105,17 @@ export default {
|
||||
modelList: [],
|
||||
selectedProviderId: '',
|
||||
selectedModelName: '',
|
||||
// 临时选择状态,用于对话框内的选择
|
||||
tempSelectedProviderId: '',
|
||||
tempSelectedModelName: '',
|
||||
loadingModels: false
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
// 从localStorage加载保存的选择
|
||||
this.loadFromStorage();
|
||||
// 初始化临时选择
|
||||
this.resetTempSelection();
|
||||
// 获取提供商列表
|
||||
this.loadProviderConfigs();
|
||||
// 如果有保存的选择,加载对应的模型列表
|
||||
@@ -145,13 +128,13 @@ export default {
|
||||
loadFromStorage() {
|
||||
const savedProvider = localStorage.getItem('selectedProvider');
|
||||
const savedModel = localStorage.getItem('selectedModel');
|
||||
|
||||
|
||||
if (savedProvider) {
|
||||
this.selectedProviderId = savedProvider;
|
||||
} else if (this.initialProvider) {
|
||||
this.selectedProviderId = this.initialProvider;
|
||||
}
|
||||
|
||||
|
||||
if (savedModel) {
|
||||
this.selectedModelName = savedModel;
|
||||
} else if (this.initialModel) {
|
||||
@@ -215,36 +198,40 @@ export default {
|
||||
|
||||
// 选择提供商
|
||||
selectProvider(provider) {
|
||||
this.selectedProviderId = provider.id;
|
||||
this.selectedModelName = ''; // 清空已选择的模型
|
||||
this.tempSelectedProviderId = provider.id;
|
||||
this.tempSelectedModelName = ''; // 清空已选择的模型
|
||||
this.modelList = []; // 清空模型列表
|
||||
this.getProviderModels(provider.id); // 获取该提供商的模型列表
|
||||
},
|
||||
|
||||
// 选择模型
|
||||
selectModel(model) {
|
||||
this.selectedModelName = model;
|
||||
this.tempSelectedModelName = model;
|
||||
},
|
||||
|
||||
// 刷新模型列表
|
||||
refreshModels() {
|
||||
if (this.selectedProviderId) {
|
||||
this.getProviderModels(this.selectedProviderId);
|
||||
if (this.tempSelectedProviderId) {
|
||||
this.getProviderModels(this.tempSelectedProviderId);
|
||||
}
|
||||
},
|
||||
|
||||
// 确认选择
|
||||
confirmSelection() {
|
||||
if (this.selectedProviderId && this.selectedModelName) {
|
||||
if (this.tempSelectedProviderId && this.tempSelectedModelName) {
|
||||
// 将临时选择应用到正式选择
|
||||
this.selectedProviderId = this.tempSelectedProviderId;
|
||||
this.selectedModelName = this.tempSelectedModelName;
|
||||
|
||||
// 保存到localStorage
|
||||
this.saveToStorage();
|
||||
|
||||
|
||||
// 触发事件通知父组件
|
||||
this.$emit('selection-changed', {
|
||||
providerId: this.selectedProviderId,
|
||||
modelName: this.selectedModelName
|
||||
});
|
||||
|
||||
|
||||
this.closeDialog();
|
||||
}
|
||||
},
|
||||
@@ -252,6 +239,24 @@ export default {
|
||||
// 关闭对话框
|
||||
closeDialog() {
|
||||
this.showDialog = false;
|
||||
// 重置临时选择为当前选择
|
||||
this.resetTempSelection();
|
||||
},
|
||||
|
||||
// 重置临时选择
|
||||
resetTempSelection() {
|
||||
this.tempSelectedProviderId = this.selectedProviderId;
|
||||
this.tempSelectedModelName = this.selectedModelName;
|
||||
// 如果有临时选择的提供商,重新加载模型列表
|
||||
if (this.tempSelectedProviderId) {
|
||||
this.getProviderModels(this.tempSelectedProviderId);
|
||||
}
|
||||
},
|
||||
|
||||
// 打开对话框
|
||||
openDialog() {
|
||||
this.resetTempSelection();
|
||||
this.showDialog = true;
|
||||
},
|
||||
|
||||
// 公开方法:获取当前选择
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<v-dialog v-model="showDialog" max-width="900px" min-height="80%">
|
||||
<v-card class="platform-selection-dialog" :title="tm('dialog.addPlatform')">
|
||||
<v-card-text class="pa-4" style="overflow-y: auto;">
|
||||
<v-row style="padding: 0px 8px;">
|
||||
<v-col v-for="(template, name) in platformTemplates"
|
||||
:key="name" cols="12" sm="6" md="6">
|
||||
<v-card variant="outlined" hover class="platform-card" @click="selectTemplate(name)">
|
||||
<div class="platform-card-content">
|
||||
<div class="platform-card-text">
|
||||
<v-card-title class="platform-card-title">{{ tm('dialog.connectTitle', { name }) }}</v-card-title>
|
||||
<v-card-text class="text-caption text-medium-emphasis platform-card-description">
|
||||
{{ getPlatformDescription(template, name) }}
|
||||
</v-card-text>
|
||||
</div>
|
||||
<div class="platform-card-logo">
|
||||
<img :src="getPlatformIcon(template.type)" v-if="getPlatformIcon(template.type)" class="platform-logo-img">
|
||||
<div v-else class="platform-logo-fallback">
|
||||
{{ name[0].toUpperCase() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col
|
||||
v-if="Object.keys(platformTemplates).length === 0"
|
||||
cols="12">
|
||||
<v-alert type="info" variant="tonal">
|
||||
{{ tm('dialog.noTemplates') }}
|
||||
</v-alert>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import { getPlatformIcon, getPlatformDescription } from '@/utils/platformUtils';
|
||||
|
||||
export default {
|
||||
name: 'AddNewPlatform',
|
||||
emits: ['update:show', 'select-template'],
|
||||
props: {
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
metadata: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
setup() {
|
||||
const { tm } = useModuleI18n('features/platform');
|
||||
return { tm };
|
||||
},
|
||||
computed: {
|
||||
showDialog: {
|
||||
get() {
|
||||
return this.show;
|
||||
},
|
||||
set(value) {
|
||||
this.$emit('update:show', value);
|
||||
}
|
||||
},
|
||||
platformTemplates() {
|
||||
return this.metadata['platform_group']?.metadata?.platform?.config_template || {};
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 从工具函数导入
|
||||
getPlatformIcon,
|
||||
getPlatformDescription,
|
||||
|
||||
selectTemplate(name) {
|
||||
this.$emit('select-template', name);
|
||||
this.closeDialog();
|
||||
},
|
||||
|
||||
closeDialog() {
|
||||
this.showDialog = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.platform-selection-dialog .v-card-title {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
|
||||
.platform-card {
|
||||
transition: all 0.3s ease;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.platform-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 25px 0 rgba(0, 0, 0, 0.05);
|
||||
border-color: var(--v-primary-base);
|
||||
}
|
||||
|
||||
.platform-card-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100px;
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.platform-card-text {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.platform-card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.platform-card-description {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.platform-card-logo {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.platform-logo-img {
|
||||
max-width: 60px;
|
||||
max-height: 60px;
|
||||
opacity: 0.6;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.platform-logo-fallback {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--v-primary-base);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
opacity: 0.3;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,239 @@
|
||||
<template>
|
||||
<v-dialog v-model="showDialog" max-width="1100px" min-height="95%">
|
||||
<v-card :title="tm('dialogs.addProvider.title')">
|
||||
<v-card-text style="overflow-y: auto;">
|
||||
<v-tabs v-model="activeProviderTab" grow>
|
||||
<v-tab value="chat_completion" class="font-weight-medium px-3">
|
||||
<v-icon start>mdi-message-text</v-icon>
|
||||
{{ tm('dialogs.addProvider.tabs.basic') }}
|
||||
</v-tab>
|
||||
<v-tab value="speech_to_text" class="font-weight-medium px-3">
|
||||
<v-icon start>mdi-microphone-message</v-icon>
|
||||
{{ tm('dialogs.addProvider.tabs.speechToText') }}
|
||||
</v-tab>
|
||||
<v-tab value="text_to_speech" class="font-weight-medium px-3">
|
||||
<v-icon start>mdi-volume-high</v-icon>
|
||||
{{ tm('dialogs.addProvider.tabs.textToSpeech') }}
|
||||
</v-tab>
|
||||
<v-tab value="embedding" class="font-weight-medium px-3">
|
||||
<v-icon start>mdi-code-json</v-icon>
|
||||
{{ tm('dialogs.addProvider.tabs.embedding') }}
|
||||
</v-tab>
|
||||
<v-tab value="rerank" class="font-weight-medium px-3">
|
||||
<v-icon start>mdi-compare-vertical</v-icon>
|
||||
{{ tm('dialogs.addProvider.tabs.rerank') }}
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<v-window v-model="activeProviderTab" class="mt-4">
|
||||
<v-window-item
|
||||
v-for="tabType in ['chat_completion', 'speech_to_text', 'text_to_speech', 'embedding', 'rerank']"
|
||||
:key="tabType" :value="tabType">
|
||||
<v-row class="mt-1">
|
||||
<v-col v-for="(template, name) in getTemplatesByType(tabType)" :key="name" cols="12" sm="6"
|
||||
md="4">
|
||||
<v-card variant="outlined" hover class="provider-card"
|
||||
@click="selectProviderTemplate(name)">
|
||||
<div class="provider-card-content">
|
||||
<div class="provider-card-text">
|
||||
<v-card-title class="provider-card-title">接入 {{ name }}</v-card-title>
|
||||
<v-card-text
|
||||
class="text-caption text-medium-emphasis provider-card-description">
|
||||
{{ getProviderDescription(template, name) }}
|
||||
</v-card-text>
|
||||
</div>
|
||||
<div class="provider-card-logo">
|
||||
<img :src="getProviderIcon(template.provider)"
|
||||
v-if="getProviderIcon(template.provider)" class="provider-logo-img">
|
||||
<div v-else class="provider-logo-fallback">
|
||||
{{ name[0].toUpperCase() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col v-if="Object.keys(getTemplatesByType(tabType)).length === 0" cols="12">
|
||||
<v-alert type="info" variant="tonal">
|
||||
{{ tm('dialogs.addProvider.noTemplates', { type: getTabTypeName(tabType) }) }}
|
||||
</v-alert>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-window-item>
|
||||
</v-window>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn text @click="closeDialog">
|
||||
Close
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import { getProviderIcon, getProviderDescription } from '@/utils/providerUtils';
|
||||
|
||||
export default {
|
||||
name: 'AddNewProvider',
|
||||
props: {
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
metadata: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
emits: ['update:show', 'select-template'],
|
||||
setup() {
|
||||
const { tm } = useModuleI18n('features/provider');
|
||||
return { tm };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
activeProviderTab: 'chat_completion'
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
showDialog: {
|
||||
get() {
|
||||
return this.show;
|
||||
},
|
||||
set(value) {
|
||||
this.$emit('update:show', value);
|
||||
}
|
||||
},
|
||||
|
||||
// 翻译消息的计算属性
|
||||
messages() {
|
||||
return {
|
||||
tabTypes: {
|
||||
'chat_completion': this.tm('providers.tabs.chatCompletion'),
|
||||
'speech_to_text': this.tm('providers.tabs.speechToText'),
|
||||
'text_to_speech': this.tm('providers.tabs.textToSpeech'),
|
||||
'embedding': this.tm('providers.tabs.embedding'),
|
||||
'rerank': this.tm('providers.tabs.rerank')
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
closeDialog() {
|
||||
this.showDialog = false;
|
||||
},
|
||||
|
||||
// 按提供商类型获取模板列表
|
||||
getTemplatesByType(type) {
|
||||
const templates = this.metadata['provider_group']?.metadata?.provider?.config_template || {};
|
||||
const filtered = {};
|
||||
|
||||
for (const [name, template] of Object.entries(templates)) {
|
||||
if (template.provider_type === type) {
|
||||
filtered[name] = template;
|
||||
}
|
||||
}
|
||||
|
||||
return filtered;
|
||||
},
|
||||
|
||||
// 从工具函数导入
|
||||
getProviderIcon,
|
||||
|
||||
// 获取Tab类型的中文名称
|
||||
getTabTypeName(tabType) {
|
||||
return this.messages.tabTypes[tabType] || tabType;
|
||||
},
|
||||
|
||||
// 获取提供商简介
|
||||
getProviderDescription(template, name) {
|
||||
return getProviderDescription(template, name, this.tm);
|
||||
},
|
||||
|
||||
// 选择提供商模板
|
||||
selectProviderTemplate(name) {
|
||||
this.$emit('select-template', name);
|
||||
this.closeDialog();
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.provider-card {
|
||||
transition: all 0.3s ease;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.provider-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 25px 0 rgba(0, 0, 0, 0.05);
|
||||
border-color: var(--v-primary-base);
|
||||
}
|
||||
|
||||
.provider-card-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100px;
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.provider-card-text {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.provider-card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.provider-card-description {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.provider-card-logo {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.provider-logo-img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
opacity: 0.6;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.provider-logo-fallback {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--v-primary-base);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
opacity: 0.3;
|
||||
}
|
||||
</style>
|
||||
@@ -4,28 +4,28 @@
|
||||
<span class="text-h2 text-truncate" :title="getItemTitle()">{{ getItemTitle() }}</span>
|
||||
<v-tooltip location="top">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-switch
|
||||
color="primary"
|
||||
hide-details
|
||||
density="compact"
|
||||
<v-switch
|
||||
color="primary"
|
||||
hide-details
|
||||
density="compact"
|
||||
:model-value="getItemEnabled()"
|
||||
:loading="loading"
|
||||
:disabled="loading"
|
||||
v-bind="props"
|
||||
v-bind="props"
|
||||
@update:model-value="toggleEnabled"
|
||||
></v-switch>
|
||||
</template>
|
||||
<span>{{ getItemEnabled() ? t('core.common.itemCard.enabled') : t('core.common.itemCard.disabled') }}</span>
|
||||
</v-tooltip>
|
||||
</v-card-title>
|
||||
|
||||
|
||||
<v-card-text>
|
||||
<slot name="item-details" :item="item"></slot>
|
||||
</v-card-text>
|
||||
|
||||
|
||||
<v-card-actions style="margin: 8px;">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
rounded="xl"
|
||||
@click="$emit('delete', item)"
|
||||
@@ -40,6 +40,15 @@
|
||||
>
|
||||
{{ t('core.common.itemCard.edit') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="showCopyButton"
|
||||
variant="tonal"
|
||||
color="secondary"
|
||||
rounded="xl"
|
||||
@click="$emit('copy', item)"
|
||||
>
|
||||
{{ t('core.common.itemCard.copy') }}
|
||||
</v-btn>
|
||||
<v-spacer></v-spacer>
|
||||
</v-card-actions>
|
||||
|
||||
@@ -83,9 +92,13 @@ export default {
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showCopyButton: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['toggle-enabled', 'delete', 'edit'],
|
||||
emits: ['toggle-enabled', 'delete', 'edit', 'copy'],
|
||||
methods: {
|
||||
getItemTitle() {
|
||||
return this.item[this.titleField];
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
title: String
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card variant="outlined" elevation="0" class="withbg">
|
||||
<v-card-item>
|
||||
<div class="d-sm-flex align-center justify-space-between">
|
||||
<v-card-title>{{ props.title }}</v-card-title>
|
||||
<slot name="action"></slot>
|
||||
</div>
|
||||
</v-card-item>
|
||||
<v-divider></v-divider>
|
||||
<v-card-text>
|
||||
<slot />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
@@ -12,6 +12,13 @@
|
||||
"title": "Conversation History",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"batch": {
|
||||
"deleteSelected": "Delete Selected ({count})"
|
||||
},
|
||||
"pagination": {
|
||||
"itemsPerPage": "Items per page",
|
||||
"showingItems": "Showing {start}-{end} of {total} items"
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
"title": "Conversation Title",
|
||||
@@ -61,6 +68,13 @@
|
||||
"message": "Are you sure you want to delete conversation {title}? This action cannot be undone.",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Delete"
|
||||
},
|
||||
"batchDelete": {
|
||||
"title": "Batch Delete Confirmation",
|
||||
"message": "Are you sure you want to delete the selected {count} conversations? This action cannot be undone, please proceed with caution!",
|
||||
"andMore": "and {count} more",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Batch Delete"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
@@ -72,6 +86,10 @@
|
||||
"historyError": "Failed to fetch conversation history",
|
||||
"historySaveSuccess": "Conversation history saved successfully",
|
||||
"historySaveError": "Failed to save conversation history",
|
||||
"invalidJson": "Invalid JSON format"
|
||||
"invalidJson": "Invalid JSON format",
|
||||
"noItemSelected": "Please select conversations to delete first",
|
||||
"batchDeleteSuccess": "Successfully deleted {count} conversations",
|
||||
"batchDeleteError": "Batch delete failed",
|
||||
"batchDeletePartial": "Delete completed: {deleted} successful, {failed} failed"
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,7 @@
|
||||
"disabled": "已禁用",
|
||||
"delete": "删除",
|
||||
"edit": "编辑",
|
||||
"copy": "复制",
|
||||
"noData": "暂无数据"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"subtitle": "管理和查看用户对话历史记录",
|
||||
"filters": {
|
||||
"title": "筛选条件",
|
||||
"platform": "平台",
|
||||
"platform": "消息平台 ID",
|
||||
"type": "类型",
|
||||
"search": "搜索关键词",
|
||||
"reset": "重置"
|
||||
@@ -12,12 +12,19 @@
|
||||
"title": "对话历史",
|
||||
"refresh": "刷新"
|
||||
},
|
||||
"batch": {
|
||||
"deleteSelected": "删除选中 ({count})"
|
||||
},
|
||||
"pagination": {
|
||||
"itemsPerPage": "每页",
|
||||
"showingItems": "显示 {start}-{end} 项,共 {total} 项"
|
||||
},
|
||||
"table": {
|
||||
"headers": {
|
||||
"title": "对话标题",
|
||||
"platform": "平台",
|
||||
"platform": "消息平台 ID",
|
||||
"type": "类型",
|
||||
"sessionId": "ID",
|
||||
"sessionId": "ID (UMO)",
|
||||
"createdAt": "创建时间",
|
||||
"updatedAt": "更新时间",
|
||||
"actions": "操作"
|
||||
@@ -61,6 +68,13 @@
|
||||
"message": "确定要删除对话 {title} 吗?此操作不可恢复。",
|
||||
"cancel": "取消",
|
||||
"confirm": "删除"
|
||||
},
|
||||
"batchDelete": {
|
||||
"title": "批量删除确认",
|
||||
"message": "确定要删除选中的 {count} 个对话吗?此操作不可恢复,请谨慎操作!",
|
||||
"andMore": "等 {count} 个",
|
||||
"cancel": "取消",
|
||||
"confirm": "批量删除"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
@@ -72,6 +86,10 @@
|
||||
"historyError": "获取对话历史失败",
|
||||
"historySaveSuccess": "对话历史保存成功",
|
||||
"historySaveError": "对话历史保存失败",
|
||||
"invalidJson": "JSON格式无效"
|
||||
"invalidJson": "JSON格式无效",
|
||||
"noItemSelected": "请先选择要删除的对话",
|
||||
"batchDeleteSuccess": "成功删除 {count} 个对话",
|
||||
"batchDeleteError": "批量删除失败",
|
||||
"batchDeletePartial": "删除完成:成功 {deleted} 个,失败 {failed} 个"
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"refresh": "刷新",
|
||||
"edit": "编辑",
|
||||
"apply": "应用批量设置",
|
||||
"editName": "编辑会话名称",
|
||||
"editName": "备注",
|
||||
"save": "保存",
|
||||
"cancel": "取消"
|
||||
},
|
||||
@@ -22,13 +22,13 @@
|
||||
"table": {
|
||||
"headers": {
|
||||
"sessionStatus": "会话状态",
|
||||
"sessionInfo": "会话信息",
|
||||
"sessionInfo": "ID (UMO)",
|
||||
"persona": "人格",
|
||||
"chatProvider": "Chat Provider",
|
||||
"sttProvider": "STT Provider",
|
||||
"ttsProvider": "TTS Provider",
|
||||
"llmStatus": "LLM启停",
|
||||
"ttsStatus": "TTS启停",
|
||||
"chatProvider": "聊天模型",
|
||||
"sttProvider": "语音识别模型",
|
||||
"ttsProvider": "语音合成模型",
|
||||
"llmStatus": "启用 LLM",
|
||||
"ttsStatus": "启用 TTS",
|
||||
"pluginManagement": "插件管理"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import {ref, computed} from 'vue';
|
||||
import {useCustomizerStore} from '@/stores/customizer';
|
||||
import { ref, computed } from 'vue';
|
||||
import { useCustomizerStore } from '@/stores/customizer';
|
||||
import axios from 'axios';
|
||||
import Logo from '@/components/shared/Logo.vue';
|
||||
import LanguageSwitcher from '@/components/shared/LanguageSwitcher.vue';
|
||||
import {md5} from 'js-md5';
|
||||
import {useAuthStore} from '@/stores/auth';
|
||||
import {useCommonStore} from '@/stores/common';
|
||||
import { md5 } from 'js-md5';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useCommonStore } from '@/stores/common';
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import { useI18n } from '@/i18n/composables';
|
||||
import { router } from '@/router';
|
||||
|
||||
// 配置markdown-it,默认安全设置
|
||||
const md = new MarkdownIt({
|
||||
html: true, // 启用HTML标签
|
||||
breaks: true, // 换行转<br>
|
||||
linkify: true, // 自动转链接
|
||||
typographer: false // 禁用智能引号
|
||||
html: true, // 启用HTML标签
|
||||
breaks: true, // 换行转<br>
|
||||
linkify: true, // 自动转链接
|
||||
typographer: false // 禁用智能引号
|
||||
});
|
||||
|
||||
const customizer = useCustomizerStore();
|
||||
@@ -44,11 +44,11 @@ let installLoading = ref(false);
|
||||
let tab = ref(0);
|
||||
|
||||
const releasesHeader = computed(() => [
|
||||
{title: t('core.header.updateDialog.table.tag'), key: 'tag_name'},
|
||||
{title: t('core.header.updateDialog.table.publishDate'), key: 'published_at'},
|
||||
{title: t('core.header.updateDialog.table.content'), key: 'body'},
|
||||
{title: t('core.header.updateDialog.table.sourceUrl'), key: 'zipball_url'},
|
||||
{title: t('core.header.updateDialog.table.actions'), key: 'switch'}
|
||||
{ title: t('core.header.updateDialog.table.tag'), key: 'tag_name' },
|
||||
{ title: t('core.header.updateDialog.table.publishDate'), key: 'published_at' },
|
||||
{ title: t('core.header.updateDialog.table.content'), key: 'body' },
|
||||
{ title: t('core.header.updateDialog.table.sourceUrl'), key: 'zipball_url' },
|
||||
{ title: t('core.header.updateDialog.table.actions'), key: 'switch' }
|
||||
]);
|
||||
|
||||
// Form validation
|
||||
@@ -103,90 +103,90 @@ function accountEdit() {
|
||||
new_password: newPassword.value,
|
||||
new_username: newUsername.value ? newUsername.value : username
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.data.status == 'error') {
|
||||
accountEditStatus.value.error = true;
|
||||
accountEditStatus.value.message = res.data.message;
|
||||
password.value = '';
|
||||
newPassword.value = '';
|
||||
return;
|
||||
}
|
||||
accountEditStatus.value.success = true;
|
||||
accountEditStatus.value.message = res.data.message;
|
||||
setTimeout(() => {
|
||||
dialog.value = !dialog.value;
|
||||
const authStore = useAuthStore();
|
||||
authStore.logout();
|
||||
}, 2000);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
.then((res) => {
|
||||
if (res.data.status == 'error') {
|
||||
accountEditStatus.value.error = true;
|
||||
accountEditStatus.value.message = typeof err === 'string' ? err : t('core.header.accountDialog.messages.updateFailed');
|
||||
accountEditStatus.value.message = res.data.message;
|
||||
password.value = '';
|
||||
newPassword.value = '';
|
||||
})
|
||||
.finally(() => {
|
||||
accountEditStatus.value.loading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
accountEditStatus.value.success = true;
|
||||
accountEditStatus.value.message = res.data.message;
|
||||
setTimeout(() => {
|
||||
dialog.value = !dialog.value;
|
||||
const authStore = useAuthStore();
|
||||
authStore.logout();
|
||||
}, 2000);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
accountEditStatus.value.error = true;
|
||||
accountEditStatus.value.message = typeof err === 'string' ? err : t('core.header.accountDialog.messages.updateFailed');
|
||||
password.value = '';
|
||||
newPassword.value = '';
|
||||
})
|
||||
.finally(() => {
|
||||
accountEditStatus.value.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
function getVersion() {
|
||||
axios.get('/api/stat/version')
|
||||
.then((res) => {
|
||||
botCurrVersion.value = "v" + res.data.data.version;
|
||||
dashboardCurrentVersion.value = res.data.data?.dashboard_version;
|
||||
let change_pwd_hint = res.data.data?.change_pwd_hint;
|
||||
if (change_pwd_hint) {
|
||||
dialog.value = true;
|
||||
accountWarning.value = true;
|
||||
localStorage.setItem('change_pwd_hint', 'true');
|
||||
} else {
|
||||
localStorage.removeItem('change_pwd_hint');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
.then((res) => {
|
||||
botCurrVersion.value = "v" + res.data.data.version;
|
||||
dashboardCurrentVersion.value = res.data.data?.dashboard_version;
|
||||
let change_pwd_hint = res.data.data?.change_pwd_hint;
|
||||
if (change_pwd_hint) {
|
||||
dialog.value = true;
|
||||
accountWarning.value = true;
|
||||
localStorage.setItem('change_pwd_hint', 'true');
|
||||
} else {
|
||||
localStorage.removeItem('change_pwd_hint');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
}
|
||||
|
||||
function checkUpdate() {
|
||||
updateStatus.value = t('core.header.updateDialog.status.checking');
|
||||
axios.get('/api/update/check')
|
||||
.then((res) => {
|
||||
hasNewVersion.value = res.data.data.has_new_version;
|
||||
.then((res) => {
|
||||
hasNewVersion.value = res.data.data.has_new_version;
|
||||
|
||||
if (res.data.data.has_new_version) {
|
||||
releaseMessage.value = res.data.message;
|
||||
updateStatus.value = t('core.header.version.hasNewVersion');
|
||||
} else {
|
||||
updateStatus.value = res.data.message;
|
||||
}
|
||||
dashboardHasNewVersion.value = res.data.data.dashboard_has_new_version;
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.response && err.response.status == 401) {
|
||||
console.log("401");
|
||||
const authStore = useAuthStore();
|
||||
authStore.logout();
|
||||
return;
|
||||
}
|
||||
console.log(err);
|
||||
updateStatus.value = err
|
||||
});
|
||||
if (res.data.data.has_new_version) {
|
||||
releaseMessage.value = res.data.message;
|
||||
updateStatus.value = t('core.header.version.hasNewVersion');
|
||||
} else {
|
||||
updateStatus.value = res.data.message;
|
||||
}
|
||||
dashboardHasNewVersion.value = res.data.data.dashboard_has_new_version;
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.response && err.response.status == 401) {
|
||||
console.log("401");
|
||||
const authStore = useAuthStore();
|
||||
authStore.logout();
|
||||
return;
|
||||
}
|
||||
console.log(err);
|
||||
updateStatus.value = err
|
||||
});
|
||||
}
|
||||
|
||||
function getReleases() {
|
||||
axios.get('/api/update/releases')
|
||||
.then((res) => {
|
||||
releases.value = res.data.data.map((item: any) => {
|
||||
item.published_at = new Date(item.published_at).toLocaleString();
|
||||
return item;
|
||||
})
|
||||
.then((res) => {
|
||||
releases.value = res.data.data.map((item: any) => {
|
||||
item.published_at = new Date(item.published_at).toLocaleString();
|
||||
return item;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
}
|
||||
|
||||
function getDevCommits() {
|
||||
@@ -209,10 +209,10 @@ function getDevCommits() {
|
||||
.then(data => {
|
||||
devCommits.value = Array.isArray(data)
|
||||
? data.map((commit: any) => ({
|
||||
sha: commit.sha,
|
||||
date: new Date(commit.commit.author.date).toLocaleString(),
|
||||
message: commit.commit.message
|
||||
}))
|
||||
sha: commit.sha,
|
||||
date: new Date(commit.commit.author.date).toLocaleString(),
|
||||
message: commit.commit.message
|
||||
}))
|
||||
: [];
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -239,40 +239,40 @@ function switchVersion(version: string) {
|
||||
version: version,
|
||||
proxy: localStorage.getItem('selectedGitHubProxy') || ''
|
||||
})
|
||||
.then((res) => {
|
||||
updateStatus.value = res.data.message;
|
||||
if (res.data.status == 'ok') {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
updateStatus.value = err
|
||||
}).finally(() => {
|
||||
installLoading.value = false;
|
||||
});
|
||||
.then((res) => {
|
||||
updateStatus.value = res.data.message;
|
||||
if (res.data.status == 'ok') {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
updateStatus.value = err
|
||||
}).finally(() => {
|
||||
installLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function updateDashboard() {
|
||||
updatingDashboardLoading.value = true;
|
||||
updateStatus.value = t('core.header.updateDialog.status.updating');
|
||||
axios.post('/api/update/dashboard')
|
||||
.then((res) => {
|
||||
updateStatus.value = res.data.message;
|
||||
if (res.data.status == 'ok') {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
updateStatus.value = err
|
||||
}).finally(() => {
|
||||
updatingDashboardLoading.value = false;
|
||||
});
|
||||
.then((res) => {
|
||||
updateStatus.value = res.data.message;
|
||||
if (res.data.status == 'ok') {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
updateStatus.value = err
|
||||
}).finally(() => {
|
||||
updatingDashboardLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleDarkMode() {
|
||||
@@ -291,29 +291,32 @@ commonStore.getStartTime();
|
||||
<template>
|
||||
<v-app-bar elevation="0" height="55">
|
||||
|
||||
<v-btn v-if="useCustomizerStore().uiTheme==='PurpleTheme'" style="margin-left: 22px;" class="hidden-md-and-down text-secondary" color="lightsecondary" icon rounded="sm"
|
||||
variant="flat" @click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)" size="small">
|
||||
<v-btn v-if="useCustomizerStore().uiTheme === 'PurpleTheme'" style="margin-left: 22px;"
|
||||
class="hidden-md-and-down text-secondary" color="lightsecondary" icon rounded="sm" variant="flat"
|
||||
@click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)" size="small">
|
||||
<v-icon>mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
<v-btn v-else style="margin-left: 22px; color: var(--v-theme-primaryText); background-color: var(--v-theme-secondary)" class="hidden-md-and-down" icon rounded="sm"
|
||||
variant="flat" @click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)" size="small">
|
||||
<v-btn v-else
|
||||
style="margin-left: 22px; color: var(--v-theme-primaryText); background-color: var(--v-theme-secondary)"
|
||||
class="hidden-md-and-down" icon rounded="sm" variant="flat"
|
||||
@click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)" size="small">
|
||||
<v-icon>mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
<v-btn v-if="useCustomizerStore().uiTheme==='PurpleTheme'" class="hidden-lg-and-up ms-3" color="lightsecondary" icon rounded="sm" variant="flat"
|
||||
@click.stop="customizer.SET_SIDEBAR_DRAWER" size="small">
|
||||
<v-btn v-if="useCustomizerStore().uiTheme === 'PurpleTheme'" class="hidden-lg-and-up ms-3" color="lightsecondary"
|
||||
icon rounded="sm" variant="flat" @click.stop="customizer.SET_SIDEBAR_DRAWER" size="small">
|
||||
<v-icon>mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
<v-btn v-else class="hidden-lg-and-up ms-3" icon rounded="sm" variant="flat"
|
||||
@click.stop="customizer.SET_SIDEBAR_DRAWER" size="small">
|
||||
@click.stop="customizer.SET_SIDEBAR_DRAWER" size="small">
|
||||
<v-icon>mdi-menu</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<div class="logo-container" :class="{'mobile-logo': $vuetify.display.xs}" @click="$router.push('/about')">
|
||||
<div class="logo-container" :class="{ 'mobile-logo': $vuetify.display.xs }" @click="$router.push('/about')">
|
||||
<span class="logo-text">Astr<span class="logo-text-light">Bot</span></span>
|
||||
<span class="version-text hidden-xs">{{ botCurrVersion }}</span>
|
||||
</div>
|
||||
|
||||
<v-spacer/>
|
||||
<v-spacer />
|
||||
|
||||
<!-- 版本提示信息 - 在手机上隐藏 -->
|
||||
<div class="mr-4 hidden-xs">
|
||||
@@ -329,19 +332,19 @@ commonStore.getStartTime();
|
||||
<LanguageSwitcher variant="header" />
|
||||
|
||||
<!-- 主题切换按钮 -->
|
||||
<v-btn size="small" @click="toggleDarkMode();" class="action-btn"
|
||||
color="var(--v-theme-surface)" variant="flat" rounded="sm">
|
||||
<v-btn size="small" @click="toggleDarkMode();" class="action-btn" color="var(--v-theme-surface)" variant="flat"
|
||||
rounded="sm" icon>
|
||||
<v-icon v-if="useCustomizerStore().uiTheme === 'PurpleThemeDark'">mdi-weather-night</v-icon>
|
||||
<v-icon v-else>mdi-white-balance-sunny</v-icon>
|
||||
</v-btn>
|
||||
|
||||
<!-- 更新对话框 -->
|
||||
<v-dialog v-model="updateStatusDialog" :width="$vuetify.display.smAndDown ? '100%' : '1200'" :fullscreen="$vuetify.display.xs">
|
||||
<v-dialog v-model="updateStatusDialog" :width="$vuetify.display.smAndDown ? '100%' : '1200'"
|
||||
:fullscreen="$vuetify.display.xs">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn size="small" @click="checkUpdate(); getReleases(); getDevCommits();" class="action-btn"
|
||||
color="var(--v-theme-surface)" variant="flat" rounded="sm" v-bind="props">
|
||||
<v-icon class="hidden-sm-and-up">mdi-update</v-icon>
|
||||
<span class="hidden-xs">{{ t('core.header.buttons.update') }}</span>
|
||||
color="var(--v-theme-surface)" variant="flat" rounded="sm" v-bind="props" icon>
|
||||
<v-icon>mdi-arrow-up-circle</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-card>
|
||||
@@ -361,8 +364,8 @@ commonStore.getStartTime();
|
||||
</div>
|
||||
|
||||
<div v-if="releaseMessage"
|
||||
style="background-color: #646cff24; padding: 16px; border-radius: 10px; font-size: 14px; max-height: 400px; overflow-y: auto;"
|
||||
v-html="md.render(releaseMessage)" class="markdown-content">
|
||||
style="background-color: #646cff24; padding: 16px; border-radius: 10px; font-size: 14px; max-height: 400px; overflow-y: auto;"
|
||||
v-html="md.render(releaseMessage)" class="markdown-content">
|
||||
</div>
|
||||
|
||||
<div class="mb-4 mt-4">
|
||||
@@ -380,15 +383,13 @@ commonStore.getStartTime();
|
||||
<v-tabs-window-item key="0" v-show="tab == 0">
|
||||
<div class="mb-4">
|
||||
<small>{{ t('core.header.updateDialog.dockerTip') }} <a
|
||||
href="https://containrrr.dev/watchtower/usage-overview/">{{ t('core.header.updateDialog.dockerTipLink') }}</a> {{ t('core.header.updateDialog.dockerTipContinue') }}</small>
|
||||
href="https://containrrr.dev/watchtower/usage-overview/">{{
|
||||
t('core.header.updateDialog.dockerTipLink')
|
||||
}}</a> {{ t('core.header.updateDialog.dockerTipContinue') }}</small>
|
||||
</div>
|
||||
|
||||
<v-alert
|
||||
v-if="releases.some(item => isPreRelease(item['tag_name']))"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
border="start"
|
||||
>
|
||||
<v-alert v-if="releases.some(item => isPreRelease(item['tag_name']))" type="warning" variant="tonal"
|
||||
border="start">
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-alert-circle-outline</v-icon>
|
||||
</template>
|
||||
@@ -406,13 +407,8 @@ commonStore.getStartTime();
|
||||
<template v-slot:item.tag_name="{ item }: { item: { tag_name: string } }">
|
||||
<div class="d-flex align-center">
|
||||
<span>{{ item.tag_name }}</span>
|
||||
<v-chip
|
||||
v-if="isPreRelease(item.tag_name)"
|
||||
size="x-small"
|
||||
color="warning"
|
||||
variant="tonal"
|
||||
class="ml-2"
|
||||
>
|
||||
<v-chip v-if="isPreRelease(item.tag_name)" size="x-small" color="warning" variant="tonal"
|
||||
class="ml-2">
|
||||
{{ t('core.header.updateDialog.preRelease') }}
|
||||
</v-chip>
|
||||
</div>
|
||||
@@ -420,7 +416,8 @@ commonStore.getStartTime();
|
||||
<template v-slot:item.body="{ item }: { item: { body: string } }">
|
||||
<v-tooltip :text="item.body">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn v-bind="props" rounded="xl" variant="tonal" color="primary" size="x-small">{{ t('core.header.updateDialog.table.view') }}</v-btn>
|
||||
<v-btn v-bind="props" rounded="xl" variant="tonal" color="primary" size="x-small">{{
|
||||
t('core.header.updateDialog.table.view') }}</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
@@ -435,14 +432,12 @@ commonStore.getStartTime();
|
||||
<!-- 开发版 -->
|
||||
<v-tabs-window-item key="1" v-show="tab == 1">
|
||||
<div style="margin-top: 16px;">
|
||||
<v-data-table
|
||||
:headers="[
|
||||
{ title: t('core.header.updateDialog.table.sha'), key: 'sha' },
|
||||
{ title: t('core.header.updateDialog.table.date'), key: 'date' },
|
||||
{ title: t('core.header.updateDialog.table.message'), key: 'message' },
|
||||
{ title: t('core.header.updateDialog.table.actions'), key: 'switch' }
|
||||
]"
|
||||
:items="devCommits" item-key="sha">
|
||||
<v-data-table :headers="[
|
||||
{ title: t('core.header.updateDialog.table.sha'), key: 'sha' },
|
||||
{ title: t('core.header.updateDialog.table.date'), key: 'date' },
|
||||
{ title: t('core.header.updateDialog.table.message'), key: 'message' },
|
||||
{ title: t('core.header.updateDialog.table.actions'), key: 'switch' }
|
||||
]" :items="devCommits" item-key="sha">
|
||||
<template v-slot:item.switch="{ item }: { item: { sha: string } }">
|
||||
<v-btn @click="switchVersion(item.sha)" rounded="xl" variant="plain" color="primary">
|
||||
{{ t('core.header.updateDialog.table.switch') }}
|
||||
@@ -457,11 +452,12 @@ commonStore.getStartTime();
|
||||
<h3 class="mb-4">{{ t('core.header.updateDialog.manualInput.title') }}</h3>
|
||||
|
||||
<v-text-field :label="t('core.header.updateDialog.manualInput.placeholder')" v-model="version" required
|
||||
variant="outlined"></v-text-field>
|
||||
variant="outlined"></v-text-field>
|
||||
<div class="mb-4">
|
||||
<small>{{ t('core.header.updateDialog.manualInput.hint') }}</small>
|
||||
<br>
|
||||
<a href="https://github.com/Soulter/AstrBot/commits/master"><small>{{ t('core.header.updateDialog.manualInput.linkText') }}</small></a>
|
||||
<a href="https://github.com/Soulter/AstrBot/commits/master"><small>{{
|
||||
t('core.header.updateDialog.manualInput.linkText') }}</small></a>
|
||||
</div>
|
||||
<v-btn color="error" style="border-radius: 10px;" @click="switchVersion(version)">
|
||||
{{ t('core.header.updateDialog.manualInput.confirm') }}
|
||||
@@ -471,7 +467,8 @@ commonStore.getStartTime();
|
||||
<div style="margin-top: 16px;">
|
||||
<h3 class="mb-4">{{ t('core.header.updateDialog.dashboardUpdate.title') }}</h3>
|
||||
<div class="mb-4">
|
||||
<small>{{ t('core.header.updateDialog.dashboardUpdate.currentVersion') }} {{ dashboardCurrentVersion }}</small>
|
||||
<small>{{ t('core.header.updateDialog.dashboardUpdate.currentVersion') }} {{ dashboardCurrentVersion
|
||||
}}</small>
|
||||
<br>
|
||||
|
||||
</div>
|
||||
@@ -486,7 +483,7 @@ commonStore.getStartTime();
|
||||
</div>
|
||||
|
||||
<v-btn color="primary" style="border-radius: 10px;" @click="updateDashboard()"
|
||||
:disabled="!dashboardHasNewVersion" :loading="updatingDashboardLoading">
|
||||
:disabled="!dashboardHasNewVersion" :loading="updatingDashboardLoading">
|
||||
{{ t('core.header.updateDialog.dashboardUpdate.downloadAndUpdate') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
@@ -504,9 +501,9 @@ commonStore.getStartTime();
|
||||
<!-- 账户对话框 -->
|
||||
<v-dialog v-model="dialog" persistent :max-width="$vuetify.display.xs ? '90%' : '500'">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn size="small" class="action-btn mr-4" color="var(--v-theme-surface)" variant="flat" rounded="sm" v-bind="props">
|
||||
<v-btn size="small" class="action-btn mr-4" color="var(--v-theme-surface)" variant="flat" rounded="sm"
|
||||
v-bind="props" icon>
|
||||
<v-icon>mdi-account</v-icon>
|
||||
<span class="hidden-xs ml-1">{{ t('core.header.buttons.account') }}</span>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-card class="account-dialog">
|
||||
@@ -514,105 +511,51 @@ commonStore.getStartTime();
|
||||
<div class="d-flex flex-column align-center mb-6">
|
||||
<logo :title="t('core.header.logoTitle')" :subtitle="t('core.header.accountDialog.title')"></logo>
|
||||
</div>
|
||||
<v-alert
|
||||
v-if="accountWarning"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
border="start"
|
||||
class="mb-4"
|
||||
>
|
||||
<v-alert v-if="accountWarning" type="warning" variant="tonal" border="start" class="mb-4">
|
||||
<strong>{{ t('core.header.accountDialog.securityWarning') }}</strong>
|
||||
</v-alert>
|
||||
|
||||
<v-alert
|
||||
v-if="accountEditStatus.success"
|
||||
type="success"
|
||||
variant="tonal"
|
||||
border="start"
|
||||
class="mb-4"
|
||||
>
|
||||
<v-alert v-if="accountEditStatus.success" type="success" variant="tonal" border="start" class="mb-4">
|
||||
{{ accountEditStatus.message }}
|
||||
</v-alert>
|
||||
|
||||
<v-alert
|
||||
v-if="accountEditStatus.error"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
border="start"
|
||||
class="mb-4"
|
||||
>
|
||||
<v-alert v-if="accountEditStatus.error" type="error" variant="tonal" border="start" class="mb-4">
|
||||
{{ accountEditStatus.message }}
|
||||
</v-alert>
|
||||
|
||||
<v-form v-model="formValid" @submit.prevent="accountEdit">
|
||||
<v-text-field
|
||||
v-model="password"
|
||||
:append-inner-icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
:label="t('core.header.accountDialog.form.currentPassword')"
|
||||
variant="outlined"
|
||||
required
|
||||
clearable
|
||||
@click:append-inner="showPassword = !showPassword"
|
||||
prepend-inner-icon="mdi-lock-outline"
|
||||
hide-details="auto"
|
||||
class="mb-4"
|
||||
></v-text-field>
|
||||
<v-text-field v-model="password" :append-inner-icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
|
||||
:type="showPassword ? 'text' : 'password'" :label="t('core.header.accountDialog.form.currentPassword')"
|
||||
variant="outlined" required clearable @click:append-inner="showPassword = !showPassword"
|
||||
prepend-inner-icon="mdi-lock-outline" hide-details="auto" class="mb-4"></v-text-field>
|
||||
|
||||
<v-text-field
|
||||
v-model="newPassword"
|
||||
:append-inner-icon="showNewPassword ? 'mdi-eye-off' : 'mdi-eye'"
|
||||
:type="showNewPassword ? 'text' : 'password'"
|
||||
:rules="passwordRules"
|
||||
:label="t('core.header.accountDialog.form.newPassword')"
|
||||
variant="outlined"
|
||||
required
|
||||
clearable
|
||||
@click:append-inner="showNewPassword = !showNewPassword"
|
||||
prepend-inner-icon="mdi-lock-plus-outline"
|
||||
:hint="t('core.header.accountDialog.form.passwordHint')"
|
||||
persistent-hint
|
||||
class="mb-4"
|
||||
></v-text-field>
|
||||
<v-text-field v-model="newPassword" :append-inner-icon="showNewPassword ? 'mdi-eye-off' : 'mdi-eye'"
|
||||
:type="showNewPassword ? 'text' : 'password'" :rules="passwordRules"
|
||||
:label="t('core.header.accountDialog.form.newPassword')" variant="outlined" required clearable
|
||||
@click:append-inner="showNewPassword = !showNewPassword" prepend-inner-icon="mdi-lock-plus-outline"
|
||||
:hint="t('core.header.accountDialog.form.passwordHint')" persistent-hint class="mb-4"></v-text-field>
|
||||
|
||||
<v-text-field
|
||||
v-model="newUsername"
|
||||
:rules="usernameRules"
|
||||
:label="t('core.header.accountDialog.form.newUsername')"
|
||||
variant="outlined"
|
||||
clearable
|
||||
prepend-inner-icon="mdi-account-edit-outline"
|
||||
:hint="t('core.header.accountDialog.form.usernameHint')"
|
||||
persistent-hint
|
||||
class="mb-3"
|
||||
></v-text-field>
|
||||
<v-text-field v-model="newUsername" :rules="usernameRules"
|
||||
:label="t('core.header.accountDialog.form.newUsername')" variant="outlined" clearable
|
||||
prepend-inner-icon="mdi-account-edit-outline" :hint="t('core.header.accountDialog.form.usernameHint')"
|
||||
persistent-hint class="mb-3"></v-text-field>
|
||||
</v-form>
|
||||
|
||||
|
||||
<div class="text-caption text-medium-emphasis mt-2">
|
||||
{{ t('core.header.accountDialog.form.defaultCredentials') }}
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn
|
||||
v-if="!accountWarning"
|
||||
variant="tonal"
|
||||
color="secondary"
|
||||
@click="dialog = false"
|
||||
:disabled="accountEditStatus.loading"
|
||||
>
|
||||
<v-btn v-if="!accountWarning" variant="tonal" color="secondary" @click="dialog = false"
|
||||
:disabled="accountEditStatus.loading">
|
||||
{{ t('core.header.accountDialog.actions.cancel') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
@click="accountEdit"
|
||||
:loading="accountEditStatus.loading"
|
||||
:disabled="!formValid"
|
||||
prepend-icon="mdi-content-save"
|
||||
>
|
||||
<v-btn color="primary" @click="accountEdit" :loading="accountEditStatus.loading" :disabled="!formValid"
|
||||
prepend-icon="mdi-content-save">
|
||||
{{ t('core.header.accountDialog.actions.save') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
@@ -665,9 +608,9 @@ commonStore.getStartTime();
|
||||
|
||||
/* 响应式布局样式 */
|
||||
.logo-container {
|
||||
margin-left: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -678,7 +621,7 @@ commonStore.getStartTime();
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 24px;
|
||||
font-size: 24px;
|
||||
font-weight: 1000;
|
||||
}
|
||||
|
||||
@@ -687,7 +630,7 @@ commonStore.getStartTime();
|
||||
}
|
||||
|
||||
.version-text {
|
||||
font-size: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--v-theme-secondaryText);
|
||||
}
|
||||
|
||||
@@ -707,7 +650,7 @@ commonStore.getStartTime();
|
||||
.logo-text {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
|
||||
.action-btn {
|
||||
margin-right: 4px;
|
||||
min-width: 32px !important;
|
||||
@@ -717,11 +660,11 @@ commonStore.getStartTime();
|
||||
.v-card-title {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
|
||||
.v-card-text {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
|
||||
.v-tabs .v-tab {
|
||||
padding: 0 10px;
|
||||
font-size: 0.9rem;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 平台相关工具函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取平台图标
|
||||
* @param {string} name - 平台名称或类型
|
||||
* @returns {string|undefined} 图标URL
|
||||
*/
|
||||
export function getPlatformIcon(name) {
|
||||
if (name === 'aiocqhttp' || name === 'qq_official' || name === 'qq_official_webhook') {
|
||||
return new URL('@/assets/images/platform_logos/qq.png', import.meta.url).href
|
||||
} else if (name === 'wecom') {
|
||||
return new URL('@/assets/images/platform_logos/wecom.png', import.meta.url).href
|
||||
} else if (name === 'wechatpadpro' || name === 'weixin_official_account' || name === 'wechat') {
|
||||
return new URL('@/assets/images/platform_logos/wechat.png', import.meta.url).href
|
||||
} else if (name === 'lark') {
|
||||
return new URL('@/assets/images/platform_logos/lark.png', import.meta.url).href
|
||||
} else if (name === 'dingtalk') {
|
||||
return new URL('@/assets/images/platform_logos/dingtalk.svg', import.meta.url).href
|
||||
} else if (name === 'telegram') {
|
||||
return new URL('@/assets/images/platform_logos/telegram.svg', import.meta.url).href
|
||||
} else if (name === 'discord') {
|
||||
return new URL('@/assets/images/platform_logos/discord.svg', import.meta.url).href
|
||||
} else if (name === 'slack') {
|
||||
return new URL('@/assets/images/platform_logos/slack.svg', import.meta.url).href
|
||||
} else if (name === 'kook') {
|
||||
return new URL('@/assets/images/platform_logos/kook.png', import.meta.url).href
|
||||
} else if (name === 'vocechat') {
|
||||
return new URL('@/assets/images/platform_logos/vocechat.png', import.meta.url).href
|
||||
} else if (name === 'satori' || name === 'Satori') {
|
||||
return new URL('@/assets/images/platform_logos/satori.png', import.meta.url).href
|
||||
} else if (name === 'misskey') {
|
||||
return new URL('@/assets/images/platform_logos/misskey.png', import.meta.url).href
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取平台教程链接
|
||||
* @param {string} platformType - 平台类型
|
||||
* @returns {string} 教程链接
|
||||
*/
|
||||
export function getTutorialLink(platformType) {
|
||||
const tutorialMap = {
|
||||
"qq_official_webhook": "https://docs.astrbot.app/deploy/platform/qqofficial/webhook.html",
|
||||
"qq_official": "https://docs.astrbot.app/deploy/platform/qqofficial/websockets.html",
|
||||
"aiocqhttp": "https://docs.astrbot.app/deploy/platform/aiocqhttp/napcat.html",
|
||||
"wecom": "https://docs.astrbot.app/deploy/platform/wecom.html",
|
||||
"lark": "https://docs.astrbot.app/deploy/platform/lark.html",
|
||||
"telegram": "https://docs.astrbot.app/deploy/platform/telegram.html",
|
||||
"dingtalk": "https://docs.astrbot.app/deploy/platform/dingtalk.html",
|
||||
"wechatpadpro": "https://docs.astrbot.app/deploy/platform/wechat/wechatpadpro.html",
|
||||
"weixin_official_account": "https://docs.astrbot.app/deploy/platform/weixin-official-account.html",
|
||||
"discord": "https://docs.astrbot.app/deploy/platform/discord.html",
|
||||
"slack": "https://docs.astrbot.app/deploy/platform/slack.html",
|
||||
"kook": "https://docs.astrbot.app/deploy/platform/kook.html",
|
||||
"vocechat": "https://docs.astrbot.app/deploy/platform/vocechat.html",
|
||||
"satori": "https://docs.astrbot.app/deploy/platform/satori/llonebot.html",
|
||||
"misskey": "https://docs.astrbot.app/deploy/platform/misskey.html",
|
||||
}
|
||||
return tutorialMap[platformType] || "https://docs.astrbot.app";
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取平台描述
|
||||
* @param {Object} template - 平台模板
|
||||
* @param {string} name - 平台名称
|
||||
* @returns {string} 平台描述
|
||||
*/
|
||||
export function getPlatformDescription(template, name) {
|
||||
// special judge for community platforms
|
||||
if (name.includes('vocechat')) {
|
||||
return "由 @HikariFroya 提供。";
|
||||
} else if (name.includes('kook')) {
|
||||
return "由 @wuyan1003 提供。"
|
||||
}
|
||||
return '';
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 提供商相关的工具函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 获取提供商类型对应的图标
|
||||
* @param {string} type - 提供商类型
|
||||
* @returns {string} 图标 URL
|
||||
*/
|
||||
export function getProviderIcon(type) {
|
||||
const icons = {
|
||||
'openai': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/openai.svg',
|
||||
'azure': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/azure.svg',
|
||||
'xai': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/xai.svg',
|
||||
'anthropic': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/anthropic.svg',
|
||||
'ollama': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/ollama.svg',
|
||||
'google': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/gemini-color.svg',
|
||||
'deepseek': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/deepseek.svg',
|
||||
'modelscope': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/modelscope.svg',
|
||||
'zhipu': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/zhipu.svg',
|
||||
'siliconflow': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/siliconcloud.svg',
|
||||
'moonshot': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/kimi.svg',
|
||||
'ppio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/ppio.svg',
|
||||
'dify': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/dify-color.svg',
|
||||
'dashscope': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/alibabacloud-color.svg',
|
||||
'fastgpt': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/fastgpt-color.svg',
|
||||
'lm_studio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/lmstudio.svg',
|
||||
'fishaudio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/fishaudio.svg',
|
||||
'minimax': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/minimax.svg',
|
||||
'302ai': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/1.53.0/files/icons/ai302-color.svg',
|
||||
'microsoft': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/microsoft.svg',
|
||||
'vllm': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/vllm.svg',
|
||||
};
|
||||
return icons[type] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取提供商简介
|
||||
* @param {Object} template - 模板对象
|
||||
* @param {string} name - 提供商名称
|
||||
* @param {Function} tm - 翻译函数
|
||||
* @returns {string} 提供商描述
|
||||
*/
|
||||
export function getProviderDescription(template, name, tm) {
|
||||
if (name == 'OpenAI') {
|
||||
return tm('providers.description.openai', { type: template.type });
|
||||
} else if (name == 'vLLM Rerank') {
|
||||
return tm('providers.description.vllm_rerank', { type: template.type });
|
||||
}
|
||||
return tm('providers.description.default', { type: template.type });
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import ChatPage from './ChatPage.vue';
|
||||
import Chat from '@/components/chat/Chat.vue'
|
||||
import { useCustomizerStore } from '@/stores/customizer';
|
||||
const customizer = useCustomizerStore();
|
||||
</script>
|
||||
@@ -9,7 +9,7 @@ const customizer = useCustomizerStore();
|
||||
<div
|
||||
style="height: 100%; width: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center;">
|
||||
<div id="container">
|
||||
<ChatPage :chatbox-mode="true"></ChatPage>
|
||||
<Chat :chatbox-mode="true"></Chat>
|
||||
</div>
|
||||
</div>
|
||||
</v-app>
|
||||
@@ -18,24 +18,6 @@ const customizer = useCustomizerStore();
|
||||
<style scoped>
|
||||
#container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
#container {
|
||||
min-width: 600px;
|
||||
min-height: 370px;
|
||||
max-width: 1100px;
|
||||
max-height: 860px;
|
||||
padding: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
#container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
+11
-2044
File diff suppressed because it is too large
Load Diff
@@ -1,50 +1,30 @@
|
||||
<template>
|
||||
<div class="conversation-page">
|
||||
<v-container fluid class="pa-0">
|
||||
<!-- 页面标题 -->
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<h1 class="text-h4 font-weight-bold mb-2">
|
||||
<v-icon size="x-large" color="primary" class="me-2">mdi-chat-processing</v-icon>{{ tm('title') }}
|
||||
</h1>
|
||||
<p class="text-subtitle-1 text-medium-emphasis mb-4">
|
||||
{{ tm('subtitle') }}
|
||||
</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- 过滤器部分 -->
|
||||
<v-card class="mb-4" elevation="2">
|
||||
<!-- 对话列表部分 -->
|
||||
<v-card flat>
|
||||
<v-card-title class="d-flex align-center py-3 px-4">
|
||||
<v-icon color="primary" class="me-2">mdi-filter-variant</v-icon>
|
||||
<span class="text-h6">{{ tm('filters.title') }}</span>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="primary" variant="text" @click="resetFilters" class="ml-2">
|
||||
<v-icon class="mr-1">mdi-refresh</v-icon>{{ tm('filters.reset') }}
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-text class="py-4">
|
||||
<v-row>
|
||||
<span class="text-h4">{{ tm('history.title') }}</span>
|
||||
<v-chip size="small" class="ml-2">{{ pagination.total || 0 }}</v-chip>
|
||||
<v-row class="me-4 ms-4" dense>
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-select v-model="platformFilter" :label="tm('filters.platform')" :items="availablePlatforms" chips multiple
|
||||
clearable variant="outlined" density="compact" hide-details>
|
||||
<v-combobox v-model="platformFilter" :label="tm('filters.platform')"
|
||||
:items="availablePlatforms" chips multiple clearable variant="solo-filled" flat
|
||||
density="compact" hide-details :disabled="loading">
|
||||
<template v-slot:selection="{ item }">
|
||||
<v-chip size="small" :color="getPlatformColor(item.value)" label>
|
||||
<v-chip size="small" label>
|
||||
{{ item.title }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-select>
|
||||
</v-combobox>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6" md="4">
|
||||
<v-select v-model="messageTypeFilter" :label="tm('filters.type')" :items="messageTypeItems" chips multiple
|
||||
clearable variant="outlined" density="compact" hide-details>
|
||||
<v-select v-model="messageTypeFilter" :label="tm('filters.type')" :items="messageTypeItems"
|
||||
chips multiple clearable variant="solo-filled" density="compact" hide-details flat
|
||||
:disabled="loading">
|
||||
<template v-slot:selection="{ item }">
|
||||
<v-chip size="small" :color="getMessageTypeColor(item.value)" variant="outlined"
|
||||
label>
|
||||
<v-chip size="small" variant="solo-filled" label>
|
||||
{{ item.title }}
|
||||
</v-chip>
|
||||
</template>
|
||||
@@ -52,49 +32,49 @@
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="12" md="4">
|
||||
<v-text-field v-model="search" prepend-inner-icon="mdi-magnify" :label="tm('filters.search')" hide-details
|
||||
density="compact" variant="outlined" clearable></v-text-field>
|
||||
<v-text-field v-model="search" prepend-inner-icon="mdi-magnify"
|
||||
:label="tm('filters.search')" hide-details density="compact" variant="solo-filled" flat
|
||||
clearable :disabled="loading"></v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- 对话列表部分 -->
|
||||
<v-card class="mb-6" elevation="2">
|
||||
<v-card-title class="d-flex align-center py-3 px-4">
|
||||
<v-icon color="primary" class="me-2">mdi-message</v-icon>
|
||||
<span class="text-h6">{{ tm('history.title') }}</span>
|
||||
<v-chip color="info" size="small" class="ml-2">{{ pagination.total || 0 }}</v-chip>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="primary" prepend-icon="mdi-refresh" variant="tonal" @click="fetchConversations"
|
||||
:loading="loading">
|
||||
:loading="loading" size="small" class="mr-2">
|
||||
{{ tm('history.refresh') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="selectedItems.length > 0"
|
||||
color="error"
|
||||
prepend-icon="mdi-delete"
|
||||
variant="tonal"
|
||||
@click="confirmBatchDelete"
|
||||
:disabled="loading"
|
||||
size="small">
|
||||
{{ tm('batch.deleteSelected', { count: selectedItems.length }) }}
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-text class="pa-0">
|
||||
<v-data-table :headers="tableHeaders" :items="conversations" :loading="loading" density="comfortable"
|
||||
hide-default-footer items-per-page="10" class="elevation-0"
|
||||
:items-per-page="pagination.page_size" :items-per-page-options="[10, 20, 50, 100]"
|
||||
@update:options="handleTableOptions">
|
||||
<v-data-table v-model="selectedItems" :headers="tableHeaders" :items="conversations"
|
||||
:loading="loading" style="font-size: 12px;" density="comfortable" hide-default-footer
|
||||
class="elevation-0" :items-per-page="pagination.page_size"
|
||||
:items-per-page-options="pageSizeOptions" show-select return-object
|
||||
:disabled="loading" @update:options="handleTableOptions">
|
||||
<template v-slot:item.title="{ item }">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon color="primary" class="mr-2">mdi-chat</v-icon>
|
||||
<span>{{ item.title || tm('status.noTitle') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.platform="{ item }">
|
||||
<v-chip :color="getPlatformColor(item.sessionInfo.platform)" size="small" label>
|
||||
<v-chip size="small" label>
|
||||
{{ item.sessionInfo.platform || tm('status.unknown') }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.messageType="{ item }">
|
||||
<v-chip :color="getMessageTypeColor(item.sessionInfo.messageType)" size="small"
|
||||
variant="outlined" label>
|
||||
<v-chip size="small" label>
|
||||
{{ getMessageTypeDisplay(item.sessionInfo.messageType) }}
|
||||
</v-chip>
|
||||
</template>
|
||||
@@ -113,17 +93,17 @@
|
||||
|
||||
<template v-slot:item.actions="{ item }">
|
||||
<div class="actions-wrapper">
|
||||
<v-btn color="primary" variant="flat" size="small" class="action-button"
|
||||
@click="viewConversation(item)">
|
||||
<v-icon class="mr-1">mdi-eye</v-icon>{{ tm('actions.view') }}
|
||||
<v-btn icon variant="plain" size="x-small" class="action-button"
|
||||
@click="viewConversation(item)" :disabled="loading">
|
||||
<v-icon>mdi-eye</v-icon>
|
||||
</v-btn>
|
||||
<v-btn color="warning" variant="flat" size="small" class="action-button"
|
||||
@click="editConversation(item)">
|
||||
<v-icon class="mr-1">mdi-pencil</v-icon>{{ tm('actions.edit') }}
|
||||
<v-btn icon variant="plain" size="x-small" class="action-button"
|
||||
@click="editConversation(item)" :disabled="loading">
|
||||
<v-icon>mdi-pencil</v-icon>
|
||||
</v-btn>
|
||||
<v-btn color="error" variant="flat" size="small" class="action-button"
|
||||
@click="confirmDeleteConversation(item)">
|
||||
<v-icon class="mr-1">mdi-delete</v-icon>{{ tm('actions.delete') }}
|
||||
<v-btn icon color="error" variant="plain" size="x-small" class="action-button"
|
||||
@click="confirmDeleteConversation(item)" :disabled="loading">
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
@@ -137,9 +117,25 @@
|
||||
</v-data-table>
|
||||
|
||||
<!-- 分页控制 -->
|
||||
<div class="d-flex justify-end pa-4">
|
||||
<div class="d-flex justify-center py-3">
|
||||
<!-- 每页大小选择器 -->
|
||||
<div class="d-flex justify-between align-center px-4 py-2 bg-grey-lighten-5">
|
||||
<div class="d-flex align-center">
|
||||
<span class="text-caption mr-2">{{ tm('pagination.itemsPerPage') }}:</span>
|
||||
<v-select v-model="pagination.page_size" :items="pageSizeOptions" variant="outlined"
|
||||
density="compact" hide-details style="max-width: 100px;"
|
||||
:disabled="loading" @update:model-value="onPageSizeChange"></v-select>
|
||||
</div>
|
||||
<div class="text-caption ml-4">
|
||||
{{ tm('pagination.showingItems', {
|
||||
start: Math.min((pagination.page - 1) * pagination.page_size + 1, pagination.total),
|
||||
end: Math.min(pagination.page * pagination.page_size, pagination.total),
|
||||
total: pagination.total
|
||||
}) }}
|
||||
</div>
|
||||
</div>
|
||||
<v-pagination v-model="pagination.page" :length="pagination.total_pages" :disabled="loading"
|
||||
@update:model-value="fetchConversations" rounded="circle"></v-pagination>
|
||||
@update:model-value="fetchConversations" rounded="circle" :total-visible="7"></v-pagination>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
@@ -148,24 +144,20 @@
|
||||
<!-- 对话详情对话框 -->
|
||||
<v-dialog v-model="dialogView" max-width="900px" scrollable>
|
||||
<v-card class="conversation-detail-card">
|
||||
<v-card-title class="bg-primary text-white py-3 d-flex align-center">
|
||||
<v-icon color="white" class="me-2">mdi-eye</v-icon>
|
||||
<v-card-title class="ml-2 mt-2 d-flex align-center">
|
||||
<span class="text-truncate">{{ selectedConversation?.title || tm('status.noTitle') }}</span>
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<div class="d-flex align-center" v-if="selectedConversation?.sessionInfo">
|
||||
<v-chip color="white" text-color="primary" size="small" class="mr-2">
|
||||
<v-chip text-color="primary" size="small" class="mr-2" rounded="md">
|
||||
{{ selectedConversation.sessionInfo.platform }}
|
||||
</v-chip>
|
||||
<v-chip color="white" text-color="secondary" size="small">
|
||||
<v-chip text-color="secondary" size="small" rounded="md">
|
||||
{{ getMessageTypeDisplay(selectedConversation.sessionInfo.messageType) }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</v-card-title>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-text class="py-4">
|
||||
<v-card-text>
|
||||
<div class="mb-4 d-flex align-center">
|
||||
<v-btn color="secondary" variant="tonal" size="small" class="mr-2"
|
||||
@click="isEditingHistory = !isEditingHistory">
|
||||
@@ -199,51 +191,11 @@
|
||||
<p class="text-disabled mt-2">{{ tm('status.emptyContent') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<div v-else class="message-list">
|
||||
<div class="message-item" v-for="(msg, index) in conversationHistory" :key="index">
|
||||
<!-- 用户消息 -->
|
||||
<div v-if="msg.role === 'user'" class="user-message">
|
||||
<div class="message-bubble user-bubble">
|
||||
<span v-html="formatMessage(msg.content)"></span>
|
||||
|
||||
<!-- 图片附件 -->
|
||||
<div class="image-attachments" v-if="msg.image_url && msg.image_url.length > 0">
|
||||
<div v-for="(img, imgIndex) in msg.image_url" :key="imgIndex"
|
||||
class="image-attachment">
|
||||
<img :src="img" class="attached-image" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 音频附件 -->
|
||||
<div class="audio-attachment" v-if="msg.audio_url">
|
||||
<audio controls class="audio-player">
|
||||
<source :src="msg.audio_url" type="audio/wav">
|
||||
{{ tm('status.audioNotSupported') }}
|
||||
</audio>
|
||||
</div>
|
||||
</div>
|
||||
<v-avatar class="user-avatar" color="deep-purple-lighten-3" size="36">
|
||||
<v-icon icon="mdi-account" />
|
||||
</v-avatar>
|
||||
</div>
|
||||
|
||||
<!-- 机器人消息 -->
|
||||
<div v-else class="bot-message">
|
||||
<v-avatar class="bot-avatar" color="deep-purple" size="36">
|
||||
<span class="text-h6">✨</span>
|
||||
</v-avatar>
|
||||
<div class="message-bubble bot-bubble">
|
||||
<div v-html="formatMessage(msg.content)" class="markdown-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 消息列表组件 -->
|
||||
<MessageList v-else :messages="formattedMessages" :isDark="false" />
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="text" @click="closeHistoryDialog">
|
||||
@@ -263,8 +215,9 @@
|
||||
|
||||
<v-card-text class="py-4">
|
||||
<v-form ref="form" v-model="valid">
|
||||
<v-text-field v-model="editedItem.title" :label="tm('dialogs.edit.titleLabel')" :placeholder="tm('dialogs.edit.titlePlaceholder')" variant="outlined"
|
||||
density="comfortable" class="mb-3"></v-text-field>
|
||||
<v-text-field v-model="editedItem.title" :label="tm('dialogs.edit.titleLabel')"
|
||||
:placeholder="tm('dialogs.edit.titlePlaceholder')" variant="outlined" density="comfortable"
|
||||
class="mb-3"></v-text-field>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
||||
@@ -291,7 +244,8 @@
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="py-4">
|
||||
<p>{{ tm('dialogs.delete.message', { title: selectedConversation?.title || tm('status.noTitle') }) }}</p>
|
||||
<p>{{ tm('dialogs.delete.message', { title: selectedConversation?.title || tm('status.noTitle') })
|
||||
}}</p>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
@@ -308,6 +262,48 @@
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 批量删除确认对话框 -->
|
||||
<v-dialog v-model="dialogBatchDelete" max-width="600px">
|
||||
<v-card>
|
||||
<v-card-title class="bg-error text-white py-3">
|
||||
<v-icon color="white" class="me-2">mdi-delete</v-icon>
|
||||
<span>{{ tm('dialogs.batchDelete.title') }}</span>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="py-4">
|
||||
<p class="mb-3">{{ tm('dialogs.batchDelete.message', { count: selectedItems.length }) }}</p>
|
||||
|
||||
<!-- 显示前几个要删除的对话 -->
|
||||
<div v-if="selectedItems.length > 0" class="mb-3">
|
||||
<v-chip v-for="(item, index) in selectedItems.slice(0, 5)" :key="`${item.user_id}-${item.cid}`"
|
||||
size="small" class="mr-1 mb-1" closable @click:close="removeFromSelection(item)"
|
||||
:disabled="loading">
|
||||
{{ item.title || tm('status.noTitle') }}
|
||||
</v-chip>
|
||||
<v-chip v-if="selectedItems.length > 5" size="small" class="mr-1 mb-1">
|
||||
{{ tm('dialogs.batchDelete.andMore', { count: selectedItems.length - 5 }) }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<v-alert type="warning" variant="tonal" class="mb-3">
|
||||
{{ tm('dialogs.batchDelete.warning') }}
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-actions class="pa-4">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="text" @click="dialogBatchDelete = false" :disabled="loading">
|
||||
{{ tm('dialogs.batchDelete.cancel') }}
|
||||
</v-btn>
|
||||
<v-btn color="error" @click="batchDeleteConversations" :loading="loading">
|
||||
{{ tm('dialogs.batchDelete.confirm') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 消息提示 -->
|
||||
<v-snackbar :timeout="3000" elevation="24" :color="messageType" v-model="showMessage" location="top">
|
||||
{{ message }}
|
||||
@@ -321,6 +317,7 @@ import { VueMonacoEditor } from '@guolao/vue-monaco-editor';
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import { useCommonStore } from '@/stores/common';
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||
import MessageList from '@/components/chat/MessageList.vue';
|
||||
|
||||
// 配置markdown-it,默认安全设置
|
||||
const md = new MarkdownIt({
|
||||
@@ -333,13 +330,14 @@ const md = new MarkdownIt({
|
||||
export default {
|
||||
name: 'ConversationPage',
|
||||
components: {
|
||||
VueMonacoEditor
|
||||
VueMonacoEditor,
|
||||
MessageList
|
||||
},
|
||||
|
||||
setup() {
|
||||
const { t, locale } = useI18n();
|
||||
const { tm } = useModuleI18n('features/conversation');
|
||||
|
||||
|
||||
return {
|
||||
t,
|
||||
tm,
|
||||
@@ -353,32 +351,13 @@ export default {
|
||||
conversations: [],
|
||||
search: '',
|
||||
headers: [],
|
||||
selectedItems: [], // 批量选择的项目
|
||||
|
||||
// 筛选条件
|
||||
platformFilter: [],
|
||||
messageTypeFilter: [],
|
||||
lastAppliedFilters: null, // 记录上次应用的筛选条件
|
||||
|
||||
// 平台颜色映射
|
||||
platformColors: {
|
||||
'telegram': 'blue-lighten-1',
|
||||
'qq_official': 'purple-lighten-1',
|
||||
'qq_official_webhook': 'purple-lighten-2',
|
||||
'aiocqhttp': 'deep-purple-lighten-1',
|
||||
'lark': 'cyan-darken-1',
|
||||
'wecom': 'green-darken-1',
|
||||
'dingtalk': 'blue-darken-2',
|
||||
'default': 'grey-lighten-1'
|
||||
},
|
||||
|
||||
// 消息类型颜色映射
|
||||
messageTypeColors: {
|
||||
'GroupMessage': 'green',
|
||||
'FriendMessage': 'blue',
|
||||
'GuildMessage': 'purple',
|
||||
'default': 'grey'
|
||||
},
|
||||
|
||||
// 分页数据
|
||||
pagination: {
|
||||
page: 1,
|
||||
@@ -386,11 +365,13 @@ export default {
|
||||
total: 0,
|
||||
total_pages: 0
|
||||
},
|
||||
pageSizeOptions: [10, 20, 50, 100], // 每页大小选项
|
||||
|
||||
// 对话框控制
|
||||
dialogView: false,
|
||||
dialogEdit: false,
|
||||
dialogDelete: false,
|
||||
dialogBatchDelete: false, // 批量删除对话框
|
||||
|
||||
// 选中的对话
|
||||
selectedConversation: null,
|
||||
@@ -402,11 +383,6 @@ export default {
|
||||
cid: '',
|
||||
title: ''
|
||||
},
|
||||
defaultItem: {
|
||||
user_id: '',
|
||||
cid: '',
|
||||
title: ''
|
||||
},
|
||||
|
||||
// 表单验证
|
||||
valid: true,
|
||||
@@ -454,12 +430,18 @@ export default {
|
||||
tableHeaders() {
|
||||
return [
|
||||
{ title: this.tm('table.headers.title'), key: 'title', sortable: true },
|
||||
{ title: this.tm('table.headers.platform'), key: 'platform', sortable: true, width: '120px' },
|
||||
{ title: this.tm('table.headers.type'), key: 'messageType', sortable: true, width: '100px' },
|
||||
{ title: this.tm('table.headers.sessionId'), key: 'sessionId', sortable: true, width: '100px' },
|
||||
{
|
||||
title: this.tm('table.headers.sessionId'),
|
||||
align: 'center',
|
||||
children: [
|
||||
{ title: this.tm('table.headers.platform'), key: 'platform', sortable: true, width: '120px' },
|
||||
{ title: this.tm('table.headers.type'), key: 'messageType', sortable: true, width: '100px' },
|
||||
{ title: '会话 ID', key: 'sessionId', sortable: true, width: '100px' },
|
||||
],
|
||||
},
|
||||
{ title: this.tm('table.headers.createdAt'), key: 'created_at', sortable: true, width: '180px' },
|
||||
{ title: this.tm('table.headers.updatedAt'), key: 'updated_at', sortable: true, width: '180px' },
|
||||
{ title: this.tm('table.headers.actions'), key: 'actions', sortable: false, align: 'center', width: '240px' }
|
||||
{ title: this.tm('table.headers.actions'), key: 'actions', sortable: false, align: 'center' }
|
||||
];
|
||||
},
|
||||
|
||||
@@ -487,24 +469,40 @@ export default {
|
||||
];
|
||||
},
|
||||
|
||||
// 筛选后的对话 - 现在只用于额外的客户端筛选(排除astrbot和webchat)
|
||||
filteredConversations() {
|
||||
return this.conversations.filter(conv => {
|
||||
// 排除 user_id 为 astrbot 或 platform 为 webchat 的对话
|
||||
if (conv.user_id === 'astrbot' || conv.sessionInfo?.platform === 'webchat') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
},
|
||||
|
||||
// 当前的筛选条件对象
|
||||
currentFilters() {
|
||||
const platforms = this.platformFilter.map(item =>
|
||||
typeof item === 'object' ? item.value : item
|
||||
);
|
||||
return {
|
||||
platforms: this.platformFilter,
|
||||
platforms: platforms,
|
||||
messageTypes: this.messageTypeFilter,
|
||||
search: this.search
|
||||
};
|
||||
},
|
||||
|
||||
// 将对话历史转换为 MessageList 组件期望的格式
|
||||
formattedMessages() {
|
||||
return this.conversationHistory.map(msg => {
|
||||
console.log('处理消息:', msg.role, msg.image_url, msg.audio_url);
|
||||
if (msg.role === 'user') {
|
||||
return {
|
||||
content: {
|
||||
type: 'user',
|
||||
message: this.extractTextFromContent(msg.content),
|
||||
image_url: this.extractImagesFromContent(msg.content),
|
||||
}
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
content: {
|
||||
type: 'bot',
|
||||
message: this.extractTextFromContent(msg.content),
|
||||
embedded_images: this.extractImagesFromContent(msg.content),
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -541,16 +539,6 @@ export default {
|
||||
};
|
||||
},
|
||||
|
||||
// 重置过滤条件
|
||||
resetFilters() {
|
||||
this.platformFilter = [];
|
||||
this.messageTypeFilter = [];
|
||||
this.search = '';
|
||||
// 立即应用筛选,不使用防抖
|
||||
this.pagination.page = 1;
|
||||
this.fetchConversations();
|
||||
},
|
||||
|
||||
// 处理表格选项变更(页面大小等)
|
||||
handleTableOptions(options) {
|
||||
// 处理页面大小变更
|
||||
@@ -579,16 +567,6 @@ export default {
|
||||
return { platform: 'default', messageType: 'default', sessionId: userId };
|
||||
},
|
||||
|
||||
// 获取平台对应的颜色
|
||||
getPlatformColor(platform) {
|
||||
return this.platformColors[platform] || this.platformColors.default;
|
||||
},
|
||||
|
||||
// 获取消息类型对应的颜色
|
||||
getMessageTypeColor(messageType) {
|
||||
return this.messageTypeColors[messageType] || this.messageTypeColors.default;
|
||||
},
|
||||
|
||||
// 获取消息类型的显示文本
|
||||
getMessageTypeDisplay(messageType) {
|
||||
const typeMap = {
|
||||
@@ -610,9 +588,12 @@ export default {
|
||||
page_size: this.pagination.page_size
|
||||
};
|
||||
|
||||
// 添加筛选条件
|
||||
// 添加筛选条件 - 处理combobox的混合数据格式
|
||||
if (this.platformFilter.length > 0) {
|
||||
params.platforms = this.platformFilter.join(',');
|
||||
const platforms = this.platformFilter.map(item =>
|
||||
typeof item === 'object' ? item.value : item
|
||||
);
|
||||
params.platforms = platforms.join(',');
|
||||
}
|
||||
|
||||
if (this.messageTypeFilter.length > 0) {
|
||||
@@ -836,6 +817,88 @@ export default {
|
||||
}
|
||||
} catch (error) {
|
||||
this.showErrorMessage(error.response?.data?.message || error.message || this.tm('messages.deleteError'));
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.selectedItems = this.selectedItems.filter(item =>
|
||||
!(item.user_id === this.selectedConversation.user_id && item.cid === this.selectedConversation.cid)
|
||||
);
|
||||
this.selectedConversation = null;
|
||||
}
|
||||
},
|
||||
|
||||
// 处理页面大小变更
|
||||
onPageSizeChange() {
|
||||
this.pagination.page = 1; // 重置到第一页
|
||||
this.fetchConversations();
|
||||
},
|
||||
|
||||
// 确认批量删除
|
||||
confirmBatchDelete() {
|
||||
if (this.selectedItems.length === 0) {
|
||||
this.showErrorMessage(this.tm('messages.noItemSelected'));
|
||||
return;
|
||||
}
|
||||
this.dialogBatchDelete = true;
|
||||
},
|
||||
|
||||
// 从选择中移除项目
|
||||
removeFromSelection(item) {
|
||||
const index = this.selectedItems.findIndex(selected =>
|
||||
selected.user_id === item.user_id && selected.cid === item.cid
|
||||
);
|
||||
if (index !== -1) {
|
||||
this.selectedItems.splice(index, 1);
|
||||
}
|
||||
},
|
||||
|
||||
// 批量删除对话
|
||||
async batchDeleteConversations() {
|
||||
if (this.selectedItems.length === 0) {
|
||||
this.showErrorMessage(this.tm('messages.noItemSelected'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
// 准备批量删除的数据
|
||||
const conversations = this.selectedItems.map(item => ({
|
||||
user_id: item.user_id,
|
||||
cid: item.cid
|
||||
}));
|
||||
|
||||
const response = await axios.post('/api/conversation/delete', {
|
||||
conversations: conversations
|
||||
});
|
||||
|
||||
if (response.data.status === "ok") {
|
||||
const result = response.data.data;
|
||||
this.dialogBatchDelete = false;
|
||||
this.selectedItems = []; // 清空选择
|
||||
|
||||
// 显示结果消息
|
||||
if (result.failed_count > 0) {
|
||||
this.showErrorMessage(
|
||||
this.tm('messages.batchDeletePartial', {
|
||||
deleted: result.deleted_count,
|
||||
failed: result.failed_count
|
||||
})
|
||||
);
|
||||
} else {
|
||||
this.showSuccessMessage(
|
||||
this.tm('messages.batchDeleteSuccess', {
|
||||
count: result.deleted_count
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 刷新列表
|
||||
this.fetchConversations();
|
||||
} else {
|
||||
this.showErrorMessage(response.data.message || this.tm('messages.batchDeleteError'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('批量删除对话出错:', error);
|
||||
this.showErrorMessage(error.response?.data?.message || error.message || this.tm('messages.batchDeleteError'));
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
@@ -858,35 +921,6 @@ export default {
|
||||
}).format(date);
|
||||
},
|
||||
|
||||
// 格式化消息内容
|
||||
formatMessage(content) {
|
||||
|
||||
// content 可能是数组
|
||||
// [{"type": "image_url", "image_url": {"url": url_or_base64}}, {"type": "text", "text": "text"}]
|
||||
|
||||
let final_content = content;
|
||||
if (Array.isArray(content)) {
|
||||
// 处理数组内容
|
||||
final_content = content.map(item => {
|
||||
if (item.type === 'image_url') {
|
||||
return `<img src="${item.image_url.url}" alt="Image" />`;
|
||||
} else if (item.type === 'text') {
|
||||
return item.text;
|
||||
}
|
||||
return '';
|
||||
}).join('\n');
|
||||
} else if (typeof content === 'object') {
|
||||
// 处理对象内容
|
||||
final_content = Object.values(content).join('');
|
||||
} else if (typeof content === 'string') {
|
||||
// 处理字符串内容
|
||||
final_content = content;
|
||||
} else if (!final_content) return this.tm('status.emptyContent');
|
||||
|
||||
// 使用markdown-it处理,默认安全(html: false会禁用HTML标签)
|
||||
return md.render(final_content);
|
||||
},
|
||||
|
||||
// 显示成功消息
|
||||
showSuccessMessage(message) {
|
||||
this.message = message;
|
||||
@@ -899,16 +933,36 @@ export default {
|
||||
this.message = message;
|
||||
this.messageType = 'error';
|
||||
this.showMessage = true;
|
||||
},
|
||||
|
||||
// 从内容中提取文本
|
||||
extractTextFromContent(content) {
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
} else if (Array.isArray(content)) {
|
||||
return content.filter(item => item.type === 'text')
|
||||
.map(item => item.text)
|
||||
.join('\n');
|
||||
} else if (typeof content === 'object') {
|
||||
return Object.values(content).filter(val => typeof val === 'string').join('');
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
// 从内容中提取图片URL
|
||||
extractImagesFromContent(content) {
|
||||
if (Array.isArray(content)) {
|
||||
return content.filter(item => item.type === 'image_url')
|
||||
.map(item => item.image_url?.url)
|
||||
.filter(url => url);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.conversation-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.actions-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -918,11 +972,6 @@ export default {
|
||||
.action-button {
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.monaco-editor-container {
|
||||
@@ -932,7 +981,7 @@ export default {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* 聊天消息样式 */
|
||||
/* 聊天消息容器样式 */
|
||||
.conversation-messages-container {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
@@ -941,87 +990,6 @@ export default {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
margin-bottom: 8px;
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.user-message {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bot-message {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
padding: 12px 16px;
|
||||
border-radius: 18px;
|
||||
max-width: 80%;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.user-bubble {
|
||||
background-color: #f0f4ff;
|
||||
color: #333;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
|
||||
.bot-bubble {
|
||||
background-color: #fff;
|
||||
border: 1px solid #eaeaea;
|
||||
color: #333;
|
||||
border-top-left-radius: 4px;
|
||||
}
|
||||
|
||||
.user-avatar,
|
||||
.bot-avatar {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* 附件样式 */
|
||||
.image-attachments {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.attached-image {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.attached-image:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.audio-attachment {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.audio-player {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
/* 对话详情卡片 */
|
||||
.conversation-detail-card {
|
||||
max-height: 90vh;
|
||||
@@ -1029,95 +997,6 @@ export default {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Markdown内容样式 */
|
||||
.markdown-content {
|
||||
font-family: inherit;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.markdown-content h1,
|
||||
.markdown-content h2,
|
||||
.markdown-content h3,
|
||||
.markdown-content h4,
|
||||
.markdown-content h5,
|
||||
.markdown-content h6 {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.markdown-content h1 {
|
||||
font-size: 1.8em;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.markdown-content h2 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.markdown-content h3 {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
|
||||
.markdown-content li {
|
||||
margin-left: 16px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.markdown-content p {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.markdown-content pre {
|
||||
background-color: #f8f8f8;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.markdown-content code {
|
||||
background-color: #f5f0ff;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Fira Code', monospace;
|
||||
font-size: 0.9em;
|
||||
color: #673ab7;
|
||||
}
|
||||
|
||||
.markdown-content img {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.markdown-content blockquote {
|
||||
border-left: 4px solid #673ab7;
|
||||
padding-left: 16px;
|
||||
color: #666;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.markdown-content table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.markdown-content th,
|
||||
.markdown-content td {
|
||||
border: 1px solid #eee;
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-content th {
|
||||
background-color: #f5f0ff;
|
||||
}
|
||||
|
||||
/* 动画 */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
|
||||
@@ -470,7 +470,7 @@ const refreshPluginMarket = async () => {
|
||||
trimExtensionName();
|
||||
checkAlreadyInstalled();
|
||||
checkUpdate();
|
||||
|
||||
|
||||
toast(tm('messages.refreshSuccess'), "success");
|
||||
} catch (err) {
|
||||
toast(tm('messages.refreshFailed') + " " + err, "error");
|
||||
@@ -518,27 +518,12 @@ onMounted(async () => {
|
||||
<v-row>
|
||||
<v-col cols="12" md="12">
|
||||
<v-card variant="flat">
|
||||
<v-card-item>
|
||||
<template v-slot:prepend>
|
||||
<div class="plugin-page-icon d-flex justify-center align-center rounded-lg mr-4">
|
||||
<v-icon size="36" color="primary">mdi-puzzle</v-icon>
|
||||
</div>
|
||||
</template>
|
||||
<v-card-title class="text-h4 font-weight-bold">
|
||||
{{ tm('title') }}
|
||||
</v-card-title>
|
||||
<v-card-subtitle class="text-subtitle-1 mt-1 text-medium-emphasis">
|
||||
{{ tm('subtitle') }}
|
||||
</v-card-subtitle>
|
||||
</v-card-item>
|
||||
|
||||
<!-- 标签页 -->
|
||||
<v-card-text>
|
||||
|
||||
<!-- 标签栏和搜索栏 - 响应式布局 -->
|
||||
<div class="mb-4">
|
||||
<div class="mb-4 d-flex flex-wrap">
|
||||
<!-- 标签栏 -->
|
||||
<v-tabs v-model="activeTab" color="primary" class="mb-3">
|
||||
<v-tabs v-model="activeTab" color="primary">
|
||||
<v-tab value="installed">
|
||||
<v-icon class="mr-2">mdi-puzzle</v-icon>
|
||||
{{ tm('tabs.installed') }}
|
||||
@@ -550,17 +535,16 @@ onMounted(async () => {
|
||||
</v-tabs>
|
||||
|
||||
<!-- 搜索栏 - 在移动端时独占一行 -->
|
||||
<v-row class="mb-2">
|
||||
<v-col cols="12" sm="6" md="4" lg="3">
|
||||
<v-text-field v-if="activeTab == 'market'" v-model="marketSearch" density="compact"
|
||||
:label="tm('search.marketPlaceholder')" prepend-inner-icon="mdi-magnify" variant="solo-filled" flat
|
||||
hide-details single-line>
|
||||
</v-text-field>
|
||||
<v-text-field v-else v-model="pluginSearch" density="compact" :label="tm('search.placeholder')"
|
||||
prepend-inner-icon="mdi-magnify" variant="solo-filled" flat hide-details single-line>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<div style="flex-grow: 1; min-width: 250px; max-width: 400px; margin-left: auto; margin-top: 8px;">
|
||||
<v-text-field v-if="activeTab == 'market'" v-model="marketSearch" density="compact"
|
||||
:label="tm('search.marketPlaceholder')" prepend-inner-icon="mdi-magnify" variant="solo-filled" flat
|
||||
hide-details single-line>
|
||||
</v-text-field>
|
||||
<v-text-field v-else v-model="pluginSearch" density="compact" :label="tm('search.placeholder')"
|
||||
prepend-inner-icon="mdi-magnify" variant="solo-filled" flat hide-details single-line>
|
||||
</v-text-field>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -776,18 +760,13 @@ onMounted(async () => {
|
||||
<div class="d-flex align-center mb-2" style="justify-content: space-between;">
|
||||
<h2>{{ tm('market.allPlugins') }}</h2>
|
||||
<div class="d-flex align-center">
|
||||
<v-btn
|
||||
variant="tonal"
|
||||
size="small"
|
||||
@click="refreshPluginMarket"
|
||||
:loading="refreshingMarket"
|
||||
class="mr-2"
|
||||
>
|
||||
<v-btn variant="tonal" size="small" @click="refreshPluginMarket" :loading="refreshingMarket"
|
||||
class="mr-2">
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
{{ tm('buttons.refresh') }}
|
||||
</v-btn>
|
||||
<v-switch v-model="showPluginFullName" :label="tm('market.showFullName')" hide-details density="compact"
|
||||
style="margin-left: 12px" />
|
||||
<v-switch v-model="showPluginFullName" :label="tm('market.showFullName')" hide-details
|
||||
density="compact" style="margin-left: 12px" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -827,7 +806,7 @@ onMounted(async () => {
|
||||
<template v-slot:item.tags="{ item }">
|
||||
<span v-if="item.tags.length === 0">-</span>
|
||||
<v-chip v-for="tag in item.tags" :key="tag" :color="tag === 'danger' ? 'error' : 'primary'"
|
||||
size="x-small" v-show="tag !== 'danger'">
|
||||
size="x-small" v-show="tag !== 'danger'" class="ma-1">
|
||||
{{ tag }}</v-chip>
|
||||
</template>
|
||||
<template v-slot:item.actions="{ item }">
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<!-- 人格卡片网格 -->
|
||||
<v-row>
|
||||
<v-col v-for="persona in personas" :key="persona.persona_id" cols="12" md="6" lg="4" xl="3">
|
||||
<v-card class="persona-card" elevation="2" rounded="lg" @click="viewPersona(persona)">
|
||||
<v-card class="persona-card" rounded="md" @click="viewPersona(persona)">
|
||||
<v-card-title class="d-flex justify-space-between align-center">
|
||||
<div class="text-truncate ml-2">
|
||||
{{ persona.persona_id }}
|
||||
@@ -296,9 +296,9 @@
|
||||
<v-card-text>
|
||||
<div class="mb-4">
|
||||
<h4 class="text-h6 mb-2">{{ tm('form.systemPrompt') }}</h4>
|
||||
<div class="system-prompt-content">
|
||||
<pre class="system-prompt-content">
|
||||
{{ viewingPersona.system_prompt }}
|
||||
</div>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div v-if="viewingPersona.begin_dialogs && viewingPersona.begin_dialogs.length > 0" class="mb-4">
|
||||
@@ -759,10 +759,6 @@ export default {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.persona-card:hover {
|
||||
box-shadow: 0 8px 25px 0 rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.system-prompt-preview {
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
@@ -775,10 +771,10 @@ export default {
|
||||
}
|
||||
|
||||
.system-prompt-content {
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.3);
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
{{ tm('subtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
<v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" @click="showAddPlatformDialog = true" rounded="xl" size="x-large">
|
||||
<v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" @click="showAddPlatformDialog = true"
|
||||
rounded="xl" size="x-large">
|
||||
{{ tm('addAdapter') }}
|
||||
</v-btn>
|
||||
</v-row>
|
||||
@@ -25,14 +26,9 @@
|
||||
|
||||
<v-row v-else>
|
||||
<v-col v-for="(platform, index) in config_data.platform || []" :key="index" cols="12" md="6" lg="4" xl="3">
|
||||
<item-card
|
||||
:item="platform"
|
||||
title-field="id"
|
||||
enabled-field="enable"
|
||||
:bglogo="getPlatformIcon(platform.type || platform.id)"
|
||||
@toggle-enabled="platformStatusChange"
|
||||
@delete="deletePlatform"
|
||||
@edit="editPlatform">
|
||||
<item-card :item="platform" title-field="id" enabled-field="enable"
|
||||
:bglogo="getPlatformIcon(platform.type || platform.id)" @toggle-enabled="platformStatusChange"
|
||||
@delete="deletePlatform" @edit="editPlatform">
|
||||
</item-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
@@ -61,59 +57,13 @@
|
||||
</v-container>
|
||||
|
||||
<!-- 添加平台适配器对话框 -->
|
||||
<v-dialog v-model="showAddPlatformDialog" max-width="900px" min-height="80%">
|
||||
<v-card class="platform-selection-dialog">
|
||||
<v-card-title class="bg-primary text-white py-3 px-4" style="display: flex; align-items: center;">
|
||||
<v-icon color="white" class="me-2">mdi-plus-circle</v-icon>
|
||||
<span>{{ tm('dialog.addPlatform') }}</span>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn icon variant="text" color="white" @click="showAddPlatformDialog = false">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-4" style="overflow-y: auto;">
|
||||
<v-row class="mt-1">
|
||||
<v-col v-for="(template, name) in metadata['platform_group']?.metadata?.platform?.config_template || {}"
|
||||
:key="name" cols="12" sm="6" md="6">
|
||||
<v-card variant="outlined" hover class="platform-card" @click="selectPlatformTemplate(name)">
|
||||
<div class="platform-card-content">
|
||||
<div class="platform-card-text">
|
||||
<v-card-title class="platform-card-title">{{ tm('dialog.connectTitle', { name }) }}</v-card-title>
|
||||
<v-card-text class="text-caption text-medium-emphasis platform-card-description">
|
||||
{{ getPlatformDescription(template, name) }}
|
||||
</v-card-text>
|
||||
</div>
|
||||
<div class="platform-card-logo">
|
||||
<img :src="getPlatformIcon(template.type)" v-if="getPlatformIcon(template.type)" class="platform-logo-img">
|
||||
<div v-else class="platform-logo-fallback">
|
||||
{{ name[0].toUpperCase() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col
|
||||
v-if="Object.keys(metadata['platform_group']?.metadata?.platform?.config_template || {}).length === 0"
|
||||
cols="12">
|
||||
<v-alert type="info" variant="tonal">
|
||||
{{ tm('dialog.noTemplates') }}
|
||||
</v-alert>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
<AddNewPlatform v-model:show="showAddPlatformDialog" :metadata="metadata"
|
||||
@select-template="selectPlatformTemplate" />
|
||||
|
||||
<!-- 配置对话框 -->
|
||||
<v-dialog v-model="showPlatformCfg" persistent width="900px" max-width="90%">
|
||||
<v-card>
|
||||
<v-card-title class="bg-primary text-white py-3">
|
||||
<v-icon color="white" class="me-2">{{ updatingMode ? 'mdi-pencil' : 'mdi-plus' }}</v-icon>
|
||||
<span>{{ updatingMode ? tm('dialog.edit') : tm('dialog.add') }} {{ newSelectedPlatformName }} {{
|
||||
tm('dialog.adapter') }}</span>
|
||||
</v-card-title>
|
||||
|
||||
<v-card
|
||||
:title="updatingMode ? tm('dialog.edit') : tm('dialog.add') + ` ${newSelectedPlatformName} ` + tm('dialog.adapter')">
|
||||
<v-card-text class="py-4">
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
@@ -164,7 +114,7 @@
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="grey" variant="text" @click="handleIdConflictConfirm(false)">{{ tm('dialog.idConflict.confirm')
|
||||
}}</v-btn>
|
||||
}}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
@@ -177,7 +127,9 @@
|
||||
</v-card-title>
|
||||
<v-card-text class="py-4">
|
||||
<p>{{ tm('dialog.securityWarning.aiocqhttpTokenMissing') }}</p>
|
||||
<span><a href="https://docs.astrbot.app/deploy/platform/aiocqhttp/napcat.html#%E9%99%84%E5%BD%95-%E5%A2%9E%E5%BC%BA%E8%BF%9E%E6%8E%A5%E5%AE%89%E5%85%A8%E6%80%A7" target="_blank">{{ tm('dialog.securityWarning.learnMore') }}</a></span>
|
||||
<span><a
|
||||
href="https://docs.astrbot.app/deploy/platform/aiocqhttp/napcat.html#%E9%99%84%E5%BD%95-%E5%A2%9E%E5%BC%BA%E8%BF%9E%E6%8E%A5%E5%AE%89%E5%85%A8%E6%80%A7"
|
||||
target="_blank">{{ tm('dialog.securityWarning.learnMore') }}</a></span>
|
||||
</v-card-text>
|
||||
<v-card-actions class="px-4 pb-4">
|
||||
<v-spacer></v-spacer>
|
||||
@@ -199,8 +151,10 @@ import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
|
||||
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
|
||||
import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
|
||||
import ItemCard from '@/components/shared/ItemCard.vue';
|
||||
import AddNewPlatform from '@/components/platform/AddNewPlatform.vue';
|
||||
import { useCommonStore } from '@/stores/common';
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||
import { getPlatformIcon, getTutorialLink } from '@/utils/platformUtils';
|
||||
|
||||
export default {
|
||||
name: 'PlatformPage',
|
||||
@@ -208,7 +162,8 @@ export default {
|
||||
AstrBotConfig,
|
||||
WaitingForRestart,
|
||||
ConsoleDisplayer,
|
||||
ItemCard
|
||||
ItemCard,
|
||||
AddNewPlatform
|
||||
},
|
||||
setup() {
|
||||
const { t } = useI18n();
|
||||
@@ -285,66 +240,14 @@ export default {
|
||||
},
|
||||
|
||||
methods: {
|
||||
// 从工具函数导入
|
||||
getPlatformIcon,
|
||||
|
||||
openTutorial() {
|
||||
const tutorialUrl = this.getTutorialLink(this.newSelectedPlatformConfig.type);
|
||||
const tutorialUrl = getTutorialLink(this.newSelectedPlatformConfig.type);
|
||||
window.open(tutorialUrl, '_blank');
|
||||
},
|
||||
|
||||
getPlatformIcon(name) {
|
||||
if (name === 'aiocqhttp' || name === 'qq_official' || name === 'qq_official_webhook') {
|
||||
return new URL('@/assets/images/platform_logos/qq.png', import.meta.url).href
|
||||
} else if (name === 'wecom') {
|
||||
return new URL('@/assets/images/platform_logos/wecom.png', import.meta.url).href
|
||||
} else if (name === 'wechatpadpro' || name === 'weixin_official_account' || name === 'wechat') {
|
||||
return new URL('@/assets/images/platform_logos/wechat.png', import.meta.url).href
|
||||
} else if (name === 'lark') {
|
||||
return new URL('@/assets/images/platform_logos/lark.png', import.meta.url).href
|
||||
} else if (name === 'dingtalk') {
|
||||
return new URL('@/assets/images/platform_logos/dingtalk.svg', import.meta.url).href
|
||||
} else if (name === 'telegram') {
|
||||
return new URL('@/assets/images/platform_logos/telegram.svg', import.meta.url).href
|
||||
} else if (name === 'discord') {
|
||||
return new URL('@/assets/images/platform_logos/discord.svg', import.meta.url).href
|
||||
} else if (name === 'slack') {
|
||||
return new URL('@/assets/images/platform_logos/slack.svg', import.meta.url).href
|
||||
} else if (name === 'kook') {
|
||||
return new URL('@/assets/images/platform_logos/kook.png', import.meta.url).href
|
||||
} else if (name === 'vocechat') {
|
||||
return new URL('@/assets/images/platform_logos/vocechat.png', import.meta.url).href
|
||||
} else if (name === 'satori' || name === 'Satori') {
|
||||
return new URL('@/assets/images/platform_logos/satori.png', import.meta.url).href
|
||||
}
|
||||
},
|
||||
|
||||
getTutorialLink(platform_type) {
|
||||
let tutorial_map = {
|
||||
"qq_official_webhook": "https://docs.astrbot.app/deploy/platform/qqofficial/webhook.html",
|
||||
"qq_official": "https://docs.astrbot.app/deploy/platform/qqofficial/websockets.html",
|
||||
"aiocqhttp": "https://docs.astrbot.app/deploy/platform/aiocqhttp/napcat.html",
|
||||
"wecom": "https://docs.astrbot.app/deploy/platform/wecom.html",
|
||||
"lark": "https://docs.astrbot.app/deploy/platform/lark.html",
|
||||
"telegram": "https://docs.astrbot.app/deploy/platform/telegram.html",
|
||||
"dingtalk": "https://docs.astrbot.app/deploy/platform/dingtalk.html",
|
||||
"wechatpadpro": "https://docs.astrbot.app/deploy/platform/wechat/wechatpadpro.html",
|
||||
"weixin_official_account": "https://docs.astrbot.app/deploy/platform/weixin-official-account.html",
|
||||
"discord": "https://docs.astrbot.app/deploy/platform/discord.html",
|
||||
"slack": "https://docs.astrbot.app/deploy/platform/slack.html",
|
||||
"kook": "https://docs.astrbot.app/deploy/platform/kook.html",
|
||||
"vocechat": "https://docs.astrbot.app/deploy/platform/vocechat.html",
|
||||
"satori": "https://docs.astrbot.app/deploy/platform/satori/llonebot.html",
|
||||
}
|
||||
return tutorial_map[platform_type] || "https://docs.astrbot.app";
|
||||
},
|
||||
|
||||
getPlatformDescription(template, name) {
|
||||
// special judge for community platforms
|
||||
if (name.includes('vocechat')) {
|
||||
return "由 @HikariFroya 提供。";
|
||||
} else if (name.includes('kook')) {
|
||||
return "由 @wuyan1003 提供。"
|
||||
}
|
||||
},
|
||||
|
||||
getConfig() {
|
||||
axios.get('/api/config/get').then((res) => {
|
||||
this.config_data = res.data.data.config;
|
||||
@@ -355,7 +258,7 @@ export default {
|
||||
});
|
||||
},
|
||||
|
||||
// 添加一个新方法来选择平台模板
|
||||
// 选择平台模板
|
||||
selectPlatformTemplate(name) {
|
||||
this.newSelectedPlatformName = name;
|
||||
this.showPlatformCfg = true;
|
||||
@@ -363,7 +266,6 @@ export default {
|
||||
this.newSelectedPlatformConfig = JSON.parse(JSON.stringify(
|
||||
this.metadata['platform_group']?.metadata?.platform?.config_template[name] || {}
|
||||
));
|
||||
this.showAddPlatformDialog = false;
|
||||
},
|
||||
|
||||
addFromDefaultConfigTmpl(index) {
|
||||
@@ -480,7 +382,7 @@ export default {
|
||||
this.oneBotEmptyTokenWarningResolve(continueWithWarning);
|
||||
this.oneBotEmptyTokenWarningResolve = null;
|
||||
}
|
||||
|
||||
|
||||
if (!continueWithWarning) {
|
||||
this.loading = false;
|
||||
}
|
||||
@@ -532,84 +434,4 @@ export default {
|
||||
padding: 20px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.platform-selection-dialog .v-card-title {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
|
||||
.platform-card {
|
||||
transition: all 0.3s ease;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.platform-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 25px 0 rgba(0, 0, 0, 0.05);
|
||||
border-color: var(--v-primary-base);
|
||||
}
|
||||
|
||||
.platform-card-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100px;
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.platform-card-text {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.platform-card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.platform-card-description {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.platform-card-logo {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.platform-logo-img {
|
||||
max-width: 60px;
|
||||
max-height: 60px;
|
||||
opacity: 0.6;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.platform-logo-fallback {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--v-primary-base);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
opacity: 0.3;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -56,14 +56,16 @@
|
||||
|
||||
<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"
|
||||
<item-card
|
||||
:item="provider"
|
||||
title-field="id"
|
||||
enabled-field="enable"
|
||||
@toggle-enabled="providerStatusChange"
|
||||
:bglogo="getProviderIcon(provider.provider)"
|
||||
@delete="deleteProvider"
|
||||
@edit="configExistingProvider">
|
||||
@delete="deleteProvider"
|
||||
@edit="configExistingProvider"
|
||||
@copy="copyProvider"
|
||||
:show-copy-button="true">
|
||||
<template v-slot:details="{ item }">
|
||||
</template>
|
||||
</item-card>
|
||||
@@ -95,7 +97,7 @@
|
||||
<v-alert v-if="providerStatuses.length === 0" type="info" variant="tonal">
|
||||
{{ tm('availability.noData') }}
|
||||
</v-alert>
|
||||
|
||||
|
||||
<v-container v-else class="pa-0">
|
||||
<v-row>
|
||||
<v-col v-for="status in providerStatuses" :key="status.id" cols="12" sm="6" md="4">
|
||||
@@ -113,7 +115,7 @@
|
||||
></v-progress-circular>
|
||||
|
||||
<span class="font-weight-bold">{{ status.id }}</span>
|
||||
|
||||
|
||||
<v-chip :color="getStatusColor(status.status)" size="small" class="ml-2">
|
||||
{{ getStatusText(status.status) }}
|
||||
</v-chip>
|
||||
@@ -153,86 +155,15 @@
|
||||
</v-container>
|
||||
|
||||
<!-- 添加提供商对话框 -->
|
||||
<v-dialog v-model="showAddProviderDialog" max-width="1100px" min-height="95%">
|
||||
<v-card class="provider-selection-dialog">
|
||||
<v-card-title class="bg-primary text-white py-3 px-4" style="display: flex; align-items: center;">
|
||||
<v-icon color="white" class="me-2">mdi-plus-circle</v-icon>
|
||||
<span>{{ tm('dialogs.addProvider.title') }}</span>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn icon variant="text" color="white" @click="showAddProviderDialog = false">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-4" style="overflow-y: auto;">
|
||||
<v-tabs v-model="activeProviderTab" grow slider-color="primary" bg-color="background">
|
||||
<v-tab value="chat_completion" class="font-weight-medium px-3">
|
||||
<v-icon start>mdi-message-text</v-icon>
|
||||
{{ tm('dialogs.addProvider.tabs.basic') }}
|
||||
</v-tab>
|
||||
<v-tab value="speech_to_text" class="font-weight-medium px-3">
|
||||
<v-icon start>mdi-microphone-message</v-icon>
|
||||
{{ tm('dialogs.addProvider.tabs.speechToText') }}
|
||||
</v-tab>
|
||||
<v-tab value="text_to_speech" class="font-weight-medium px-3">
|
||||
<v-icon start>mdi-volume-high</v-icon>
|
||||
{{ tm('dialogs.addProvider.tabs.textToSpeech') }}
|
||||
</v-tab>
|
||||
<v-tab value="embedding" class="font-weight-medium px-3">
|
||||
<v-icon start>mdi-code-json</v-icon>
|
||||
{{ tm('dialogs.addProvider.tabs.embedding') }}
|
||||
</v-tab>
|
||||
<v-tab value="rerank" class="font-weight-medium px-3">
|
||||
<v-icon start>mdi-compare-vertical</v-icon>
|
||||
{{ tm('dialogs.addProvider.tabs.rerank') }}
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<v-window v-model="activeProviderTab" class="mt-4">
|
||||
<v-window-item v-for="tabType in ['chat_completion', 'speech_to_text', 'text_to_speech', 'embedding', 'rerank']"
|
||||
:key="tabType"
|
||||
:value="tabType">
|
||||
<v-row class="mt-1">
|
||||
<v-col v-for="(template, name) in getTemplatesByType(tabType)"
|
||||
:key="name"
|
||||
cols="12" sm="6" md="4">
|
||||
<v-card variant="outlined" hover class="provider-card" @click="selectProviderTemplate(name)">
|
||||
<div class="provider-card-content">
|
||||
<div class="provider-card-text">
|
||||
<v-card-title class="provider-card-title">接入 {{ name }}</v-card-title>
|
||||
<v-card-text class="text-caption text-medium-emphasis provider-card-description">
|
||||
{{ getProviderDescription(template, name) }}
|
||||
</v-card-text>
|
||||
</div>
|
||||
<div class="provider-card-logo">
|
||||
<img :src="getProviderIcon(template.provider)" v-if="getProviderIcon(template.provider)" class="provider-logo-img">
|
||||
<div v-else class="provider-logo-fallback">
|
||||
{{ name[0].toUpperCase() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col v-if="Object.keys(getTemplatesByType(tabType)).length === 0" cols="12">
|
||||
<v-alert type="info" variant="tonal">
|
||||
{{ tm('dialogs.addProvider.noTemplates', { type: getTabTypeName(tabType) }) }}
|
||||
</v-alert>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-window-item>
|
||||
</v-window>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
<AddNewProvider
|
||||
v-model:show="showAddProviderDialog"
|
||||
:metadata="metadata"
|
||||
@select-template="selectProviderTemplate"
|
||||
/>
|
||||
|
||||
<!-- 配置对话框 -->
|
||||
<v-dialog v-model="showProviderCfg" width="900" persistent>
|
||||
<v-card>
|
||||
<v-card-title class="bg-primary text-white py-3">
|
||||
<v-icon color="white" class="me-2">{{ updatingMode ? 'mdi-pencil' : 'mdi-plus' }}</v-icon>
|
||||
<span>{{ updatingMode ? tm('dialogs.config.editTitle') : tm('dialogs.config.addTitle') }} {{ newSelectedProviderName }} {{ tm('dialogs.config.provider') }}</span>
|
||||
</v-card-title>
|
||||
|
||||
<v-card :title="updatingMode ? tm('dialogs.config.editTitle') : tm('dialogs.config.addTitle') + ` ${newSelectedProviderName} ` + tm('dialogs.config.provider')">
|
||||
<v-card-text class="py-4">
|
||||
<AstrBotConfig
|
||||
:iterable="newSelectedProviderConfig"
|
||||
@@ -307,7 +238,9 @@ import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
|
||||
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
|
||||
import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
|
||||
import ItemCard from '@/components/shared/ItemCard.vue';
|
||||
import AddNewProvider from '@/components/provider/AddNewProvider.vue';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import { getProviderIcon } from '@/utils/providerUtils';
|
||||
|
||||
export default {
|
||||
name: 'ProviderPage',
|
||||
@@ -315,7 +248,8 @@ export default {
|
||||
AstrBotConfig,
|
||||
WaitingForRestart,
|
||||
ConsoleDisplayer,
|
||||
ItemCard
|
||||
ItemCard,
|
||||
AddNewProvider
|
||||
},
|
||||
setup() {
|
||||
const { tm } = useModuleI18n('features/provider');
|
||||
@@ -348,17 +282,16 @@ export default {
|
||||
save_message_success: "success",
|
||||
|
||||
showConsole: false,
|
||||
|
||||
|
||||
// 显示状态部分
|
||||
showStatus: false,
|
||||
|
||||
|
||||
// 供应商状态相关
|
||||
providerStatuses: [],
|
||||
loadingStatus: false,
|
||||
|
||||
// 新增提供商对话框相关
|
||||
showAddProviderDialog: false,
|
||||
activeProviderTab: 'chat_completion',
|
||||
|
||||
// 添加提供商类型分类
|
||||
activeProviderTypeTab: 'all',
|
||||
@@ -437,7 +370,7 @@ export default {
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
// 根据选择的标签过滤提供商列表
|
||||
filteredProviders() {
|
||||
if (!this.config_data.provider || this.activeProviderTypeTab === 'all') {
|
||||
@@ -449,7 +382,7 @@ export default {
|
||||
if (provider.provider_type) {
|
||||
return provider.provider_type === this.activeProviderTypeTab;
|
||||
}
|
||||
|
||||
|
||||
// 否则使用映射关系
|
||||
const mappedType = this.oldVersionProviderTypeMapping[provider.type];
|
||||
return mappedType === this.activeProviderTypeTab;
|
||||
@@ -472,6 +405,9 @@ export default {
|
||||
});
|
||||
},
|
||||
|
||||
// 从工具函数导入
|
||||
getProviderIcon,
|
||||
|
||||
// 获取空列表文本
|
||||
getEmptyText() {
|
||||
if (this.activeProviderTypeTab === 'all') {
|
||||
@@ -481,63 +417,11 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
// 按提供商类型获取模板列表
|
||||
getTemplatesByType(type) {
|
||||
const templates = this.metadata['provider_group']?.metadata?.provider?.config_template || {};
|
||||
const filtered = {};
|
||||
|
||||
for (const [name, template] of Object.entries(templates)) {
|
||||
if (template.provider_type === type) {
|
||||
filtered[name] = template;
|
||||
}
|
||||
}
|
||||
|
||||
return filtered;
|
||||
},
|
||||
|
||||
// 获取提供商类型对应的图标
|
||||
getProviderIcon(type) {
|
||||
const icons = {
|
||||
'openai': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/openai.svg',
|
||||
'azure': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/azure.svg',
|
||||
'xai': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/xai.svg',
|
||||
'anthropic': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/anthropic.svg',
|
||||
'ollama': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/ollama.svg',
|
||||
'google': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/gemini-color.svg',
|
||||
'deepseek': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/deepseek.svg',
|
||||
'modelscope': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/modelscope.svg',
|
||||
'zhipu': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/zhipu.svg',
|
||||
'siliconflow': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/siliconcloud.svg',
|
||||
'moonshot': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/kimi.svg',
|
||||
'ppio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/ppio.svg',
|
||||
'dify': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/dify-color.svg',
|
||||
'dashscope': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/alibabacloud-color.svg',
|
||||
'fastgpt': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/fastgpt-color.svg',
|
||||
'lm_studio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/lmstudio.svg',
|
||||
'fishaudio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/fishaudio.svg',
|
||||
'minimax': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/minimax.svg',
|
||||
'302ai': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/1.53.0/files/icons/ai302-color.svg',
|
||||
'microsoft': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/microsoft.svg',
|
||||
'vllm': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/vllm.svg',
|
||||
};
|
||||
return icons[type] || '';
|
||||
},
|
||||
|
||||
// 获取Tab类型的中文名称
|
||||
getTabTypeName(tabType) {
|
||||
return this.messages.tabTypes[tabType] || tabType;
|
||||
},
|
||||
|
||||
// 获取提供商简介
|
||||
getProviderDescription(template, name) {
|
||||
if (name == 'OpenAI') {
|
||||
return this.tm('providers.description.openai', { type: template.type });
|
||||
} else if (name == 'vLLM Rerank') {
|
||||
return this.tm('providers.description.vllm_rerank', { type: template.type });
|
||||
}
|
||||
return this.tm('providers.description.default', { type: template.type });
|
||||
},
|
||||
|
||||
// 选择提供商模板
|
||||
selectProviderTemplate(name) {
|
||||
this.newSelectedProviderName = name;
|
||||
@@ -546,7 +430,6 @@ export default {
|
||||
this.newSelectedProviderConfig = JSON.parse(JSON.stringify(
|
||||
this.metadata['provider_group']?.metadata?.provider?.config_template[name] || {}
|
||||
));
|
||||
this.showAddProviderDialog = false;
|
||||
},
|
||||
|
||||
configExistingProvider(provider) {
|
||||
@@ -657,6 +540,40 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
async copyProvider(providerToCopy) {
|
||||
console.log('copyProvider triggered for:', providerToCopy);
|
||||
// 1. 创建深拷贝
|
||||
const newProviderConfig = JSON.parse(JSON.stringify(providerToCopy));
|
||||
|
||||
// 2. 生成唯一的 ID
|
||||
const generateUniqueId = (baseId) => {
|
||||
let newId = `${baseId}_copy`;
|
||||
let counter = 1;
|
||||
const existingIds = this.config_data.provider.map(p => p.id);
|
||||
while (existingIds.includes(newId)) {
|
||||
newId = `${baseId}_copy_${counter}`;
|
||||
counter++;
|
||||
}
|
||||
return newId;
|
||||
};
|
||||
newProviderConfig.id = generateUniqueId(providerToCopy.id);
|
||||
|
||||
// 3. 设置为禁用状态,等待用户手动开启
|
||||
newProviderConfig.enable = false;
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
// 4. 调用后端接口创建
|
||||
const res = await axios.post('/api/config/provider/new', newProviderConfig);
|
||||
this.showSuccess(res.data.message || `成功复制并创建了 ${newProviderConfig.id}`);
|
||||
this.getConfig(); // 5. 刷新列表
|
||||
} catch (err) {
|
||||
this.showError(err.response?.data?.message || err.message);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
deleteProvider(provider) {
|
||||
if (confirm(this.tm('messages.confirm.delete', { id: provider.id }))) {
|
||||
axios.post('/api/config/provider/delete', { id: provider.id }).then((res) => {
|
||||
@@ -694,14 +611,14 @@ export default {
|
||||
this.save_message_success = "error";
|
||||
this.save_message_snack = true;
|
||||
},
|
||||
|
||||
|
||||
// 获取供应商状态
|
||||
async fetchProviderStatus() {
|
||||
if (this.loadingStatus) return;
|
||||
|
||||
this.loadingStatus = true;
|
||||
this.showStatus = true; // 自动展开状态部分
|
||||
|
||||
|
||||
// 1. 立即初始化UI为pending状态
|
||||
this.providerStatuses = this.config_data.provider.map(p => ({
|
||||
id: p.id,
|
||||
@@ -712,6 +629,19 @@ export default {
|
||||
|
||||
// 2. 为每个provider创建一个并发的测试请求
|
||||
const promises = this.config_data.provider.map(p => {
|
||||
if (!p.enable) {
|
||||
const index = this.providerStatuses.findIndex(s => s.id === p.id);
|
||||
if (index !== -1) {
|
||||
const disabledStatus = {
|
||||
...this.providerStatuses[index],
|
||||
status: 'unavailable',
|
||||
error: '该提供商未被用户启用'
|
||||
};
|
||||
this.providerStatuses.splice(index, 1, disabledStatus);
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return axios.get(`/api/config/provider/check_one?id=${p.id}`)
|
||||
.then(res => {
|
||||
if (res.data && res.data.status === 'ok') {
|
||||
@@ -805,86 +735,8 @@ export default {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.provider-card {
|
||||
transition: all 0.3s ease;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.provider-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 25px 0 rgba(0, 0, 0, 0.05);
|
||||
border-color: var(--v-primary-base);
|
||||
}
|
||||
|
||||
.provider-card-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100px;
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.provider-card-text {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.provider-card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.provider-card-description {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.provider-card-logo {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.provider-logo-img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
opacity: 0.6;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.provider-logo-fallback {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--v-primary-base);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.v-tabs {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.v-window {
|
||||
border-radius: 4px;
|
||||
.status-card {
|
||||
height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user