Compare commits

..

23 Commits

Author SHA1 Message Date
Soulter
c76b7ec387 Merge remote-tracking branch 'origin/master' into feat/memory 2025-11-21 20:23:41 +08:00
Soulter
b7f3010d72 stage simple webui 2025-11-21 17:59:22 +08:00
Soulter
fbbaf1cd08 delete(memory): remove memory module and its components 2025-11-21 17:34:33 +08:00
Soulter
9c8025acce stage 2025-11-21 17:25:55 +08:00
Soulter
98c5466b5d feat(chat): refactor chat component structure and add new features (#3701)
- Introduced `ConversationSidebar.vue` for improved conversation management and sidebar functionality.
- Enhanced `MessageList.vue` to handle loading states and improved message rendering.
- Created new composables: `useConversations`, `useMessages`, `useMediaHandling`, `useRecording` for better code organization and reusability.
- Added loading indicators and improved user experience during message processing.
- Ensured backward compatibility and maintained existing functionalities.
2025-11-20 17:30:51 +08:00
Soulter
6345ac6ff8 feat(chat): refactor chat component structure and add new features (#3701)
- Introduced `ConversationSidebar.vue` for improved conversation management and sidebar functionality.
- Enhanced `MessageList.vue` to handle loading states and improved message rendering.
- Created new composables: `useConversations`, `useMessages`, `useMediaHandling`, `useRecording` for better code organization and reusability.
- Added loading indicators and improved user experience during message processing.
- Ensured backward compatibility and maintained existing functionalities.
2025-11-20 17:29:27 +08:00
Soulter
5bcd683012 delete: remove useConversations composable 2025-11-20 17:29:27 +08:00
Soulter
eaa193c6c5 feat(chat): refactor chat component structure and add new features (#3701)
- Introduced `ConversationSidebar.vue` for improved conversation management and sidebar functionality.
- Enhanced `MessageList.vue` to handle loading states and improved message rendering.
- Created new composables: `useConversations`, `useMessages`, `useMediaHandling`, `useRecording` for better code organization and reusability.
- Added loading indicators and improved user experience during message processing.
- Ensured backward compatibility and maintained existing functionalities.
2025-11-20 17:29:27 +08:00
Soulter
1bdcaa1318 delete: useConversations 2025-11-20 17:29:27 +08:00
Soulter
6b6c48354d feat(chat): refactor chat component structure and add new features (#3701)
- Introduced `ConversationSidebar.vue` for improved conversation management and sidebar functionality.
- Enhanced `MessageList.vue` to handle loading states and improved message rendering.
- Created new composables: `useConversations`, `useMessages`, `useMediaHandling`, `useRecording` for better code organization and reusability.
- Added loading indicators and improved user experience during message processing.
- Ensured backward compatibility and maintained existing functionalities.
2025-11-20 17:29:27 +08:00
Soulter
774efb2fe0 refactor: update timestamp handling in session management and chat components 2025-11-20 17:29:27 +08:00
Soulter
3ec76636f9 refactor(sqlite): remove auto-generation of session_id in insert method 2025-11-20 17:29:26 +08:00
Soulter
283810d103 feat(chat): refactor chat component structure and add new features (#3701)
- Introduced `ConversationSidebar.vue` for improved conversation management and sidebar functionality.
- Enhanced `MessageList.vue` to handle loading states and improved message rendering.
- Created new composables: `useConversations`, `useMessages`, `useMediaHandling`, `useRecording` for better code organization and reusability.
- Added loading indicators and improved user experience during message processing.
- Ensured backward compatibility and maintained existing functionalities.
2025-11-20 17:29:26 +08:00
Soulter
81a76bc8e5 fix: anyio.ClosedResourceError when calling mcp tools (#3700)
* fix: anyio.ClosedResourceError when calling mcp tools

added reconnect mechanism

fixes: 3676

* fix(mcp_client): implement thread-safe reconnection using asyncio.Lock
2025-11-20 17:29:26 +08:00
Soulter
788764be02 refactor: implement migration for WebChat sessions by creating PlatformSession records from platform_message_history 2025-11-20 17:29:26 +08:00
Soulter
802ab26934 refactor: update session handling by replacing conversation_id with session_id in chat routes and components 2025-11-20 17:29:26 +08:00
Soulter
6857c81a14 refactor: enhance PlatformSession migration by adding display_name from Conversations and improve session item styling 2025-11-20 17:29:26 +08:00
Soulter
a6ed511a30 refactor: update message history deletion logic to remove newer records based on offset 2025-11-20 17:29:26 +08:00
Soulter
44c2b58206 refactor: optimize WebChat session migration by batch inserting records 2025-11-20 17:29:26 +08:00
Soulter
0e2adab3fd refactor: change to platform session 2025-11-20 17:29:26 +08:00
Soulter
0fe87d6b98 fix: restore migration check for version 4.7 2025-11-20 17:29:26 +08:00
Soulter
31ef3d1084 refactor: Implement WebChat session management and migration from version 4.6 to 4.7
- Added WebChatSession model for managing user sessions.
- Introduced methods for creating, retrieving, updating, and deleting WebChat sessions in the database.
- Updated core lifecycle to include migration from version 4.6 to 4.7, creating WebChat sessions from existing platform message history.
- Refactored chat routes to support new session-based architecture, replacing conversation-related endpoints with session endpoints.
- Updated frontend components to handle sessions instead of conversations, including session creation and management.
2025-11-20 17:29:26 +08:00
Soulter
b984bb2513 stage 2025-11-20 13:51:53 +08:00
124 changed files with 6354 additions and 6576 deletions

View File

@@ -13,7 +13,7 @@ jobs:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Dashboard Build
run: |
@@ -70,7 +70,7 @@ jobs:
needs: build-and-publish-to-github-release
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v6

View File

@@ -12,7 +12,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v6

View File

@@ -56,7 +56,7 @@ jobs:
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL

View File

@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
fetch-depth: 0

View File

@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -20,7 +20,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
fetch-depth: 1
fetch-tag: true
@@ -118,7 +118,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
fetch-depth: 1
fetch-tag: true

3
.gitignore vendored
View File

@@ -34,7 +34,6 @@ dashboard/node_modules/
dashboard/dist/
package-lock.json
package.json
yarn.lock
# Operating System
**/.DS_Store
@@ -48,5 +47,3 @@ astrbot.lock
chroma
venv/*
pytest.ini
AGENTS.md
IFLOW.md

View File

@@ -32,7 +32,7 @@
<a href="https://github.com/AstrBotDevs/AstrBot/issues">问题提交</a>
</div>
AstrBot 是一个开源的一站式 Agent 聊天机器人平台,可无缝接入主流即时通讯软件,为个人、开发者和团队打造可靠、可扩展的对话式智能基础设施。无论是个人 AI 伙伴、智能客服、自动化助手还是企业知识库AstrBot 都能在你的即时通讯软件平台的工作流中快速构建生产可用的 AI 应用
AstrBot 是一个开源的一站式 Agent 聊天机器人平台及开发框架
## 主要功能

View File

@@ -1 +1 @@
__version__ = "4.7.4"
__version__ = "3.5.23"

View File

@@ -345,6 +345,9 @@ class MCPClient:
async def cleanup(self):
"""Clean up resources including old exit stacks from reconnections"""
# Set running_event first to unblock any waiting tasks
self.running_event.set()
# Close current exit stack
try:
await self.exit_stack.aclose()
@@ -356,9 +359,6 @@ class MCPClient:
# Just clear the list to release references
self._old_exit_stacks.clear()
# Set running_event first to unblock any waiting tasks
self.running_event.set()
class MCPTool(FunctionTool, Generic[TContext]):
"""A function tool that calls an MCP service."""

View File

@@ -3,7 +3,7 @@
from typing import Any, ClassVar, Literal, cast
from pydantic import BaseModel, GetCoreSchemaHandler, model_validator
from pydantic import BaseModel, GetCoreSchemaHandler
from pydantic_core import core_schema
@@ -145,39 +145,22 @@ class Message(BaseModel):
"tool",
]
content: str | list[ContentPart] | None = None
content: str | list[ContentPart]
"""The content of the message."""
tool_calls: list[ToolCall] | list[dict] | None = None
"""The tool calls of the message."""
tool_call_id: str | None = None
"""The ID of the tool call."""
@model_validator(mode="after")
def check_content_required(self):
# assistant + tool_calls is not None: allow content to be None
if self.role == "assistant" and self.tool_calls is not None:
return self
# other all cases: content is required
if self.content is None:
raise ValueError(
"content is required unless role='assistant' and tool_calls is not None"
)
return self
class AssistantMessageSegment(Message):
"""A message segment from the assistant."""
role: Literal["assistant"] = "assistant"
tool_calls: list[ToolCall] | list[dict] | None = None
class ToolCallMessageSegment(Message):
"""A message segment representing a tool call."""
role: Literal["tool"] = "tool"
tool_call_id: str
class UserMessageSegment(Message):

View File

@@ -2,12 +2,13 @@ import abc
import typing as T
from enum import Enum, auto
from astrbot import logger
from astrbot.core.provider import Provider
from astrbot.core.provider.entities import LLMResponse
from ..hooks import BaseAgentRunHooks
from ..response import AgentResponse
from ..run_context import ContextWrapper, TContext
from ..tool_executor import BaseFunctionToolExecutor
class AgentState(Enum):
@@ -23,7 +24,9 @@ class BaseAgentRunner(T.Generic[TContext]):
@abc.abstractmethod
async def reset(
self,
provider: Provider,
run_context: ContextWrapper[TContext],
tool_executor: BaseFunctionToolExecutor[TContext],
agent_hooks: BaseAgentRunHooks[TContext],
**kwargs: T.Any,
) -> None:
@@ -57,9 +60,3 @@ class BaseAgentRunner(T.Generic[TContext]):
This method should be called after the agent is done.
"""
...
def _transition_state(self, new_state: AgentState) -> None:
"""Transition the agent state."""
if self._state != new_state:
logger.debug(f"Agent state transition: {self._state} -> {new_state}")
self._state = new_state

View File

@@ -1,367 +0,0 @@
import base64
import json
import sys
import typing as T
import astrbot.core.message.components as Comp
from astrbot import logger
from astrbot.core import sp
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import (
LLMResponse,
ProviderRequest,
)
from ...hooks import BaseAgentRunHooks
from ...response import AgentResponseData
from ...run_context import ContextWrapper, TContext
from ..base import AgentResponse, AgentState, BaseAgentRunner
from .coze_api_client import CozeAPIClient
if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import override
class CozeAgentRunner(BaseAgentRunner[TContext]):
"""Coze Agent Runner"""
@override
async def reset(
self,
request: ProviderRequest,
run_context: ContextWrapper[TContext],
agent_hooks: BaseAgentRunHooks[TContext],
provider_config: dict,
**kwargs: T.Any,
) -> None:
self.req = request
self.streaming = kwargs.get("streaming", False)
self.final_llm_resp = None
self._state = AgentState.IDLE
self.agent_hooks = agent_hooks
self.run_context = run_context
self.api_key = provider_config.get("coze_api_key", "")
if not self.api_key:
raise Exception("Coze API Key 不能为空。")
self.bot_id = provider_config.get("bot_id", "")
if not self.bot_id:
raise Exception("Coze Bot ID 不能为空。")
self.api_base: str = provider_config.get("coze_api_base", "https://api.coze.cn")
if not isinstance(self.api_base, str) or not self.api_base.startswith(
("http://", "https://"),
):
raise Exception(
"Coze API Base URL 格式不正确,必须以 http:// 或 https:// 开头。",
)
self.timeout = provider_config.get("timeout", 120)
if isinstance(self.timeout, str):
self.timeout = int(self.timeout)
self.auto_save_history = provider_config.get("auto_save_history", True)
# 创建 API 客户端
self.api_client = CozeAPIClient(api_key=self.api_key, api_base=self.api_base)
# 会话相关缓存
self.file_id_cache: dict[str, dict[str, str]] = {}
@override
async def step(self):
"""
执行 Coze Agent 的一个步骤
"""
if not self.req:
raise ValueError("Request is not set. Please call reset() first.")
if self._state == AgentState.IDLE:
try:
await self.agent_hooks.on_agent_begin(self.run_context)
except Exception as e:
logger.error(f"Error in on_agent_begin hook: {e}", exc_info=True)
# 开始处理,转换到运行状态
self._transition_state(AgentState.RUNNING)
try:
# 执行 Coze 请求并处理结果
async for response in self._execute_coze_request():
yield response
except Exception as e:
logger.error(f"Coze 请求失败:{str(e)}")
self._transition_state(AgentState.ERROR)
self.final_llm_resp = LLMResponse(
role="err", completion_text=f"Coze 请求失败:{str(e)}"
)
yield AgentResponse(
type="err",
data=AgentResponseData(
chain=MessageChain().message(f"Coze 请求失败:{str(e)}")
),
)
finally:
await self.api_client.close()
@override
async def step_until_done(
self, max_step: int = 30
) -> T.AsyncGenerator[AgentResponse, None]:
while not self.done():
async for resp in self.step():
yield resp
async def _execute_coze_request(self):
"""执行 Coze 请求的核心逻辑"""
prompt = self.req.prompt or ""
session_id = self.req.session_id or "unknown"
image_urls = self.req.image_urls or []
contexts = self.req.contexts or []
system_prompt = self.req.system_prompt
# 用户ID参数
user_id = session_id
# 获取或创建会话ID
conversation_id = await sp.get_async(
scope="umo",
scope_id=user_id,
key="coze_conversation_id",
default="",
)
# 构建消息
additional_messages = []
if system_prompt:
if not self.auto_save_history or not conversation_id:
additional_messages.append(
{
"role": "system",
"content": system_prompt,
"content_type": "text",
},
)
# 处理历史上下文
if not self.auto_save_history and contexts:
for ctx in contexts:
if isinstance(ctx, dict) and "role" in ctx and "content" in ctx:
# 处理上下文中的图片
content = ctx["content"]
if isinstance(content, list):
# 多模态内容,需要处理图片
processed_content = []
for item in content:
if isinstance(item, dict):
if item.get("type") == "text":
processed_content.append(item)
elif item.get("type") == "image_url":
# 处理图片上传
try:
image_data = item.get("image_url", {})
url = image_data.get("url", "")
if url:
file_id = (
await self._download_and_upload_image(
url, session_id
)
)
processed_content.append(
{
"type": "file",
"file_id": file_id,
"file_url": url,
}
)
except Exception as e:
logger.warning(f"处理上下文图片失败: {e}")
continue
if processed_content:
additional_messages.append(
{
"role": ctx["role"],
"content": processed_content,
"content_type": "object_string",
}
)
else:
# 纯文本内容
additional_messages.append(
{
"role": ctx["role"],
"content": content,
"content_type": "text",
}
)
# 构建当前消息
if prompt or image_urls:
if image_urls:
# 多模态
object_string_content = []
if prompt:
object_string_content.append({"type": "text", "text": prompt})
for url in image_urls:
# the url is a base64 string
try:
image_data = base64.b64decode(url)
file_id = await self.api_client.upload_file(image_data)
object_string_content.append(
{
"type": "image",
"file_id": file_id,
}
)
except Exception as e:
logger.warning(f"处理图片失败 {url}: {e}")
continue
if object_string_content:
content = json.dumps(object_string_content, ensure_ascii=False)
additional_messages.append(
{
"role": "user",
"content": content,
"content_type": "object_string",
}
)
elif prompt:
# 纯文本
additional_messages.append(
{
"role": "user",
"content": prompt,
"content_type": "text",
},
)
# 执行 Coze API 请求
accumulated_content = ""
message_started = False
async for chunk in self.api_client.chat_messages(
bot_id=self.bot_id,
user_id=user_id,
additional_messages=additional_messages,
conversation_id=conversation_id,
auto_save_history=self.auto_save_history,
stream=True,
timeout=self.timeout,
):
event_type = chunk.get("event")
data = chunk.get("data", {})
if event_type == "conversation.chat.created":
if isinstance(data, dict) and "conversation_id" in data:
await sp.put_async(
scope="umo",
scope_id=user_id,
key="coze_conversation_id",
value=data["conversation_id"],
)
if event_type == "conversation.message.delta":
# 增量消息
content = data.get("content", "")
if not content and "delta" in data:
content = data["delta"].get("content", "")
if not content and "text" in data:
content = data.get("text", "")
if content:
accumulated_content += content
message_started = True
# 如果是流式响应,发送增量数据
if self.streaming:
yield AgentResponse(
type="streaming_delta",
data=AgentResponseData(
chain=MessageChain().message(content)
),
)
elif event_type == "conversation.message.completed":
# 消息完成
logger.debug("Coze message completed")
message_started = True
elif event_type == "conversation.chat.completed":
# 对话完成
logger.debug("Coze chat completed")
break
elif event_type == "error":
# 错误处理
error_msg = data.get("msg", "未知错误")
error_code = data.get("code", "UNKNOWN")
logger.error(f"Coze 出现错误: {error_code} - {error_msg}")
raise Exception(f"Coze 出现错误: {error_code} - {error_msg}")
if not message_started and not accumulated_content:
logger.warning("Coze 未返回任何内容")
accumulated_content = ""
# 创建最终响应
chain = MessageChain(chain=[Comp.Plain(accumulated_content)])
self.final_llm_resp = LLMResponse(role="assistant", result_chain=chain)
self._transition_state(AgentState.DONE)
try:
await self.agent_hooks.on_agent_done(self.run_context, self.final_llm_resp)
except Exception as e:
logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
# 返回最终结果
yield AgentResponse(
type="llm_result",
data=AgentResponseData(chain=chain),
)
async def _download_and_upload_image(
self,
image_url: str,
session_id: str | None = None,
) -> str:
"""下载图片并上传到 Coze返回 file_id"""
import hashlib
# 计算哈希实现缓存
cache_key = hashlib.md5(image_url.encode("utf-8")).hexdigest()
if session_id:
if session_id not in self.file_id_cache:
self.file_id_cache[session_id] = {}
if cache_key in self.file_id_cache[session_id]:
file_id = self.file_id_cache[session_id][cache_key]
logger.debug(f"[Coze] 使用缓存的 file_id: {file_id}")
return file_id
try:
image_data = await self.api_client.download_image(image_url)
file_id = await self.api_client.upload_file(image_data)
if session_id:
self.file_id_cache[session_id][cache_key] = file_id
logger.debug(f"[Coze] 图片上传成功并缓存file_id: {file_id}")
return file_id
except Exception as e:
logger.error(f"处理图片失败 {image_url}: {e!s}")
raise Exception(f"处理图片失败: {e!s}")
@override
def done(self) -> bool:
"""检查 Agent 是否已完成工作"""
return self._state in (AgentState.DONE, AgentState.ERROR)
@override
def get_final_llm_resp(self) -> LLMResponse | None:
return self.final_llm_resp

View File

@@ -1,403 +0,0 @@
import asyncio
import functools
import queue
import re
import sys
import threading
import typing as T
from dashscope import Application
from dashscope.app.application_response import ApplicationResponse
import astrbot.core.message.components as Comp
from astrbot.core import logger, sp
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import (
LLMResponse,
ProviderRequest,
)
from ...hooks import BaseAgentRunHooks
from ...response import AgentResponseData
from ...run_context import ContextWrapper, TContext
from ..base import AgentResponse, AgentState, BaseAgentRunner
if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import override
class DashscopeAgentRunner(BaseAgentRunner[TContext]):
"""Dashscope Agent Runner"""
@override
async def reset(
self,
request: ProviderRequest,
run_context: ContextWrapper[TContext],
agent_hooks: BaseAgentRunHooks[TContext],
provider_config: dict,
**kwargs: T.Any,
) -> None:
self.req = request
self.streaming = kwargs.get("streaming", False)
self.final_llm_resp = None
self._state = AgentState.IDLE
self.agent_hooks = agent_hooks
self.run_context = run_context
self.api_key = provider_config.get("dashscope_api_key", "")
if not self.api_key:
raise Exception("阿里云百炼 API Key 不能为空。")
self.app_id = provider_config.get("dashscope_app_id", "")
if not self.app_id:
raise Exception("阿里云百炼 APP ID 不能为空。")
self.dashscope_app_type = provider_config.get("dashscope_app_type", "")
if not self.dashscope_app_type:
raise Exception("阿里云百炼 APP 类型不能为空。")
self.variables: dict = provider_config.get("variables", {}) or {}
self.rag_options: dict = provider_config.get("rag_options", {})
self.output_reference = self.rag_options.get("output_reference", False)
self.rag_options = self.rag_options.copy()
self.rag_options.pop("output_reference", None)
self.timeout = provider_config.get("timeout", 120)
if isinstance(self.timeout, str):
self.timeout = int(self.timeout)
def has_rag_options(self):
"""判断是否有 RAG 选项
Returns:
bool: 是否有 RAG 选项
"""
if self.rag_options and (
len(self.rag_options.get("pipeline_ids", [])) > 0
or len(self.rag_options.get("file_ids", [])) > 0
):
return True
return False
@override
async def step(self):
"""
执行 Dashscope Agent 的一个步骤
"""
if not self.req:
raise ValueError("Request is not set. Please call reset() first.")
if self._state == AgentState.IDLE:
try:
await self.agent_hooks.on_agent_begin(self.run_context)
except Exception as e:
logger.error(f"Error in on_agent_begin hook: {e}", exc_info=True)
# 开始处理,转换到运行状态
self._transition_state(AgentState.RUNNING)
try:
# 执行 Dashscope 请求并处理结果
async for response in self._execute_dashscope_request():
yield response
except Exception as e:
logger.error(f"阿里云百炼请求失败:{str(e)}")
self._transition_state(AgentState.ERROR)
self.final_llm_resp = LLMResponse(
role="err", completion_text=f"阿里云百炼请求失败:{str(e)}"
)
yield AgentResponse(
type="err",
data=AgentResponseData(
chain=MessageChain().message(f"阿里云百炼请求失败:{str(e)}")
),
)
@override
async def step_until_done(
self, max_step: int = 30
) -> T.AsyncGenerator[AgentResponse, None]:
while not self.done():
async for resp in self.step():
yield resp
def _consume_sync_generator(
self, response: T.Any, response_queue: queue.Queue
) -> None:
"""在线程中消费同步generator,将结果放入队列
Args:
response: 同步generator对象
response_queue: 用于传递数据的队列
"""
try:
if self.streaming:
for chunk in response:
response_queue.put(("data", chunk))
else:
response_queue.put(("data", response))
except Exception as e:
response_queue.put(("error", e))
finally:
response_queue.put(("done", None))
async def _process_stream_chunk(
self, chunk: ApplicationResponse, output_text: str
) -> tuple[str, list | None, AgentResponse | None]:
"""处理流式响应的单个chunk
Args:
chunk: Dashscope响应chunk
output_text: 当前累积的输出文本
Returns:
(更新后的output_text, doc_references, AgentResponse或None)
"""
logger.debug(f"dashscope stream chunk: {chunk}")
if chunk.status_code != 200:
logger.error(
f"阿里云百炼请求失败: request_id={chunk.request_id}, code={chunk.status_code}, message={chunk.message}, 请参考文档https://help.aliyun.com/zh/model-studio/developer-reference/error-code",
)
self._transition_state(AgentState.ERROR)
error_msg = (
f"阿里云百炼请求失败: message={chunk.message} code={chunk.status_code}"
)
self.final_llm_resp = LLMResponse(
role="err",
result_chain=MessageChain().message(error_msg),
)
return (
output_text,
None,
AgentResponse(
type="err",
data=AgentResponseData(chain=MessageChain().message(error_msg)),
),
)
chunk_text = chunk.output.get("text", "") or ""
# RAG 引用脚标格式化
chunk_text = re.sub(r"<ref>\[(\d+)\]</ref>", r"[\1]", chunk_text)
response = None
if chunk_text:
output_text += chunk_text
response = AgentResponse(
type="streaming_delta",
data=AgentResponseData(chain=MessageChain().message(chunk_text)),
)
# 获取文档引用
doc_references = chunk.output.get("doc_references", None)
return output_text, doc_references, response
def _format_doc_references(self, doc_references: list) -> str:
"""格式化文档引用为文本
Args:
doc_references: 文档引用列表
Returns:
格式化后的引用文本
"""
ref_parts = []
for ref in doc_references:
ref_title = (
ref.get("title", "") if ref.get("title") else ref.get("doc_name", "")
)
ref_parts.append(f"{ref['index_id']}. {ref_title}\n")
ref_str = "".join(ref_parts)
return f"\n\n回答来源:\n{ref_str}"
async def _build_request_payload(
self, prompt: str, session_id: str, contexts: list, system_prompt: str
) -> dict:
"""构建请求payload
Args:
prompt: 用户输入
session_id: 会话ID
contexts: 上下文列表
system_prompt: 系统提示词
Returns:
请求payload字典
"""
conversation_id = await sp.get_async(
scope="umo",
scope_id=session_id,
key="dashscope_conversation_id",
default="",
)
# 获得会话变量
payload_vars = self.variables.copy()
session_var = await sp.get_async(
scope="umo",
scope_id=session_id,
key="session_variables",
default={},
)
payload_vars.update(session_var)
if (
self.dashscope_app_type in ["agent", "dialog-workflow"]
and not self.has_rag_options()
):
# 支持多轮对话的
p = {
"app_id": self.app_id,
"api_key": self.api_key,
"prompt": prompt,
"biz_params": payload_vars or None,
"stream": self.streaming,
"incremental_output": True,
}
if conversation_id:
p["session_id"] = conversation_id
return p
else:
# 不支持多轮对话的
payload = {
"app_id": self.app_id,
"prompt": prompt,
"api_key": self.api_key,
"biz_params": payload_vars or None,
"stream": self.streaming,
"incremental_output": True,
}
if self.rag_options:
payload["rag_options"] = self.rag_options
return payload
async def _handle_streaming_response(
self, response: T.Any, session_id: str
) -> T.AsyncGenerator[AgentResponse, None]:
"""处理流式响应
Args:
response: Dashscope 流式响应 generator
Yields:
AgentResponse 对象
"""
response_queue = queue.Queue()
consumer_thread = threading.Thread(
target=self._consume_sync_generator,
args=(response, response_queue),
daemon=True,
)
consumer_thread.start()
output_text = ""
doc_references = None
while True:
try:
item_type, item_data = await asyncio.get_event_loop().run_in_executor(
None, response_queue.get, True, 1
)
except queue.Empty:
continue
if item_type == "done":
break
elif item_type == "error":
raise item_data
elif item_type == "data":
chunk = item_data
assert isinstance(chunk, ApplicationResponse)
(
output_text,
chunk_doc_refs,
response,
) = await self._process_stream_chunk(chunk, output_text)
if response:
if response.type == "err":
yield response
return
yield response
if chunk_doc_refs:
doc_references = chunk_doc_refs
if chunk.output.session_id:
await sp.put_async(
scope="umo",
scope_id=session_id,
key="dashscope_conversation_id",
value=chunk.output.session_id,
)
# 添加 RAG 引用
if self.output_reference and doc_references:
ref_text = self._format_doc_references(doc_references)
output_text += ref_text
if self.streaming:
yield AgentResponse(
type="streaming_delta",
data=AgentResponseData(chain=MessageChain().message(ref_text)),
)
# 创建最终响应
chain = MessageChain(chain=[Comp.Plain(output_text)])
self.final_llm_resp = LLMResponse(role="assistant", result_chain=chain)
self._transition_state(AgentState.DONE)
try:
await self.agent_hooks.on_agent_done(self.run_context, self.final_llm_resp)
except Exception as e:
logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
# 返回最终结果
yield AgentResponse(
type="llm_result",
data=AgentResponseData(chain=chain),
)
async def _execute_dashscope_request(self):
"""执行 Dashscope 请求的核心逻辑"""
prompt = self.req.prompt or ""
session_id = self.req.session_id or "unknown"
image_urls = self.req.image_urls or []
contexts = self.req.contexts or []
system_prompt = self.req.system_prompt
# 检查图片输入
if image_urls:
logger.warning("阿里云百炼暂不支持图片输入,将自动忽略图片内容。")
# 构建请求payload
payload = await self._build_request_payload(
prompt, session_id, contexts, system_prompt
)
if not self.streaming:
payload["incremental_output"] = False
# 发起请求
partial = functools.partial(Application.call, **payload)
response = await asyncio.get_event_loop().run_in_executor(None, partial)
async for resp in self._handle_streaming_response(response, session_id):
yield resp
@override
def done(self) -> bool:
"""检查 Agent 是否已完成工作"""
return self._state in (AgentState.DONE, AgentState.ERROR)
@override
def get_final_llm_resp(self) -> LLMResponse | None:
return self.final_llm_resp

View File

@@ -1,336 +0,0 @@
import base64
import os
import sys
import typing as T
import astrbot.core.message.components as Comp
from astrbot.core import logger, sp
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import (
LLMResponse,
ProviderRequest,
)
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.core.utils.io import download_file
from ...hooks import BaseAgentRunHooks
from ...response import AgentResponseData
from ...run_context import ContextWrapper, TContext
from ..base import AgentResponse, AgentState, BaseAgentRunner
from .dify_api_client import DifyAPIClient
if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import override
class DifyAgentRunner(BaseAgentRunner[TContext]):
"""Dify Agent Runner"""
@override
async def reset(
self,
request: ProviderRequest,
run_context: ContextWrapper[TContext],
agent_hooks: BaseAgentRunHooks[TContext],
provider_config: dict,
**kwargs: T.Any,
) -> None:
self.req = request
self.streaming = kwargs.get("streaming", False)
self.final_llm_resp = None
self._state = AgentState.IDLE
self.agent_hooks = agent_hooks
self.run_context = run_context
self.api_key = provider_config.get("dify_api_key", "")
self.api_base = provider_config.get("dify_api_base", "https://api.dify.ai/v1")
self.api_type = provider_config.get("dify_api_type", "chat")
self.workflow_output_key = provider_config.get(
"dify_workflow_output_key",
"astrbot_wf_output",
)
self.dify_query_input_key = provider_config.get(
"dify_query_input_key",
"astrbot_text_query",
)
self.variables: dict = provider_config.get("variables", {}) or {}
self.timeout = provider_config.get("timeout", 60)
if isinstance(self.timeout, str):
self.timeout = int(self.timeout)
self.api_client = DifyAPIClient(self.api_key, self.api_base)
@override
async def step(self):
"""
执行 Dify Agent 的一个步骤
"""
if not self.req:
raise ValueError("Request is not set. Please call reset() first.")
if self._state == AgentState.IDLE:
try:
await self.agent_hooks.on_agent_begin(self.run_context)
except Exception as e:
logger.error(f"Error in on_agent_begin hook: {e}", exc_info=True)
# 开始处理,转换到运行状态
self._transition_state(AgentState.RUNNING)
try:
# 执行 Dify 请求并处理结果
async for response in self._execute_dify_request():
yield response
except Exception as e:
logger.error(f"Dify 请求失败:{str(e)}")
self._transition_state(AgentState.ERROR)
self.final_llm_resp = LLMResponse(
role="err", completion_text=f"Dify 请求失败:{str(e)}"
)
yield AgentResponse(
type="err",
data=AgentResponseData(
chain=MessageChain().message(f"Dify 请求失败:{str(e)}")
),
)
finally:
await self.api_client.close()
@override
async def step_until_done(
self, max_step: int = 30
) -> T.AsyncGenerator[AgentResponse, None]:
while not self.done():
async for resp in self.step():
yield resp
async def _execute_dify_request(self):
"""执行 Dify 请求的核心逻辑"""
prompt = self.req.prompt or ""
session_id = self.req.session_id or "unknown"
image_urls = self.req.image_urls or []
system_prompt = self.req.system_prompt
conversation_id = await sp.get_async(
scope="umo",
scope_id=session_id,
key="dify_conversation_id",
default="",
)
result = ""
# 处理图片上传
files_payload = []
for image_url in image_urls:
# image_url is a base64 string
try:
image_data = base64.b64decode(image_url)
file_response = await self.api_client.file_upload(
file_data=image_data,
user=session_id,
mime_type="image/png",
file_name="image.png",
)
logger.debug(f"Dify 上传图片响应:{file_response}")
if "id" not in file_response:
logger.warning(
f"上传图片后得到未知的 Dify 响应:{file_response},图片将忽略。"
)
continue
files_payload.append(
{
"type": "image",
"transfer_method": "local_file",
"upload_file_id": file_response["id"],
}
)
except Exception as e:
logger.warning(f"上传图片失败:{e}")
continue
# 获得会话变量
payload_vars = self.variables.copy()
# 动态变量
session_var = await sp.get_async(
scope="umo",
scope_id=session_id,
key="session_variables",
default={},
)
payload_vars.update(session_var)
payload_vars["system_prompt"] = system_prompt
# 处理不同的 API 类型
match self.api_type:
case "chat" | "agent" | "chatflow":
if not prompt:
prompt = "请描述这张图片。"
async for chunk in self.api_client.chat_messages(
inputs={
**payload_vars,
},
query=prompt,
user=session_id,
conversation_id=conversation_id,
files=files_payload,
timeout=self.timeout,
):
logger.debug(f"dify resp chunk: {chunk}")
if chunk["event"] == "message" or chunk["event"] == "agent_message":
result += chunk["answer"]
if not conversation_id:
await sp.put_async(
scope="umo",
scope_id=session_id,
key="dify_conversation_id",
value=chunk["conversation_id"],
)
conversation_id = chunk["conversation_id"]
# 如果是流式响应,发送增量数据
if self.streaming and chunk["answer"]:
yield AgentResponse(
type="streaming_delta",
data=AgentResponseData(
chain=MessageChain().message(chunk["answer"])
),
)
elif chunk["event"] == "message_end":
logger.debug("Dify message end")
break
elif chunk["event"] == "error":
logger.error(f"Dify 出现错误:{chunk}")
raise Exception(
f"Dify 出现错误 status: {chunk['status']} message: {chunk['message']}"
)
case "workflow":
async for chunk in self.api_client.workflow_run(
inputs={
self.dify_query_input_key: prompt,
"astrbot_session_id": session_id,
**payload_vars,
},
user=session_id,
files=files_payload,
timeout=self.timeout,
):
logger.debug(f"dify workflow resp chunk: {chunk}")
match chunk["event"]:
case "workflow_started":
logger.info(
f"Dify 工作流(ID: {chunk['workflow_run_id']})开始运行。"
)
case "node_finished":
logger.debug(
f"Dify 工作流节点(ID: {chunk['data']['node_id']} Title: {chunk['data'].get('title', '')})运行结束。"
)
case "text_chunk":
if self.streaming and chunk["data"]["text"]:
yield AgentResponse(
type="streaming_delta",
data=AgentResponseData(
chain=MessageChain().message(
chunk["data"]["text"]
)
),
)
case "workflow_finished":
logger.info(
f"Dify 工作流(ID: {chunk['workflow_run_id']})运行结束"
)
logger.debug(f"Dify 工作流结果:{chunk}")
if chunk["data"]["error"]:
logger.error(
f"Dify 工作流出现错误:{chunk['data']['error']}"
)
raise Exception(
f"Dify 工作流出现错误:{chunk['data']['error']}"
)
if self.workflow_output_key not in chunk["data"]["outputs"]:
raise Exception(
f"Dify 工作流的输出不包含指定的键名:{self.workflow_output_key}"
)
result = chunk
case _:
raise Exception(f"未知的 Dify API 类型:{self.api_type}")
if not result:
logger.warning("Dify 请求结果为空,请查看 Debug 日志。")
# 解析结果
chain = await self.parse_dify_result(result)
# 创建最终响应
self.final_llm_resp = LLMResponse(role="assistant", result_chain=chain)
self._transition_state(AgentState.DONE)
try:
await self.agent_hooks.on_agent_done(self.run_context, self.final_llm_resp)
except Exception as e:
logger.error(f"Error in on_agent_done hook: {e}", exc_info=True)
# 返回最终结果
yield AgentResponse(
type="llm_result",
data=AgentResponseData(chain=chain),
)
async def parse_dify_result(self, chunk: dict | str) -> MessageChain:
"""解析 Dify 的响应结果"""
if isinstance(chunk, str):
# Chat
return MessageChain(chain=[Comp.Plain(chunk)])
async def parse_file(item: dict):
match item["type"]:
case "image":
return Comp.Image(file=item["url"], url=item["url"])
case "audio":
# 仅支持 wav
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
path = os.path.join(temp_dir, f"{item['filename']}.wav")
await download_file(item["url"], path)
return Comp.Image(file=item["url"], url=item["url"])
case "video":
return Comp.Video(file=item["url"])
case _:
return Comp.File(name=item["filename"], file=item["url"])
output = chunk["data"]["outputs"][self.workflow_output_key]
chains = []
if isinstance(output, str):
# 纯文本输出
chains.append(Comp.Plain(output))
elif isinstance(output, list):
# 主要适配 Dify 的 HTTP 请求结点的多模态输出
for item in output:
# handle Array[File]
if (
not isinstance(item, dict)
or item.get("dify_model_identity", "") != "__dify__file__"
):
chains.append(Comp.Plain(str(output)))
break
else:
chains.append(Comp.Plain(str(output)))
# scan file
files = chunk["data"].get("files", [])
for item in files:
comp = await parse_file(item)
chains.append(comp)
return MessageChain(chain=chains)
@override
def done(self) -> bool:
"""检查 Agent 是否已完成工作"""
return self._state in (AgentState.DONE, AgentState.ERROR)
@override
def get_final_llm_resp(self) -> LLMResponse | None:
return self.final_llm_resp

View File

@@ -69,6 +69,12 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
)
self.run_context.messages = messages
def _transition_state(self, new_state: AgentState) -> None:
"""转换 Agent 状态"""
if self._state != new_state:
logger.debug(f"Agent state transition: {self._state} -> {new_state}")
self._state = new_state
async def _iter_llm_responses(self) -> T.AsyncGenerator[LLMResponse, None]:
"""Yields chunks *and* a final LLMResponse."""
if self.streaming:

View File

@@ -4,7 +4,7 @@ import os
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.7.4"
VERSION = "4.6.0"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
# 默认配置
@@ -68,19 +68,9 @@ DEFAULT_CONFIG = {
"dequeue_context_length": 1,
"streaming_response": False,
"show_tool_use_status": False,
"agent_runner_type": "local",
"dify_agent_runner_provider_id": "",
"coze_agent_runner_provider_id": "",
"dashscope_agent_runner_provider_id": "",
"unsupported_streaming_strategy": "realtime_segmenting",
"reachability_check": False,
"max_agent_step": 30,
"tool_call_timeout": 60,
"file_extract": {
"enable": False,
"provider": "moonshotai",
"moonshotai_api_key": "",
},
},
"provider_stt_settings": {
"enable": False,
@@ -96,7 +86,6 @@ DEFAULT_CONFIG = {
"group_icl_enable": False,
"group_message_max_cnt": 300,
"image_caption": False,
"image_caption_provider_id": "",
"active_reply": {
"enable": False,
"method": "possibility_reply",
@@ -152,16 +141,7 @@ DEFAULT_CONFIG = {
}
"""
AstrBot v3 时代的配置元数据,目前仅承担以下功能:
1. 保存配置时,配置项的类型验证
2. WebUI 展示提供商和平台适配器模版
WebUI 的配置文件在 `CONFIG_METADATA_3` 中。
未来将会逐步淘汰此配置元数据。
"""
# 配置项的中文描述、值类型
CONFIG_METADATA_2 = {
"platform_group": {
"metadata": {
@@ -654,7 +634,7 @@ CONFIG_METADATA_2 = {
},
"words_count_threshold": {
"type": "int",
"hint": "分段回复的字数上限。只有字数小于此值的消息会被分段,超过此值的长消息将直接发送(不分段)。默认为 150",
"hint": "超过这个字数的消息会被分段回复。默认为 150",
},
"regex": {
"type": "string",
@@ -1031,7 +1011,7 @@ CONFIG_METADATA_2 = {
"id": "dify_app_default",
"provider": "dify",
"type": "dify",
"provider_type": "agent_runner",
"provider_type": "chat_completion",
"enable": True,
"dify_api_type": "chat",
"dify_api_key": "",
@@ -1045,20 +1025,20 @@ CONFIG_METADATA_2 = {
"Coze": {
"id": "coze",
"provider": "coze",
"provider_type": "agent_runner",
"provider_type": "chat_completion",
"type": "coze",
"enable": True,
"coze_api_key": "",
"bot_id": "",
"coze_api_base": "https://api.coze.cn",
"timeout": 60,
# "auto_save_history": True,
"auto_save_history": True,
},
"阿里云百炼应用": {
"id": "dashscope",
"provider": "dashscope",
"type": "dashscope",
"provider_type": "agent_runner",
"provider_type": "chat_completion",
"enable": True,
"dashscope_app_type": "agent",
"dashscope_api_key": "",
@@ -1107,7 +1087,7 @@ CONFIG_METADATA_2 = {
"api_base": "",
"model": "whisper-1",
},
"Whisper(Local)": {
"Whisper(本地加载)": {
"hint": "启用前请 pip 安装 openai-whisper 库N卡用户大约下载 2GB主要是 torch 和 cudaCPU 用户大约下载 1 GB并且安装 ffmpeg。否则将无法正常转文字。",
"provider": "openai",
"type": "openai_whisper_selfhost",
@@ -1116,7 +1096,7 @@ CONFIG_METADATA_2 = {
"id": "whisper_selfhost",
"model": "tiny",
},
"SenseVoice(Local)": {
"SenseVoice(本地加载)": {
"hint": "启用前请 pip 安装 funasr、funasr_onnx、torchaudio、torch、modelscope、jieba 库默认使用CPU大约下载 1 GB并且安装 ffmpeg。否则将无法正常转文字。",
"type": "sensevoice_stt_selfhost",
"provider": "sensevoice",
@@ -1151,7 +1131,7 @@ CONFIG_METADATA_2 = {
"pitch": "+0Hz",
"timeout": 20,
},
"GSV TTS(Local)": {
"GSV TTS(本地加载)": {
"id": "gsv_tts",
"enable": False,
"provider": "gpt_sovits",
@@ -1328,19 +1308,6 @@ CONFIG_METADATA_2 = {
"timeout": 20,
"launch_model_if_not_running": False,
},
"阿里云百炼重排序": {
"id": "bailian_rerank",
"type": "bailian_rerank",
"provider": "bailian",
"provider_type": "rerank",
"enable": True,
"rerank_api_key": "",
"rerank_api_base": "https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank",
"rerank_model": "qwen3-rerank",
"timeout": 30,
"return_documents": False,
"instruct": "",
},
"Xinference STT": {
"id": "xinference_stt",
"type": "xinference_stt",
@@ -1375,16 +1342,6 @@ CONFIG_METADATA_2 = {
"description": "重排序模型名称",
"type": "string",
},
"return_documents": {
"description": "是否在排序结果中返回文档原文",
"type": "bool",
"hint": "默认值false以减少网络传输开销。",
},
"instruct": {
"description": "自定义排序任务类型说明",
"type": "string",
"hint": "仅在使用 qwen3-rerank 模型时生效。建议使用英文撰写。",
},
"launch_model_if_not_running": {
"description": "模型未运行时自动启动",
"type": "bool",
@@ -1927,6 +1884,7 @@ CONFIG_METADATA_2 = {
"enable": {
"description": "启用",
"type": "bool",
"hint": "是否启用。",
},
"key": {
"description": "API Key",
@@ -2056,38 +2014,14 @@ CONFIG_METADATA_2 = {
"unsupported_streaming_strategy": {
"type": "string",
},
"agent_runner_type": {
"type": "string",
},
"dify_agent_runner_provider_id": {
"type": "string",
},
"coze_agent_runner_provider_id": {
"type": "string",
},
"dashscope_agent_runner_provider_id": {
"type": "string",
},
"max_agent_step": {
"description": "工具调用轮数上限",
"type": "int",
},
"tool_call_timeout": {
"description": "工具调用超时时间(秒)",
"type": "int",
},
"file_extract": {
"type": "object",
"items": {
"enable": {
"type": "bool",
},
"provider": {
"type": "string",
},
"moonshotai_api_key": {
"type": "string",
},
},
},
},
},
"provider_stt_settings": {
@@ -2130,9 +2064,6 @@ CONFIG_METADATA_2 = {
"image_caption": {
"type": "bool",
},
"image_caption_provider_id": {
"type": "string",
},
"image_caption_prompt": {
"type": "string",
},
@@ -2222,87 +2153,34 @@ CONFIG_METADATA_2 = {
}
"""
v4.7.0 之后name, description, hint 等字段已经实现 i18n 国际化。国际化资源文件位于:
- dashboard/src/i18n/locales/en-US/features/config-metadata.json
- dashboard/src/i18n/locales/zh-CN/features/config-metadata.json
如果在此文件中添加了新的配置字段,请务必同步更新上述两个国际化资源文件。
"""
CONFIG_METADATA_3 = {
"ai_group": {
"name": "AI 配置",
"metadata": {
"agent_runner": {
"description": "Agent 执行方式",
"hint": "选择 AI 对话的执行器,默认为 AstrBot 内置 Agent 执行器,可使用 AstrBot 内的知识库、人格、工具调用功能。如果不打算接入 Dify 或 Coze 等第三方 Agent 执行器,不需要修改此节。",
"ai": {
"description": "模型",
"type": "object",
"items": {
"provider_settings.enable": {
"description": "启用",
"description": "启用大语言模型聊天",
"type": "bool",
"hint": "AI 对话总开关",
},
"provider_settings.agent_runner_type": {
"description": "执行器",
"type": "string",
"options": ["local", "dify", "coze", "dashscope"],
"labels": ["内置 Agent", "Dify", "Coze", "阿里云百炼应用"],
"condition": {
"provider_settings.enable": True,
},
},
"provider_settings.coze_agent_runner_provider_id": {
"description": "Coze Agent 执行器提供商 ID",
"type": "string",
"_special": "select_agent_runner_provider:coze",
"condition": {
"provider_settings.agent_runner_type": "coze",
"provider_settings.enable": True,
},
},
"provider_settings.dify_agent_runner_provider_id": {
"description": "Dify Agent 执行器提供商 ID",
"type": "string",
"_special": "select_agent_runner_provider:dify",
"condition": {
"provider_settings.agent_runner_type": "dify",
"provider_settings.enable": True,
},
},
"provider_settings.dashscope_agent_runner_provider_id": {
"description": "阿里云百炼应用 Agent 执行器提供商 ID",
"type": "string",
"_special": "select_agent_runner_provider:dashscope",
"condition": {
"provider_settings.agent_runner_type": "dashscope",
"provider_settings.enable": True,
},
},
},
},
"ai": {
"description": "模型",
"hint": "当使用非内置 Agent 执行器时,默认聊天模型和默认图片转述模型可能会无效,但某些插件会依赖此配置项来调用 AI 能力。",
"type": "object",
"items": {
"provider_settings.default_provider_id": {
"description": "默认聊天模型",
"type": "string",
"_special": "select_provider",
"hint": "留空时使用第一个模型",
"hint": "留空时使用第一个模型",
},
"provider_settings.default_image_caption_provider_id": {
"description": "默认图片转述模型",
"type": "string",
"_special": "select_provider",
"hint": "留空代表不使用可用于非多模态模型",
"hint": "留空代表不使用可用于不支持视觉模态的聊天模型",
},
"provider_stt_settings.enable": {
"description": "启用语音转文本",
"type": "bool",
"hint": "STT 总开关",
"hint": "STT 总开关",
},
"provider_stt_settings.provider_id": {
"description": "默认语音转文本模型",
@@ -2316,11 +2194,12 @@ CONFIG_METADATA_3 = {
"provider_tts_settings.enable": {
"description": "启用文本转语音",
"type": "bool",
"hint": "TTS 总开关",
"hint": "TTS 总开关。当关闭时,会话启用 TTS 也不会生效。",
},
"provider_tts_settings.provider_id": {
"description": "默认文本转语音模型",
"type": "string",
"hint": "用户也可使用 /provider 单独选择会话的 TTS 模型。",
"_special": "select_provider_tts",
"condition": {
"provider_tts_settings.enable": True,
@@ -2331,9 +2210,6 @@ CONFIG_METADATA_3 = {
"type": "text",
},
},
"condition": {
"provider_settings.enable": True,
},
},
"persona": {
"description": "人格",
@@ -2345,10 +2221,6 @@ CONFIG_METADATA_3 = {
"_special": "select_persona",
},
},
"condition": {
"provider_settings.agent_runner_type": "local",
"provider_settings.enable": True,
},
},
"knowledgebase": {
"description": "知识库",
@@ -2377,10 +2249,6 @@ CONFIG_METADATA_3 = {
"hint": "启用后,知识库检索将作为 LLM Tool由模型自主决定何时调用知识库进行查询。需要模型支持函数调用能力。",
},
},
"condition": {
"provider_settings.agent_runner_type": "local",
"provider_settings.enable": True,
},
},
"websearch": {
"description": "网页搜索",
@@ -2417,41 +2285,7 @@ CONFIG_METADATA_3 = {
"type": "bool",
},
},
"condition": {
"provider_settings.agent_runner_type": "local",
"provider_settings.enable": True,
},
},
# "file_extract": {
# "description": "文档解析能力 [beta]",
# "type": "object",
# "items": {
# "provider_settings.file_extract.enable": {
# "description": "启用文档解析能力",
# "type": "bool",
# },
# "provider_settings.file_extract.provider": {
# "description": "文档解析提供商",
# "type": "string",
# "options": ["moonshotai"],
# "condition": {
# "provider_settings.file_extract.enable": True,
# },
# },
# "provider_settings.file_extract.moonshotai_api_key": {
# "description": "Moonshot AI API Key",
# "type": "string",
# "condition": {
# "provider_settings.file_extract.provider": "moonshotai",
# "provider_settings.file_extract.enable": True,
# },
# },
# },
# "condition": {
# "provider_settings.agent_runner_type": "local",
# "provider_settings.enable": True,
# },
# },
"others": {
"description": "其他配置",
"type": "object",
@@ -2459,51 +2293,34 @@ CONFIG_METADATA_3 = {
"provider_settings.display_reasoning_text": {
"description": "显示思考内容",
"type": "bool",
"condition": {
"provider_settings.agent_runner_type": "local",
},
},
"provider_settings.identifier": {
"description": "用户识别",
"type": "bool",
"hint": "启用后,会在提示词前包含用户 ID 信息。",
},
"provider_settings.group_name_display": {
"description": "显示群名称",
"type": "bool",
"hint": "启用后,在支持的平台(OneBot v11)上会在提示词前包含群名称信息。",
"hint": "启用后,在支持的平台(aiocqhttp)上会在 prompt 中包含群名称信息。",
},
"provider_settings.datetime_system_prompt": {
"description": "现实世界时间感知",
"type": "bool",
"hint": "启用后,会在系统提示词中附带当前时间信息。",
"condition": {
"provider_settings.agent_runner_type": "local",
},
},
"provider_settings.show_tool_use_status": {
"description": "输出函数调用状态",
"type": "bool",
"condition": {
"provider_settings.agent_runner_type": "local",
},
},
"provider_settings.max_agent_step": {
"description": "工具调用轮数上限",
"type": "int",
"condition": {
"provider_settings.agent_runner_type": "local",
},
},
"provider_settings.tool_call_timeout": {
"description": "工具调用超时时间(秒)",
"type": "int",
"condition": {
"provider_settings.agent_runner_type": "local",
},
},
"provider_settings.streaming_response": {
"description": "流式输出",
"description": "流式回复",
"type": "bool",
},
"provider_settings.unsupported_streaming_strategy": {
@@ -2519,23 +2336,17 @@ CONFIG_METADATA_3 = {
"provider_settings.max_context_length": {
"description": "最多携带对话轮数",
"type": "int",
"hint": "超出这个数量时丢弃最旧的部分,一轮聊天记为 1 条-1 为不限制",
"condition": {
"provider_settings.agent_runner_type": "local",
},
"hint": "超出这个数量时丢弃最旧的部分,一轮聊天记为 1 条-1 为不限制",
},
"provider_settings.dequeue_context_length": {
"description": "丢弃对话轮数",
"type": "int",
"hint": "超出最多携带对话轮数时, 一次丢弃的聊天轮数",
"condition": {
"provider_settings.agent_runner_type": "local",
},
"hint": "超出最多携带对话轮数时, 一次丢弃的聊天轮数",
},
"provider_settings.wake_prefix": {
"description": "LLM 聊天额外唤醒前缀 ",
"type": "string",
"hint": "如果唤醒前缀为 /, 额外聊天唤醒前缀为 chat则需要 /chat 才会触发 LLM 请求",
"hint": "如果唤醒前缀为 `/`, 额外聊天唤醒前缀为 `chat`,则需要 `/chat` 才会触发 LLM 请求。默认为空。",
},
"provider_settings.prompt_prefix": {
"description": "用户提示词",
@@ -2546,14 +2357,6 @@ CONFIG_METADATA_3 = {
"description": "开启 TTS 时同时输出语音和文字内容",
"type": "bool",
},
"provider_settings.reachability_check": {
"description": "提供商可达性检测",
"type": "bool",
"hint": "/provider 命令列出模型时是否并发检测连通性。开启后会主动调用模型测试连通性,可能产生额外 token 消耗。",
},
},
"condition": {
"provider_settings.enable": True,
},
},
},
@@ -2844,16 +2647,7 @@ CONFIG_METADATA_3 = {
"provider_ltm_settings.image_caption": {
"description": "自动理解图片",
"type": "bool",
"hint": "需要设置群聊图片转述模型。",
},
"provider_ltm_settings.image_caption_provider_id": {
"description": "群聊图片转述模型",
"type": "string",
"_special": "select_provider",
"hint": "用于群聊上下文感知的图片理解,与默认图片转述模型分开配置。",
"condition": {
"provider_ltm_settings.image_caption": True,
},
"hint": "需要设置默认图片转述模型。",
},
"provider_ltm_settings.active_reply.enable": {
"description": "主动回复",

View File

@@ -1,110 +0,0 @@
"""
配置元数据国际化工具
提供配置元数据的国际化键转换功能
"""
from typing import Any
class ConfigMetadataI18n:
"""配置元数据国际化转换器"""
@staticmethod
def _get_i18n_key(group: str, section: str, field: str, attr: str) -> str:
"""
生成国际化键
Args:
group: 配置组,如 'ai_group', 'platform_group'
section: 配置节,如 'agent_runner', 'general'
field: 字段名,如 'enable', 'default_provider'
attr: 属性类型,如 'description', 'hint', 'labels'
Returns:
国际化键,格式如: 'ai_group.agent_runner.enable.description'
"""
if field:
return f"{group}.{section}.{field}.{attr}"
else:
return f"{group}.{section}.{attr}"
@staticmethod
def convert_to_i18n_keys(metadata: dict[str, Any]) -> dict[str, Any]:
"""
将配置元数据转换为使用国际化键
Args:
metadata: 原始配置元数据字典
Returns:
使用国际化键的配置元数据字典
"""
result = {}
for group_key, group_data in metadata.items():
group_result = {
"name": f"{group_key}.name",
"metadata": {},
}
for section_key, section_data in group_data.get("metadata", {}).items():
section_result = {
"description": f"{group_key}.{section_key}.description",
"type": section_data.get("type"),
}
# 复制其他属性
for key in ["items", "condition", "_special", "invisible"]:
if key in section_data:
section_result[key] = section_data[key]
# 处理 hint
if "hint" in section_data:
section_result["hint"] = f"{group_key}.{section_key}.hint"
# 处理 items 中的字段
if "items" in section_data and isinstance(section_data["items"], dict):
items_result = {}
for field_key, field_data in section_data["items"].items():
# 处理嵌套的点号字段名(如 provider_settings.enable
field_name = field_key
field_result = {}
# 复制基本属性
for attr in [
"type",
"condition",
"_special",
"invisible",
"options",
]:
if attr in field_data:
field_result[attr] = field_data[attr]
# 转换文本属性为国际化键
if "description" in field_data:
field_result["description"] = (
f"{group_key}.{section_key}.{field_name}.description"
)
if "hint" in field_data:
field_result["hint"] = (
f"{group_key}.{section_key}.{field_name}.hint"
)
if "labels" in field_data:
field_result["labels"] = (
f"{group_key}.{section_key}.{field_name}.labels"
)
items_result[field_key] = field_result
section_result["items"] = items_result
group_result["metadata"][section_key] = section_result
result[group_key] = group_result
return result

View File

@@ -16,13 +16,15 @@ import time
import traceback
from asyncio import Queue
from astrbot.api import logger, sp
from astrbot.core import LogBroker
from astrbot.core import LogBroker, logger, sp
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
from astrbot.core.config.default import VERSION
from astrbot.core.conversation_mgr import ConversationManager
from astrbot.core.db import BaseDatabase
from astrbot.core.db.migration.migra_45_to_46 import migrate_45_to_46
from astrbot.core.db.migration.migra_webchat_session import migrate_webchat_session
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
from astrbot.core.memory.memory_manager import MemoryManager
from astrbot.core.persona_mgr import PersonaManager
from astrbot.core.pipeline.scheduler import PipelineContext, PipelineScheduler
from astrbot.core.platform.manager import PlatformManager
@@ -33,7 +35,6 @@ from astrbot.core.star.context import Context
from astrbot.core.star.star_handler import EventType, star_handlers_registry, star_map
from astrbot.core.umop_config_router import UmopConfigRouter
from astrbot.core.updator import AstrBotUpdator
from astrbot.core.utils.migra_helper import migra
from . import astrbot_config, html_renderer
from .event_bus import EventBus
@@ -97,16 +98,18 @@ class AstrBotCoreLifecycle:
sp=sp,
)
# apply migration
# 4.5 to 4.6 migration for umop_config_router
try:
await migra(
self.db,
self.astrbot_config_mgr,
self.umop_config_router,
self.astrbot_config_mgr,
)
await migrate_45_to_46(self.astrbot_config_mgr, self.umop_config_router)
except Exception as e:
logger.error(f"AstrBot migration failed: {e!s}")
logger.error(f"Migration from version 4.5 to 4.6 failed: {e!s}")
logger.error(traceback.format_exc())
# migration for webchat session
try:
await migrate_webchat_session(self.db)
except Exception as e:
logger.error(f"Migration for webchat session failed: {e!s}")
logger.error(traceback.format_exc())
# 初始化事件队列
@@ -134,6 +137,8 @@ class AstrBotCoreLifecycle:
# 初始化知识库管理器
self.kb_manager = KnowledgeBaseManager(self.provider_manager)
# 初始化记忆管理器
self.memory_manager = MemoryManager()
# 初始化提供给插件的上下文
self.star_context = Context(
@@ -147,6 +152,7 @@ class AstrBotCoreLifecycle:
self.persona_mgr,
self.astrbot_config_mgr,
self.kb_manager,
self.memory_manager,
)
# 初始化插件管理器

View File

@@ -25,7 +25,7 @@ async def migrate_webchat_session(db_helper: BaseDatabase):
"""
# 检查是否已经完成迁移
migration_done = await db_helper.get_preference(
"global", "global", "migration_done_webchat_session_1"
"global", "global", "migration_done_webchat_session"
)
if migration_done:
return
@@ -43,7 +43,7 @@ async def migrate_webchat_session(db_helper: BaseDatabase):
func.max(PlatformMessageHistory.updated_at).label("latest"),
)
.where(col(PlatformMessageHistory.platform_id) == "webchat")
.where(col(PlatformMessageHistory.sender_id) != "bot")
.where(col(PlatformMessageHistory.sender_id) == "astrbot")
.group_by(col(PlatformMessageHistory.user_id))
)
@@ -53,7 +53,7 @@ async def migrate_webchat_session(db_helper: BaseDatabase):
if not webchat_users:
logger.info("没有找到需要迁移的 WebChat 数据")
await sp.put_async(
"global", "global", "migration_done_webchat_session_1", True
"global", "global", "migration_done_webchat_session", True
)
return
@@ -124,7 +124,7 @@ async def migrate_webchat_session(db_helper: BaseDatabase):
logger.info("没有新会话需要迁移")
# 标记迁移完成
await sp.put_async("global", "global", "migration_done_webchat_session_1", True)
await sp.put_async("global", "global", "migration_done_webchat_session", True)
except Exception as e:
logger.error(f"迁移过程中发生错误: {e}", exc_info=True)

View File

@@ -173,7 +173,7 @@ class PlatformSession(SQLModel, table=True):
max_length=100,
nullable=False,
unique=True,
default_factory=lambda: str(uuid.uuid4()),
default_factory=lambda: f"webchat_{uuid.uuid4()}",
)
platform_id: str = Field(default="webchat", nullable=False)
"""Platform identifier (e.g., 'webchat', 'qq', 'discord')"""

View File

@@ -794,7 +794,7 @@ class SQLiteDatabase(BaseDatabase):
await session.execute(
update(PlatformSession)
.where(col(PlatformSession.session_id) == session_id)
.where(col(PlatformSession.session_id == session_id))
.values(**values),
)
@@ -805,6 +805,6 @@ class SQLiteDatabase(BaseDatabase):
async with session.begin():
await session.execute(
delete(PlatformSession).where(
col(PlatformSession.session_id) == session_id,
col(PlatformSession.session_id == session_id),
),
)

View File

@@ -1,11 +1,20 @@
import abc
from dataclasses import dataclass
from typing import TypedDict
@dataclass
class Result:
class ResultData(TypedDict):
id: str
doc_id: str
text: str
metadata: str
created_at: int
updated_at: int
similarity: float
data: dict
data: ResultData | dict
class BaseVecDB:

View File

@@ -0,0 +1,822 @@
{
"type": "excalidraw",
"version": 2,
"source": "https://marketplace.visualstudio.com/items?itemName=pomdtr.excalidraw-editor",
"elements": [
{
"id": "l6cYurMvF69IM4Kc33Qou",
"type": "rectangle",
"x": 173.140625,
"y": -29.0234375,
"width": 92.95703125,
"height": 77.109375,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a0",
"roundness": {
"type": 3
},
"seed": 1409469537,
"version": 91,
"versionNonce": 307958671,
"isDeleted": false,
"boundElements": [],
"updated": 1763703733605,
"link": null,
"locked": false
},
{
"id": "1ZvS6t8U6ihUjNU0dakgl",
"type": "arrow",
"x": 409.30859375,
"y": 9.6875,
"width": 118.2734375,
"height": 1.9609375,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a1",
"roundness": {
"type": 2
},
"seed": 326508865,
"version": 120,
"versionNonce": 199367023,
"isDeleted": false,
"boundElements": null,
"updated": 1763703733605,
"link": null,
"locked": false,
"points": [
[
0,
0
],
[
-118.2734375,
-1.9609375
]
],
"lastCommittedPoint": null,
"startBinding": null,
"endBinding": null,
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": false
},
{
"id": "tfdUGiJdcMoOHGfqFHXK6",
"type": "text",
"x": 153.46875,
"y": -70.9765625,
"width": 136.4598846435547,
"height": 25,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a2",
"roundness": null,
"seed": 688712865,
"version": 67,
"versionNonce": 300660705,
"isDeleted": false,
"boundElements": null,
"updated": 1763703743816,
"link": null,
"locked": false,
"text": "FAISS+SQLite",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "FAISS+SQLite",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "AeL3kEB9a8_TAvAXpAbpl",
"type": "text",
"x": 438.36328125,
"y": -3.78125,
"width": 116.109375,
"height": 25,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a3",
"roundness": null,
"seed": 788579535,
"version": 33,
"versionNonce": 946602095,
"isDeleted": false,
"boundElements": null,
"updated": 1763703932431,
"link": null,
"locked": false,
"text": "FACT",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "FACT",
"autoResize": false,
"lineHeight": 1.25
},
{
"id": "Pe3TeMZvxQ8tRTcbD5v6P",
"type": "arrow",
"x": 297.125,
"y": 40.2578125,
"width": 120.2421875,
"height": 1.421875,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a4",
"roundness": {
"type": 2
},
"seed": 1146229999,
"version": 44,
"versionNonce": 636917679,
"isDeleted": false,
"boundElements": null,
"updated": 1763703759050,
"link": null,
"locked": false,
"points": [
[
0,
0
],
[
120.2421875,
1.421875
]
],
"lastCommittedPoint": null,
"startBinding": null,
"endBinding": null,
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": false
},
{
"id": "GhmQoadtQRK8c8aEEbYKQ",
"type": "text",
"x": 283.53515625,
"y": 64.76171875,
"width": 130.85989379882812,
"height": 50,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a5",
"roundness": null,
"seed": 1445650959,
"version": 79,
"versionNonce": 566193167,
"isDeleted": false,
"boundElements": null,
"updated": 1763703768982,
"link": null,
"locked": false,
"text": "top-n Similary\n",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "top-n Similary\n",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "uTEFJs8cNS09WFq2pi9P7",
"type": "rectangle",
"x": 528.1586158430439,
"y": -173.43472375183552,
"width": 135.7578125,
"height": 128.73828125,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a6",
"roundness": {
"type": 3
},
"seed": 223409231,
"version": 44,
"versionNonce": 1066827105,
"isDeleted": false,
"boundElements": [
{
"id": "FfWdx1_yCq6UYfXamJX9N",
"type": "arrow"
}
],
"updated": 1763704050188,
"link": null,
"locked": false
},
{
"id": "2SzqzpJ4C2ymVj8-8vN7H",
"type": "text",
"x": 548.1480270948795,
"y": -211,
"width": 86.43992614746094,
"height": 25,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a7",
"roundness": null,
"seed": 1015608623,
"version": 23,
"versionNonce": 950374849,
"isDeleted": false,
"boundElements": null,
"updated": 1763704047884,
"link": null,
"locked": false,
"text": "Memories",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "Memories",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "CgW6Yf9v0a9q1tsjhDl7b",
"type": "text",
"x": 568.3099317299038,
"y": -154.69469411681115,
"width": 62.099945068359375,
"height": 25,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aA",
"roundness": null,
"seed": 452254927,
"version": 10,
"versionNonce": 972895023,
"isDeleted": false,
"boundElements": null,
"updated": 1763704057762,
"link": null,
"locked": false,
"text": "chunk1",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "chunk1",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "knvlKpaFZ8lY-73Y-e9W6",
"type": "text",
"x": 569.11328125,
"y": -116.91056665512056,
"width": 67.55995178222656,
"height": 25,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aB",
"roundness": null,
"seed": 914644015,
"version": 90,
"versionNonce": 158135631,
"isDeleted": false,
"boundElements": null,
"updated": 1763704057762,
"link": null,
"locked": false,
"text": "chunk2",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "chunk2",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "Q7URqvTSMpvj08ye-afTT",
"type": "rectangle",
"x": 444.515625,
"y": 36.7890625,
"width": 58.859375,
"height": 29.41796875,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aC",
"roundness": {
"type": 3
},
"seed": 1642537601,
"version": 19,
"versionNonce": 948406575,
"isDeleted": false,
"boundElements": null,
"updated": 1763703870173,
"link": null,
"locked": false
},
{
"id": "JjxBt9cZIZXNTd6CmwyKL",
"type": "rectangle",
"x": 452.203125,
"y": 46.064453125,
"width": 58.859375,
"height": 29.41796875,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aD",
"roundness": {
"type": 3
},
"seed": 1746916641,
"version": 40,
"versionNonce": 1650978255,
"isDeleted": false,
"boundElements": [],
"updated": 1763703871882,
"link": null,
"locked": false
},
{
"id": "XGBCPPFnjriqsL8LvLwyQ",
"type": "rectangle",
"x": 461.56640625,
"y": 56.162109375,
"width": 58.859375,
"height": 29.41796875,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aE",
"roundness": {
"type": 3
},
"seed": 529794575,
"version": 85,
"versionNonce": 2131900641,
"isDeleted": false,
"boundElements": [],
"updated": 1763703874182,
"link": null,
"locked": false
},
{
"id": "FfWdx1_yCq6UYfXamJX9N",
"type": "arrow",
"x": 537.6875,
"y": 48.203125,
"width": 6.615850226297994,
"height": 75.81335873223107,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aF",
"roundness": {
"type": 2
},
"seed": 1982870689,
"version": 90,
"versionNonce": 25307457,
"isDeleted": false,
"boundElements": null,
"updated": 1763704050188,
"link": null,
"locked": false,
"points": [
[
0,
0
],
[
6.615850226297994,
-75.81335873223107
]
],
"lastCommittedPoint": null,
"startBinding": null,
"endBinding": {
"elementId": "uTEFJs8cNS09WFq2pi9P7",
"focus": 0.6071885090336794,
"gap": 24.64453125
},
"startArrowhead": null,
"endArrowhead": "arrow",
"elbowed": false
},
{
"id": "jgJgqGMRWcaNX_28wY4CU",
"type": "text",
"x": 570,
"y": 10,
"width": 67.11994934082031,
"height": 25,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aG",
"roundness": null,
"seed": 1065220559,
"version": 26,
"versionNonce": 2115991521,
"isDeleted": false,
"boundElements": null,
"updated": 1763703959397,
"link": null,
"locked": false,
"text": "update",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "update",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "_5pSPPOpp9h1TpFCIc055",
"type": "text",
"x": 292.36328125,
"y": -138.5703125,
"width": 122.87992858886719,
"height": 25,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aH",
"roundness": null,
"seed": 51461025,
"version": 26,
"versionNonce": 1647492655,
"isDeleted": false,
"boundElements": null,
"updated": 1763703925147,
"link": null,
"locked": false,
"text": "ADD Memory",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "ADD Memory",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "YG6MdL14l7lk4ypQNMZ_k",
"type": "text",
"x": 296.71885397566257,
"y": 161.399157096715,
"width": 295.27984619140625,
"height": 25,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aJ",
"roundness": null,
"seed": 1183210273,
"version": 122,
"versionNonce": 1702733281,
"isDeleted": false,
"boundElements": [],
"updated": 1763704085083,
"link": null,
"locked": false,
"text": "RETRIEVE Memory (STATIC)",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "RETRIEVE Memory (STATIC)",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "Foa3VPJYqhj1uAX5mn3n0",
"type": "rectangle",
"x": 324.7616636099071,
"y": 248.63213980937013,
"width": 135.7578125,
"height": 128.73828125,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aL",
"roundness": {
"type": 3
},
"seed": 995116257,
"version": 225,
"versionNonce": 1886900225,
"isDeleted": false,
"boundElements": [],
"updated": 1763704055846,
"link": null,
"locked": false
},
{
"id": "pe3veI_yBFKYtbaJwDKQT",
"type": "text",
"x": 344.7510748617428,
"y": 211.06686356120565,
"width": 86.43992614746094,
"height": 25,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aM",
"roundness": null,
"seed": 26673345,
"version": 204,
"versionNonce": 1004546017,
"isDeleted": false,
"boundElements": [],
"updated": 1763704055846,
"link": null,
"locked": false,
"text": "Memories",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "Memories",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "bOlhO8AaKE86_43viu5UG",
"type": "text",
"x": 365.50408375566445,
"y": 269.24725381983865,
"width": 62.099945068359375,
"height": 25,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aN",
"roundness": null,
"seed": 1849784033,
"version": 106,
"versionNonce": 762320737,
"isDeleted": false,
"boundElements": [],
"updated": 1763704060295,
"link": null,
"locked": false,
"text": "chunk1",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "chunk1",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "V_iDW10PKwMe7vWb5S5HF",
"type": "text",
"x": 366.3074332757606,
"y": 307.03138128152926,
"width": 67.55995178222656,
"height": 25,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aO",
"roundness": null,
"seed": 1670509249,
"version": 186,
"versionNonce": 1964540737,
"isDeleted": false,
"boundElements": [],
"updated": 1763704060295,
"link": null,
"locked": false,
"text": "chunk2",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "chunk2",
"autoResize": true,
"lineHeight": 1.25
},
{
"id": "LHKMRdSowgcl2LsKacxTz",
"type": "text",
"x": 484.9493410573871,
"y": 292.45619471187945,
"width": 273.579833984375,
"height": 50,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "aP",
"roundness": null,
"seed": 945666991,
"version": 104,
"versionNonce": 1512137505,
"isDeleted": false,
"boundElements": null,
"updated": 1763704096016,
"link": null,
"locked": false,
"text": "RANKED By DECAY SCORE,\nTOP K",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "left",
"verticalAlign": "top",
"containerId": null,
"originalText": "RANKED By DECAY SCORE,\nTOP K",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
"gridSize": 20,
"gridStep": 5,
"gridModeEnabled": false,
"viewBackgroundColor": "#ffffff"
},
"files": {}
}

View File

@@ -0,0 +1,76 @@
## Decay Score
记忆衰减分数定义为:
\[
\text{decay\_score}
= \alpha \cdot e^{-\lambda \cdot \Delta t \cdot \beta}
+ (1-\alpha)\cdot (1 - e^{-\gamma \cdot c})
\]
其中:
+ \(\Delta t\):自上次检索以来经过的时间(天),由 `last_retrieval_at` 计算;
+ \(c\):检索次数,对应字段 `retrieval_count`
+ \(\alpha\):控制时间衰减和检索次数影响的权重;
+ \(\gamma\):控制检索次数影响的速率;
+ \(\lambda\):控制时间衰减的速率;
+ \(\beta\):时间衰减调节因子;
\[
\beta = \frac{1}{1 + a \cdot c}
\]
+ \(a\):控制检索次数对时间衰减影响的权重。
## ADD MEMORY
+ LLM 通过 `astr_add_memory` 工具调用,传入记忆内容和记忆类型。
+ 生成 `mem_id = uuid4()`
+ 从上下文中获取 `owner_id = unified_message_origin`
步骤:
1. 使用 VecDB 以新记忆内容为 query检索前 20 条相似记忆。
2. 从中取相似度最高的前 5 条:
+ 若相似度超过“合并阈值”(如 `sim >= merge_threshold`
+ 将该条记忆视为同一记忆,使用 LLM 将旧内容与新内容合并;
+ 在同一个 `mem_id` 上更新 MemoryDB 和 VecDBUPDATE而非新建
+ 否则:
+ 作为全新的记忆插入:
+ 写入 VecDBmetadata 中包含 `mem_id`, `owner_id`
+ 写入 MemoryDB 的 `memory_chunks` 表,初始化:
+ `created_at = now`
+ `last_retrieval_at = now`
+ `retrieval_count = 1` 等。
3. 对 VecDB 返回的前 20 条记忆,如果相似度高于某个“赫布阈值”(`hebb_threshold`),则:
+ `retrieval_count += 1`
+ `last_retrieval_at = now`
这一步体现了赫布学习:与新记忆共同被激活的旧记忆会获得一次强化。
## QUERY MEMORY (STATIC)
+ LLM 通过 `astr_query_memory` 工具调用,无参数。
步骤:
1. 从 MemoryDB 的 `memory_chunks` 表中查询当前用户所有活跃记忆:
+ `SELECT * FROM memory_chunks WHERE owner_id = ? AND is_active = 1`
2. 对每条记忆,根据 `last_retrieval_at``retrieval_count` 计算对应的 `decay_score`
3.`decay_score` 从高到低排序,返回前 `top_k` 条记忆内容给 LLM。
4. 对返回的这 `top_k` 条记忆:
+ `retrieval_count += 1`
+ `last_retrieval_at = now`
## QUERY MEMORY (DYNAMIC)(暂不实现)
+ LLM 提供查询内容作为语义 query。
+ 使用 VecDB 检索与该 query 最相似的前 `N` 条记忆(`N > top_k`)。
+ 根据 `mem_id``memory_chunks` 中加载对应记录。
+ 对这批候选记忆计算:
+ 语义相似度(来自 VecDB
+ `decay_score`
+ 最终排序分数(例如 `w1 * sim + w2 * decay_score`
+ 按最终排序分数从高到低返回前 `top_k` 条记忆内容,并更新它们的 `retrieval_count``last_retrieval_at`

View File

@@ -0,0 +1,63 @@
import uuid
from datetime import datetime, timezone
import numpy as np
from sqlmodel import Field, MetaData, SQLModel
MEMORY_TYPE_IMPORTANCE = {"persona": 1.3, "fact": 1.0, "ephemeral": 0.8}
class BaseMemoryModel(SQLModel, table=False):
metadata = MetaData()
class MemoryChunk(BaseMemoryModel, table=True):
"""A chunk of memory stored in the system."""
__tablename__ = "memory_chunks" # type: ignore
id: int | None = Field(
primary_key=True,
sa_column_kwargs={"autoincrement": True},
default=None,
)
mem_id: str = Field(
max_length=36,
nullable=False,
unique=True,
default_factory=lambda: str(uuid.uuid4()),
index=True,
)
fact: str = Field(nullable=False)
"""The factual content of the memory chunk."""
owner_id: str = Field(max_length=255, nullable=False, index=True)
"""The identifier of the owner (user) of the memory chunk."""
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
"""The timestamp when the memory chunk was created."""
last_retrieval_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc)
)
"""The timestamp when the memory chunk was last retrieved."""
retrieval_count: int = Field(default=1, nullable=False)
"""The number of times the memory chunk has been retrieved."""
memory_type: str = Field(max_length=20, nullable=False, default="fact")
"""The type of memory (e.g., 'persona', 'fact', 'ephemeral')."""
is_active: bool = Field(default=True, nullable=False)
"""Whether the memory chunk is active."""
def compute_decay_score(self, current_time: datetime) -> float:
"""Compute the decay score of the memory chunk based on time and retrievals."""
# Constants for the decay formula
alpha = 0.5
gamma = 0.1
lambda_ = 0.05
a = 0.1
# Calculate delta_t in days
delta_t = (current_time - self.last_retrieval_at).total_seconds() / 86400
c = self.retrieval_count
beta = 1 / (1 + a * c)
decay_score = alpha * np.exp(-lambda_ * delta_t * beta) + (1 - alpha) * (
1 - np.exp(-gamma * c)
)
return decay_score * MEMORY_TYPE_IMPORTANCE.get(self.memory_type, 1.0)

View File

@@ -0,0 +1,174 @@
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy import select, text, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlmodel import col
from astrbot.core import logger
from .entities import BaseMemoryModel, MemoryChunk
class MemoryDatabase:
def __init__(self, db_path: str = "data/astr_memory/memory.db") -> None:
"""Initialize memory database
Args:
db_path: Database file path, default is data/astr_memory/memory.db
"""
self.db_path = db_path
self.DATABASE_URL = f"sqlite+aiosqlite:///{db_path}"
self.inited = False
# Ensure directory exists
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
# Create async engine
self.engine = create_async_engine(
self.DATABASE_URL,
echo=False,
pool_pre_ping=True,
pool_recycle=3600,
)
# Create session factory
self.async_session = async_sessionmaker(
self.engine,
class_=AsyncSession,
expire_on_commit=False,
)
@asynccontextmanager
async def get_db(self):
"""Get database session
Usage:
async with mem_db.get_db() as session:
# Perform database operations
result = await session.execute(stmt)
"""
async with self.async_session() as session:
yield session
async def initialize(self) -> None:
"""Initialize database, create tables and configure SQLite parameters"""
async with self.engine.begin() as conn:
# Create all memory related tables
await conn.run_sync(BaseMemoryModel.metadata.create_all)
# Configure SQLite performance optimization parameters
await conn.execute(text("PRAGMA journal_mode=WAL"))
await conn.execute(text("PRAGMA synchronous=NORMAL"))
await conn.execute(text("PRAGMA cache_size=20000"))
await conn.execute(text("PRAGMA temp_store=MEMORY"))
await conn.execute(text("PRAGMA mmap_size=134217728"))
await conn.execute(text("PRAGMA optimize"))
await conn.commit()
await self._create_indexes()
self.inited = True
logger.info(f"Memory database initialized: {self.db_path}")
async def _create_indexes(self) -> None:
"""Create indexes for memory_chunks table"""
async with self.get_db() as session:
async with session.begin():
# Create memory chunks table indexes
await session.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_mem_mem_id "
"ON memory_chunks(mem_id)",
),
)
await session.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_mem_owner_id "
"ON memory_chunks(owner_id)",
),
)
await session.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_mem_owner_active "
"ON memory_chunks(owner_id, is_active)",
),
)
await session.commit()
async def close(self) -> None:
"""Close database connection"""
await self.engine.dispose()
logger.info(f"Memory database closed: {self.db_path}")
async def insert_memory(self, memory: MemoryChunk) -> MemoryChunk:
"""Insert a new memory chunk"""
async with self.get_db() as session:
session.add(memory)
await session.commit()
await session.refresh(memory)
return memory
async def get_memory_by_id(self, mem_id: str) -> MemoryChunk | None:
"""Get memory chunk by mem_id"""
async with self.get_db() as session:
stmt = select(MemoryChunk).where(col(MemoryChunk.mem_id) == mem_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def update_memory(self, memory: MemoryChunk) -> MemoryChunk:
"""Update an existing memory chunk"""
async with self.get_db() as session:
session.add(memory)
await session.commit()
await session.refresh(memory)
return memory
async def get_active_memories(self, owner_id: str) -> list[MemoryChunk]:
"""Get all active memories for a user"""
async with self.get_db() as session:
stmt = select(MemoryChunk).where(
col(MemoryChunk.owner_id) == owner_id,
col(MemoryChunk.is_active) == True, # noqa: E712
)
result = await session.execute(stmt)
return list(result.scalars().all())
async def update_retrieval_stats(
self,
mem_ids: list[str],
current_time: datetime | None = None,
) -> None:
"""Update retrieval statistics for multiple memories"""
if not mem_ids:
return
if current_time is None:
current_time = datetime.now(timezone.utc)
async with self.get_db() as session:
async with session.begin():
stmt = (
update(MemoryChunk)
.where(col(MemoryChunk.mem_id).in_(mem_ids))
.values(
retrieval_count=MemoryChunk.retrieval_count + 1,
last_retrieval_at=current_time,
)
)
await session.execute(stmt)
await session.commit()
async def deactivate_memory(self, mem_id: str) -> bool:
"""Deactivate a memory chunk"""
async with self.get_db() as session:
async with session.begin():
stmt = (
update(MemoryChunk)
.where(col(MemoryChunk.mem_id) == mem_id)
.values(is_active=False)
)
result = await session.execute(stmt)
await session.commit()
return result.rowcount > 0 if result.rowcount else False # type: ignore

View File

@@ -0,0 +1,281 @@
import json
import uuid
from datetime import datetime, timezone
from pathlib import Path
from astrbot.core import logger
from astrbot.core.db.vec_db.faiss_impl import FaissVecDB
from astrbot.core.provider.provider import EmbeddingProvider
from astrbot.core.provider.provider import Provider as LLMProvider
from .entities import MemoryChunk
from .mem_db_sqlite import MemoryDatabase
MERGE_THRESHOLD = 0.85
"""Similarity threshold for merging memories"""
HEBB_THRESHOLD = 0.70
"""Similarity threshold for Hebbian learning reinforcement"""
MERGE_SYSTEM_PROMPT = """You are a memory consolidation assistant. Your task is to merge two related memory entries into a single, comprehensive memory.
Input format:
- Old memory: [existing memory content]
- New memory: [new memory content to be integrated]
Your output should be a single, concise memory that combines the essential information from both entries. Preserve specific details, update outdated information, and eliminate redundancy. Output only the merged memory content without any explanations or meta-commentary."""
class MemoryManager:
"""Manager for user long-term memory storage and retrieval"""
def __init__(self, memory_root_dir: str = "data/astr_memory"):
self.memory_root_dir = Path(memory_root_dir)
self.memory_root_dir.mkdir(parents=True, exist_ok=True)
self.mem_db: MemoryDatabase | None = None
self.vec_db: FaissVecDB | None = None
self._initialized = False
async def initialize(
self,
embedding_provider: EmbeddingProvider,
merge_llm_provider: LLMProvider,
):
"""Initialize memory database and vector database"""
# Initialize MemoryDB
db_path = self.memory_root_dir / "memory.db"
self.mem_db = MemoryDatabase(db_path.as_posix())
await self.mem_db.initialize()
self.embedding_provider = embedding_provider
self.merge_llm_provider = merge_llm_provider
# Initialize VecDB
doc_store_path = self.memory_root_dir / "doc.db"
index_store_path = self.memory_root_dir / "index.faiss"
self.vec_db = FaissVecDB(
doc_store_path=doc_store_path.as_posix(),
index_store_path=index_store_path.as_posix(),
embedding_provider=self.embedding_provider,
)
await self.vec_db.initialize()
logger.info("Memory manager initialized")
self._initialized = True
async def terminate(self):
"""Close all database connections"""
if self.vec_db:
await self.vec_db.close()
if self.mem_db:
await self.mem_db.close()
async def add_memory(
self,
fact: str,
owner_id: str,
memory_type: str = "fact",
) -> MemoryChunk:
"""Add a new memory with similarity check and merge logic
Implements the ADD MEMORY workflow from _README.md:
1. Search for similar memories using VecDB
2. If similarity >= merge_threshold, merge with existing memory
3. Otherwise, create new memory
4. Apply Hebbian learning to similar memories (similarity >= hebb_threshold)
Args:
fact: Memory content
owner_id: User identifier
memory_type: Memory type ('persona', 'fact', 'ephemeral')
Returns:
The created or updated MemoryChunk
"""
if not self.vec_db or not self.mem_db:
raise RuntimeError("Memory manager not initialized")
current_time = datetime.now(timezone.utc)
# Step 1: Search for similar memories
similar_results = await self.vec_db.retrieve(
query=fact,
k=20,
fetch_k=50,
metadata_filters={"owner_id": owner_id},
)
# Step 2: Check if we should merge with existing memories (top 3 similar ones)
merge_candidates = [
r for r in similar_results[:3] if r.similarity >= MERGE_THRESHOLD
]
if merge_candidates:
# Get all candidate memories from database
candidate_memories: list[tuple[str, MemoryChunk]] = []
for candidate in merge_candidates:
mem_id = json.loads(candidate.data["metadata"])["mem_id"]
memory = await self.mem_db.get_memory_by_id(mem_id)
if memory:
candidate_memories.append((mem_id, memory))
if candidate_memories:
# Use the most similar memory as the base
base_mem_id, base_memory = candidate_memories[0]
# Collect all facts to merge (existing candidates + new fact)
all_facts = [mem.fact for _, mem in candidate_memories] + [fact]
merged_fact = await self._merge_multiple_memories(all_facts)
# Update the base memory
base_memory.fact = merged_fact
base_memory.last_retrieval_at = current_time
base_memory.retrieval_count += 1
updated_memory = await self.mem_db.update_memory(base_memory)
# Update VecDB for base memory
await self.vec_db.delete(base_mem_id)
await self.vec_db.insert(
content=merged_fact,
metadata={
"mem_id": base_mem_id,
"owner_id": owner_id,
"memory_type": memory_type,
},
id=base_mem_id,
)
# Deactivate and remove other merged memories
for mem_id, _ in candidate_memories[1:]:
await self.mem_db.deactivate_memory(mem_id)
await self.vec_db.delete(mem_id)
logger.info(
f"Merged {len(candidate_memories)} memories into {base_mem_id} for user {owner_id}"
)
return updated_memory
# Step 3: Create new memory
mem_id = str(uuid.uuid4())
new_memory = MemoryChunk(
mem_id=mem_id,
fact=fact,
owner_id=owner_id,
memory_type=memory_type,
created_at=current_time,
last_retrieval_at=current_time,
retrieval_count=1,
is_active=True,
)
# Insert into MemoryDB
created_memory = await self.mem_db.insert_memory(new_memory)
# Insert into VecDB
await self.vec_db.insert(
content=fact,
metadata={
"mem_id": mem_id,
"owner_id": owner_id,
"memory_type": memory_type,
},
id=mem_id,
)
# Step 4: Apply Hebbian learning to similar memories
hebb_mem_ids = [
json.loads(r.data["metadata"])["mem_id"]
for r in similar_results
if r.similarity >= HEBB_THRESHOLD
]
if hebb_mem_ids:
await self.mem_db.update_retrieval_stats(hebb_mem_ids, current_time)
logger.debug(
f"Applied Hebbian learning to {len(hebb_mem_ids)} memories for user {owner_id}",
)
logger.info(f"Created new memory {mem_id} for user {owner_id}")
return created_memory
async def query_memory(
self,
owner_id: str,
top_k: int = 5,
) -> list[MemoryChunk]:
"""Query user's memories using static retrieval with decay score ranking
Implements the QUERY MEMORY (STATIC) workflow from _README.md:
1. Get all active memories for user from MemoryDB
2. Compute decay_score for each memory
3. Sort by decay_score and return top_k
4. Update retrieval statistics for returned memories
Args:
owner_id: User identifier
top_k: Number of memories to return
Returns:
List of top_k MemoryChunk sorted by decay score
"""
if not self.mem_db:
raise RuntimeError("Memory manager not initialized")
current_time = datetime.now(timezone.utc)
# Step 1: Get all active memories for user
all_memories = await self.mem_db.get_active_memories(owner_id)
if not all_memories:
return []
# Step 2-3: Compute decay scores and sort
memories_with_scores = [
(mem, mem.compute_decay_score(current_time)) for mem in all_memories
]
memories_with_scores.sort(key=lambda x: x[1], reverse=True)
# Get top_k memories
top_memories = [mem for mem, _ in memories_with_scores[:top_k]]
# Step 4: Update retrieval statistics
mem_ids = [mem.mem_id for mem in top_memories]
await self.mem_db.update_retrieval_stats(mem_ids, current_time)
logger.debug(f"Retrieved {len(top_memories)} memories for user {owner_id}")
return top_memories
async def _merge_multiple_memories(self, facts: list[str]) -> str:
"""Merge multiple memory facts using LLM in one call
Args:
facts: List of memory facts to merge
Returns:
Merged memory content
"""
if not self.merge_llm_provider:
return " ".join(facts)
if len(facts) == 1:
return facts[0]
try:
# Format all facts as a numbered list
facts_list = "\n".join(f"{i + 1}. {fact}" for i, fact in enumerate(facts))
user_prompt = (
f"Please merge the following {len(facts)} related memory entries "
"into a single, comprehensive memory:"
f"\n{facts_list}\n\nOutput only the merged memory content."
)
response = await self.merge_llm_provider.text_chat(
prompt=user_prompt,
system_prompt=MERGE_SYSTEM_PROMPT,
)
merged_content = response.completion_text.strip()
return merged_content if merged_content else " ".join(facts)
except Exception as e:
logger.warning(f"Failed to merge memories with LLM: {e}, using fallback")
return " ".join(facts)

View File

@@ -0,0 +1,156 @@
from pydantic import Field
from pydantic.dataclasses import dataclass
from astrbot.core.agent.tool import FunctionTool, ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext, ContextWrapper
@dataclass
class AddMemory(FunctionTool[AstrAgentContext]):
"""Tool for adding memories to user's long-term memory storage"""
name: str = "astr_add_memory"
description: str = (
"Add a new memory to the user's long-term memory storage. "
"Use this tool only when the user explicitly asks you to remember something, "
"or when they share stable preferences, identity, or long-term goals that will be useful in future interactions."
)
parameters: dict = Field(
default_factory=lambda: {
"type": "object",
"properties": {
"fact": {
"type": "string",
"description": (
"The concrete memory content to store, such as a user preference, "
"identity detail, long-term goal, or stable profile fact."
),
},
"memory_type": {
"type": "string",
"enum": ["persona", "fact", "ephemeral"],
"description": (
"The relative importance of this memory. "
"Use 'persona' for core identity or highly impactful information, "
"'fact' for normal long-term preferences, "
"and 'ephemeral' for minor or tentative facts."
),
},
},
"required": ["fact", "memory_type"],
}
)
async def call(
self, context: ContextWrapper[AstrAgentContext], **kwargs
) -> ToolExecResult:
"""Add a memory to long-term storage
Args:
context: Agent context
**kwargs: Must contain 'fact' and 'memory_type'
Returns:
ToolExecResult with success message
"""
mm = context.context.context.memory_manager
fact = kwargs.get("fact")
memory_type = kwargs.get("memory_type", "fact")
if not fact:
return "Missing required parameter: fact"
try:
# Get owner_id from context
owner_id = context.context.event.unified_msg_origin
# Add memory using memory manager
memory = await mm.add_memory(
fact=fact,
owner_id=owner_id,
memory_type=memory_type,
)
return f"Memory added successfully (ID: {memory.mem_id})"
except Exception as e:
return f"Failed to add memory: {str(e)}"
@dataclass
class QueryMemory(FunctionTool[AstrAgentContext]):
"""Tool for querying user's long-term memories"""
name: str = "astr_query_memory"
description: str = (
"Query the user's long-term memory storage and return the most relevant memories. "
"Use this tool when you need user-specific context, preferences, or past facts "
"that are not explicitly present in the current conversation."
)
parameters: dict = Field(
default_factory=lambda: {
"type": "object",
"properties": {
"top_k": {
"type": "integer",
"description": (
"Maximum number of memories to retrieve after retention-based ranking. "
"Typically between 3 and 10."
),
"default": 5,
"minimum": 1,
"maximum": 20,
},
},
"required": [],
}
)
async def call(
self, context: ContextWrapper[AstrAgentContext], **kwargs
) -> ToolExecResult:
"""Query memories from long-term storage
Args:
context: Agent context
**kwargs: Optional 'top_k' parameter
Returns:
ToolExecResult with formatted memory list
"""
mm = context.context.context.memory_manager
top_k = kwargs.get("top_k", 5)
try:
# Get owner_id from context
owner_id = context.context.event.unified_msg_origin
# Query memories using memory manager
memories = await mm.query_memory(
owner_id=owner_id,
top_k=top_k,
)
if not memories:
return "No memories found for this user."
# Format memories for output
formatted_memories = []
for i, mem in enumerate(memories, 1):
formatted_memories.append(
f"{i}. [{mem.memory_type.upper()}] {mem.fact} "
f"(retrieved {mem.retrieval_count} times, "
f"last: {mem.last_retrieval_at.strftime('%Y-%m-%d')})"
)
result_text = "Retrieved memories:\n" + "\n".join(formatted_memories)
return result_text
except Exception as e:
return f"Failed to query memories: {str(e)}"
ADD_MEMORY_TOOL = AddMemory()
QUERY_MEMORY_TOOL = QueryMemory()

View File

@@ -722,12 +722,7 @@ class File(BaseMessageComponent):
"""下载文件"""
download_dir = os.path.join(get_astrbot_data_path(), "temp")
os.makedirs(download_dir, exist_ok=True)
if self.name:
name, ext = os.path.splitext(self.name)
filename = f"{name}_{uuid.uuid4().hex[:8]}{ext}"
else:
filename = f"{uuid.uuid4().hex}"
file_path = os.path.join(download_dir, filename)
file_path = os.path.join(download_dir, f"{uuid.uuid4().hex}")
await download_file(self.url, file_path)
self.file_ = os.path.abspath(file_path)

View File

@@ -1,48 +0,0 @@
from collections.abc import AsyncGenerator
from astrbot.core import logger
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.star.session_llm_manager import SessionServiceManager
from ...context import PipelineContext
from ..stage import Stage
from .agent_sub_stages.internal import InternalAgentSubStage
from .agent_sub_stages.third_party import ThirdPartyAgentSubStage
class AgentRequestSubStage(Stage):
async def initialize(self, ctx: PipelineContext) -> None:
self.ctx = ctx
self.config = ctx.astrbot_config
self.bot_wake_prefixs: list[str] = self.config["wake_prefix"]
self.prov_wake_prefix: str = self.config["provider_settings"]["wake_prefix"]
for bwp in self.bot_wake_prefixs:
if self.prov_wake_prefix.startswith(bwp):
logger.info(
f"识别 LLM 聊天额外唤醒前缀 {self.prov_wake_prefix} 以机器人唤醒前缀 {bwp} 开头,已自动去除。",
)
self.prov_wake_prefix = self.prov_wake_prefix[len(bwp) :]
agent_runner_type = self.config["provider_settings"]["agent_runner_type"]
if agent_runner_type == "local":
self.agent_sub_stage = InternalAgentSubStage()
else:
self.agent_sub_stage = ThirdPartyAgentSubStage()
await self.agent_sub_stage.initialize(ctx)
async def process(self, event: AstrMessageEvent) -> AsyncGenerator[None, None]:
if not self.ctx.astrbot_config["provider_settings"]["enable"]:
logger.debug(
"This pipeline does not enable AI capability, skip processing."
)
return
if not SessionServiceManager.should_process_llm_request(event):
logger.debug(
f"The session {event.unified_msg_origin} has disabled AI capability, skipping processing."
)
return
async for resp in self.agent_sub_stage.process(event, self.prov_wake_prefix):
yield resp

View File

@@ -1,205 +0,0 @@
import asyncio
from collections.abc import AsyncGenerator
from typing import TYPE_CHECKING
from astrbot.core import astrbot_config, logger
from astrbot.core.agent.runners.coze.coze_agent_runner import CozeAgentRunner
from astrbot.core.agent.runners.dashscope.dashscope_agent_runner import (
DashscopeAgentRunner,
)
from astrbot.core.agent.runners.dify.dify_agent_runner import DifyAgentRunner
from astrbot.core.message.components import Image
from astrbot.core.message.message_event_result import (
MessageChain,
MessageEventResult,
ResultContentType,
)
if TYPE_CHECKING:
from astrbot.core.agent.runners.base import BaseAgentRunner
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.provider.entities import (
ProviderRequest,
)
from astrbot.core.star.star_handler import EventType
from astrbot.core.utils.metrics import Metric
from .....astr_agent_context import AgentContextWrapper, AstrAgentContext
from .....astr_agent_hooks import MAIN_AGENT_HOOKS
from ....context import PipelineContext, call_event_hook
from ...stage import Stage
AGENT_RUNNER_TYPE_KEY = {
"dify": "dify_agent_runner_provider_id",
"coze": "coze_agent_runner_provider_id",
"dashscope": "dashscope_agent_runner_provider_id",
}
async def run_third_party_agent(
runner: "BaseAgentRunner",
stream_to_general: bool = False,
) -> AsyncGenerator[MessageChain | None, None]:
"""
运行第三方 agent runner 并转换响应格式
类似于 run_agent 函数,但专门处理第三方 agent runner
"""
try:
async for resp in runner.step_until_done(max_step=30): # type: ignore[misc]
if resp.type == "streaming_delta":
if stream_to_general:
continue
yield resp.data["chain"]
elif resp.type == "llm_result":
if stream_to_general:
yield resp.data["chain"]
except Exception as e:
logger.error(f"Third party agent runner error: {e}")
err_msg = (
f"\nAstrBot 请求失败。\n错误类型: {type(e).__name__}\n"
f"错误信息: {e!s}\n\n请在控制台查看和分享错误详情。\n"
)
yield MessageChain().message(err_msg)
class ThirdPartyAgentSubStage(Stage):
async def initialize(self, ctx: PipelineContext) -> None:
self.ctx = ctx
self.conf = ctx.astrbot_config
self.runner_type = self.conf["provider_settings"]["agent_runner_type"]
self.prov_id = self.conf["provider_settings"].get(
AGENT_RUNNER_TYPE_KEY.get(self.runner_type, ""),
"",
)
settings = ctx.astrbot_config["provider_settings"]
self.streaming_response: bool = settings["streaming_response"]
self.unsupported_streaming_strategy: str = settings[
"unsupported_streaming_strategy"
]
async def process(
self, event: AstrMessageEvent, provider_wake_prefix: str
) -> AsyncGenerator[None, None]:
req: ProviderRequest | None = None
if provider_wake_prefix and not event.message_str.startswith(
provider_wake_prefix
):
return
self.prov_cfg: dict = next(
(p for p in astrbot_config["provider"] if p["id"] == self.prov_id),
{},
)
if not self.prov_id:
logger.error("没有填写 Agent Runner 提供商 ID请前往配置页面配置。")
return
if not self.prov_cfg:
logger.error(
f"Agent Runner 提供商 {self.prov_id} 配置不存在,请前往配置页面修改配置。"
)
return
# make provider request
req = ProviderRequest()
req.session_id = event.unified_msg_origin
req.prompt = event.message_str[len(provider_wake_prefix) :]
for comp in event.message_obj.message:
if isinstance(comp, Image):
image_path = await comp.convert_to_base64()
req.image_urls.append(image_path)
if not req.prompt and not req.image_urls:
return
# call event hook
if await call_event_hook(event, EventType.OnLLMRequestEvent, req):
return
if self.runner_type == "dify":
runner = DifyAgentRunner[AstrAgentContext]()
elif self.runner_type == "coze":
runner = CozeAgentRunner[AstrAgentContext]()
elif self.runner_type == "dashscope":
runner = DashscopeAgentRunner[AstrAgentContext]()
else:
raise ValueError(
f"Unsupported third party agent runner type: {self.runner_type}",
)
astr_agent_ctx = AstrAgentContext(
context=self.ctx.plugin_manager.context,
event=event,
)
streaming_response = self.streaming_response
if (enable_streaming := event.get_extra("enable_streaming")) is not None:
streaming_response = bool(enable_streaming)
stream_to_general = (
self.unsupported_streaming_strategy == "turn_off"
and not event.platform_meta.support_streaming_message
)
await runner.reset(
request=req,
run_context=AgentContextWrapper(
context=astr_agent_ctx,
tool_call_timeout=60,
),
agent_hooks=MAIN_AGENT_HOOKS,
provider_config=self.prov_cfg,
streaming=streaming_response,
)
if streaming_response and not stream_to_general:
# 流式响应
event.set_result(
MessageEventResult()
.set_result_content_type(ResultContentType.STREAMING_RESULT)
.set_async_stream(
run_third_party_agent(
runner,
stream_to_general=False,
),
),
)
yield
if runner.done():
final_resp = runner.get_final_llm_resp()
if final_resp and final_resp.result_chain:
event.set_result(
MessageEventResult(
chain=final_resp.result_chain.chain or [],
result_content_type=ResultContentType.STREAMING_FINISH,
),
)
else:
# 非流式响应或转换为普通响应
async for _ in run_third_party_agent(
runner,
stream_to_general=stream_to_general,
):
yield
final_resp = runner.get_final_llm_resp()
if not final_resp or not final_resp.result_chain:
logger.warning("Agent Runner 未返回最终结果。")
return
event.set_result(
MessageEventResult(
chain=final_resp.result_chain.chain or [],
result_content_type=ResultContentType.LLM_RESULT,
),
)
yield
asyncio.create_task(
Metric.upload(
llm_tick=1,
model_name=self.runner_type,
provider_type=self.runner_type,
),
)

View File

@@ -9,7 +9,7 @@ from astrbot.core import logger
from astrbot.core.agent.tool import ToolSet
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.conversation_mgr import Conversation
from astrbot.core.message.components import File, Image, Reply
from astrbot.core.message.components import Image
from astrbot.core.message.message_event_result import (
MessageChain,
MessageEventResult,
@@ -21,25 +21,28 @@ from astrbot.core.provider.entities import (
LLMResponse,
ProviderRequest,
)
from astrbot.core.star.session_llm_manager import SessionServiceManager
from astrbot.core.star.star_handler import EventType, star_map
from astrbot.core.utils.file_extract import extract_file_moonshotai
from astrbot.core.utils.metrics import Metric
from astrbot.core.utils.session_lock import session_lock_manager
from .....astr_agent_context import AgentContextWrapper
from .....astr_agent_hooks import MAIN_AGENT_HOOKS
from .....astr_agent_run_util import AgentRunner, run_agent
from .....astr_agent_tool_exec import FunctionToolExecutor
from ....context import PipelineContext, call_event_hook
from ...stage import Stage
from ...utils import KNOWLEDGE_BASE_QUERY_TOOL, retrieve_knowledge_base
from ....astr_agent_context import AgentContextWrapper
from ....astr_agent_hooks import MAIN_AGENT_HOOKS
from ....astr_agent_run_util import AgentRunner, run_agent
from ....astr_agent_tool_exec import FunctionToolExecutor
from ....memory.tools import ADD_MEMORY_TOOL, QUERY_MEMORY_TOOL
from ...context import PipelineContext, call_event_hook
from ..stage import Stage
from ..utils import KNOWLEDGE_BASE_QUERY_TOOL, retrieve_knowledge_base
class InternalAgentSubStage(Stage):
class LLMRequestSubStage(Stage):
async def initialize(self, ctx: PipelineContext) -> None:
self.ctx = ctx
conf = ctx.astrbot_config
settings = conf["provider_settings"]
self.bot_wake_prefixs: list[str] = conf["wake_prefix"] # list
self.provider_wake_prefix: str = settings["wake_prefix"] # str
self.max_context_length = settings["max_context_length"] # int
self.dequeue_context_length: int = min(
max(1, settings["dequeue_context_length"]),
@@ -57,12 +60,12 @@ class InternalAgentSubStage(Stage):
self.show_reasoning = settings.get("display_reasoning_text", False)
self.kb_agentic_mode: bool = conf.get("kb_agentic_mode", False)
file_extract_conf: dict = settings.get("file_extract", {})
self.file_extract_enabled: bool = file_extract_conf.get("enable", False)
self.file_extract_prov: str = file_extract_conf.get("provider", "moonshotai")
self.file_extract_msh_api_key: str = file_extract_conf.get(
"moonshotai_api_key", ""
)
for bwp in self.bot_wake_prefixs:
if self.provider_wake_prefix.startswith(bwp):
logger.info(
f"识别 LLM 聊天额外唤醒前缀 {self.provider_wake_prefix} 以机器人唤醒前缀 {bwp} 开头,已自动去除。",
)
self.provider_wake_prefix = self.provider_wake_prefix[len(bwp) :]
self.conv_manager = ctx.plugin_manager.context.conversation_manager
@@ -122,49 +125,14 @@ class InternalAgentSubStage(Stage):
req.func_tool = ToolSet()
req.func_tool.add_tool(KNOWLEDGE_BASE_QUERY_TOOL)
async def _apply_file_extract(
self,
event: AstrMessageEvent,
req: ProviderRequest,
):
"""Apply file extract to the provider request"""
file_paths = []
file_names = []
for comp in event.message_obj.message:
if isinstance(comp, File):
file_paths.append(await comp.get_file())
file_names.append(comp.name)
elif isinstance(comp, Reply) and comp.chain:
for reply_comp in comp.chain:
if isinstance(reply_comp, File):
file_paths.append(await reply_comp.get_file())
file_names.append(reply_comp.name)
if not file_paths:
async def _apply_memory(self, req: ProviderRequest):
mm = self.ctx.plugin_manager.context.memory_manager
if not mm or not mm._initialized:
return
if not req.prompt:
req.prompt = "总结一下文件里面讲了什么?"
if self.file_extract_prov == "moonshotai":
if not self.file_extract_msh_api_key:
logger.error("Moonshot AI API key for file extract is not set")
return
file_contents = await asyncio.gather(
*[
extract_file_moonshotai(file_path, self.file_extract_msh_api_key)
for file_path in file_paths
]
)
else:
logger.error(f"Unsupported file extract provider: {self.file_extract_prov}")
return
# add file extract results to contexts
for file_content, file_name in zip(file_contents, file_names):
req.contexts.append(
{
"role": "system",
"content": f"File Extract Results of user uploaded files:\n{file_content}\nFile Name: {file_name or 'Unknown'}",
},
)
if req.func_tool is None:
req.func_tool = ToolSet()
req.func_tool.add_tool(ADD_MEMORY_TOOL)
req.func_tool.add_tool(QUERY_MEMORY_TOOL)
def _truncate_contexts(
self,
@@ -346,10 +314,21 @@ class InternalAgentSubStage(Stage):
return fixed_messages
async def process(
self, event: AstrMessageEvent, provider_wake_prefix: str
) -> AsyncGenerator[None, None]:
self,
event: AstrMessageEvent,
_nested: bool = False,
) -> None | AsyncGenerator[None, None]:
req: ProviderRequest | None = None
if not self.ctx.astrbot_config["provider_settings"]["enable"]:
logger.debug("未启用 LLM 能力,跳过处理。")
return
# 检查会话级别的LLM启停状态
if not SessionServiceManager.should_process_llm_request(event):
logger.debug(f"会话 {event.unified_msg_origin} 禁用了 LLM跳过处理。")
return
provider = self._select_provider(event)
if provider is None:
return
@@ -379,12 +358,12 @@ class InternalAgentSubStage(Stage):
req.image_urls = []
if sel_model := event.get_extra("selected_model"):
req.model = sel_model
if provider_wake_prefix and not event.message_str.startswith(
provider_wake_prefix
if self.provider_wake_prefix and not event.message_str.startswith(
self.provider_wake_prefix
):
return
req.prompt = event.message_str[len(provider_wake_prefix) :]
req.prompt = event.message_str[len(self.provider_wake_prefix) :]
# func_tool selection 现在已经转移到 packages/astrbot 插件中进行选择。
# req.func_tool = self.ctx.plugin_manager.context.get_llm_tool_manager()
for comp in event.message_obj.message:
@@ -398,17 +377,6 @@ class InternalAgentSubStage(Stage):
event.set_extra("provider_request", req)
# fix contexts json str
if isinstance(req.contexts, str):
req.contexts = json.loads(req.contexts)
# apply file extract
if self.file_extract_enabled:
try:
await self._apply_file_extract(event, req)
except Exception as e:
logger.error(f"Error occurred while applying file extract: {e}")
if not req.prompt and not req.image_urls:
return
@@ -419,6 +387,13 @@ class InternalAgentSubStage(Stage):
# apply knowledge base feature
await self._apply_kb(event, req)
# apply memory feature
await self._apply_memory(req)
# fix contexts json str
if isinstance(req.contexts, str):
req.contexts = json.loads(req.contexts)
# truncate contexts to fit max length
if req.contexts:
req.contexts = self._truncate_contexts(req.contexts)

View File

@@ -24,7 +24,7 @@ class StarRequestSubStage(Stage):
async def process(
self,
event: AstrMessageEvent,
) -> AsyncGenerator[None, None]:
) -> None | AsyncGenerator[None, None]:
activated_handlers: list[StarHandlerMetadata] = event.get_extra(
"activated_handlers",
)

View File

@@ -1,12 +1,13 @@
from collections.abc import AsyncGenerator
from astrbot.core import logger
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.provider.entities import ProviderRequest
from astrbot.core.star.star_handler import StarHandlerMetadata
from ..context import PipelineContext
from ..stage import Stage, register_stage
from .method.agent_request import AgentRequestSubStage
from .method.llm_request import LLMRequestSubStage
from .method.star_request import StarRequestSubStage
@@ -16,12 +17,9 @@ class ProcessStage(Stage):
self.ctx = ctx
self.config = ctx.astrbot_config
self.plugin_manager = ctx.plugin_manager
self.llm_request_sub_stage = LLMRequestSubStage()
await self.llm_request_sub_stage.initialize(ctx)
# initialize agent sub stage
self.agent_sub_stage = AgentRequestSubStage()
await self.agent_sub_stage.initialize(ctx)
# initialize star request sub stage
self.star_request_sub_stage = StarRequestSubStage()
await self.star_request_sub_stage.initialize(ctx)
@@ -41,7 +39,7 @@ class ProcessStage(Stage):
# Handler 的 LLM 请求
event.set_extra("provider_request", resp)
_t = False
async for _ in self.agent_sub_stage.process(event):
async for _ in self.llm_request_sub_stage.process(event):
_t = True
yield
if not _t:
@@ -62,5 +60,12 @@ class ProcessStage(Stage):
if (
event.get_result() and not event.get_result().is_stopped()
) or not event.get_result():
async for _ in self.agent_sub_stage.process(event):
# 事件没有终止传播
provider = self.ctx.plugin_manager.context.get_using_provider()
if not provider:
logger.info("未找到可用的 LLM 提供商,请先前往配置服务提供商。")
return
async for _ in self.llm_request_sub_stage.process(event):
yield

View File

@@ -161,21 +161,11 @@ class ResultDecorateStage(Stage):
# 不分段回复
new_chain.append(comp)
continue
try:
split_response = re.findall(
self.regex,
comp.text,
re.DOTALL | re.MULTILINE,
)
except re.error:
logger.error(
f"分段回复正则表达式错误,使用默认分段方式: {traceback.format_exc()}",
)
split_response = re.findall(
r".*?[。?!~…]+|.+$",
comp.text,
re.DOTALL | re.MULTILINE,
)
split_response = re.findall(
self.regex,
comp.text,
re.DOTALL | re.MULTILINE,
)
if not split_response:
new_chain.append(comp)
continue

View File

@@ -246,13 +246,7 @@ class AiocqhttpAdapter(Platform):
if m["data"].get("url") and m["data"].get("url").startswith("http"):
# Lagrange
logger.info("guessing lagrange")
# 检查多个可能的文件名字段
file_name = (
m["data"].get("file_name", "")
or m["data"].get("name", "")
or m["data"].get("file", "")
or "file"
)
file_name = m["data"].get("file_name", "file")
abm.message.append(File(name=file_name, url=m["data"]["url"]))
else:
try:
@@ -271,14 +265,7 @@ class AiocqhttpAdapter(Platform):
)
if ret and "url" in ret:
file_url = ret["url"] # https
# 优先从 API 返回值获取文件名,其次从原始消息数据获取
file_name = (
ret.get("file_name", "")
or ret.get("name", "")
or m["data"].get("file", "")
or m["data"].get("file_name", "")
)
a = File(name=file_name, url=file_url)
a = File(name="", url=file_url)
abm.message.append(a)
else:
logger.error(f"获取文件失败: {ret}")

View File

@@ -250,7 +250,7 @@ class DingtalkPlatformAdapter(Platform):
async def terminate(self):
def monkey_patch_close():
raise KeyboardInterrupt("Graceful shutdown")
raise Exception("Graceful shutdown")
self.client_.open_connection = monkey_patch_close
await self.client_.websocket.close(code=1000, reason="Graceful shutdown")

View File

@@ -381,9 +381,7 @@ class TelegramPlatformAdapter(Platform):
f"Telegram document file_path is None, cannot save the file {file_name}.",
)
else:
message.message.append(
Comp.File(file=file_path, name=file_name, url=file_path)
)
message.message.append(Comp.File(file=file_path, name=file_name))
elif update.message.video:
file = await update.message.video.get_file()

View File

@@ -1,7 +1,7 @@
import asyncio
import traceback
from astrbot.core import astrbot_config, logger, sp
from astrbot.core import logger, sp
from astrbot.core.astrbot_config_mgr import AstrBotConfigManager
from astrbot.core.db import BaseDatabase
@@ -24,7 +24,6 @@ class ProviderManager:
db_helper: BaseDatabase,
persona_mgr: PersonaManager,
):
self.reload_lock = asyncio.Lock()
self.persona_mgr = persona_mgr
self.acm = acm
config = acm.confs["default"]
@@ -227,9 +226,6 @@ class ProviderManager:
async def load_provider(self, provider_config: dict):
if not provider_config["enable"]:
logger.info(f"Provider {provider_config['id']} is disabled, skipping")
return
if provider_config.get("provider_type", "") == "agent_runner":
return
logger.info(
@@ -251,6 +247,14 @@ class ProviderManager:
from .sources.anthropic_source import (
ProviderAnthropic as ProviderAnthropic,
)
case "dify":
from .sources.dify_source import ProviderDify as ProviderDify
case "coze":
from .sources.coze_source import ProviderCoze as ProviderCoze
case "dashscope":
from .sources.dashscope_source import (
ProviderDashscope as ProviderDashscope,
)
case "googlegenai_chat_completion":
from .sources.gemini_source import (
ProviderGoogleGenAI as ProviderGoogleGenAI,
@@ -327,10 +331,6 @@ class ProviderManager:
from .sources.xinference_rerank_source import (
XinferenceRerankProvider as XinferenceRerankProvider,
)
case "bailian_rerank":
from .sources.bailian_rerank_source import (
BailianRerankProvider as BailianRerankProvider,
)
except (ImportError, ModuleNotFoundError) as e:
logger.critical(
f"加载 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}。可能是因为有未安装的依赖。",
@@ -436,46 +436,40 @@ class ProviderManager:
)
async def reload(self, provider_config: dict):
async with self.reload_lock:
await self.terminate_provider(provider_config["id"])
if provider_config["enable"]:
await self.load_provider(provider_config)
await self.terminate_provider(provider_config["id"])
if provider_config["enable"]:
await self.load_provider(provider_config)
# 和配置文件保持同步
self.providers_config = astrbot_config["provider"]
config_ids = [provider["id"] for provider in self.providers_config]
logger.info(f"providers in user's config: {config_ids}")
for key in list(self.inst_map.keys()):
if key not in config_ids:
await self.terminate_provider(key)
# 和配置文件保持同步
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)
if len(self.provider_insts) == 0:
self.curr_provider_inst = None
elif self.curr_provider_inst is None and len(self.provider_insts) > 0:
self.curr_provider_inst = self.provider_insts[0]
logger.info(
f"自动选择 {self.curr_provider_inst.meta().id} 作为当前提供商适配器。",
)
if len(self.provider_insts) == 0:
self.curr_provider_inst = None
elif self.curr_provider_inst is None and len(self.provider_insts) > 0:
self.curr_provider_inst = self.provider_insts[0]
logger.info(
f"自动选择 {self.curr_provider_inst.meta().id} 作为当前提供商适配器。",
)
if len(self.stt_provider_insts) == 0:
self.curr_stt_provider_inst = None
elif (
self.curr_stt_provider_inst is None and len(self.stt_provider_insts) > 0
):
self.curr_stt_provider_inst = self.stt_provider_insts[0]
logger.info(
f"自动选择 {self.curr_stt_provider_inst.meta().id} 作为当前语音转文本提供商适配器。",
)
if len(self.stt_provider_insts) == 0:
self.curr_stt_provider_inst = None
elif self.curr_stt_provider_inst is None and len(self.stt_provider_insts) > 0:
self.curr_stt_provider_inst = self.stt_provider_insts[0]
logger.info(
f"自动选择 {self.curr_stt_provider_inst.meta().id} 作为当前语音转文本提供商适配器。",
)
if len(self.tts_provider_insts) == 0:
self.curr_tts_provider_inst = None
elif (
self.curr_tts_provider_inst is None and len(self.tts_provider_insts) > 0
):
self.curr_tts_provider_inst = self.tts_provider_insts[0]
logger.info(
f"自动选择 {self.curr_tts_provider_inst.meta().id} 作为当前文本转语音提供商适配器。",
)
if len(self.tts_provider_insts) == 0:
self.curr_tts_provider_inst = None
elif self.curr_tts_provider_inst is None and len(self.tts_provider_insts) > 0:
self.curr_tts_provider_inst = self.tts_provider_insts[0]
logger.info(
f"自动选择 {self.curr_tts_provider_inst.meta().id} 作为当前文本转语音提供商适配器。",
)
def get_insts(self):
return self.provider_insts

View File

@@ -1,6 +1,5 @@
import abc
import asyncio
import os
from collections.abc import AsyncGenerator
from astrbot.core.agent.message import Message
@@ -12,7 +11,6 @@ from astrbot.core.provider.entities import (
ToolCallsResult,
)
from astrbot.core.provider.register import provider_cls_map
from astrbot.core.utils.astrbot_path import get_astrbot_path
class AbstractProvider(abc.ABC):
@@ -45,14 +43,6 @@ class AbstractProvider(abc.ABC):
)
return meta
async def test(self):
"""test the provider is a
raises:
Exception: if the provider is not available
"""
...
class Provider(AbstractProvider):
"""Chat Provider"""
@@ -175,12 +165,6 @@ class Provider(AbstractProvider):
return dicts
async def test(self, timeout: float = 45.0):
await asyncio.wait_for(
self.text_chat(prompt="REPLY `PONG` ONLY"),
timeout=timeout,
)
class STTProvider(AbstractProvider):
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
@@ -193,14 +177,6 @@ class STTProvider(AbstractProvider):
"""获取音频的文本"""
raise NotImplementedError
async def test(self):
sample_audio_path = os.path.join(
get_astrbot_path(),
"samples",
"stt_health_check.wav",
)
await self.get_text(sample_audio_path)
class TTSProvider(AbstractProvider):
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
@@ -213,9 +189,6 @@ class TTSProvider(AbstractProvider):
"""获取文本的音频,返回音频文件路径"""
raise NotImplementedError
async def test(self):
await self.get_audio("hi")
class EmbeddingProvider(AbstractProvider):
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
@@ -238,9 +211,6 @@ class EmbeddingProvider(AbstractProvider):
"""获取向量的维度"""
...
async def test(self):
await self.get_embedding("astrbot")
async def get_embeddings_batch(
self,
texts: list[str],
@@ -324,8 +294,3 @@ class RerankProvider(AbstractProvider):
) -> list[RerankResult]:
"""获取查询和文档的重排序分数"""
...
async def test(self):
result = await self.rerank("Apple", documents=["apple", "banana"])
if not result:
raise Exception("Rerank provider test failed, no results returned")

View File

@@ -290,7 +290,7 @@ class ProviderAnthropic(Provider):
try:
llm_response = await self._query(payloads, func_tool)
except Exception as e:
# logger.error(f"发生了错误。Provider 配置如下: {model_config}")
logger.error(f"发生了错误。Provider 配置如下: {model_config}")
raise e
return llm_response

View File

@@ -1,236 +0,0 @@
import os
import aiohttp
from astrbot import logger
from ..entities import ProviderType, RerankResult
from ..provider import RerankProvider
from ..register import register_provider_adapter
class BailianRerankError(Exception):
"""百炼重排序服务异常基类"""
pass
class BailianAPIError(BailianRerankError):
"""百炼API返回错误"""
pass
class BailianNetworkError(BailianRerankError):
"""百炼网络请求错误"""
pass
@register_provider_adapter(
"bailian_rerank", "阿里云百炼文本排序适配器", provider_type=ProviderType.RERANK
)
class BailianRerankProvider(RerankProvider):
"""阿里云百炼文本重排序适配器."""
def __init__(self, provider_config: dict, provider_settings: dict) -> None:
super().__init__(provider_config, provider_settings)
self.provider_config = provider_config
self.provider_settings = provider_settings
# API配置
self.api_key = provider_config.get("rerank_api_key") or os.getenv(
"DASHSCOPE_API_KEY", ""
)
if not self.api_key:
raise ValueError("阿里云百炼 API Key 不能为空。")
self.model = provider_config.get("rerank_model", "qwen3-rerank")
self.timeout = provider_config.get("timeout", 30)
self.return_documents = provider_config.get("return_documents", False)
self.instruct = provider_config.get("instruct", "")
self.base_url = provider_config.get(
"rerank_api_base",
"https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank",
)
# 设置HTTP客户端
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
self.client = aiohttp.ClientSession(
headers=headers, timeout=aiohttp.ClientTimeout(total=self.timeout)
)
# 设置模型名称
self.set_model(self.model)
logger.info(f"AstrBot 百炼 Rerank 初始化完成。模型: {self.model}")
def _build_payload(
self, query: str, documents: list[str], top_n: int | None
) -> dict:
"""构建请求载荷
Args:
query: 查询文本
documents: 文档列表
top_n: 返回前N个结果如果为None则返回所有结果
Returns:
请求载荷字典
"""
base = {"model": self.model, "input": {"query": query, "documents": documents}}
params = {
k: v
for k, v in [
("top_n", top_n if top_n is not None and top_n > 0 else None),
("return_documents", True if self.return_documents else None),
(
"instruct",
self.instruct
if self.instruct and self.model == "qwen3-rerank"
else None,
),
]
if v is not None
}
if params:
base["parameters"] = params
return base
def _parse_results(self, data: dict) -> list[RerankResult]:
"""解析API响应结果
Args:
data: API响应数据
Returns:
重排序结果列表
Raises:
BailianAPIError: API返回错误
KeyError: 结果缺少必要字段
"""
# 检查响应状态
if data.get("code", "200") != "200":
raise BailianAPIError(
f"百炼 API 错误: {data.get('code')} {data.get('message', '')}"
)
results = data.get("output", {}).get("results", [])
if not results:
logger.warning(f"百炼 Rerank 返回空结果: {data}")
return []
# 转换为RerankResult对象使用.get()避免KeyError
rerank_results = []
for idx, result in enumerate(results):
try:
index = result.get("index", idx)
relevance_score = result.get("relevance_score", 0.0)
if relevance_score is None:
logger.warning(f"结果 {idx} 缺少 relevance_score使用默认值 0.0")
relevance_score = 0.0
rerank_result = RerankResult(
index=index, relevance_score=relevance_score
)
rerank_results.append(rerank_result)
except Exception as e:
logger.warning(f"解析结果 {idx} 时出错: {e}, result={result}")
continue
return rerank_results
def _log_usage(self, data: dict) -> None:
"""记录使用量信息
Args:
data: API响应数据
"""
tokens = data.get("usage", {}).get("total_tokens", 0)
if tokens > 0:
logger.debug(f"百炼 Rerank 消耗 Token: {tokens}")
async def rerank(
self,
query: str,
documents: list[str],
top_n: int | None = None,
) -> list[RerankResult]:
"""
对文档进行重排序
Args:
query: 查询文本
documents: 待排序的文档列表
top_n: 返回前N个结果如果为None则使用配置中的默认值
Returns:
重排序结果列表
"""
if not documents:
logger.warning("文档列表为空,返回空结果")
return []
if not query.strip():
logger.warning("查询文本为空,返回空结果")
return []
# 检查限制
if len(documents) > 500:
logger.warning(
f"文档数量({len(documents)})超过限制(500)将截断前500个文档"
)
documents = documents[:500]
try:
# 构建请求载荷如果top_n为None则返回所有重排序结果
payload = self._build_payload(query, documents, top_n)
logger.debug(
f"百炼 Rerank 请求: query='{query[:50]}...', 文档数量={len(documents)}"
)
# 发送请求
async with self.client.post(self.base_url, json=payload) as response:
response.raise_for_status()
response_data = await response.json()
# 解析结果并记录使用量
results = self._parse_results(response_data)
self._log_usage(response_data)
logger.debug(f"百炼 Rerank 成功返回 {len(results)} 个结果")
return results
except aiohttp.ClientError as e:
error_msg = f"网络请求失败: {e}"
logger.error(f"百炼 Rerank 网络请求失败: {e}")
raise BailianNetworkError(error_msg) from e
except BailianRerankError:
raise
except Exception as e:
error_msg = f"重排序失败: {e}"
logger.error(f"百炼 Rerank 处理失败: {e}")
raise BailianRerankError(error_msg) from e
async def terminate(self) -> None:
"""关闭HTTP客户端会话."""
if self.client:
logger.info("关闭 百炼 Rerank 客户端会话")
try:
await self.client.close()
except Exception as e:
logger.error(f"关闭 百炼 Rerank 客户端时出错: {e}")
finally:
self.client = None

View File

@@ -0,0 +1,650 @@
import base64
import hashlib
import json
import os
from collections.abc import AsyncGenerator
import astrbot.core.message.components as Comp
from astrbot import logger
from astrbot.api.provider import Provider
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import LLMResponse
from ..register import register_provider_adapter
from .coze_api_client import CozeAPIClient
@register_provider_adapter("coze", "Coze (扣子) 智能体适配器")
class ProviderCoze(Provider):
def __init__(
self,
provider_config,
provider_settings,
) -> None:
super().__init__(
provider_config,
provider_settings,
)
self.api_key = provider_config.get("coze_api_key", "")
if not self.api_key:
raise Exception("Coze API Key 不能为空。")
self.bot_id = provider_config.get("bot_id", "")
if not self.bot_id:
raise Exception("Coze Bot ID 不能为空。")
self.api_base: str = provider_config.get("coze_api_base", "https://api.coze.cn")
if not isinstance(self.api_base, str) or not self.api_base.startswith(
("http://", "https://"),
):
raise Exception(
"Coze API Base URL 格式不正确,必须以 http:// 或 https:// 开头。",
)
self.timeout = provider_config.get("timeout", 120)
if isinstance(self.timeout, str):
self.timeout = int(self.timeout)
self.auto_save_history = provider_config.get("auto_save_history", True)
self.conversation_ids: dict[str, str] = {}
self.file_id_cache: dict[str, dict[str, str]] = {}
# 创建 API 客户端
self.api_client = CozeAPIClient(api_key=self.api_key, api_base=self.api_base)
def _generate_cache_key(self, data: str, is_base64: bool = False) -> str:
"""生成统一的缓存键
Args:
data: 图片数据或路径
is_base64: 是否是 base64 数据
Returns:
str: 缓存键
"""
try:
if is_base64 and data.startswith("data:image/"):
try:
header, encoded = data.split(",", 1)
image_bytes = base64.b64decode(encoded)
cache_key = hashlib.md5(image_bytes).hexdigest()
return cache_key
except Exception:
cache_key = hashlib.md5(encoded.encode("utf-8")).hexdigest()
return cache_key
elif data.startswith(("http://", "https://")):
# URL图片使用URL作为缓存键
cache_key = hashlib.md5(data.encode("utf-8")).hexdigest()
return cache_key
else:
clean_path = (
data.split("_")[0]
if "_" in data and len(data.split("_")) >= 3
else data
)
if os.path.exists(clean_path):
with open(clean_path, "rb") as f:
file_content = f.read()
cache_key = hashlib.md5(file_content).hexdigest()
return cache_key
cache_key = hashlib.md5(clean_path.encode("utf-8")).hexdigest()
return cache_key
except Exception as e:
cache_key = hashlib.md5(data.encode("utf-8")).hexdigest()
logger.debug(f"[Coze] 异常文件缓存键: {cache_key}, error={e}")
return cache_key
async def _upload_file(
self,
file_data: bytes,
session_id: str | None = None,
cache_key: str | None = None,
) -> str:
"""上传文件到 Coze 并返回 file_id"""
# 使用 API 客户端上传文件
file_id = await self.api_client.upload_file(file_data)
# 缓存 file_id
if session_id and cache_key:
if session_id not in self.file_id_cache:
self.file_id_cache[session_id] = {}
self.file_id_cache[session_id][cache_key] = file_id
logger.debug(f"[Coze] 图片上传成功并缓存file_id: {file_id}")
return file_id
async def _download_and_upload_image(
self,
image_url: str,
session_id: str | None = None,
) -> str:
"""下载图片并上传到 Coze返回 file_id"""
# 计算哈希实现缓存
cache_key = self._generate_cache_key(image_url) if session_id else None
if session_id and cache_key:
if session_id not in self.file_id_cache:
self.file_id_cache[session_id] = {}
if cache_key in self.file_id_cache[session_id]:
file_id = self.file_id_cache[session_id][cache_key]
return file_id
try:
image_data = await self.api_client.download_image(image_url)
file_id = await self._upload_file(image_data, session_id, cache_key)
if session_id and cache_key:
self.file_id_cache[session_id][cache_key] = file_id
return file_id
except Exception as e:
logger.error(f"处理图片失败 {image_url}: {e!s}")
raise Exception(f"处理图片失败: {e!s}")
async def _process_context_images(
self,
content: str | list,
session_id: str,
) -> str:
"""处理上下文中的图片内容,将 base64 图片上传并替换为 file_id"""
try:
if isinstance(content, str):
return content
processed_content = []
if session_id not in self.file_id_cache:
self.file_id_cache[session_id] = {}
for item in content:
if not isinstance(item, dict):
processed_content.append(item)
continue
if item.get("type") == "text":
processed_content.append(item)
elif item.get("type") == "image_url":
# 处理图片逻辑
if "file_id" in item:
# 已经有 file_id
logger.debug(f"[Coze] 图片已有file_id: {item['file_id']}")
processed_content.append(item)
else:
# 获取图片数据
image_data = ""
if "image_url" in item and isinstance(item["image_url"], dict):
image_data = item["image_url"].get("url", "")
elif "data" in item:
image_data = item.get("data", "")
elif "url" in item:
image_data = item.get("url", "")
if not image_data:
continue
# 计算哈希用于缓存
cache_key = self._generate_cache_key(
image_data,
is_base64=image_data.startswith("data:image/"),
)
# 检查缓存
if cache_key in self.file_id_cache[session_id]:
file_id = self.file_id_cache[session_id][cache_key]
processed_content.append(
{"type": "image", "file_id": file_id},
)
else:
# 上传图片并缓存
if image_data.startswith("data:image/"):
# base64 处理
_, encoded = image_data.split(",", 1)
image_bytes = base64.b64decode(encoded)
file_id = await self._upload_file(
image_bytes,
session_id,
cache_key,
)
elif image_data.startswith(("http://", "https://")):
# URL 图片
file_id = await self._download_and_upload_image(
image_data,
session_id,
)
# 为URL图片也添加缓存
self.file_id_cache[session_id][cache_key] = file_id
elif os.path.exists(image_data):
# 本地文件
with open(image_data, "rb") as f:
image_bytes = f.read()
file_id = await self._upload_file(
image_bytes,
session_id,
cache_key,
)
else:
logger.warning(
f"无法处理的图片格式: {image_data[:50]}...",
)
continue
processed_content.append(
{"type": "image", "file_id": file_id},
)
result = json.dumps(processed_content, ensure_ascii=False)
return result
except Exception as e:
logger.error(f"处理上下文图片失败: {e!s}")
if isinstance(content, str):
return content
return json.dumps(content, ensure_ascii=False)
async def text_chat(
self,
prompt: str,
session_id=None,
image_urls=None,
func_tool=None,
contexts=None,
system_prompt=None,
tool_calls_result=None,
model=None,
**kwargs,
) -> LLMResponse:
"""文本对话, 内部使用流式接口实现非流式
Args:
prompt (str): 用户提示词
session_id (str): 会话ID
image_urls (List[str]): 图片URL列表
func_tool (FuncCall): 函数调用工具(不支持)
contexts (List): 上下文列表
system_prompt (str): 系统提示语
tool_calls_result (ToolCallsResult | List[ToolCallsResult]): 工具调用结果(不支持)
model (str): 模型名称(不支持)
Returns:
LLMResponse: LLM响应对象
"""
accumulated_content = ""
final_response = None
async for llm_response in self.text_chat_stream(
prompt=prompt,
session_id=session_id,
image_urls=image_urls,
func_tool=func_tool,
contexts=contexts,
system_prompt=system_prompt,
tool_calls_result=tool_calls_result,
model=model,
**kwargs,
):
if llm_response.is_chunk:
if llm_response.completion_text:
accumulated_content += llm_response.completion_text
else:
final_response = llm_response
if final_response:
return final_response
if accumulated_content:
chain = MessageChain(chain=[Comp.Plain(accumulated_content)])
return LLMResponse(role="assistant", result_chain=chain)
return LLMResponse(role="assistant", completion_text="")
async def text_chat_stream(
self,
prompt: str,
session_id=None,
image_urls=None,
func_tool=None,
contexts=None,
system_prompt=None,
tool_calls_result=None,
model=None,
**kwargs,
) -> AsyncGenerator[LLMResponse, None]:
"""流式对话接口"""
# 用户ID参数(参考文档, 可以自定义)
user_id = session_id or kwargs.get("user", "default_user")
# 获取或创建会话ID
conversation_id = self.conversation_ids.get(user_id)
# 构建消息
additional_messages = []
if system_prompt:
if not self.auto_save_history or not conversation_id:
additional_messages.append(
{
"role": "system",
"content": system_prompt,
"content_type": "text",
},
)
contexts = self._ensure_message_to_dicts(contexts)
if not self.auto_save_history and contexts:
# 如果关闭了自动保存历史,传入上下文
for ctx in contexts:
if isinstance(ctx, dict) and "role" in ctx and "content" in ctx:
content = ctx["content"]
content_type = ctx.get("content_type", "text")
# 处理可能包含图片的上下文
if (
content_type == "object_string"
or (isinstance(content, str) and content.startswith("["))
or (
isinstance(content, list)
and any(
isinstance(item, dict)
and item.get("type") == "image_url"
for item in content
)
)
):
processed_content = await self._process_context_images(
content,
user_id,
)
additional_messages.append(
{
"role": ctx["role"],
"content": processed_content,
"content_type": "object_string",
},
)
else:
# 纯文本
additional_messages.append(
{
"role": ctx["role"],
"content": (
content
if isinstance(content, str)
else json.dumps(content, ensure_ascii=False)
),
"content_type": "text",
},
)
else:
logger.info(f"[Coze] 跳过格式不正确的上下文: {ctx}")
if prompt or image_urls:
if image_urls:
# 多模态
object_string_content = []
if prompt:
object_string_content.append({"type": "text", "text": prompt})
for url in image_urls:
try:
if url.startswith(("http://", "https://")):
# 网络图片
file_id = await self._download_and_upload_image(
url,
user_id,
)
else:
# 本地文件或 base64
if url.startswith("data:image/"):
# base64
_, encoded = url.split(",", 1)
image_data = base64.b64decode(encoded)
cache_key = self._generate_cache_key(
url,
is_base64=True,
)
file_id = await self._upload_file(
image_data,
user_id,
cache_key,
)
# 本地文件
elif os.path.exists(url):
with open(url, "rb") as f:
image_data = f.read()
# 用文件路径和修改时间来缓存
file_stat = os.stat(url)
cache_key = self._generate_cache_key(
f"{url}_{file_stat.st_mtime}_{file_stat.st_size}",
is_base64=False,
)
file_id = await self._upload_file(
image_data,
user_id,
cache_key,
)
else:
logger.warning(f"图片文件不存在: {url}")
continue
object_string_content.append(
{
"type": "image",
"file_id": file_id,
},
)
except Exception as e:
logger.error(f"处理图片失败 {url}: {e!s}")
continue
if object_string_content:
content = json.dumps(object_string_content, ensure_ascii=False)
additional_messages.append(
{
"role": "user",
"content": content,
"content_type": "object_string",
},
)
# 纯文本
elif prompt:
additional_messages.append(
{
"role": "user",
"content": prompt,
"content_type": "text",
},
)
try:
accumulated_content = ""
message_started = False
async for chunk in self.api_client.chat_messages(
bot_id=self.bot_id,
user_id=user_id,
additional_messages=additional_messages,
conversation_id=conversation_id,
auto_save_history=self.auto_save_history,
stream=True,
timeout=self.timeout,
):
event_type = chunk.get("event")
data = chunk.get("data", {})
if event_type == "conversation.chat.created":
if isinstance(data, dict) and "conversation_id" in data:
self.conversation_ids[user_id] = data["conversation_id"]
elif event_type == "conversation.message.delta":
if isinstance(data, dict):
content = data.get("content", "")
if not content and "delta" in data:
content = data["delta"].get("content", "")
if not content and "text" in data:
content = data.get("text", "")
if content:
message_started = True
accumulated_content += content
yield LLMResponse(
role="assistant",
completion_text=content,
is_chunk=True,
)
elif event_type == "conversation.message.completed":
if isinstance(data, dict):
msg_type = data.get("type")
if msg_type == "answer" and data.get("role") == "assistant":
final_content = data.get("content", "")
if not accumulated_content and final_content:
chain = MessageChain(chain=[Comp.Plain(final_content)])
yield LLMResponse(
role="assistant",
result_chain=chain,
is_chunk=False,
)
elif event_type == "conversation.chat.completed":
if accumulated_content:
chain = MessageChain(chain=[Comp.Plain(accumulated_content)])
yield LLMResponse(
role="assistant",
result_chain=chain,
is_chunk=False,
)
break
elif event_type == "done":
break
elif event_type == "error":
error_msg = (
data.get("message", "未知错误")
if isinstance(data, dict)
else str(data)
)
logger.error(f"Coze 流式响应错误: {error_msg}")
yield LLMResponse(
role="err",
completion_text=f"Coze 错误: {error_msg}",
is_chunk=False,
)
break
if not message_started and not accumulated_content:
yield LLMResponse(
role="assistant",
completion_text="LLM 未响应任何内容。",
is_chunk=False,
)
elif message_started and accumulated_content:
chain = MessageChain(chain=[Comp.Plain(accumulated_content)])
yield LLMResponse(
role="assistant",
result_chain=chain,
is_chunk=False,
)
except Exception as e:
logger.error(f"Coze 流式请求失败: {e!s}")
yield LLMResponse(
role="err",
completion_text=f"Coze 流式请求失败: {e!s}",
is_chunk=False,
)
async def forget(self, session_id: str):
"""清空指定会话的上下文"""
user_id = session_id
conversation_id = self.conversation_ids.get(user_id)
if user_id in self.file_id_cache:
self.file_id_cache.pop(user_id, None)
if not conversation_id:
return True
try:
response = await self.api_client.clear_context(conversation_id)
if "code" in response and response["code"] == 0:
self.conversation_ids.pop(user_id, None)
return True
logger.warning(f"清空 Coze 会话上下文失败: {response}")
return False
except Exception as e:
logger.error(f"清空 Coze 会话失败: {e!s}")
return False
async def get_current_key(self):
"""获取当前API Key"""
return self.api_key
async def set_key(self, key: str):
"""设置新的API Key"""
raise NotImplementedError("Coze 适配器不支持设置 API Key。")
async def get_models(self):
"""获取可用模型列表"""
return [f"bot_{self.bot_id}"]
def get_model(self):
"""获取当前模型"""
return f"bot_{self.bot_id}"
def set_model(self, model: str):
"""设置模型在Coze中是Bot ID"""
if model.startswith("bot_"):
self.bot_id = model[4:]
else:
self.bot_id = model
async def get_human_readable_context(
self,
session_id: str,
page: int = 1,
page_size: int = 10,
):
"""获取人类可读的上下文历史"""
user_id = session_id
conversation_id = self.conversation_ids.get(user_id)
if not conversation_id:
return []
try:
data = await self.api_client.get_message_list(
conversation_id=conversation_id,
order="desc",
limit=page_size,
offset=(page - 1) * page_size,
)
if data.get("code") != 0:
logger.warning(f"获取 Coze 消息历史失败: {data}")
return []
messages = data.get("data", {}).get("messages", [])
readable_history = []
for msg in messages:
role = msg.get("role", "unknown")
content = msg.get("content", "")
msg_type = msg.get("type", "")
if role == "user":
readable_history.append(f"用户: {content}")
elif role == "assistant" and msg_type == "answer":
readable_history.append(f"助手: {content}")
return readable_history
except Exception as e:
logger.error(f"获取 Coze 消息历史失败: {e!s}")
return []
async def terminate(self):
"""清理资源"""
await self.api_client.close()

View File

@@ -0,0 +1,207 @@
import asyncio
import functools
import re
from dashscope import Application
from dashscope.app.application_response import ApplicationResponse
from astrbot.core import logger, sp
from astrbot.core.message.message_event_result import MessageChain
from .. import Provider
from ..entities import LLMResponse
from ..register import register_provider_adapter
from .openai_source import ProviderOpenAIOfficial
@register_provider_adapter("dashscope", "Dashscope APP 适配器。")
class ProviderDashscope(ProviderOpenAIOfficial):
def __init__(
self,
provider_config: dict,
provider_settings: dict,
) -> None:
Provider.__init__(
self,
provider_config,
provider_settings,
)
self.api_key = provider_config.get("dashscope_api_key", "")
if not self.api_key:
raise Exception("阿里云百炼 API Key 不能为空。")
self.app_id = provider_config.get("dashscope_app_id", "")
if not self.app_id:
raise Exception("阿里云百炼 APP ID 不能为空。")
self.dashscope_app_type = provider_config.get("dashscope_app_type", "")
if not self.dashscope_app_type:
raise Exception("阿里云百炼 APP 类型不能为空。")
self.model_name = "dashscope"
self.variables: dict = provider_config.get("variables", {})
self.rag_options: dict = provider_config.get("rag_options", {})
self.output_reference = self.rag_options.get("output_reference", False)
self.rag_options = self.rag_options.copy()
self.rag_options.pop("output_reference", None)
self.timeout = provider_config.get("timeout", 120)
if isinstance(self.timeout, str):
self.timeout = int(self.timeout)
def has_rag_options(self):
"""判断是否有 RAG 选项
Returns:
bool: 是否有 RAG 选项
"""
if self.rag_options and (
len(self.rag_options.get("pipeline_ids", [])) > 0
or len(self.rag_options.get("file_ids", [])) > 0
):
return True
return False
async def text_chat(
self,
prompt: str,
session_id=None,
image_urls=None,
func_tool=None,
contexts=None,
system_prompt=None,
model=None,
**kwargs,
) -> LLMResponse:
if image_urls is None:
image_urls = []
if contexts is None:
contexts = []
# 获得会话变量
payload_vars = self.variables.copy()
# 动态变量
session_var = await sp.session_get(session_id, "session_variables", default={})
payload_vars.update(session_var)
if (
self.dashscope_app_type in ["agent", "dialog-workflow"]
and not self.has_rag_options()
):
# 支持多轮对话的
new_record = {"role": "user", "content": prompt}
if image_urls:
logger.warning("阿里云百炼暂不支持图片输入,将自动忽略图片内容。")
contexts_no_img = await self._remove_image_from_context(contexts)
context_query = [*contexts_no_img, new_record]
if system_prompt:
context_query.insert(0, {"role": "system", "content": system_prompt})
for part in context_query:
if "_no_save" in part:
del part["_no_save"]
# 调用阿里云百炼 API
payload = {
"app_id": self.app_id,
"api_key": self.api_key,
"messages": context_query,
"biz_params": payload_vars or None,
}
partial = functools.partial(
Application.call,
**payload,
)
response = await asyncio.get_event_loop().run_in_executor(None, partial)
else:
# 不支持多轮对话的
# 调用阿里云百炼 API
payload = {
"app_id": self.app_id,
"prompt": prompt,
"api_key": self.api_key,
"biz_params": payload_vars or None,
}
if self.rag_options:
payload["rag_options"] = self.rag_options
partial = functools.partial(
Application.call,
**payload,
)
response = await asyncio.get_event_loop().run_in_executor(None, partial)
assert isinstance(response, ApplicationResponse)
logger.debug(f"dashscope resp: {response}")
if response.status_code != 200:
logger.error(
f"阿里云百炼请求失败: request_id={response.request_id}, code={response.status_code}, message={response.message}, 请参考文档https://help.aliyun.com/zh/model-studio/developer-reference/error-code",
)
return LLMResponse(
role="err",
result_chain=MessageChain().message(
f"阿里云百炼请求失败: message={response.message} code={response.status_code}",
),
)
output_text = response.output.get("text", "") or ""
# RAG 引用脚标格式化
output_text = re.sub(r"<ref>\[(\d+)\]</ref>", r"[\1]", output_text)
if self.output_reference and response.output.get("doc_references", None):
ref_parts = []
for ref in response.output.get("doc_references", []) or []:
ref_title = (
ref.get("title", "")
if ref.get("title")
else ref.get("doc_name", "")
)
ref_parts.append(f"{ref['index_id']}. {ref_title}\n")
ref_str = "".join(ref_parts)
output_text += f"\n\n回答来源:\n{ref_str}"
llm_response = LLMResponse("assistant")
llm_response.result_chain = MessageChain().message(output_text)
return llm_response
async def text_chat_stream(
self,
prompt,
session_id=None,
image_urls=...,
func_tool=None,
contexts=...,
system_prompt=None,
tool_calls_result=None,
model=None,
**kwargs,
):
# raise NotImplementedError("This method is not implemented yet.")
# 调用 text_chat 模拟流式
llm_response = await self.text_chat(
prompt=prompt,
session_id=session_id,
image_urls=image_urls,
func_tool=func_tool,
contexts=contexts,
system_prompt=system_prompt,
tool_calls_result=tool_calls_result,
)
llm_response.is_chunk = True
yield llm_response
llm_response.is_chunk = False
yield llm_response
async def forget(self, session_id):
return True
async def get_current_key(self):
return self.api_key
async def set_key(self, key):
raise Exception("阿里云百炼 适配器不支持设置 API Key。")
async def get_models(self):
return [self.get_model()]
async def get_human_readable_context(self, session_id, page, page_size):
raise Exception("暂不支持获得 阿里云百炼 的历史消息记录。")
async def terminate(self):
pass

View File

@@ -0,0 +1,285 @@
import os
import astrbot.core.message.components as Comp
from astrbot.core import logger, sp
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.core.utils.dify_api_client import DifyAPIClient
from astrbot.core.utils.io import download_file, download_image_by_url
from .. import Provider
from ..entities import LLMResponse
from ..register import register_provider_adapter
@register_provider_adapter("dify", "Dify APP 适配器。")
class ProviderDify(Provider):
def __init__(
self,
provider_config,
provider_settings,
) -> None:
super().__init__(
provider_config,
provider_settings,
)
self.api_key = provider_config.get("dify_api_key", "")
if not self.api_key:
raise Exception("Dify API Key 不能为空。")
api_base = provider_config.get("dify_api_base", "https://api.dify.ai/v1")
self.api_type = provider_config.get("dify_api_type", "")
if not self.api_type:
raise Exception("Dify API 类型不能为空。")
self.model_name = "dify"
self.workflow_output_key = provider_config.get(
"dify_workflow_output_key",
"astrbot_wf_output",
)
self.dify_query_input_key = provider_config.get(
"dify_query_input_key",
"astrbot_text_query",
)
if not self.dify_query_input_key:
self.dify_query_input_key = "astrbot_text_query"
if not self.workflow_output_key:
self.workflow_output_key = "astrbot_wf_output"
self.variables: dict = provider_config.get("variables", {})
self.timeout = provider_config.get("timeout", 120)
if isinstance(self.timeout, str):
self.timeout = int(self.timeout)
self.conversation_ids = {}
"""记录当前 session id 的对话 ID"""
self.api_client = DifyAPIClient(self.api_key, api_base)
async def text_chat(
self,
prompt: str,
session_id=None,
image_urls=None,
func_tool=None,
contexts=None,
system_prompt=None,
tool_calls_result=None,
model=None,
**kwargs,
) -> LLMResponse:
if image_urls is None:
image_urls = []
result = ""
session_id = session_id or kwargs.get("user") or "unknown" # 1734
conversation_id = self.conversation_ids.get(session_id, "")
files_payload = []
for image_url in image_urls:
image_path = (
await download_image_by_url(image_url)
if image_url.startswith("http")
else image_url
)
file_response = await self.api_client.file_upload(
image_path,
user=session_id,
)
logger.debug(f"Dify 上传图片响应:{file_response}")
if "id" not in file_response:
logger.warning(
f"上传图片后得到未知的 Dify 响应:{file_response},图片将忽略。",
)
continue
files_payload.append(
{
"type": "image",
"transfer_method": "local_file",
"upload_file_id": file_response["id"],
},
)
# 获得会话变量
payload_vars = self.variables.copy()
# 动态变量
session_var = await sp.session_get(session_id, "session_variables", default={})
payload_vars.update(session_var)
payload_vars["system_prompt"] = system_prompt
try:
match self.api_type:
case "chat" | "agent" | "chatflow":
if not prompt:
prompt = "请描述这张图片。"
async for chunk in self.api_client.chat_messages(
inputs={
**payload_vars,
},
query=prompt,
user=session_id,
conversation_id=conversation_id,
files=files_payload,
timeout=self.timeout,
):
logger.debug(f"dify resp chunk: {chunk}")
if (
chunk["event"] == "message"
or chunk["event"] == "agent_message"
):
result += chunk["answer"]
if not conversation_id:
self.conversation_ids[session_id] = chunk[
"conversation_id"
]
conversation_id = chunk["conversation_id"]
elif chunk["event"] == "message_end":
logger.debug("Dify message end")
break
elif chunk["event"] == "error":
logger.error(f"Dify 出现错误:{chunk}")
raise Exception(
f"Dify 出现错误 status: {chunk['status']} message: {chunk['message']}",
)
case "workflow":
async for chunk in self.api_client.workflow_run(
inputs={
self.dify_query_input_key: prompt,
"astrbot_session_id": session_id,
**payload_vars,
},
user=session_id,
files=files_payload,
timeout=self.timeout,
):
match chunk["event"]:
case "workflow_started":
logger.info(
f"Dify 工作流(ID: {chunk['workflow_run_id']})开始运行。",
)
case "node_finished":
logger.debug(
f"Dify 工作流节点(ID: {chunk['data']['node_id']} Title: {chunk['data'].get('title', '')})运行结束。",
)
case "workflow_finished":
logger.info(
f"Dify 工作流(ID: {chunk['workflow_run_id']})运行结束",
)
logger.debug(f"Dify 工作流结果:{chunk}")
if chunk["data"]["error"]:
logger.error(
f"Dify 工作流出现错误:{chunk['data']['error']}",
)
raise Exception(
f"Dify 工作流出现错误:{chunk['data']['error']}",
)
if (
self.workflow_output_key
not in chunk["data"]["outputs"]
):
raise Exception(
f"Dify 工作流的输出不包含指定的键名:{self.workflow_output_key}",
)
result = chunk
case _:
raise Exception(f"未知的 Dify API 类型:{self.api_type}")
except Exception as e:
logger.error(f"Dify 请求失败:{e!s}")
return LLMResponse(role="err", completion_text=f"Dify 请求失败:{e!s}")
if not result:
logger.warning("Dify 请求结果为空,请查看 Debug 日志。")
chain = await self.parse_dify_result(result)
return LLMResponse(role="assistant", result_chain=chain)
async def text_chat_stream(
self,
prompt,
session_id=None,
image_urls=...,
func_tool=None,
contexts=...,
system_prompt=None,
tool_calls_result=None,
model=None,
**kwargs,
):
# raise NotImplementedError("This method is not implemented yet.")
# 调用 text_chat 模拟流式
llm_response = await self.text_chat(
prompt=prompt,
session_id=session_id,
image_urls=image_urls,
func_tool=func_tool,
contexts=contexts,
system_prompt=system_prompt,
tool_calls_result=tool_calls_result,
)
llm_response.is_chunk = True
yield llm_response
llm_response.is_chunk = False
yield llm_response
async def parse_dify_result(self, chunk: dict | str) -> MessageChain:
if isinstance(chunk, str):
# Chat
return MessageChain(chain=[Comp.Plain(chunk)])
async def parse_file(item: dict):
match item["type"]:
case "image":
return Comp.Image(file=item["url"], url=item["url"])
case "audio":
# 仅支持 wav
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
path = os.path.join(temp_dir, f"{item['filename']}.wav")
await download_file(item["url"], path)
return Comp.Image(file=item["url"], url=item["url"])
case "video":
return Comp.Video(file=item["url"])
case _:
return Comp.File(name=item["filename"], file=item["url"])
output = chunk["data"]["outputs"][self.workflow_output_key]
chains = []
if isinstance(output, str):
# 纯文本输出
chains.append(Comp.Plain(output))
elif isinstance(output, list):
# 主要适配 Dify 的 HTTP 请求结点的多模态输出
for item in output:
# handle Array[File]
if (
not isinstance(item, dict)
or item.get("dify_model_identity", "") != "__dify__file__"
):
chains.append(Comp.Plain(str(output)))
break
else:
chains.append(Comp.Plain(str(output)))
# scan file
files = chunk["data"].get("files", [])
for item in files:
comp = await parse_file(item)
chains.append(comp)
return MessageChain(chain=chains)
async def forget(self, session_id):
self.conversation_ids[session_id] = ""
return True
async def get_current_key(self):
return self.api_key
async def set_key(self, key):
raise Exception("Dify 适配器不支持设置 API Key。")
async def get_models(self):
return [self.get_model()]
async def get_human_readable_context(self, session_id, page, page_size):
raise Exception("暂不支持获得 Dify 的历史消息记录。")
async def terminate(self):
await self.api_client.close()

View File

@@ -111,9 +111,9 @@ class ProviderGoogleGenAI(Provider):
f"检测到 Key 异常({e.message}),且已没有可用的 Key。 当前 Key: {self.chosen_api_key[:12]}...",
)
raise Exception("达到了 Gemini 速率限制, 请稍后再试...")
# logger.error(
# f"发生了错误(gemini_source)。Provider 配置如下: {self.provider_config}",
# )
logger.error(
f"发生了错误(gemini_source)。Provider 配置如下: {self.provider_config}",
)
raise e
async def _prepare_query_config(

View File

@@ -433,7 +433,7 @@ class ProviderOpenAIOfficial(Provider):
)
payloads.pop("tools", None)
return False, chosen_key, available_api_keys, payloads, context_query, None
# logger.error(f"发生了错误。Provider 配置如下: {self.provider_config}")
logger.error(f"发生了错误。Provider 配置如下: {self.provider_config}")
if "tool" in str(e).lower() and "support" in str(e).lower():
logger.error("疑似该模型不支持函数调用工具调用。请输入 /tool off_all")

View File

@@ -14,6 +14,7 @@ from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.conversation_mgr import ConversationManager
from astrbot.core.db import BaseDatabase
from astrbot.core.knowledge_base.kb_mgr import KnowledgeBaseManager
from astrbot.core.memory.memory_manager import MemoryManager
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.persona_mgr import PersonaManager
from astrbot.core.platform import Platform
@@ -65,6 +66,7 @@ class Context:
persona_manager: PersonaManager,
astrbot_config_mgr: AstrBotConfigManager,
knowledge_base_manager: KnowledgeBaseManager,
memory_manager: MemoryManager,
):
self._event_queue = event_queue
"""事件队列。消息平台通过事件队列传递消息事件。"""
@@ -79,6 +81,7 @@ class Context:
self.persona_manager = persona_manager
self.astrbot_config_mgr = astrbot_config_mgr
self.kb_manager = knowledge_base_manager
self.memory_manager = memory_manager
async def llm_generate(
self,

View File

@@ -171,3 +171,110 @@ class SessionServiceManager:
# 如果没有配置,默认为启用(兼容性考虑)
return True
@staticmethod
def set_session_status(session_id: str, enabled: bool) -> None:
"""设置会话的整体启停状态
Args:
session_id: 会话ID (unified_msg_origin)
enabled: True表示启用False表示禁用
"""
session_config = (
sp.get("session_service_config", {}, scope="umo", scope_id=session_id) or {}
)
session_config["session_enabled"] = enabled
sp.put(
"session_service_config",
session_config,
scope="umo",
scope_id=session_id,
)
logger.info(
f"会话 {session_id} 的整体状态已更新为: {'启用' if enabled else '禁用'}",
)
@staticmethod
def should_process_session_request(event: AstrMessageEvent) -> bool:
"""检查是否应该处理会话请求(会话整体启停检查)
Args:
event: 消息事件
Returns:
bool: True表示应该处理False表示跳过
"""
session_id = event.unified_msg_origin
return SessionServiceManager.is_session_enabled(session_id)
# =============================================================================
# 会话命名相关方法
# =============================================================================
@staticmethod
def get_session_custom_name(session_id: str) -> str | None:
"""获取会话的自定义名称
Args:
session_id: 会话ID (unified_msg_origin)
Returns:
str: 自定义名称如果没有设置则返回None
"""
session_services = sp.get(
"session_service_config",
{},
scope="umo",
scope_id=session_id,
)
return session_services.get("custom_name")
@staticmethod
def set_session_custom_name(session_id: str, custom_name: str) -> None:
"""设置会话的自定义名称
Args:
session_id: 会话ID (unified_msg_origin)
custom_name: 自定义名称,可以为空字符串来清除名称
"""
session_config = (
sp.get("session_service_config", {}, scope="umo", scope_id=session_id) or {}
)
if custom_name and custom_name.strip():
session_config["custom_name"] = custom_name.strip()
else:
# 如果传入空名称,则删除自定义名称
session_config.pop("custom_name", None)
sp.put(
"session_service_config",
session_config,
scope="umo",
scope_id=session_id,
)
logger.info(
f"会话 {session_id} 的自定义名称已更新为: {custom_name.strip() if custom_name and custom_name.strip() else '已清除'}",
)
@staticmethod
def get_session_display_name(session_id: str) -> str:
"""获取会话的显示名称优先显示自定义名称否则显示原始session_id的最后一段
Args:
session_id: 会话ID (unified_msg_origin)
Returns:
str: 显示名称
"""
custom_name = SessionServiceManager.get_session_custom_name(session_id)
if custom_name:
return custom_name
# 如果没有自定义名称返回session_id的最后一段
return session_id.split(":")[2] if session_id.count(":") >= 2 else session_id

View File

@@ -42,6 +42,87 @@ class SessionPluginManager:
# 如果都没有配置,默认为启用(兼容性考虑)
return True
@staticmethod
def set_plugin_status_for_session(
session_id: str,
plugin_name: str,
enabled: bool,
) -> None:
"""设置插件在指定会话中的启停状态
Args:
session_id: 会话ID (unified_msg_origin)
plugin_name: 插件名称
enabled: True表示启用False表示禁用
"""
# 获取当前配置
session_plugin_config = sp.get(
"session_plugin_config",
{},
scope="umo",
scope_id=session_id,
)
if session_id not in session_plugin_config:
session_plugin_config[session_id] = {
"enabled_plugins": [],
"disabled_plugins": [],
}
session_config = session_plugin_config[session_id]
enabled_plugins = session_config.get("enabled_plugins", [])
disabled_plugins = session_config.get("disabled_plugins", [])
if enabled:
# 启用插件
if plugin_name in disabled_plugins:
disabled_plugins.remove(plugin_name)
if plugin_name not in enabled_plugins:
enabled_plugins.append(plugin_name)
else:
# 禁用插件
if plugin_name in enabled_plugins:
enabled_plugins.remove(plugin_name)
if plugin_name not in disabled_plugins:
disabled_plugins.append(plugin_name)
# 保存配置
session_config["enabled_plugins"] = enabled_plugins
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,
)
logger.info(
f"会话 {session_id} 的插件 {plugin_name} 状态已更新为: {'启用' if enabled else '禁用'}",
)
@staticmethod
def get_session_plugin_config(session_id: str) -> dict[str, list[str]]:
"""获取指定会话的插件配置
Args:
session_id: 会话ID (unified_msg_origin)
Returns:
Dict[str, List[str]]: 包含enabled_plugins和disabled_plugins的字典
"""
session_plugin_config = sp.get(
"session_plugin_config",
{},
scope="umo",
scope_id=session_id,
)
return session_plugin_config.get(
session_id,
{"enabled_plugins": [], "disabled_plugins": []},
)
@staticmethod
def filter_handlers_by_session(event: AstrMessageEvent, handlers: list) -> list:
"""根据会话配置过滤处理器列表

View File

@@ -85,22 +85,3 @@ class UmopConfigRouter:
self.umop_to_conf_id[umo] = conf_id
await self.sp.global_put("umop_config_routing", self.umop_to_conf_id)
async def delete_route(self, umo: str):
"""删除一条路由
Args:
umo (str): 需要删除的 UMO 字符串
Raises:
ValueError: 当 umo 格式不正确时抛出
"""
if not isinstance(umo, str) or len(umo.split(":")) != 3:
raise ValueError(
"umop must be a string in the format [platform_id]:[message_type]:[session_id], with optional wildcards * or empty for all",
)
if umo in self.umop_to_conf_id:
del self.umop_to_conf_id[umo]
await self.sp.global_put("umop_config_routing", self.umop_to_conf_id)

View File

@@ -3,7 +3,7 @@ import json
from collections.abc import AsyncGenerator
from typing import Any
from aiohttp import ClientResponse, ClientSession, FormData
from aiohttp import ClientResponse, ClientSession
from astrbot.core import logger
@@ -101,59 +101,21 @@ class DifyAPIClient:
async def file_upload(
self,
file_path: str,
user: str,
file_path: str | None = None,
file_data: bytes | None = None,
file_name: str | None = None,
mime_type: str | None = None,
) -> dict[str, Any]:
"""Upload a file to Dify. Must provide either file_path or file_data.
Args:
user: The user ID.
file_path: The path to the file to upload.
file_data: The file data in bytes.
file_name: Optional file name when using file_data.
Returns:
A dictionary containing the uploaded file information.
"""
url = f"{self.api_base}/files/upload"
form = FormData()
form.add_field("user", user)
if file_data is not None:
# 使用 bytes 数据
form.add_field(
"file",
file_data,
filename=file_name or "uploaded_file",
content_type=mime_type or "application/octet-stream",
)
elif file_path is not None:
# 使用文件路径
import os
with open(file_path, "rb") as f:
file_content = f.read()
form.add_field(
"file",
file_content,
filename=os.path.basename(file_path),
content_type=mime_type or "application/octet-stream",
)
else:
raise ValueError("file_path 和 file_data 不能同时为 None")
async with self.session.post(
url,
data=form,
headers=self.headers, # 不包含 Content-Type让 aiohttp 自动设置
) as resp:
if resp.status != 200 and resp.status != 201:
text = await resp.text()
raise Exception(f"Dify 文件上传失败:{resp.status}. {text}")
return await resp.json() # {"id": "xxx", ...}
with open(file_path, "rb") as f:
payload = {
"user": user,
"file": f,
}
async with self.session.post(
url,
data=payload,
headers=self.headers,
) as resp:
return await resp.json() # {"id": "xxx", ...}
async def close(self):
await self.session.close()

View File

@@ -1,23 +0,0 @@
from pathlib import Path
from openai import AsyncOpenAI
async def extract_file_moonshotai(file_path: str, api_key: str) -> str:
"""Extract text from a file using Moonshot AI API"""
"""
Args:
file_path: The path to the file to extract text from
api_key: The API key to use to extract text from the file
Returns:
The text extracted from the file
"""
client = AsyncOpenAI(
api_key=api_key,
base_url="https://api.moonshot.cn/v1",
)
file_object = await client.files.create(
file=Path(file_path),
purpose="file-extract", # type: ignore
)
return (await client.files.content(file_id=file_object.id)).text

View File

@@ -1,73 +0,0 @@
import traceback
from astrbot.core import astrbot_config, logger
from astrbot.core.astrbot_config_mgr import AstrBotConfig, AstrBotConfigManager
from astrbot.core.db.migration.migra_45_to_46 import migrate_45_to_46
from astrbot.core.db.migration.migra_webchat_session import migrate_webchat_session
def _migra_agent_runner_configs(conf: AstrBotConfig, ids_map: dict) -> None:
"""
Migra agent runner configs from provider configs.
"""
try:
default_prov_id = conf["provider_settings"]["default_provider_id"]
if default_prov_id in ids_map:
conf["provider_settings"]["default_provider_id"] = ""
p = ids_map[default_prov_id]
if p["type"] == "dify":
conf["provider_settings"]["dify_agent_runner_provider_id"] = p["id"]
conf["provider_settings"]["agent_runner_type"] = "dify"
elif p["type"] == "coze":
conf["provider_settings"]["coze_agent_runner_provider_id"] = p["id"]
conf["provider_settings"]["agent_runner_type"] = "coze"
elif p["type"] == "dashscope":
conf["provider_settings"]["dashscope_agent_runner_provider_id"] = p[
"id"
]
conf["provider_settings"]["agent_runner_type"] = "dashscope"
conf.save_config()
except Exception as e:
logger.error(f"Migration for third party agent runner configs failed: {e!s}")
logger.error(traceback.format_exc())
async def migra(
db, astrbot_config_mgr, umop_config_router, acm: AstrBotConfigManager
) -> None:
"""
Stores the migration logic here.
btw, i really don't like migration :(
"""
# 4.5 to 4.6 migration for umop_config_router
try:
await migrate_45_to_46(astrbot_config_mgr, umop_config_router)
except Exception as e:
logger.error(f"Migration from version 4.5 to 4.6 failed: {e!s}")
logger.error(traceback.format_exc())
# migration for webchat session
try:
await migrate_webchat_session(db)
except Exception as e:
logger.error(f"Migration for webchat session failed: {e!s}")
logger.error(traceback.format_exc())
# migra third party agent runner configs
_c = False
providers = astrbot_config["provider"]
ids_map = {}
for prov in providers:
type_ = prov.get("type")
if type_ in ["dify", "coze", "dashscope"]:
prov["provider_type"] = "agent_runner"
ids_map[prov["id"]] = {
"type": type_,
"id": prov["id"],
}
_c = True
if _c:
astrbot_config.save_config()
for conf in acm.confs.values():
_migra_agent_runner_configs(conf, ids_map)

View File

@@ -40,6 +40,9 @@ class SharedPreferences:
else:
ret = default
return ret
raise ValueError(
"scope_id and key cannot be None when getting a specific preference.",
)
async def range_get_async(
self,
@@ -53,6 +56,30 @@ class SharedPreferences:
ret = await self.db_helper.get_preferences(scope, scope_id, key)
return ret
@overload
async def session_get(
self,
umo: None,
key: str,
default: Any = None,
) -> list[Preference]: ...
@overload
async def session_get(
self,
umo: str,
key: None,
default: Any = None,
) -> list[Preference]: ...
@overload
async def session_get(
self,
umo: None,
key: None,
default: Any = None,
) -> list[Preference]: ...
async def session_get(
self,
umo: str | None,
@@ -61,7 +88,7 @@ class SharedPreferences:
) -> _VT | list[Preference]:
"""获取会话范围的偏好设置
Note: 当 umo 或者 key 为 None返回 Preference 列表,其中的 value 属性是一个 dictvalue["val"] 为值。
Note: 当 scope_id 或者 key 为 None返回 Preference 列表,其中的 value 属性是一个 dictvalue["val"] 为值。
"""
if umo is None or key is None:
return await self.range_get_async("umo", umo, key)

View File

@@ -5,6 +5,7 @@ from .conversation import ConversationRoute
from .file import FileRoute
from .knowledge_base import KnowledgeBaseRoute
from .log import LogRoute
from .memory import MemoryRoute
from .persona import PersonaRoute
from .plugin import PluginRoute
from .session_management import SessionManagementRoute
@@ -21,6 +22,7 @@ __all__ = [
"FileRoute",
"KnowledgeBaseRoute",
"LogRoute",
"MemoryRoute",
"PersonaRoute",
"PluginRoute",
"SessionManagementRoute",

View File

@@ -56,7 +56,6 @@ class ChatRoute(Route):
self.conv_mgr = core_lifecycle.conversation_manager
self.platform_history_mgr = core_lifecycle.platform_message_history_manager
self.db = db
self.umop_config_router = core_lifecycle.umop_config_router
self.running_convs: dict[str, bool] = {}
@@ -267,8 +266,7 @@ class ChatRoute(Route):
return Response().error("Permission denied").__dict__
# 删除该会话下的所有对话
message_type = "GroupMessage" if session.is_group else "FriendMessage"
unified_msg_origin = f"{session.platform_id}:{message_type}:{session.platform_id}!{username}!{session_id}"
unified_msg_origin = f"{session.platform_id}:FriendMessage:{session.platform_id}!{username}!{session_id}"
await self.conv_mgr.delete_conversations_by_user_id(unified_msg_origin)
# 删除消息历史
@@ -278,16 +276,6 @@ class ChatRoute(Route):
offset_sec=99999999,
)
# 删除与会话关联的配置路由
try:
await self.umop_config_router.delete_route(unified_msg_origin)
except ValueError as exc:
logger.warning(
"Failed to delete UMO route %s during session cleanup: %s",
unified_msg_origin,
exc,
)
# 清理队列(仅对 webchat
if session.platform_id == "webchat":
webchat_queue_mgr.remove_queues(session_id)

View File

@@ -14,12 +14,14 @@ from astrbot.core.config.default import (
DEFAULT_CONFIG,
DEFAULT_VALUE_MAP,
)
from astrbot.core.config.i18n_utils import ConfigMetadataI18n
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.platform.register import platform_cls_map, platform_registry
from astrbot.core.provider import Provider
from astrbot.core.provider.entities import ProviderType
from astrbot.core.provider.provider import RerankProvider
from astrbot.core.provider.register import provider_registry
from astrbot.core.star.star import star_registry
from astrbot.core.utils.astrbot_path import get_astrbot_path
from .route import Response, Route, RouteContext
@@ -131,9 +133,7 @@ def save_config(post_config: dict, config: AstrBotConfig, is_core: bool = False)
is_core,
)
else:
errors, post_config = validate_config(
post_config, getattr(config, "schema", {}), is_core
)
errors, post_config = validate_config(post_config, config.schema, is_core)
except BaseException as e:
logger.error(traceback.format_exc())
logger.warning(f"验证配置时出现异常: {e}")
@@ -247,8 +247,11 @@ class ConfigRoute(Route):
async def get_default_config(self):
"""获取默认配置文件"""
metadata = ConfigMetadataI18n.convert_to_i18n_keys(CONFIG_METADATA_3)
return Response().ok({"config": DEFAULT_CONFIG, "metadata": metadata}).__dict__
return (
Response()
.ok({"config": DEFAULT_CONFIG, "metadata": CONFIG_METADATA_3})
.__dict__
)
async def get_abconf_list(self):
"""获取所有 AstrBot 配置文件的列表"""
@@ -279,15 +282,17 @@ class ConfigRoute(Route):
try:
if system_config:
abconf = self.acm.confs["default"]
metadata = ConfigMetadataI18n.convert_to_i18n_keys(
CONFIG_METADATA_3_SYSTEM
return (
Response()
.ok({"config": abconf, "metadata": CONFIG_METADATA_3_SYSTEM})
.__dict__
)
return Response().ok({"config": abconf, "metadata": metadata}).__dict__
if abconf_id is None:
raise ValueError("abconf_id cannot be None")
abconf = self.acm.confs[abconf_id]
metadata = ConfigMetadataI18n.convert_to_i18n_keys(CONFIG_METADATA_3)
return Response().ok({"config": abconf, "metadata": metadata}).__dict__
return (
Response()
.ok({"config": abconf, "metadata": CONFIG_METADATA_3})
.__dict__
)
except ValueError as e:
return Response().error(str(e)).__dict__
@@ -353,20 +358,169 @@ class ConfigRoute(Route):
f"Attempting to check provider: {status_info['name']} (ID: {status_info['id']}, Type: {status_info['type']}, Model: {status_info['model']})",
)
try:
await provider.test()
status_info["status"] = "available"
logger.info(
f"Provider {status_info['name']} (ID: {status_info['id']}) is available.",
)
except Exception as e:
error_message = str(e)
status_info["error"] = error_message
logger.warning(
f"Provider {status_info['name']} (ID: {status_info['id']}) is unavailable. Error: {error_message}",
)
if provider_capability_type == ProviderType.CHAT_COMPLETION:
try:
logger.debug(f"Sending 'Ping' to provider: {status_info['name']}")
response = await asyncio.wait_for(
provider.text_chat(prompt="REPLY `PONG` ONLY"),
timeout=45.0,
)
logger.debug(
f"Received response from {status_info['name']}: {response}",
)
if response is not None:
status_info["status"] = "available"
response_text_snippet = ""
if (
hasattr(response, "completion_text")
and response.completion_text
):
response_text_snippet = (
response.completion_text[:70] + "..."
if len(response.completion_text) > 70
else response.completion_text
)
elif hasattr(response, "result_chain") and response.result_chain:
try:
response_text_snippet = (
response.result_chain.get_plain_text()[:70] + "..."
if len(response.result_chain.get_plain_text()) > 70
else response.result_chain.get_plain_text()
)
except Exception as _:
pass
logger.info(
f"Provider {status_info['name']} (ID: {status_info['id']}) is available. Response snippet: '{response_text_snippet}'",
)
else:
status_info["error"] = (
"Test call returned None, but expected an LLMResponse object."
)
logger.warning(
f"Provider {status_info['name']} (ID: {status_info['id']}) test call returned None.",
)
except asyncio.TimeoutError:
status_info["error"] = (
"Connection timed out after 45 seconds during test call."
)
logger.warning(
f"Provider {status_info['name']} (ID: {status_info['id']}) timed out.",
)
except Exception as e:
error_message = str(e)
status_info["error"] = error_message
logger.warning(
f"Provider {status_info['name']} (ID: {status_info['id']}) is unavailable. Error: {error_message}",
)
logger.debug(
f"Traceback for {status_info['name']}:\n{traceback.format_exc()}",
)
elif provider_capability_type == ProviderType.EMBEDDING:
try:
# For embedding, we can call the get_embedding method with a short prompt.
embedding_result = await provider.get_embedding("health_check")
if isinstance(embedding_result, list) and (
not embedding_result or isinstance(embedding_result[0], float)
):
status_info["status"] = "available"
else:
status_info["status"] = "unavailable"
status_info["error"] = (
f"Embedding test failed: unexpected result type {type(embedding_result)}"
)
except Exception as e:
logger.error(
f"Error testing embedding provider {provider_name}: {e}",
exc_info=True,
)
status_info["status"] = "unavailable"
status_info["error"] = f"Embedding test failed: {e!s}"
elif provider_capability_type == ProviderType.TEXT_TO_SPEECH:
try:
# For TTS, we can call the get_audio method with a short prompt.
audio_result = await provider.get_audio("你好")
if isinstance(audio_result, str) and audio_result:
status_info["status"] = "available"
else:
status_info["status"] = "unavailable"
status_info["error"] = (
f"TTS test failed: unexpected result type {type(audio_result)}"
)
except Exception as e:
logger.error(
f"Error testing TTS provider {provider_name}: {e}",
exc_info=True,
)
status_info["status"] = "unavailable"
status_info["error"] = f"TTS test failed: {e!s}"
elif provider_capability_type == ProviderType.SPEECH_TO_TEXT:
try:
logger.debug(
f"Sending health check audio to provider: {status_info['name']}",
)
sample_audio_path = os.path.join(
get_astrbot_path(),
"samples",
"stt_health_check.wav",
)
if not os.path.exists(sample_audio_path):
status_info["status"] = "unavailable"
status_info["error"] = (
"STT test failed: sample audio file not found."
)
logger.warning(
f"STT test for {status_info['name']} failed: sample audio file not found at {sample_audio_path}",
)
else:
text_result = await provider.get_text(sample_audio_path)
if isinstance(text_result, str) and text_result:
status_info["status"] = "available"
snippet = (
text_result[:70] + "..."
if len(text_result) > 70
else text_result
)
logger.info(
f"Provider {status_info['name']} (ID: {status_info['id']}) is available. Response snippet: '{snippet}'",
)
else:
status_info["status"] = "unavailable"
status_info["error"] = (
f"STT test failed: unexpected result type {type(text_result)}"
)
logger.warning(
f"STT test for {status_info['name']} failed: unexpected result type {type(text_result)}",
)
except Exception as e:
logger.error(
f"Error testing STT provider {provider_name}: {e}",
exc_info=True,
)
status_info["status"] = "unavailable"
status_info["error"] = f"STT test failed: {e!s}"
elif provider_capability_type == ProviderType.RERANK:
try:
assert isinstance(provider, RerankProvider)
await provider.rerank("Apple", documents=["apple", "banana"])
status_info["status"] = "available"
except Exception as e:
logger.error(
f"Error testing rerank provider {provider_name}: {e}",
exc_info=True,
)
status_info["status"] = "unavailable"
status_info["error"] = f"Rerank test failed: {e!s}"
else:
logger.debug(
f"Traceback for {status_info['name']}:\n{traceback.format_exc()}",
f"Provider {provider_name} is not a Chat Completion or Embedding provider. Marking as available without test. Meta: {meta}",
)
status_info["status"] = "available"
status_info["error"] = (
"This provider type is not tested and is assumed to be available."
)
return status_info
@@ -444,15 +598,9 @@ class ConfigRoute(Route):
return Response().error("缺少参数 provider_id").__dict__
prov_mgr = self.core_lifecycle.provider_manager
provider = prov_mgr.inst_map.get(provider_id, None)
provider: Provider | None = prov_mgr.inst_map.get(provider_id, None)
if not provider:
return Response().error(f"未找到 ID 为 {provider_id} 的提供商").__dict__
if not isinstance(provider, Provider):
return (
Response()
.error(f"提供商 {provider_id} 类型不支持获取模型列表")
.__dict__
)
try:
models = await provider.get_models()

View File

@@ -60,6 +60,10 @@ class KnowledgeBaseRoute(Route):
# "/kb/media/delete": ("POST", self.delete_media),
# 检索
"/kb/retrieve": ("POST", self.retrieve),
# 会话知识库配置
"/kb/session/config/get": ("GET", self.get_session_kb_config),
"/kb/session/config/set": ("POST", self.set_session_kb_config),
"/kb/session/config/delete": ("POST", self.delete_session_kb_config),
}
self.register_routes()
@@ -916,6 +920,158 @@ class KnowledgeBaseRoute(Route):
logger.error(traceback.format_exc())
return Response().error(f"检索失败: {e!s}").__dict__
# ===== 会话知识库配置 API =====
async def get_session_kb_config(self):
"""获取会话的知识库配置
Query 参数:
- session_id: 会话 ID (必填)
返回:
- kb_ids: 知识库 ID 列表
- top_k: 返回结果数量
- enable_rerank: 是否启用重排序
"""
try:
from astrbot.core import sp
session_id = request.args.get("session_id")
if not session_id:
return Response().error("缺少参数 session_id").__dict__
# 从 SharedPreferences 获取配置
config = await sp.session_get(session_id, "kb_config", default={})
logger.debug(f"[KB配置] 读取到配置: session_id={session_id}")
# 如果没有配置,返回默认值
if not config:
config = {"kb_ids": [], "top_k": 5, "enable_rerank": True}
return Response().ok(config).__dict__
except Exception as e:
logger.error(f"[KB配置] 获取配置时出错: {e}", exc_info=True)
return Response().error(f"获取会话知识库配置失败: {e!s}").__dict__
async def set_session_kb_config(self):
"""设置会话的知识库配置
Body:
- scope: 配置范围 (目前只支持 "session")
- scope_id: 会话 ID (必填)
- kb_ids: 知识库 ID 列表 (必填)
- top_k: 返回结果数量 (可选, 默认 5)
- enable_rerank: 是否启用重排序 (可选, 默认 true)
"""
try:
from astrbot.core import sp
data = await request.json
scope = data.get("scope")
scope_id = data.get("scope_id")
kb_ids = data.get("kb_ids", [])
top_k = data.get("top_k", 5)
enable_rerank = data.get("enable_rerank", True)
# 验证参数
if scope != "session":
return Response().error("目前仅支持 session 范围的配置").__dict__
if not scope_id:
return Response().error("缺少参数 scope_id").__dict__
if not isinstance(kb_ids, list):
return Response().error("kb_ids 必须是列表").__dict__
# 验证知识库是否存在
kb_mgr = self._get_kb_manager()
invalid_ids = []
valid_ids = []
for kb_id in kb_ids:
kb_helper = await kb_mgr.get_kb(kb_id)
if kb_helper:
valid_ids.append(kb_id)
else:
invalid_ids.append(kb_id)
logger.warning(f"[KB配置] 知识库不存在: {kb_id}")
if invalid_ids:
logger.warning(f"[KB配置] 以下知识库ID无效: {invalid_ids}")
# 允许保存空列表,表示明确不使用任何知识库
if kb_ids and not valid_ids:
# 只有当用户提供了 kb_ids 但全部无效时才报错
return Response().error(f"所有提供的知识库ID都无效: {kb_ids}").__dict__
# 如果 kb_ids 为空列表,表示用户想清空配置
if not kb_ids:
valid_ids = []
# 构建配置对象只保存有效的ID
config = {
"kb_ids": valid_ids,
"top_k": top_k,
"enable_rerank": enable_rerank,
}
# 保存到 SharedPreferences
await sp.session_put(scope_id, "kb_config", config)
# 立即验证是否保存成功
verify_config = await sp.session_get(scope_id, "kb_config", default={})
if verify_config == config:
return (
Response()
.ok(
{"valid_ids": valid_ids, "invalid_ids": invalid_ids},
"保存知识库配置成功",
)
.__dict__
)
logger.error("[KB配置] 配置保存失败,验证不匹配")
return Response().error("配置保存失败").__dict__
except Exception as e:
logger.error(f"[KB配置] 设置配置时出错: {e}", exc_info=True)
return Response().error(f"设置会话知识库配置失败: {e!s}").__dict__
async def delete_session_kb_config(self):
"""删除会话的知识库配置
Body:
- scope: 配置范围 (目前只支持 "session")
- scope_id: 会话 ID (必填)
"""
try:
from astrbot.core import sp
data = await request.json
scope = data.get("scope")
scope_id = data.get("scope_id")
# 验证参数
if scope != "session":
return Response().error("目前仅支持 session 范围的配置").__dict__
if not scope_id:
return Response().error("缺少参数 scope_id").__dict__
# 从 SharedPreferences 删除配置
await sp.session_remove(scope_id, "kb_config")
return Response().ok(message="删除知识库配置成功").__dict__
except Exception as e:
logger.error(f"删除会话知识库配置失败: {e}")
logger.error(traceback.format_exc())
return Response().error(f"删除会话知识库配置失败: {e!s}").__dict__
async def upload_document_from_url(self):
"""从 URL 上传文档

View File

@@ -0,0 +1,174 @@
"""Memory management API routes"""
from quart import jsonify, request
from astrbot.core import logger
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db import BaseDatabase
from .route import Response, Route, RouteContext
class MemoryRoute(Route):
"""Memory management routes"""
def __init__(
self,
context: RouteContext,
db: BaseDatabase,
core_lifecycle: AstrBotCoreLifecycle,
):
super().__init__(context)
self.db = db
self.core_lifecycle = core_lifecycle
self.memory_manager = core_lifecycle.memory_manager
self.provider_manager = core_lifecycle.provider_manager
self.routes = [
("/memory/status", ("GET", self.get_status)),
("/memory/initialize", ("POST", self.initialize)),
("/memory/update_merge_llm", ("POST", self.update_merge_llm)),
]
self.register_routes()
async def get_status(self):
"""Get memory system status"""
try:
is_initialized = self.memory_manager._initialized
status_data = {
"initialized": is_initialized,
"embedding_provider_id": None,
"merge_llm_provider_id": None,
}
if is_initialized:
# Get embedding provider info
if self.memory_manager.embedding_provider:
status_data["embedding_provider_id"] = (
self.memory_manager.embedding_provider.provider_config["id"]
)
# Get merge LLM provider info
if self.memory_manager.merge_llm_provider:
status_data["merge_llm_provider_id"] = (
self.memory_manager.merge_llm_provider.provider_config["id"]
)
return jsonify(Response().ok(status_data).__dict__)
except Exception as e:
logger.error(f"Failed to get memory status: {e}")
return jsonify(Response().error(str(e)).__dict__)
async def initialize(self):
"""Initialize memory system with embedding and merge LLM providers"""
try:
data = await request.get_json()
embedding_provider_id = data.get("embedding_provider_id")
merge_llm_provider_id = data.get("merge_llm_provider_id")
if not embedding_provider_id or not merge_llm_provider_id:
return jsonify(
Response()
.error(
"embedding_provider_id and merge_llm_provider_id are required"
)
.__dict__,
)
# Check if already initialized
if self.memory_manager._initialized:
return jsonify(
Response()
.error(
"Memory system already initialized. Embedding provider cannot be changed.",
)
.__dict__,
)
# Get providers
embedding_provider = await self.provider_manager.get_provider_by_id(
embedding_provider_id,
)
merge_llm_provider = await self.provider_manager.get_provider_by_id(
merge_llm_provider_id,
)
if not embedding_provider:
return jsonify(
Response()
.error(f"Embedding provider {embedding_provider_id} not found")
.__dict__,
)
if not merge_llm_provider:
return jsonify(
Response()
.error(f"Merge LLM provider {merge_llm_provider_id} not found")
.__dict__,
)
# Initialize memory manager
await self.memory_manager.initialize(
embedding_provider=embedding_provider,
merge_llm_provider=merge_llm_provider,
)
logger.info(
f"Memory system initialized with embedding: {embedding_provider_id}, "
f"merge LLM: {merge_llm_provider_id}",
)
return jsonify(
Response()
.ok({"message": "Memory system initialized successfully"})
.__dict__,
)
except Exception as e:
logger.error(f"Failed to initialize memory system: {e}")
return jsonify(Response().error(str(e)).__dict__)
async def update_merge_llm(self):
"""Update merge LLM provider (only allowed after initialization)"""
try:
data = await request.get_json()
merge_llm_provider_id = data.get("merge_llm_provider_id")
if not merge_llm_provider_id:
return jsonify(
Response().error("merge_llm_provider_id is required").__dict__,
)
# Check if initialized
if not self.memory_manager._initialized:
return jsonify(
Response()
.error("Memory system not initialized. Please initialize first.")
.__dict__,
)
# Get new merge LLM provider
merge_llm_provider = await self.provider_manager.get_provider_by_id(
merge_llm_provider_id,
)
if not merge_llm_provider:
return jsonify(
Response()
.error(f"Merge LLM provider {merge_llm_provider_id} not found")
.__dict__,
)
# Update merge LLM provider
self.memory_manager.merge_llm_provider = merge_llm_provider
logger.info(f"Updated merge LLM provider to: {merge_llm_provider_id}")
return jsonify(
Response()
.ok({"message": "Merge LLM provider updated successfully"})
.__dict__,
)
except Exception as e:
logger.error(f"Failed to update merge LLM provider: {e}")
return jsonify(Response().error(str(e)).__dict__)

View File

@@ -1,4 +1,3 @@
import asyncio
import json
import os
import ssl
@@ -20,10 +19,6 @@ from astrbot.core.star.star_manager import PluginManager
from .route import Response, Route, RouteContext
PLUGIN_UPDATE_CONCURRENCY = (
3 # limit concurrent updates to avoid overwhelming plugin sources
)
class PluginRoute(Route):
def __init__(
@@ -38,7 +33,6 @@ class PluginRoute(Route):
"/plugin/install": ("POST", self.install_plugin),
"/plugin/install-upload": ("POST", self.install_plugin_upload),
"/plugin/update": ("POST", self.update_plugin),
"/plugin/update-all": ("POST", self.update_all_plugins),
"/plugin/uninstall": ("POST", self.uninstall_plugin),
"/plugin/market_list": ("GET", self.get_online_plugins),
"/plugin/off": ("POST", self.off_plugin),
@@ -69,7 +63,7 @@ class PluginRoute(Route):
.__dict__
)
data = await request.get_json()
data = await request.json
plugin_name = data.get("name", None)
try:
success, message = await self.plugin_manager.reload(plugin_name)
@@ -352,7 +346,7 @@ class PluginRoute(Route):
.__dict__
)
post_data = await request.get_json()
post_data = await request.json
repo_url = post_data["url"]
proxy: str = post_data.get("proxy", None)
@@ -399,7 +393,7 @@ class PluginRoute(Route):
.__dict__
)
post_data = await request.get_json()
post_data = await request.json
plugin_name = post_data["name"]
delete_config = post_data.get("delete_config", False)
delete_data = post_data.get("delete_data", False)
@@ -424,7 +418,7 @@ class PluginRoute(Route):
.__dict__
)
post_data = await request.get_json()
post_data = await request.json
plugin_name = post_data["name"]
proxy: str = post_data.get("proxy", None)
try:
@@ -438,59 +432,6 @@ class PluginRoute(Route):
logger.error(f"/api/plugin/update: {traceback.format_exc()}")
return Response().error(str(e)).__dict__
async def update_all_plugins(self):
if DEMO_MODE:
return (
Response()
.error("You are not permitted to do this operation in demo mode")
.__dict__
)
post_data = await request.get_json()
plugin_names: list[str] = post_data.get("names") or []
proxy: str = post_data.get("proxy", "")
if not isinstance(plugin_names, list) or not plugin_names:
return Response().error("插件列表不能为空").__dict__
results = []
sem = asyncio.Semaphore(PLUGIN_UPDATE_CONCURRENCY)
async def _update_one(name: str):
async with sem:
try:
logger.info(f"批量更新插件 {name}")
await self.plugin_manager.update_plugin(name, proxy)
return {"name": name, "status": "ok", "message": "更新成功"}
except Exception as e:
logger.error(
f"/api/plugin/update-all: 更新插件 {name} 失败: {traceback.format_exc()}",
)
return {"name": name, "status": "error", "message": str(e)}
raw_results = await asyncio.gather(
*(_update_one(name) for name in plugin_names),
return_exceptions=True,
)
for name, result in zip(plugin_names, raw_results):
if isinstance(result, asyncio.CancelledError):
raise result
if isinstance(result, BaseException):
results.append(
{"name": name, "status": "error", "message": str(result)}
)
else:
results.append(result)
failed = [r for r in results if r["status"] == "error"]
message = (
"批量更新完成,全部成功。"
if not failed
else f"批量更新完成,其中 {len(failed)}/{len(results)} 个插件失败。"
)
return Response().ok({"results": results}, message).__dict__
async def off_plugin(self):
if DEMO_MODE:
return (
@@ -499,7 +440,7 @@ class PluginRoute(Route):
.__dict__
)
post_data = await request.get_json()
post_data = await request.json
plugin_name = post_data["name"]
try:
await self.plugin_manager.turn_off_plugin(plugin_name)
@@ -517,7 +458,7 @@ class PluginRoute(Route):
.__dict__
)
post_data = await request.get_json()
post_data = await request.json
plugin_name = post_data["name"]
try:
await self.plugin_manager.turn_on_plugin(plugin_name)

View File

@@ -1,24 +1,16 @@
import traceback
from quart import request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import col, select
from astrbot.core import logger, sp
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db import BaseDatabase
from astrbot.core.db.po import ConversationV2, Preference
from astrbot.core.provider.entities import ProviderType
from astrbot.core.star.session_llm_manager import SessionServiceManager
from astrbot.core.star.session_plugin_manager import SessionPluginManager
from .route import Response, Route, RouteContext
AVAILABLE_SESSION_RULE_KEYS = [
"session_service_config",
"session_plugin_config",
"kb_config",
f"provider_perf_{ProviderType.CHAT_COMPLETION.value}",
f"provider_perf_{ProviderType.SPEECH_TO_TEXT.value}",
f"provider_perf_{ProviderType.TEXT_TO_SPEECH.value}",
]
class SessionManagementRoute(Route):
def __init__(
@@ -30,364 +22,667 @@ class SessionManagementRoute(Route):
super().__init__(context)
self.db_helper = db_helper
self.routes = {
"/session/list-rule": ("GET", self.list_session_rule),
"/session/update-rule": ("POST", self.update_session_rule),
"/session/delete-rule": ("POST", self.delete_session_rule),
"/session/batch-delete-rule": ("POST", self.batch_delete_session_rule),
"/session/active-umos": ("GET", self.list_umos),
"/session/list": ("GET", self.list_sessions),
"/session/update_persona": ("POST", self.update_session_persona),
"/session/update_provider": ("POST", self.update_session_provider),
"/session/plugins": ("GET", self.get_session_plugins),
"/session/update_plugin": ("POST", self.update_session_plugin),
"/session/update_llm": ("POST", self.update_session_llm),
"/session/update_tts": ("POST", self.update_session_tts),
"/session/update_name": ("POST", self.update_session_name),
"/session/update_status": ("POST", self.update_session_status),
"/session/delete": ("POST", self.delete_session),
}
self.conv_mgr = core_lifecycle.conversation_manager
self.core_lifecycle = core_lifecycle
self.register_routes()
async def _get_umo_rules(
self, page: int = 1, page_size: int = 10, search: str = ""
) -> tuple[dict, int]:
"""获取所有带有自定义规则的 umo 及其规则内容(支持分页和搜索)。
如果某个 umo 在 preference 中有以下字段,则表示有自定义规则:
1. session_service_config (包含了 是否启用这个umo, 这个umo是否启用 llm, 这个umo是否启用tts, umo自定义名称。)
2. session_plugin_config (包含了 这个 umo 的 plugin set)
3. provider_perf_{ProviderType.value} (包含了这个 umo 所选择使用的 provider 信息)
4. kb_config (包含了这个 umo 的知识库相关配置)
Args:
page: 页码,从 1 开始
page_size: 每页数量
search: 搜索关键词,匹配 umo 或 custom_name
Returns:
tuple[dict, int]: (umo_rules, total) - 分页后的 umo 规则和总数
"""
umo_rules = {}
async with self.db_helper.get_db() as session:
session: AsyncSession
result = await session.execute(
select(Preference).where(
col(Preference.scope) == "umo",
col(Preference.key).in_(AVAILABLE_SESSION_RULE_KEYS),
)
)
prefs = result.scalars().all()
for pref in prefs:
umo_id = pref.scope_id
if umo_id not in umo_rules:
umo_rules[umo_id] = {}
if pref.key == "session_plugin_config" and umo_id in pref.value["val"]:
umo_rules[umo_id][pref.key] = pref.value["val"][umo_id]
else:
umo_rules[umo_id][pref.key] = pref.value["val"]
# 搜索过滤
if search:
search_lower = search.lower()
filtered_rules = {}
for umo_id, rules in umo_rules.items():
# 匹配 umo
if search_lower in umo_id.lower():
filtered_rules[umo_id] = rules
continue
# 匹配 custom_name
svc_config = rules.get("session_service_config", {})
custom_name = svc_config.get("custom_name", "") if svc_config else ""
if custom_name and search_lower in custom_name.lower():
filtered_rules[umo_id] = rules
umo_rules = filtered_rules
# 获取总数
total = len(umo_rules)
# 分页处理
all_umo_ids = list(umo_rules.keys())
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
paginated_umo_ids = all_umo_ids[start_idx:end_idx]
# 只返回分页后的数据
paginated_rules = {umo_id: umo_rules[umo_id] for umo_id in paginated_umo_ids}
return paginated_rules, total
async def list_session_rule(self):
"""获取所有自定义的规则(支持分页和搜索)
返回已配置规则的 umo 列表及其规则内容,以及可用的 personas 和 providers
Query 参数:
page: 页码,默认为 1
page_size: 每页数量,默认为 10
search: 搜索关键词,匹配 umo 或 custom_name
"""
async def list_sessions(self):
"""获取所有会话的列表,包括 persona 和 provider 信息"""
try:
# 获取分页和搜索参数
page = request.args.get("page", 1, type=int)
page_size = request.args.get("page_size", 10, type=int)
search = request.args.get("search", "", type=str).strip()
page = int(request.args.get("page", 1))
page_size = int(request.args.get("page_size", 20))
search_query = request.args.get("search", "")
platform = request.args.get("platform", "")
# 参数校验
if page < 1:
page = 1
if page_size < 1:
page_size = 10
if page_size > 100:
page_size = 100
umo_rules, total = await self._get_umo_rules(
page=page, page_size=page_size, search=search
# 获取活跃的会话数据(处于对话内的会话)
sessions_data, total = await self.db_helper.get_session_conversations(
page,
page_size,
search_query,
platform,
)
# 构建规则列表
rules_list = []
for umo, rules in umo_rules.items():
rule_info = {
"umo": umo,
"rules": rules,
}
# 解析 umo 格式: 平台:消息类型:会话ID
parts = umo.split(":")
if len(parts) >= 3:
rule_info["platform"] = parts[0]
rule_info["message_type"] = parts[1]
rule_info["session_id"] = parts[2]
rules_list.append(rule_info)
# 获取可用的 providers 和 personas
provider_manager = self.core_lifecycle.provider_manager
persona_mgr = self.core_lifecycle.persona_mgr
personas = persona_mgr.personas_v3
sessions = []
# 循环补充非数据库信息,如 provider 和 session 状态
for data in sessions_data:
session_id = data["session_id"]
conversation_id = data["conversation_id"]
conv_persona_id = data["persona_id"]
title = data["title"]
persona_name = data["persona_name"]
# 处理 persona 显示
if persona_name is None:
if conv_persona_id is None:
if default_persona := persona_mgr.selected_default_persona_v3:
persona_name = default_persona["name"]
else:
persona_name = "[%None]"
session_info = {
"session_id": session_id,
"conversation_id": conversation_id,
"persona_id": persona_name,
"chat_provider_id": None,
"stt_provider_id": None,
"tts_provider_id": None,
"session_enabled": SessionServiceManager.is_session_enabled(
session_id,
),
"llm_enabled": SessionServiceManager.is_llm_enabled_for_session(
session_id,
),
"tts_enabled": SessionServiceManager.is_tts_enabled_for_session(
session_id,
),
"platform": session_id.split(":")[0]
if ":" in session_id
else "unknown",
"message_type": session_id.split(":")[1]
if session_id.count(":") >= 1
else "unknown",
"session_name": SessionServiceManager.get_session_display_name(
session_id,
),
"session_raw_name": session_id.split(":")[2]
if session_id.count(":") >= 2
else session_id,
"title": title,
}
# 获取 provider 信息
chat_provider = provider_manager.get_using_provider(
provider_type=ProviderType.CHAT_COMPLETION,
umo=session_id,
)
tts_provider = provider_manager.get_using_provider(
provider_type=ProviderType.TEXT_TO_SPEECH,
umo=session_id,
)
stt_provider = provider_manager.get_using_provider(
provider_type=ProviderType.SPEECH_TO_TEXT,
umo=session_id,
)
if chat_provider:
meta = chat_provider.meta()
session_info["chat_provider_id"] = meta.id
if tts_provider:
meta = tts_provider.meta()
session_info["tts_provider_id"] = meta.id
if stt_provider:
meta = stt_provider.meta()
session_info["stt_provider_id"] = meta.id
sessions.append(session_info)
# 获取可用的 personas 和 providers 列表
available_personas = [
{"name": p["name"], "prompt": p.get("prompt", "")}
for p in persona_mgr.personas_v3
{"name": p["name"], "prompt": p.get("prompt", "")} for p in personas
]
available_chat_providers = [
available_chat_providers = []
for provider in provider_manager.provider_insts:
meta = provider.meta()
available_chat_providers.append(
{
"id": meta.id,
"name": meta.id,
"model": meta.model,
"type": meta.type,
},
)
available_stt_providers = []
for provider in provider_manager.stt_provider_insts:
meta = provider.meta()
available_stt_providers.append(
{
"id": meta.id,
"name": meta.id,
"model": meta.model,
"type": meta.type,
},
)
available_tts_providers = []
for provider in provider_manager.tts_provider_insts:
meta = provider.meta()
available_tts_providers.append(
{
"id": meta.id,
"name": meta.id,
"model": meta.model,
"type": meta.type,
},
)
result = {
"sessions": sessions,
"available_personas": available_personas,
"available_chat_providers": available_chat_providers,
"available_stt_providers": available_stt_providers,
"available_tts_providers": available_tts_providers,
"pagination": {
"page": page,
"page_size": page_size,
"total": total,
"total_pages": (total + page_size - 1) // page_size
if page_size > 0
else 0,
},
}
return Response().ok(result).__dict__
except Exception as e:
error_msg = f"获取会话列表失败: {e!s}\n{traceback.format_exc()}"
logger.error(error_msg)
return Response().error(f"获取会话列表失败: {e!s}").__dict__
async def _update_single_session_persona(self, session_id: str, persona_name: str):
"""更新单个会话的 persona 的内部方法"""
conversation_manager = self.core_lifecycle.star_context.conversation_manager
conversation_id = await conversation_manager.get_curr_conversation_id(
session_id,
)
conv = None
if conversation_id:
conv = await conversation_manager.get_conversation(
unified_msg_origin=session_id,
conversation_id=conversation_id,
)
if not conv or not conversation_id:
conversation_id = await conversation_manager.new_conversation(session_id)
# 更新 persona
await conversation_manager.update_conversation_persona_id(
session_id,
persona_name,
)
async def _handle_batch_operation(
self,
session_ids: list,
operation_func,
operation_name: str,
**kwargs,
):
"""通用的批量操作处理方法"""
success_count = 0
error_sessions = []
for session_id in session_ids:
try:
await operation_func(session_id, **kwargs)
success_count += 1
except Exception as e:
logger.error(f"批量{operation_name} 会话 {session_id} 失败: {e!s}")
error_sessions.append(session_id)
if error_sessions:
return (
Response()
.ok(
{
"message": f"批量更新完成,成功: {success_count},失败: {len(error_sessions)}",
"success_count": success_count,
"error_count": len(error_sessions),
"error_sessions": error_sessions,
},
)
.__dict__
)
return (
Response()
.ok(
{
"id": p.meta().id,
"name": p.meta().id,
"model": p.meta().model,
}
for p in provider_manager.provider_insts
]
"message": f"成功批量{operation_name} {success_count} 个会话",
"success_count": success_count,
},
)
.__dict__
)
available_stt_providers = [
{
"id": p.meta().id,
"name": p.meta().id,
"model": p.meta().model,
}
for p in provider_manager.stt_provider_insts
]
async def update_session_persona(self):
"""更新指定会话的 persona支持批量操作"""
try:
data = await request.get_json()
is_batch = data.get("is_batch", False)
persona_name = data.get("persona_name")
available_tts_providers = [
{
"id": p.meta().id,
"name": p.meta().id,
"model": p.meta().model,
}
for p in provider_manager.tts_provider_insts
]
if persona_name is None:
return Response().error("缺少必要参数: persona_name").__dict__
# 获取可用的插件列表(排除 reserved 的系统插件)
if is_batch:
session_ids = data.get("session_ids", [])
if not session_ids:
return Response().error("缺少必要参数: session_ids").__dict__
return await self._handle_batch_operation(
session_ids,
self._update_single_session_persona,
"更新人格",
persona_name=persona_name,
)
session_id = data.get("session_id")
if not session_id:
return Response().error("缺少必要参数: session_id").__dict__
await self._update_single_session_persona(session_id, persona_name)
return (
Response()
.ok(
{
"message": f"成功更新会话 {session_id} 的人格为 {persona_name}",
},
)
.__dict__
)
except Exception as e:
error_msg = f"更新会话人格失败: {e!s}\n{traceback.format_exc()}"
logger.error(error_msg)
return Response().error(f"更新会话人格失败: {e!s}").__dict__
async def _update_single_session_provider(
self,
session_id: str,
provider_id: str,
provider_type_enum,
):
"""更新单个会话的 provider 的内部方法"""
provider_manager = self.core_lifecycle.star_context.provider_manager
await provider_manager.set_provider(
provider_id=provider_id,
provider_type=provider_type_enum,
umo=session_id,
)
async def update_session_provider(self):
"""更新指定会话的 provider支持批量操作"""
try:
data = await request.get_json()
is_batch = data.get("is_batch", False)
provider_id = data.get("provider_id")
provider_type = data.get("provider_type")
if not provider_id or not provider_type:
return (
Response()
.error("缺少必要参数: provider_id, provider_type")
.__dict__
)
# 转换 provider_type 字符串为枚举
if provider_type == "chat_completion":
provider_type_enum = ProviderType.CHAT_COMPLETION
elif provider_type == "speech_to_text":
provider_type_enum = ProviderType.SPEECH_TO_TEXT
elif provider_type == "text_to_speech":
provider_type_enum = ProviderType.TEXT_TO_SPEECH
else:
return (
Response()
.error(f"不支持的 provider_type: {provider_type}")
.__dict__
)
if is_batch:
session_ids = data.get("session_ids", [])
if not session_ids:
return Response().error("缺少必要参数: session_ids").__dict__
return await self._handle_batch_operation(
session_ids,
self._update_single_session_provider,
f"更新 {provider_type} 提供商",
provider_id=provider_id,
provider_type_enum=provider_type_enum,
)
session_id = data.get("session_id")
if not session_id:
return Response().error("缺少必要参数: session_id").__dict__
await self._update_single_session_provider(
session_id,
provider_id,
provider_type_enum,
)
return (
Response()
.ok(
{
"message": f"成功更新会话 {session_id}{provider_type} 提供商为 {provider_id}",
},
)
.__dict__
)
except Exception as e:
error_msg = f"更新会话提供商失败: {e!s}\n{traceback.format_exc()}"
logger.error(error_msg)
return Response().error(f"更新会话提供商失败: {e!s}").__dict__
async def get_session_plugins(self):
"""获取指定会话的插件配置信息"""
try:
session_id = request.args.get("session_id")
if not session_id:
return Response().error("缺少必要参数: session_id").__dict__
# 获取所有已激活的插件
all_plugins = []
plugin_manager = self.core_lifecycle.plugin_manager
available_plugins = [
{
"name": p.name,
"display_name": p.display_name or p.name,
"desc": p.desc,
}
for p in plugin_manager.context.get_all_stars()
if not p.reserved and p.name
]
# 获取可用的知识库列表
available_kbs = []
kb_manager = self.core_lifecycle.kb_manager
if kb_manager:
try:
kbs = await kb_manager.list_kbs()
available_kbs = [
for plugin in plugin_manager.context.get_all_stars():
# 只显示已激活的插件,不包括保留插件
if plugin.activated and not plugin.reserved:
plugin_name = plugin.name or ""
plugin_enabled = SessionPluginManager.is_plugin_enabled_for_session(
session_id,
plugin_name,
)
all_plugins.append(
{
"kb_id": kb.kb_id,
"kb_name": kb.kb_name,
"emoji": kb.emoji,
}
for kb in kbs
]
except Exception as e:
logger.warning(f"获取知识库列表失败: {e!s}")
"name": plugin_name,
"author": plugin.author,
"desc": plugin.desc,
"enabled": plugin_enabled,
},
)
return (
Response()
.ok(
{
"rules": rules_list,
"total": total,
"page": page,
"page_size": page_size,
"available_personas": available_personas,
"available_chat_providers": available_chat_providers,
"available_stt_providers": available_stt_providers,
"available_tts_providers": available_tts_providers,
"available_plugins": available_plugins,
"available_kbs": available_kbs,
"available_rule_keys": AVAILABLE_SESSION_RULE_KEYS,
}
"session_id": session_id,
"plugins": all_plugins,
},
)
.__dict__
)
except Exception as e:
logger.error(f"获取规则列表失败: {e!s}")
return Response().error(f"获取规则列表失败: {e!s}").__dict__
error_msg = f"获取会话插件配置失败: {e!s}\n{traceback.format_exc()}"
logger.error(error_msg)
return Response().error(f"获取会话插件配置失败: {e!s}").__dict__
async def update_session_rule(self):
"""更新某个 umo 的自定义规则
请求体:
{
"umo": "平台:消息类型:会话ID",
"rule_key": "session_service_config" | "session_plugin_config" | "kb_config" | "provider_perf_xxx",
"rule_value": {...} // 规则值,具体结构根据 rule_key 不同而不同
}
"""
async def update_session_plugin(self):
"""更新指定会话的插件启停状态"""
try:
data = await request.get_json()
umo = data.get("umo")
rule_key = data.get("rule_key")
rule_value = data.get("rule_value")
session_id = data.get("session_id")
plugin_name = data.get("plugin_name")
enabled = data.get("enabled")
if not umo:
return Response().error("缺少必要参数: umo").__dict__
if not rule_key:
return Response().error("缺少必要参数: rule_key").__dict__
if rule_key not in AVAILABLE_SESSION_RULE_KEYS:
return Response().error(f"不支持的规则键: {rule_key}").__dict__
if not session_id:
return Response().error("缺少必要参数: session_id").__dict__
if rule_key == "session_plugin_config":
rule_value = {
umo: rule_value,
}
if not plugin_name:
return Response().error("缺少必要参数: plugin_name").__dict__
# 使用 shared preferences 更新规则
await sp.session_put(umo, rule_key, rule_value)
if enabled is None:
return Response().error("缺少必要参数: enabled").__dict__
# 验证插件是否存在且已激活
plugin_manager = self.core_lifecycle.plugin_manager
plugin = plugin_manager.context.get_registered_star(plugin_name)
if not plugin:
return Response().error(f"插件 {plugin_name} 不存在").__dict__
if not plugin.activated:
return Response().error(f"插件 {plugin_name} 未激活").__dict__
if plugin.reserved:
return (
Response()
.error(f"插件 {plugin_name} 是系统保留插件,无法管理")
.__dict__
)
# 使用 SessionPluginManager 更新插件状态
SessionPluginManager.set_plugin_status_for_session(
session_id,
plugin_name,
enabled,
)
return (
Response()
.ok({"message": f"规则 {rule_key} 已更新", "umo": umo})
.ok(
{
"message": f"插件 {plugin_name}{'启用' if enabled else '禁用'}",
"session_id": session_id,
"plugin_name": plugin_name,
"enabled": enabled,
},
)
.__dict__
)
except Exception as e:
logger.error(f"更新会话规则失败: {e!s}")
return Response().error(f"更新会话规则失败: {e!s}").__dict__
error_msg = f"更新会话插件状态失败: {e!s}\n{traceback.format_exc()}"
logger.error(error_msg)
return Response().error(f"更新会话插件状态失败: {e!s}").__dict__
async def delete_session_rule(self):
"""删除某个 umo 的自定义规则
async def _update_single_session_llm(self, session_id: str, enabled: bool):
"""更新单个会话的LLM状态的内部方法"""
SessionServiceManager.set_llm_status_for_session(session_id, enabled)
请求体:
{
"umo": "平台:消息类型:会话ID",
"rule_key": "session_service_config" | "session_plugin_config" | ... (可选,不传则删除所有规则)
}
"""
async def update_session_llm(self):
"""更新指定会话的LLM启停状态支持批量操作"""
try:
data = await request.get_json()
umo = data.get("umo")
rule_key = data.get("rule_key")
is_batch = data.get("is_batch", False)
enabled = data.get("enabled")
if not umo:
return Response().error("缺少必要参数: umo").__dict__
if enabled is None:
return Response().error("缺少必要参数: enabled").__dict__
if rule_key:
# 删除单个规则
if rule_key not in AVAILABLE_SESSION_RULE_KEYS:
return Response().error(f"不支持的规则键: {rule_key}").__dict__
await sp.session_remove(umo, rule_key)
return (
Response()
.ok({"message": f"规则 {rule_key} 已删除", "umo": umo})
.__dict__
if is_batch:
session_ids = data.get("session_ids", [])
if not session_ids:
return Response().error("缺少必要参数: session_ids").__dict__
result = await self._handle_batch_operation(
session_ids,
self._update_single_session_llm,
f"{'启用' if enabled else '禁用'}LLM",
enabled=enabled,
)
else:
# 删除该 umo 的所有规则
await sp.clear_async("umo", umo)
return Response().ok({"message": "所有规则已删除", "umo": umo}).__dict__
return result
session_id = data.get("session_id")
if not session_id:
return Response().error("缺少必要参数: session_id").__dict__
await self._update_single_session_llm(session_id, enabled)
return (
Response()
.ok(
{
"message": f"LLM已{'启用' if enabled else '禁用'}",
"session_id": session_id,
"llm_enabled": enabled,
},
)
.__dict__
)
except Exception as e:
logger.error(f"删除会话规则失败: {e!s}")
return Response().error(f"删除会话规则失败: {e!s}").__dict__
error_msg = f"更新会话LLM状态失败: {e!s}\n{traceback.format_exc()}"
logger.error(error_msg)
return Response().error(f"更新会话LLM状态失败: {e!s}").__dict__
async def batch_delete_session_rule(self):
"""批量删除多个 umo 的自定义规则
async def _update_single_session_tts(self, session_id: str, enabled: bool):
"""更新单个会话的TTS状态的内部方法"""
SessionServiceManager.set_tts_status_for_session(session_id, enabled)
请求体:
{
"umos": ["平台:消息类型:会话ID", ...] // umo 列表
}
"""
async def update_session_tts(self):
"""更新指定会话的TTS启停状态支持批量操作"""
try:
data = await request.get_json()
umos = data.get("umos", [])
is_batch = data.get("is_batch", False)
enabled = data.get("enabled")
if not umos:
return Response().error("缺少必要参数: umos").__dict__
if enabled is None:
return Response().error("缺少必要参数: enabled").__dict__
if not isinstance(umos, list):
return Response().error("参数 umos 必须是数组").__dict__
if is_batch:
session_ids = data.get("session_ids", [])
if not session_ids:
return Response().error("缺少必要参数: session_ids").__dict__
# 批量删除
deleted_count = 0
failed_umos = []
for umo in umos:
try:
await sp.clear_async("umo", umo)
deleted_count += 1
except Exception as e:
logger.error(f"删除 umo {umo} 的规则失败: {e!s}")
failed_umos.append(umo)
if failed_umos:
return (
Response()
.ok(
{
"message": f"已删除 {deleted_count} 条规则,{len(failed_umos)} 条删除失败",
"deleted_count": deleted_count,
"failed_umos": failed_umos,
}
)
.__dict__
result = await self._handle_batch_operation(
session_ids,
self._update_single_session_tts,
f"{'启用' if enabled else '禁用'}TTS",
enabled=enabled,
)
else:
return (
Response()
.ok(
{
"message": f"已删除 {deleted_count} 条规则",
"deleted_count": deleted_count,
}
)
.__dict__
return result
session_id = data.get("session_id")
if not session_id:
return Response().error("缺少必要参数: session_id").__dict__
await self._update_single_session_tts(session_id, enabled)
return (
Response()
.ok(
{
"message": f"TTS已{'启用' if enabled else '禁用'}",
"session_id": session_id,
"tts_enabled": enabled,
},
)
.__dict__
)
except Exception as e:
logger.error(f"批量删除会话规则失败: {e!s}")
return Response().error(f"批量删除会话规则失败: {e!s}").__dict__
error_msg = f"更新会话TTS状态失败: {e!s}\n{traceback.format_exc()}"
logger.error(error_msg)
return Response().error(f"更新会话TTS状态失败: {e!s}").__dict__
async def list_umos(self):
"""列出所有有对话记录的 umo从 Conversations 表中找
仅返回 umo 字符串列表,用于用户在创建规则时选择 umo
"""
async def update_session_name(self):
"""更新指定会话的自定义名称"""
try:
# 从 Conversation 表获取所有 distinct user_id (即 umo)
async with self.db_helper.get_db() as session:
session: AsyncSession
result = await session.execute(
select(ConversationV2.user_id)
.distinct()
.order_by(ConversationV2.user_id)
)
umos = [row[0] for row in result.fetchall()]
data = await request.get_json()
session_id = data.get("session_id")
custom_name = data.get("custom_name", "")
if not session_id:
return Response().error("缺少必要参数: session_id").__dict__
# 使用 SessionServiceManager 更新会话名称
SessionServiceManager.set_session_custom_name(session_id, custom_name)
return (
Response()
.ok(
{
"message": f"会话名称已更新为: {custom_name if custom_name.strip() else '已清除自定义名称'}",
"session_id": session_id,
"custom_name": custom_name,
"display_name": SessionServiceManager.get_session_display_name(
session_id,
),
},
)
.__dict__
)
return Response().ok({"umos": umos}).__dict__
except Exception as e:
logger.error(f"获取 UMO 列表失败: {e!s}")
return Response().error(f"获取 UMO 列表失败: {e!s}").__dict__
error_msg = f"更新会话名称失败: {e!s}\n{traceback.format_exc()}"
logger.error(error_msg)
return Response().error(f"更新会话名称失败: {e!s}").__dict__
async def update_session_status(self):
"""更新指定会话的整体启停状态"""
try:
data = await request.get_json()
session_id = data.get("session_id")
session_enabled = data.get("session_enabled")
if not session_id:
return Response().error("缺少必要参数: session_id").__dict__
if session_enabled is None:
return Response().error("缺少必要参数: session_enabled").__dict__
# 使用 SessionServiceManager 更新会话整体状态
SessionServiceManager.set_session_status(session_id, session_enabled)
return (
Response()
.ok(
{
"message": f"会话整体状态已更新为: {'启用' if session_enabled else '禁用'}",
"session_id": session_id,
"session_enabled": session_enabled,
},
)
.__dict__
)
except Exception as e:
error_msg = f"更新会话整体状态失败: {e!s}\n{traceback.format_exc()}"
logger.error(error_msg)
return Response().error(f"更新会话整体状态失败: {e!s}").__dict__
async def delete_session(self):
"""删除指定会话及其所有相关数据"""
try:
data = await request.get_json()
session_id = data.get("session_id")
if not session_id:
return Response().error("缺少必要参数: session_id").__dict__
# 删除会话的所有相关数据
conversation_manager = self.core_lifecycle.conversation_manager
# 1. 删除会话的所有对话
try:
await conversation_manager.delete_conversations_by_user_id(session_id)
except Exception as e:
logger.warning(f"删除会话 {session_id} 的对话失败: {e!s}")
# 2. 清除会话的偏好设置数据(清空该会话的所有配置)
try:
await sp.clear_async("umo", session_id)
except Exception as e:
logger.warning(f"清除会话 {session_id} 的偏好设置失败: {e!s}")
return (
Response()
.ok(
{
"message": f"会话 {session_id} 及其相关所有对话数据已成功删除",
"session_id": session_id,
},
)
.__dict__
)
except Exception as e:
error_msg = f"删除会话失败: {e!s}\n{traceback.format_exc()}"
logger.error(error_msg)
return Response().error(f"删除会话失败: {e!s}").__dict__

View File

@@ -79,6 +79,7 @@ class AstrBotDashboard:
self.persona_route = PersonaRoute(self.context, db, core_lifecycle)
self.t2i_route = T2iRoute(self.context, core_lifecycle)
self.kb_route = KnowledgeBaseRoute(self.context, core_lifecycle)
self.memory_route = MemoryRoute(self.context, db, core_lifecycle)
self.app.add_url_rule(
"/api/plug/<path:subpath>",

View File

@@ -1,29 +0,0 @@
## What's Changed
**hot fix of v4.6.0**
fix(core.db): 修复升级后 webchat 相关对话数据未正确迁移的问题 ([#3745](https://github.com/AstrBotDevs/AstrBot/issues/3745))
---
1. 新增: 支持 gemini-3 系列的 thought signature ([#3698](https://github.com/AstrBotDevs/AstrBot/issues/3698))
2. 新增: 支持知识库的 Agentic 检索功能 ([#3667](https://github.com/AstrBotDevs/AstrBot/issues/3667))
3. 新增: 为知识库添加 URL 文档解析器 ([#3622](https://github.com/AstrBotDevs/AstrBot/issues/3622))
4. 修复(core.platform): 修复启用多个企业微信智能机器人适配器时消息混乱的问题 ([#3693](https://github.com/AstrBotDevs/AstrBot/issues/3693))
5. 修复: MCP Server 连接成功一段时间后,调用 mcp 工具时可能出现 `anyio.ClosedResourceError` 错误 ([#3700](https://github.com/AstrBotDevs/AstrBot/issues/3700))
6. 新增(chat): 重构聊天组件结构并添加新功能 ([#3701](https://github.com/AstrBotDevs/AstrBot/issues/3701))
7. 修复(dashboard.i18n): 完善缺失的英文国际化键值 ([#3699](https://github.com/AstrBotDevs/AstrBot/issues/3699))
8. 重构: 实现 WebChat 会话管理及从版本 4.6 迁移到 4.7
9. 持续集成(docker-build): 每日构建 Nightly 版本 Docker 镜像 ([#3120](https://github.com/AstrBotDevs/AstrBot/issues/3120))
---
1. feat: add supports for gemini-3 series thought signature ([#3698](https://github.com/AstrBotDevs/AstrBot/issues/3698))
2. feat: supports knowledge base agentic search ([#3667](https://github.com/AstrBotDevs/AstrBot/issues/3667))
3. feat: Add URL document parser for knowledge base ([#3622](https://github.com/AstrBotDevs/AstrBot/issues/3622))
4. fix(core.platform): fix message mix-up issue when enabling multiple WeCom AI Bot adapters ([#3693](https://github.com/AstrBotDevs/AstrBot/issues/3693))
5. fix: fix `anyio.ClosedResourceError` that may occur when calling mcp tools after a period of successful connection to MCP Server ([#3700](https://github.com/AstrBotDevs/AstrBot/issues/3700))
6. feat(chat): refactor chat component structure and add new features ([#3701](https://github.com/AstrBotDevs/AstrBot/issues/3701))
7. fix(dashboard.i18n): complete the missing i18n keys for en([#3699](https://github.com/AstrBotDevs/AstrBot/issues/3699))
8. refactor: Implement WebChat session management and migration from version 4.6 to 4.7
9. ci(docker-build): build nightly image everyday ([#3120](https://github.com/AstrBotDevs/AstrBot/issues/3120))

View File

@@ -1,18 +0,0 @@
## What's Changed
重构:
- 将 Dify、Coze、阿里云百炼应用等 LLMOps 提供商迁移到 Agent 执行器层,理清和本地 Agent 执行器的边界
- 将「会话管理」功能重构为「自定义规则」功能,理清和多配置文件功能的边界。详见:[自定义规则](https://docs.astrbot.app/use/custom-rules.html)
优化:
- Dify、阿里云百炼应用支持流式输出
- 防止分段回复正则表达式解析错误导致消息不发送
- 群聊上下文感知记录 At 信息
- 优化模型提供商页面的测试提供商功能
新增:
- 支持在配置文件页面快速测试对话
- 为配置文件配置项内容添加国际化支持
修复:
- 在更新 MCP Server 配置后MCP 无法正常重启的问题

View File

@@ -1,22 +0,0 @@
## What's Changed
### 修复了自定义规则页面无法设置插件和知识库的规则的问题
---
重构:
- 将 Dify、Coze、阿里云百炼应用等 LLMOps 提供商迁移到 Agent 执行器层,理清和本地 Agent 执行器的边界。详见:[Agent 执行器](https://docs.astrbot.app/use/agent-runner.html)
- 将「会话管理」功能重构为「自定义规则」功能,理清和多配置文件功能的边界。详见:[自定义规则](https://docs.astrbot.app/use/custom-rules.html)
优化:
- Dify、阿里云百炼应用支持流式输出
- 防止分段回复正则表达式解析错误导致消息不发送
- 群聊上下文感知记录 At 信息
- 优化模型提供商页面的测试提供商功能
新增:
- 支持在配置文件页面快速测试对话
- 为配置文件配置项内容添加国际化支持
修复:
- 在更新 MCP Server 配置后MCP 无法正常重启的问题

View File

@@ -1,25 +0,0 @@
## What's Changed
1. 修复使用非默认配置文件情况下时,第三方 Agent Runner (Dify、Coze、阿里云百炼应用等)无法正常工作的问题
2. 修复当“聊天模型”未设置,并且模型提供商中仅有 Agent Runner 时,无法正常使用 Agent Runner 的问题
3. 修复部分情况下报错 `pydantic_core._pydantic_core.ValidationError: 1 validation error for Message content` 的问题
4. 新增群聊模式下的专用图片转述模型配置 ([#3822](https://github.com/AstrBotDevs/AstrBot/issues/3822))
---
重构:
- 将 Dify、Coze、阿里云百炼应用等 LLMOps 提供商迁移到 Agent 执行器层,理清和本地 Agent 执行器的边界。详见:[Agent 执行器](https://docs.astrbot.app/use/agent-runner.html)
- 将「会话管理」功能重构为「自定义规则」功能,理清和多配置文件功能的边界。详见:[自定义规则](https://docs.astrbot.app/use/custom-rules.html)
优化:
- Dify、阿里云百炼应用支持流式输出
- 防止分段回复正则表达式解析错误导致消息不发送
- 群聊上下文感知记录 At 信息
- 优化模型提供商页面的测试提供商功能
新增:
- 支持在配置文件页面快速测试对话
- 为配置文件配置项内容添加国际化支持
修复:
- 在更新 MCP Server 配置后MCP 无法正常重启的问题

View File

@@ -1,7 +0,0 @@
## What's Changed
1. 修复assistant message 中 tool_call 存在但 content 不存在时,导致验证错误的问题 ([#3862](https://github.com/AstrBotDevs/AstrBot/issues/3862))
2. 修复fix: aiocqhttp 适配器 NapCat 文件名获取为空 ([#3853](https://github.com/AstrBotDevs/AstrBot/issues/3853))
3. 新增:升级所有插件按钮
4. 新增:/provider 指令支持同时测试提供商可用性
5. 优化:主动回复的 prompt

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -84,11 +84,9 @@
v-model:prompt="prompt"
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:disabled="isStreaming"
:disabled="isStreaming || isConvRunning"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
:session-id="currSessionId || null"
:current-session="getCurrentSession"
@send="handleSendMessage"
@toggleStreaming="toggleStreaming"
@removeImage="removeImage"

View File

@@ -11,14 +11,7 @@
style="width: 100%; resize: none; outline: none; border: 1px solid var(--v-theme-border); border-radius: 12px; padding: 8px 16px; min-height: 40px; font-family: inherit; font-size: 16px; background-color: var(--v-theme-surface);"></textarea>
<div style="display: flex; justify-content: space-between; align-items: center; padding: 0px 12px;">
<div style="display: flex; justify-content: flex-start; margin-top: 4px; align-items: center; gap: 8px;">
<ConfigSelector
:session-id="sessionId || null"
:platform-id="sessionPlatformId"
:is-group="sessionIsGroup"
:initial-config-id="props.configId"
@config-changed="handleConfigChange"
/>
<ProviderModelSelector v-if="showProviderSelector" ref="providerModelSelectorRef" />
<ProviderModelSelector ref="providerModelSelectorRef" />
<v-tooltip :text="enableStreaming ? tm('streaming.enabled') : tm('streaming.disabled')" location="top">
<template v-slot:activator="{ props }">
@@ -65,11 +58,9 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import ProviderModelSelector from './ProviderModelSelector.vue';
import ConfigSelector from './ConfigSelector.vue';
import type { Session } from '@/composables/useSessions';
interface Props {
prompt: string;
@@ -78,16 +69,9 @@ interface Props {
disabled: boolean;
enableStreaming: boolean;
isRecording: boolean;
sessionId?: string | null;
currentSession?: Session | null;
configId?: string | null;
}
const props = withDefaults(defineProps<Props>(), {
sessionId: null,
currentSession: null,
configId: null
});
const props = defineProps<Props>();
const emit = defineEmits<{
'update:prompt': [value: string];
@@ -106,16 +90,12 @@ const { tm } = useModuleI18n('features/chat');
const inputField = ref<HTMLTextAreaElement | null>(null);
const imageInputRef = ref<HTMLInputElement | null>(null);
const providerModelSelectorRef = ref<InstanceType<typeof ProviderModelSelector> | null>(null);
const showProviderSelector = ref(true);
const localPrompt = computed({
get: () => props.prompt,
set: (value) => emit('update:prompt', value)
});
const sessionPlatformId = computed(() => props.currentSession?.platform_id || 'webchat');
const sessionIsGroup = computed(() => Boolean(props.currentSession?.is_group));
const canSend = computed(() => {
return (props.prompt && props.prompt.trim()) || props.stagedImagesUrl.length > 0 || props.stagedAudioUrl;
});
@@ -188,16 +168,7 @@ function handleRecordClick() {
}
}
function handleConfigChange(payload: { configId: string; agentRunnerType: string }) {
const runnerType = (payload.agentRunnerType || '').toLowerCase();
const isInternal = runnerType === 'internal' || runnerType === 'local';
showProviderSelector.value = isInternal;
}
function getCurrentSelection() {
if (!showProviderSelector.value) {
return null;
}
return providerModelSelectorRef.value?.getCurrentSelection();
}

View File

@@ -1,313 +0,0 @@
<template>
<div>
<v-tooltip text="选择用于当前会话的配置文件" location="top">
<template #activator="{ props: tooltipProps }">
<v-chip
v-bind="tooltipProps"
class="text-none config-chip"
variant="tonal"
size="x-small"
rounded="lg"
@click="openDialog"
:disabled="loadingConfigs || saving"
>
<v-icon start size="14">mdi-cog</v-icon>
{{ selectedConfigLabel }}
</v-chip>
</template>
</v-tooltip>
<v-dialog v-model="dialog" max-width="480" persistent>
<v-card>
<v-card-title class="d-flex align-center justify-space-between">
<span>选择配置文件</span>
<v-btn icon variant="text" @click="closeDialog">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text>
<div v-if="loadingConfigs" class="text-center py-6">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
</div>
<v-list v-else class="config-list" density="comfortable">
<v-list-item
v-for="config in configOptions"
:key="config.id"
:active="tempSelectedConfig === config.id"
rounded="lg"
variant="text"
@click="tempSelectedConfig = config.id"
>
<v-list-item-title>{{ config.name }}</v-list-item-title>
<v-list-item-subtitle class="text-caption text-grey">
{{ config.id }}
</v-list-item-subtitle>
<template #append>
<v-icon v-if="tempSelectedConfig === config.id" color="primary">mdi-check</v-icon>
</template>
</v-list-item>
<div v-if="configOptions.length === 0" class="text-center text-body-2 text-medium-emphasis">
暂无可选配置请先在配置页创建
</div>
</v-list>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn variant="text" @click="closeDialog">取消</v-btn>
<v-btn
color="primary"
@click="confirmSelection"
:disabled="!tempSelectedConfig"
:loading="saving"
>
应用
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import axios from 'axios';
import { useToast } from '@/utils/toast';
interface ConfigInfo {
id: string;
name: string;
}
interface ConfigChangedPayload {
configId: string;
agentRunnerType: string;
}
const STORAGE_KEY = 'chat.selectedConfigId';
const props = withDefaults(defineProps<{
sessionId?: string | null;
platformId?: string;
isGroup?: boolean;
initialConfigId?: string | null;
}>(), {
sessionId: null,
platformId: 'webchat',
isGroup: false,
initialConfigId: null
});
const emit = defineEmits<{ 'config-changed': [ConfigChangedPayload] }>();
const configOptions = ref<ConfigInfo[]>([]);
const loadingConfigs = ref(false);
const dialog = ref(false);
const tempSelectedConfig = ref('');
const selectedConfigId = ref('default');
const agentRunnerType = ref('local');
const saving = ref(false);
const pendingSync = ref(false);
const routingEntries = ref<Array<{ pattern: string; confId: string }>>([]);
const configCache = ref<Record<string, string>>({});
const toast = useToast();
const normalizedSessionId = computed(() => {
const id = props.sessionId?.trim();
return id ? id : null;
});
const hasActiveSession = computed(() => !!normalizedSessionId.value);
const messageType = computed(() => (props.isGroup ? 'GroupMessage' : 'FriendMessage'));
const username = computed(() => localStorage.getItem('user') || 'guest');
const sessionKey = computed(() => {
if (!normalizedSessionId.value) {
return null;
}
return `${props.platformId}!${username.value}!${normalizedSessionId.value}`;
});
const targetUmo = computed(() => {
if (!sessionKey.value) {
return null;
}
return `${props.platformId}:${messageType.value}:${sessionKey.value}`;
});
const selectedConfigLabel = computed(() => {
const target = configOptions.value.find((item) => item.id === selectedConfigId.value);
return target?.name || selectedConfigId.value || 'default';
});
function openDialog() {
tempSelectedConfig.value = selectedConfigId.value;
dialog.value = true;
}
function closeDialog() {
dialog.value = false;
}
async function fetchConfigList() {
loadingConfigs.value = true;
try {
const res = await axios.get('/api/config/abconfs');
configOptions.value = res.data.data?.info_list || [];
} catch (error) {
console.error('加载配置文件列表失败', error);
configOptions.value = [];
} finally {
loadingConfigs.value = false;
}
}
async function fetchRoutingEntries() {
try {
const res = await axios.get('/api/config/umo_abconf_routes');
const routing = res.data.data?.routing || {};
routingEntries.value = Object.entries(routing).map(([pattern, confId]) => ({
pattern,
confId: confId as string
}));
} catch (error) {
console.error('获取配置路由失败', error);
routingEntries.value = [];
}
}
function matchesPattern(pattern: string, target: string): boolean {
const parts = pattern.split(':');
const targetParts = target.split(':');
if (parts.length !== 3 || targetParts.length !== 3) {
return false;
}
return parts.every((part, index) => part === '' || part === '*' || part === targetParts[index]);
}
function resolveConfigId(umo: string | null): string {
if (!umo) {
return 'default';
}
for (const entry of routingEntries.value) {
if (matchesPattern(entry.pattern, umo)) {
return entry.confId;
}
}
return 'default';
}
async function getAgentRunnerType(confId: string): Promise<string> {
if (configCache.value[confId]) {
return configCache.value[confId];
}
try {
const res = await axios.get('/api/config/abconf', {
params: { id: confId }
});
const type = res.data.data?.config?.provider_settings?.agent_runner_type || 'local';
configCache.value[confId] = type;
return type;
} catch (error) {
console.error('获取配置文件详情失败', error);
return 'local';
}
}
async function setSelection(confId: string) {
const normalized = confId || 'default';
selectedConfigId.value = normalized;
const runnerType = await getAgentRunnerType(normalized);
agentRunnerType.value = runnerType;
emit('config-changed', {
configId: normalized,
agentRunnerType: runnerType
});
}
async function applySelectionToBackend(confId: string): Promise<boolean> {
if (!targetUmo.value) {
pendingSync.value = true;
return true;
}
saving.value = true;
try {
await axios.post('/api/config/umo_abconf_route/update', {
umo: targetUmo.value,
conf_id: confId
});
const filtered = routingEntries.value.filter((entry) => entry.pattern !== targetUmo.value);
filtered.push({ pattern: targetUmo.value, confId });
routingEntries.value = filtered;
return true;
} catch (error) {
const err = error as any;
console.error('更新配置文件失败', err);
toast.error(err?.response?.data?.message || '配置文件应用失败');
return false;
} finally {
saving.value = false;
}
}
async function confirmSelection() {
if (!tempSelectedConfig.value) {
return;
}
const previousId = selectedConfigId.value;
await setSelection(tempSelectedConfig.value);
localStorage.setItem(STORAGE_KEY, tempSelectedConfig.value);
const applied = await applySelectionToBackend(tempSelectedConfig.value);
if (!applied) {
localStorage.setItem(STORAGE_KEY, previousId);
await setSelection(previousId);
}
dialog.value = false;
}
async function syncSelectionForSession() {
if (!targetUmo.value) {
pendingSync.value = true;
return;
}
if (pendingSync.value) {
pendingSync.value = false;
await applySelectionToBackend(selectedConfigId.value);
return;
}
await fetchRoutingEntries();
const resolved = resolveConfigId(targetUmo.value);
await setSelection(resolved);
localStorage.setItem(STORAGE_KEY, resolved);
}
watch(
() => [props.sessionId, props.platformId, props.isGroup],
async () => {
await syncSelectionForSession();
}
);
onMounted(async () => {
await fetchConfigList();
const stored = props.initialConfigId || localStorage.getItem(STORAGE_KEY) || 'default';
selectedConfigId.value = stored;
await setSelection(stored);
await syncSelectionForSession();
});
</script>
<style scoped>
.config-chip {
cursor: pointer;
justify-content: flex-start;
}
.config-list {
max-height: 360px;
overflow-y: auto;
}
</style>

View File

@@ -64,7 +64,7 @@
@click.stop="$emit('editTitle', item.session_id, item.display_name)" />
<v-btn icon="mdi-delete" size="x-small" variant="text"
class="delete-conversation-btn" color="error"
@click.stop="handleDeleteConversation(item)" />
@click.stop="$emit('deleteConversation', item.session_id)" />
</div>
</template>
</v-list-item>
@@ -85,7 +85,7 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import type { Session } from '@/composables/useSessions';
interface Props {
@@ -109,6 +109,7 @@ const emit = defineEmits<{
}>();
const { tm } = useModuleI18n('features/chat');
const { t } = useI18n();
const sidebarCollapsed = ref(true);
const sidebarHovered = ref(false);
@@ -158,14 +159,6 @@ function handleSidebarMouseLeave() {
}
sidebarHoverExpanded.value = false;
}
function handleDeleteConversation(session: Session) {
const sessionTitle = session.display_name || tm('conversation.newConversation');
const message = tm('conversation.confirmDelete', { name: sessionTitle });
if (window.confirm(message)) {
emit('deleteConversation', session.session_id);
}
}
</script>
<style scoped>
@@ -300,4 +293,3 @@ function handleDeleteConversation(session: Session) {
}
}
</style>

View File

@@ -549,7 +549,7 @@ export default {
}
.bot-embedded-image {
max-width: 40%;
max-width: 80%;
width: auto;
height: auto;
border-radius: 8px;
@@ -558,6 +558,10 @@ export default {
transition: transform 0.2s ease;
}
.bot-embedded-image:hover {
transform: scale(1.02);
}
.embedded-audio {
width: 300px;
margin-top: 8px;

View File

@@ -3,7 +3,6 @@
<!-- 选择提供商和模型按钮 -->
<v-chip class="text-none" variant="tonal" size="x-small"
v-if="selectedProviderId && selectedModelName" @click="openDialog">
<v-icon start size="14">mdi-creation</v-icon>
{{ selectedProviderId }} / {{ selectedModelName }}
</v-chip>
<v-chip variant="tonal" rounded="xl" size="x-small" v-else @click="openDialog">

View File

@@ -1,319 +0,0 @@
<template>
<v-card class="standalone-chat-card" elevation="0" rounded="0">
<v-card-text class="standalone-chat-container">
<div class="chat-layout">
<!-- 聊天内容区域 -->
<div class="chat-content-panel">
<MessageList v-if="messages && messages.length > 0" :messages="messages" :isDark="isDark"
:isStreaming="isStreaming || isConvRunning" @openImagePreview="openImagePreview"
ref="messageList" />
<div class="welcome-container fade-in" v-else>
<div class="welcome-title">
<span>Hello, I'm</span>
<span class="bot-name">AstrBot ⭐</span>
</div>
<p class="text-caption text-medium-emphasis mt-2">
测试配置: {{ configId || 'default' }}
</p>
</div>
<!-- 输入区域 -->
<ChatInput
v-model:prompt="prompt"
:stagedImagesUrl="stagedImagesUrl"
:stagedAudioUrl="stagedAudioUrl"
:disabled="isStreaming"
:enableStreaming="enableStreaming"
:isRecording="isRecording"
:session-id="currSessionId || null"
:current-session="getCurrentSession"
:config-id="configId"
@send="handleSendMessage"
@toggleStreaming="toggleStreaming"
@removeImage="removeImage"
@removeAudio="removeAudio"
@startRecording="handleStartRecording"
@stopRecording="handleStopRecording"
@pasteImage="handlePaste"
@fileSelect="handleFileSelect"
ref="chatInputRef"
/>
</div>
</div>
</v-card-text>
</v-card>
<!-- 图片预览对话框 -->
<v-dialog v-model="imagePreviewDialog" max-width="90vw" max-height="90vh">
<v-card class="image-preview-card" elevation="8">
<v-card-title class="d-flex justify-space-between align-center pa-4">
<span>{{ t('core.common.imagePreview') }}</span>
<v-btn icon="mdi-close" variant="text" @click="imagePreviewDialog = false" />
</v-card-title>
<v-card-text class="text-center pa-4">
<img :src="previewImageUrl" class="preview-image-large" />
</v-card-text>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
import axios from 'axios';
import { useCustomizerStore } from '@/stores/customizer';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { useTheme } from 'vuetify';
import MessageList from '@/components/chat/MessageList.vue';
import ChatInput from '@/components/chat/ChatInput.vue';
import { useMessages } from '@/composables/useMessages';
import { useMediaHandling } from '@/composables/useMediaHandling';
import { useRecording } from '@/composables/useRecording';
import { useToast } from '@/utils/toast';
interface Props {
configId?: string | null;
}
const props = withDefaults(defineProps<Props>(), {
configId: null
});
const { t } = useI18n();
const { error: showError } = useToast();
// UI 状态
const imagePreviewDialog = ref(false);
const previewImageUrl = ref('');
// 会话管理(不使用 useSessions 避免路由跳转)
const currSessionId = ref('');
const getCurrentSession = computed(() => null); // 独立测试模式不需要会话信息
async function newSession() {
try {
const response = await axios.get('/api/chat/new_session');
const sessionId = response.data.data.session_id;
currSessionId.value = sessionId;
return sessionId;
} catch (err) {
console.error(err);
throw err;
}
}
function updateSessionTitle(sessionId: string, title: string) {
// 独立模式不需要更新会话标题
}
function getSessions() {
// 独立模式不需要加载会话列表
}
const {
stagedImagesName,
stagedImagesUrl,
stagedAudioUrl,
getMediaFile,
processAndUploadImage,
handlePaste,
removeImage,
removeAudio,
clearStaged,
cleanupMediaCache
} = useMediaHandling();
const { isRecording, startRecording: startRec, stopRecording: stopRec } = useRecording();
const {
messages,
isStreaming,
isConvRunning,
enableStreaming,
getSessionMessages: getSessionMsg,
sendMessage: sendMsg,
toggleStreaming
} = useMessages(currSessionId, getMediaFile, updateSessionTitle, getSessions);
// 组件引用
const messageList = ref<InstanceType<typeof MessageList> | null>(null);
const chatInputRef = ref<InstanceType<typeof ChatInput> | null>(null);
// 输入状态
const prompt = ref('');
const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark');
function openImagePreview(imageUrl: string) {
previewImageUrl.value = imageUrl;
imagePreviewDialog.value = true;
}
async function handleStartRecording() {
await startRec();
}
async function handleStopRecording() {
const audioFilename = await stopRec();
stagedAudioUrl.value = audioFilename;
}
async function handleFileSelect(files: FileList) {
for (const file of files) {
await processAndUploadImage(file);
}
}
async function handleSendMessage() {
if (!prompt.value.trim() && stagedImagesName.value.length === 0 && !stagedAudioUrl.value) {
return;
}
try {
if (!currSessionId.value) {
await newSession();
}
const promptToSend = prompt.value.trim();
const imageNamesToSend = [...stagedImagesName.value];
const audioNameToSend = stagedAudioUrl.value;
// 清空输入和附件
prompt.value = '';
clearStaged();
// 获取选择的提供商和模型
const selection = chatInputRef.value?.getCurrentSelection();
const selectedProviderId = selection?.providerId || '';
const selectedModelName = selection?.modelName || '';
await sendMsg(
promptToSend,
imageNamesToSend,
audioNameToSend,
selectedProviderId,
selectedModelName
);
// 滚动到底部
nextTick(() => {
messageList.value?.scrollToBottom();
});
} catch (err) {
console.error('Failed to send message:', err);
showError(t('features.chat.errors.sendMessageFailed'));
// 恢复输入内容,让用户可以重试
// 注意:附件已经上传到服务器,所以不恢复附件
}
}
onMounted(async () => {
// 独立模式在挂载时创建新会话
try {
await newSession();
} catch (err) {
console.error('Failed to create initial session:', err);
showError(t('features.chat.errors.createSessionFailed'));
}
});
onBeforeUnmount(() => {
cleanupMediaCache();
});
</script>
<style scoped>
/* 基础动画 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.standalone-chat-card {
width: 100%;
height: 100%;
max-height: 100%;
overflow: hidden;
}
.standalone-chat-container {
width: 100%;
height: 100%;
max-height: 100%;
padding: 0;
overflow: hidden;
}
.chat-layout {
height: 100%;
max-height: 100%;
display: flex;
overflow: hidden;
}
.chat-content-panel {
height: 100%;
max-height: 100%;
width: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.conversation-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
padding-left: 16px;
border-bottom: 1px solid var(--v-theme-border);
width: 100%;
padding-right: 32px;
flex-shrink: 0;
}
.conversation-header-info h4 {
margin: 0;
font-weight: 500;
}
.conversation-header-actions {
display: flex;
gap: 8px;
align-items: center;
}
.welcome-container {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.welcome-title {
font-size: 28px;
margin-bottom: 8px;
}
.bot-name {
font-weight: 700;
margin-left: 8px;
color: var(--v-theme-secondary);
}
.fade-in {
animation: fadeIn 0.3s ease-in-out;
}
.preview-image-large {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
}
</style>

View File

@@ -4,7 +4,7 @@
:align-tabs="$vuetify.display.mobile ? 'left' : 'start'" color="deep-purple-accent-4" class="config-tabs">
<v-tab v-for="(val, key, index) in metadata" :key="index" :value="index"
style="font-weight: 1000; font-size: 15px">
{{ tm(metadata[key]['name']) }}
{{ metadata[key]['name'] }}
</v-tab>
</v-tabs>
<v-tabs-window v-model="tab" class="config-tabs-window" :style="readonly ? 'pointer-events: none; opacity: 0.6;' : ''">
@@ -59,17 +59,7 @@ export default {
}
},
setup() {
const { tm: tmConfig } = useModuleI18n('features/config');
const { tm: tmMetadata } = useModuleI18n('features/config-metadata');
const tm = (key) => {
const metadataResult = tmMetadata(key);
if (!metadataResult.startsWith('[MISSING:') && !metadataResult.startsWith('[INVALID:')) {
return metadataResult;
}
return tmConfig(key);
};
const { tm } = useModuleI18n('features/config');
return {
tm
};

View File

@@ -7,10 +7,6 @@
<v-icon start>mdi-message-text</v-icon>
{{ tm('dialogs.addProvider.tabs.basic') }}
</v-tab>
<v-tab value="agent_runner" class="font-weight-medium px-3">
<v-icon start>mdi-cogs</v-icon>
{{ tm('dialogs.addProvider.tabs.agentRunner') }}
</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') }}
@@ -31,7 +27,7 @@
<v-window v-model="activeProviderTab" class="mt-4">
<v-window-item
v-for="tabType in ['chat_completion', 'agent_runner', 'speech_to_text', 'text_to_speech', 'embedding', 'rerank']"
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"
@@ -40,7 +36,7 @@
@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-title class="provider-card-title">接入 {{ name }}</v-card-title>
<v-card-text
class="text-caption text-medium-emphasis provider-card-description">
{{ getProviderDescription(template, name) }}
@@ -58,7 +54,7 @@
</v-col>
<v-col v-if="Object.keys(getTemplatesByType(tabType)).length === 0" cols="12">
<v-alert type="info" variant="tonal">
{{ tm('dialogs.addProvider.noTemplates') }}
{{ tm('dialogs.addProvider.noTemplates', { type: getTabTypeName(tabType) }) }}
</v-alert>
</v-col>
</v-row>
@@ -108,6 +104,19 @@ export default {
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() {
@@ -131,6 +140,11 @@ export default {
// 从工具函数导入
getProviderIcon,
// 获取Tab类型的中文名称
getTabTypeName(tabType) {
return this.messages.tabTypes[tabType] || tabType;
},
// 获取提供商简介
getProviderDescription(template, name) {
return getProviderDescription(template, name, this.tm);

View File

@@ -8,7 +8,7 @@ import PersonaSelector from './PersonaSelector.vue'
import KnowledgeBaseSelector from './KnowledgeBaseSelector.vue'
import PluginSetSelector from './PluginSetSelector.vue'
import T2ITemplateEditor from './T2ITemplateEditor.vue'
import { useI18n, useModuleI18n } from '@/i18n/composables'
import { useI18n } from '@/i18n/composables'
const props = defineProps({
@@ -27,34 +27,6 @@ const props = defineProps({
})
const { t } = useI18n()
const { tm } = useModuleI18n('features/config-metadata')
// 翻译器函数 - 如果是国际化键则翻译,否则原样返回
const translateIfKey = (value) => {
if (!value || typeof value !== 'string') return value
return tm(value)
}
// 处理labels翻译 - labels可以是数组或国际化键
const getTranslatedLabels = (itemMeta) => {
if (!itemMeta?.labels) return null
// 如果labels是字符串国际化键
if (typeof itemMeta.labels === 'string') {
const translatedLabels = tm(itemMeta.labels)
// 如果翻译成功且是数组,返回翻译结果
if (Array.isArray(translatedLabels)) {
return translatedLabels
}
}
// 如果labels是数组直接返回
if (Array.isArray(itemMeta.labels)) {
return itemMeta.labels
}
return null
}
const dialog = ref(false)
const currentEditingKey = ref('')
@@ -129,21 +101,6 @@ function shouldShowItem(itemMeta, itemKey) {
return true
}
// 检查最外层的 object 是否应该显示
function shouldShowSection() {
const sectionMeta = props.metadata[props.metadataKey]
if (!sectionMeta?.condition) {
return true
}
for (const [conditionKey, expectedValue] of Object.entries(sectionMeta.condition)) {
const actualValue = getValueBySelector(props.iterable, conditionKey)
if (actualValue !== expectedValue) {
return false
}
}
return true
}
function hasVisibleItemsAfter(items, currentIndex) {
const itemEntries = Object.entries(items)
@@ -157,40 +114,19 @@ function hasVisibleItemsAfter(items, currentIndex) {
return false
}
function parseSpecialValue(value) {
if (!value || typeof value !== 'string') {
return { name: '', subtype: '' }
}
const [name, ...rest] = value.split(':')
return {
name,
subtype: rest.join(':') || ''
}
}
function getSpecialName(value) {
return parseSpecialValue(value).name
}
function getSpecialSubtype(value) {
return parseSpecialValue(value).subtype
}
</script>
<template>
<v-card v-if="shouldShowSection()" style="margin-bottom: 16px; padding-bottom: 8px; background-color: rgb(var(--v-theme-background));"
rounded="md" variant="outlined">
<v-card style="margin-bottom: 16px; padding-bottom: 8px; background-color: rgb(var(--v-theme-background));" rounded="md" variant="outlined">
<v-card-text class="config-section" v-if="metadata[metadataKey]?.type === 'object'" style="padding-bottom: 8px;">
<v-list-item-title class="config-title">
{{ translateIfKey(metadata[metadataKey]?.description) }}
{{ metadata[metadataKey]?.description }}
</v-list-item-title>
<v-list-item-subtitle class="config-hint">
<span v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint" class="important-hint"></span>
{{ translateIfKey(metadata[metadataKey]?.hint) }}
{{ metadata[metadataKey]?.hint }}
</v-list-item-subtitle>
</v-card-text>
@@ -204,13 +140,13 @@ function getSpecialSubtype(value) {
<v-col cols="12" sm="6" class="property-info">
<v-list-item density="compact">
<v-list-item-title class="property-name">
{{ translateIfKey(itemMeta?.description) || itemKey }}
{{ itemMeta?.description || itemKey }}
<span class="property-key">({{ itemKey }})</span>
</v-list-item-title>
<v-list-item-subtitle class="property-hint">
<span v-if="itemMeta?.obvious_hint && itemMeta?.hint" class="important-hint"></span>
{{ translateIfKey(itemMeta?.hint) }}
{{ itemMeta?.hint }}
</v-list-item-subtitle>
</v-list-item>
</v-col>
@@ -218,12 +154,7 @@ function getSpecialSubtype(value) {
<div class="w-100" v-if="!itemMeta?._special">
<!-- Select input for JSON selector -->
<v-select v-if="itemMeta?.options" v-model="createSelectorModel(itemKey).value"
:items="(() => {
const labels = getTranslatedLabels(itemMeta);
return labels
? itemMeta.options.map((value, index) => ({ title: labels[index] || value, value: value }))
: itemMeta.options;
})()"
:items="itemMeta?.labels ? itemMeta.options.map((value, index) => ({ title: itemMeta.labels[index] || value, value: value })) : itemMeta.options"
:disabled="itemMeta?.readonly" density="compact" variant="outlined"
class="config-field" hide-details></v-select>
@@ -256,16 +187,22 @@ function getSpecialSubtype(value) {
<!-- Boolean switch for JSON selector -->
<v-switch v-else-if="itemMeta?.type === 'bool'" v-model="createSelectorModel(itemKey).value"
color="primary" inset density="compact" hide-details
style="display: flex; justify-content: end;"></v-switch>
color="primary" inset density="compact" hide-details style="display: flex; justify-content: end;"></v-switch>
<!-- List item for JSON selector -->
<ListConfigItem v-else-if="itemMeta?.type === 'list'" v-model="createSelectorModel(itemKey).value"
button-text="修改" class="config-field" />
<ListConfigItem
v-else-if="itemMeta?.type === 'list'"
v-model="createSelectorModel(itemKey).value"
button-text="修改"
class="config-field"
/>
<!-- Object editor for JSON selector -->
<ObjectEditor v-else-if="itemMeta?.type === 'dict'" v-model="createSelectorModel(itemKey).value"
class="config-field" />
<ObjectEditor
v-else-if="itemMeta?.type === 'dict'"
v-model="createSelectorModel(itemKey).value"
class="config-field"
/>
<!-- Fallback for JSON selector -->
<v-text-field v-else v-model="createSelectorModel(itemKey).value" density="compact" variant="outlined"
@@ -274,36 +211,50 @@ function getSpecialSubtype(value) {
<!-- Special handling for specific metadata types -->
<div v-else-if="itemMeta?._special === 'select_provider'">
<ProviderSelector v-model="createSelectorModel(itemKey).value" :provider-type="'chat_completion'" />
</div>
<div v-else-if="itemMeta?._special === 'select_provider_stt'">
<ProviderSelector v-model="createSelectorModel(itemKey).value" :provider-type="'speech_to_text'" />
</div>
<div v-else-if="itemMeta?._special === 'select_provider_tts'">
<ProviderSelector v-model="createSelectorModel(itemKey).value" :provider-type="'text_to_speech'" />
</div>
<div v-else-if="getSpecialName(itemMeta?._special) === 'select_agent_runner_provider'">
<ProviderSelector
v-model="createSelectorModel(itemKey).value"
:provider-type="'agent_runner'"
:provider-subtype="getSpecialSubtype(itemMeta?._special)"
:provider-type="'chat_completion'"
/>
</div>
<div v-else-if="itemMeta?._special === 'select_provider_stt'">
<ProviderSelector
v-model="createSelectorModel(itemKey).value"
:provider-type="'speech_to_text'"
/>
</div>
<div v-else-if="itemMeta?._special === 'select_provider_tts'">
<ProviderSelector
v-model="createSelectorModel(itemKey).value"
:provider-type="'text_to_speech'"
/>
</div>
<div v-else-if="itemMeta?._special === 'provider_pool'">
<ProviderSelector v-model="createSelectorModel(itemKey).value" :provider-type="'chat_completion'"
button-text="选择提供商池..." />
<ProviderSelector
v-model="createSelectorModel(itemKey).value"
:provider-type="'chat_completion'"
button-text="选择提供商池..."
/>
</div>
<div v-else-if="itemMeta?._special === 'select_persona'">
<PersonaSelector v-model="createSelectorModel(itemKey).value" />
<PersonaSelector
v-model="createSelectorModel(itemKey).value"
/>
</div>
<div v-else-if="itemMeta?._special === 'persona_pool'">
<PersonaSelector v-model="createSelectorModel(itemKey).value" button-text="选择人格池..." />
<PersonaSelector
v-model="createSelectorModel(itemKey).value"
button-text="选择人格池..."
/>
</div>
<div v-else-if="itemMeta?._special === 'select_knowledgebase'">
<KnowledgeBaseSelector v-model="createSelectorModel(itemKey).value" />
<KnowledgeBaseSelector
v-model="createSelectorModel(itemKey).value"
/>
</div>
<div v-else-if="itemMeta?._special === 'select_plugin_set'">
<PluginSetSelector v-model="createSelectorModel(itemKey).value" />
<PluginSetSelector
v-model="createSelectorModel(itemKey).value"
/>
</div>
<div v-else-if="itemMeta?._special === 't2i_template'">
<T2ITemplateEditor />
@@ -312,17 +263,21 @@ function getSpecialSubtype(value) {
</v-row>
<!-- Plugin Set Selector 全宽显示区域 -->
<v-row v-if="!itemMeta?.invisible && itemMeta?._special === 'select_plugin_set'"
class="plugin-set-display-row">
<v-row v-if="!itemMeta?.invisible && itemMeta?._special === 'select_plugin_set'" class="plugin-set-display-row">
<v-col cols="12" class="plugin-set-display">
<div v-if="createSelectorModel(itemKey).value && createSelectorModel(itemKey).value.length > 0"
class="selected-plugins-full-width">
<div v-if="createSelectorModel(itemKey).value && createSelectorModel(itemKey).value.length > 0" class="selected-plugins-full-width">
<div class="plugins-header">
<small class="text-grey">已选择的插件</small>
</div>
<div class="d-flex flex-wrap ga-2 mt-2">
<v-chip v-for="plugin in (createSelectorModel(itemKey).value || [])" :key="plugin" size="small" label
color="primary" variant="outlined">
<v-chip
v-for="plugin in (createSelectorModel(itemKey).value || [])"
:key="plugin"
size="small"
label
color="primary"
variant="outlined"
>
{{ plugin === '*' ? '所有插件' : plugin }}
</v-chip>
</div>
@@ -330,8 +285,7 @@ function getSpecialSubtype(value) {
</v-col>
</v-row>
</template>
<v-divider class="config-divider"
v-if="shouldShowItem(itemMeta, itemKey) && hasVisibleItemsAfter(metadata[metadataKey].items, index)"></v-divider>
<v-divider class="config-divider" v-if="shouldShowItem(itemMeta, itemKey) && hasVisibleItemsAfter(metadata[metadataKey].items, index)"></v-divider>
</div>
</div>

View File

@@ -7,8 +7,8 @@ import { useCommonStore } from '@/stores/common';
<!-- 添加筛选级别控件 -->
<div class="filter-controls mb-2" v-if="showLevelBtns">
<v-chip-group v-model="selectedLevels" column multiple>
<v-chip v-for="level in logLevels" :key="level" :color="getLevelColor(level)" filter variant="flat" size="small"
:text-color="level === 'DEBUG' || level === 'INFO' ? 'black' : 'white'" class="font-weight-medium">
<v-chip v-for="level in logLevels" :key="level" :color="getLevelColor(level)" filter
:text-color="level === 'DEBUG' || level === 'INFO' ? 'black' : 'white'">
{{ level }}
</v-chip>
</v-chip-group>
@@ -168,7 +168,6 @@ export default {
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
margin-left: 20px;
}
.fade-in {

View File

@@ -3,7 +3,7 @@
<div style="flex: 1; min-width: 0; overflow: hidden;">
<span v-if="!modelValue || (Array.isArray(modelValue) && modelValue.length === 0)"
style="color: rgb(var(--v-theme-primaryText));">
{{ tm('knowledgeBaseSelector.notSelected') }}
未选择
</span>
<div v-else class="d-flex flex-wrap gap-1">
<v-chip
@@ -28,7 +28,7 @@
<v-dialog v-model="dialog" max-width="600px">
<v-card>
<v-card-title class="text-h3 py-4" style="font-weight: normal;">
{{ tm('knowledgeBaseSelector.dialogTitle') }}
选择知识库
</v-card-title>
<v-card-text class="pa-0" style="max-height: 400px; overflow-y: auto;">
@@ -50,9 +50,9 @@
</template>
<v-list-item-title>{{ kb.kb_name }}</v-list-item-title>
<v-list-item-subtitle>
{{ kb.description || tm('knowledgeBaseSelector.noDescription') }}
<span v-if="kb.doc_count !== undefined"> - {{ tm('knowledgeBaseSelector.documentCount', { count: kb.doc_count }) }}</span>
<span v-if="kb.chunk_count !== undefined"> - {{ tm('knowledgeBaseSelector.chunkCount', { count: kb.chunk_count }) }}</span>
{{ kb.description || '无描述' }}
<span v-if="kb.doc_count !== undefined"> - {{ kb.doc_count }} 个文档</span>
<span v-if="kb.chunk_count !== undefined"> - {{ kb.chunk_count }} 个块</span>
</v-list-item-subtitle>
<template v-slot:append>
@@ -68,9 +68,9 @@
<!-- 当没有知识库时显示创建提示 -->
<div v-if="knowledgeBaseList.length === 0" class="text-center py-8">
<v-icon size="64" color="grey-lighten-1">mdi-database-off</v-icon>
<p class="text-grey mt-4 mb-4">{{ tm('knowledgeBaseSelector.noKnowledgeBases') }}</p>
<p class="text-grey mt-4 mb-4">暂无知识库</p>
<v-btn color="primary" variant="tonal" @click="goToKnowledgeBasePage">
{{ tm('knowledgeBaseSelector.createKnowledgeBase') }}
创建知识库
</v-btn>
</div>
</v-list>
@@ -78,14 +78,14 @@
<v-card-actions class="pa-4">
<div v-if="selectedKnowledgeBases.length > 0" class="text-caption text-grey">
{{ tm('knowledgeBaseSelector.selectedCount', { count: selectedKnowledgeBases.length }) }}
已选择 {{ selectedKnowledgeBases.length }} 个知识库
</div>
<v-spacer></v-spacer>
<v-btn variant="text" @click="cancelSelection">{{ tm('knowledgeBaseSelector.cancelSelection') }}</v-btn>
<v-btn variant="text" @click="cancelSelection">取消</v-btn>
<v-btn
color="primary"
@click="confirmSelection">
{{ tm('knowledgeBaseSelector.confirmSelection') }}
确认选择
</v-btn>
</v-card-actions>
</v-card>
@@ -96,7 +96,6 @@
import { ref, watch } from 'vue'
import axios from 'axios'
import { useRouter } from 'vue-router'
import { useModuleI18n } from '@/i18n/composables'
const props = defineProps({
modelValue: {
@@ -111,7 +110,6 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue'])
const router = useRouter()
const { tm } = useModuleI18n('core.shared')
const dialog = ref(false)
const knowledgeBaseList = ref([])

View File

@@ -4,13 +4,13 @@
<div class="d-flex align-center justify-space-between mb-2">
<div class="flex-grow-1">
<span v-if="!modelValue || modelValue.length === 0" style="color: rgb(var(--v-theme-primaryText));">
{{ tm('pluginSetSelector.notSelected') }}
未启用任何插件
</span>
<span v-else-if="isAllPlugins" style="color: rgb(var(--v-theme-primaryText));">
{{ tm('pluginSetSelector.allPlugins') }}
启用所有插件 (*)
</span>
<span v-else style="color: rgb(var(--v-theme-primaryText));">
{{ tm('pluginSetSelector.selectedCount', { count: modelValue.length }) }}
已选择 {{ modelValue.length }} 个插件
</span>
</div>
<v-btn size="small" color="primary" variant="tonal" @click="openDialog">
@@ -23,7 +23,7 @@
<v-dialog v-model="dialog" max-width="700px">
<v-card>
<v-card-title class="text-h3 py-4" style="font-weight: normal;">
{{ tm('pluginSetSelector.dialogTitle') }}
选择插件集合
</v-card-title>
<v-card-text class="pa-4">
@@ -34,17 +34,17 @@
<v-radio-group v-model="selectionMode" class="mb-4" hide-details>
<v-radio
value="all"
:label="tm('pluginSetSelector.enableAll')"
label="启用所有插件"
color="primary"
></v-radio>
<v-radio
value="none"
:label="tm('pluginSetSelector.enableNone')"
label="不启用任何插件"
color="primary"
></v-radio>
<v-radio
value="custom"
:label="tm('pluginSetSelector.customSelect')"
label="自定义选择"
color="primary"
></v-radio>
</v-radio-group>
@@ -68,21 +68,21 @@
<v-list-item-title>{{ plugin.name }}</v-list-item-title>
<v-list-item-subtitle>
{{ plugin.desc || tm('pluginSetSelector.noDescription') }}
{{ plugin.desc || '无描述' }}
<v-chip v-if="!plugin.activated" size="x-small" color="grey" class="ml-1">
{{ tm('pluginSetSelector.notActivated') }}
未激活
</v-chip>
</v-list-item-subtitle>
</v-list-item>
<div class="pl-8 pt-2">
<small>{{ tm('pluginSetSelector.note') }}</small>
<small>*不显示系统插件和已经在插件页禁用的插件</small>
</div>
</v-list>
<div v-else class="text-center py-8">
<v-icon size="64" color="grey-lighten-1">mdi-puzzle-outline</v-icon>
<p class="text-grey mt-4">{{ tm('pluginSetSelector.noPlugins') }}</p>
<p class="text-grey mt-4">暂无可用的插件</p>
</div>
</div>
</div>
@@ -90,11 +90,11 @@
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="cancelSelection">{{ tm('pluginSetSelector.cancelSelection') }}</v-btn>
<v-btn variant="text" @click="cancelSelection">取消</v-btn>
<v-btn
color="primary"
@click="confirmSelection">
{{ tm('pluginSetSelector.confirmSelection') }}
确认选择
</v-btn>
</v-card-actions>
</v-card>
@@ -104,7 +104,6 @@
<script setup>
import { ref, computed, watch } from 'vue'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
const props = defineProps({
modelValue: {
@@ -122,7 +121,6 @@ const props = defineProps({
})
const emit = defineEmits(['update:modelValue'])
const { tm } = useModuleI18n('core.shared')
const dialog = ref(false)
const pluginList = ref([])

View File

@@ -1,7 +1,7 @@
<template>
<div class="d-flex align-center justify-space-between">
<span v-if="!modelValue" style="color: rgb(var(--v-theme-primaryText));">
{{ tm('providerSelector.notSelected') }}
未选择
</span>
<span v-else>
{{ modelValue }}
@@ -15,7 +15,7 @@
<v-dialog v-model="dialog" max-width="600px">
<v-card>
<v-card-title class="text-h3 py-4" style="font-weight: normal;">
{{ tm('providerSelector.dialogTitle') }}
选择提供商
</v-card-title>
<v-card-text class="pa-0" style="max-height: 400px; overflow-y: auto;">
@@ -30,8 +30,8 @@
:active="selectedProvider === ''"
rounded="md"
class="ma-1">
<v-list-item-title>{{ tm('providerSelector.clearSelection') }}</v-list-item-title>
<v-list-item-subtitle>{{ tm('providerSelector.clearSelectionSubtitle') }}</v-list-item-subtitle>
<v-list-item-title>不选择</v-list-item-title>
<v-list-item-subtitle>清除当前选择</v-list-item-subtitle>
<template v-slot:append>
<v-icon v-if="selectedProvider === ''" color="primary">mdi-check-circle</v-icon>
@@ -50,7 +50,7 @@
class="ma-1">
<v-list-item-title>{{ provider.id }}</v-list-item-title>
<v-list-item-subtitle>
{{ provider.type || provider.provider_type || tm('providerSelector.unknownType') }}
{{ provider.type || provider.provider_type || '未知类型' }}
<span v-if="provider.model_config?.model">- {{ provider.model_config.model }}</span>
</v-list-item-subtitle>
@@ -62,7 +62,7 @@
<div v-else-if="!loading && providerList.length === 0" class="text-center py-8">
<v-icon size="64" color="grey-lighten-1">mdi-api-off</v-icon>
<p class="text-grey mt-4">{{ tm('providerSelector.noProviders') }}</p>
<p class="text-grey mt-4">暂无可用的提供商</p>
</div>
</v-card-text>
@@ -70,11 +70,11 @@
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="cancelSelection">{{ tm('providerSelector.cancelSelection') }}</v-btn>
<v-btn variant="text" @click="cancelSelection">取消</v-btn>
<v-btn
color="primary"
@click="confirmSelection">
{{ tm('providerSelector.confirmSelection') }}
确认选择
</v-btn>
</v-card-actions>
</v-card>
@@ -84,7 +84,6 @@
<script setup>
import { ref, watch } from 'vue'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
const props = defineProps({
modelValue: {
@@ -95,10 +94,6 @@ const props = defineProps({
type: String,
default: 'chat_completion'
},
providerSubtype: {
type: String,
default: ''
},
buttonText: {
type: String,
default: '选择提供商...'
@@ -106,7 +101,6 @@ const props = defineProps({
})
const emit = defineEmits(['update:modelValue'])
const { tm } = useModuleI18n('core.shared')
const dialog = ref(false)
const providerList = ref([])
@@ -133,10 +127,7 @@ async function loadProviders() {
}
})
if (response.data.status === 'ok') {
const providers = response.data.data || []
providerList.value = props.providerSubtype
? providers.filter((provider) => matchesProviderSubtype(provider, props.providerSubtype))
: providers
providerList.value = response.data.data || []
}
} catch (error) {
console.error('加载提供商列表失败:', error)
@@ -146,17 +137,6 @@ async function loadProviders() {
}
}
function matchesProviderSubtype(provider, subtype) {
if (!subtype) {
return true
}
const normalized = String(subtype).toLowerCase()
const candidates = [provider.type, provider.provider, provider.id]
.filter(Boolean)
.map((value) => String(value).toLowerCase())
return candidates.includes(normalized)
}
function selectProvider(provider) {
selectedProvider.value = provider.id
}

View File

@@ -301,4 +301,3 @@ export function useMessages(
toggleStreaming
};
}

View File

@@ -4,12 +4,8 @@ import { useRouter } from 'vue-router';
export interface Session {
session_id: string;
display_name: string | null;
display_name: string;
updated_at: string;
platform_id: string;
creator: string;
is_group: number;
created_at: string;
}
export function useSessions(chatboxMode: boolean = false) {

View File

@@ -33,7 +33,6 @@ export class I18nLoader {
{ name: 'core/status', path: 'core/status.json' },
{ name: 'core/navigation', path: 'core/navigation.json' },
{ name: 'core/header', path: 'core/header.json' },
{ name: 'core/shared', path: 'core/shared.json' },
// 功能模块
{ name: 'features/chat', path: 'features/chat.json' },
@@ -44,7 +43,6 @@ export class I18nLoader {
{ name: 'features/provider', path: 'features/provider.json' },
{ name: 'features/platform', path: 'features/platform.json' },
{ name: 'features/config', path: 'features/config.json' },
{ name: 'features/config-metadata', path: 'features/config-metadata.json' },
{ name: 'features/console', path: 'features/console.json' },
{ name: 'features/about', path: 'features/about.json' },
{ name: 'features/settings', path: 'features/settings.json' },

View File

@@ -74,7 +74,6 @@
"delete": "Delete",
"copy": "Copy",
"edit": "Edit",
"copy": "Copy",
"noData": "No data available"
}
}

View File

@@ -8,10 +8,11 @@
"chat": "Chat",
"extension": "Extensions",
"conversation": "Conversations",
"sessionManagement": "Custom Rules",
"sessionManagement": "Session Management",
"console": "Console",
"alkaid": "Alkaid Lab",
"knowledgeBase": "Knowledge Base",
"memory": "Long-term Memory",
"about": "About",
"settings": "Settings",
"documentation": "Documentation",

View File

@@ -1,45 +0,0 @@
{
"knowledgeBaseSelector": {
"notSelected": "Not selected",
"buttonText": "Select Knowledge Base...",
"dialogTitle": "Select Knowledge Base",
"loading": "Loading...",
"noKnowledgeBases": "No knowledge bases available",
"createKnowledgeBase": "Create Knowledge Base",
"selectedCount": "{count} knowledge base(s) selected",
"confirmSelection": "Confirm Selection",
"cancelSelection": "Cancel",
"noDescription": "No description",
"documentCount": "{count} document(s)",
"chunkCount": "{count} chunk(s)"
},
"pluginSetSelector": {
"notSelected": "No plugins enabled",
"allPlugins": "All plugins enabled (*)",
"selectedCount": "{count} plugin(s) selected",
"buttonText": "Select Plugin Set...",
"dialogTitle": "Select Plugin Set",
"loading": "Loading...",
"enableAll": "Enable all plugins",
"enableNone": "Disable all plugins",
"customSelect": "Custom selection",
"noPlugins": "No plugins available",
"confirmSelection": "Confirm Selection",
"cancelSelection": "Cancel",
"noDescription": "No description",
"notActivated": "Not activated",
"note": "*System plugins and disabled plugins are not shown."
},
"providerSelector": {
"notSelected": "Not selected",
"buttonText": "Select Provider...",
"dialogTitle": "Select Provider",
"loading": "Loading...",
"noProviders": "No providers available",
"confirmSelection": "Confirm Selection",
"cancelSelection": "Cancel",
"clearSelection": "None",
"clearSelectionSubtitle": "Clear current selection",
"unknownType": "Unknown type"
}
}

View File

@@ -51,8 +51,7 @@
"editDisplayName": "Edit Session Name",
"displayName": "Session Name",
"displayNameUpdated": "Session name updated",
"displayNameUpdateFailed": "Failed to update session name",
"confirmDelete": "Are you sure you want to delete \"{name}\"? This action cannot be undone."
"displayNameUpdateFailed": "Failed to update session name"
},
"modes": {
"darkMode": "Switch to Dark Mode",
@@ -85,9 +84,5 @@
"reconnected": "Chat connection re-established",
"failed": "Connection failed, please refresh the page"
}
},
"errors": {
"sendMessageFailed": "Failed to send message, please try again",
"createSessionFailed": "Failed to create session, please refresh the page"
}
}

View File

@@ -1,476 +0,0 @@
{
"ai_group": {
"name": "AI",
"agent_runner": {
"description": "Agent Runner",
"hint": "Select the runner for AI conversations. Defaults to AstrBot's built-in Agent runner, which supports knowledge base, persona, and tool calling features. You don't need to modify this section unless you plan to integrate third-party Agent runners like Dify or Coze.",
"provider_settings": {
"enable": {
"description": "Enable",
"hint": "Master switch for AI conversations"
},
"agent_runner_type": {
"description": "Runner",
"labels": ["Built-in Agent", "Dify", "Coze", "Alibaba Cloud Bailian Application"]
},
"coze_agent_runner_provider_id": {
"description": "Coze Agent Runner Provider ID"
},
"dify_agent_runner_provider_id": {
"description": "Dify Agent Runner Provider ID"
},
"dashscope_agent_runner_provider_id": {
"description": "Alibaba Cloud Bailian Application Agent Runner Provider ID"
}
}
},
"ai": {
"description": "Model",
"hint": "When using non-built-in Agent runners, the default chat model and default image caption model may not take effect, but some plugins rely on these settings to invoke AI capabilities.",
"provider_settings": {
"default_provider_id": {
"description": "Default Chat Model",
"hint": "Uses the first model when left empty"
},
"default_image_caption_provider_id": {
"description": "Default Image Caption Model",
"hint": "Leave empty to disable; useful for non-multimodal models"
},
"image_caption_prompt": {
"description": "Image Caption Prompt"
}
},
"provider_stt_settings": {
"enable": {
"description": "Enable Speech-to-Text",
"hint": "Master switch for STT"
},
"provider_id": {
"description": "Default Speech-to-Text Model",
"hint": "Users can also select session-specific STT models using the /provider command."
}
},
"provider_tts_settings": {
"enable": {
"description": "Enable Text-to-Speech",
"hint": "Master switch for TTS"
},
"provider_id": {
"description": "Default Text-to-Speech Model"
}
}
},
"persona": {
"description": "Persona",
"provider_settings": {
"default_personality": {
"description": "Default Persona"
}
}
},
"knowledgebase": {
"description": "Knowledge Base",
"kb_names": {
"description": "Knowledge Base List",
"hint": "Supports multiple selections"
},
"kb_fusion_top_k": {
"description": "Fusion Search Results Count",
"hint": "Number of results returned after fusing search results from multiple knowledge bases"
},
"kb_final_top_k": {
"description": "Final Results Count",
"hint": "Number of results retrieved from the knowledge base. Higher values may provide more relevant information but could also introduce noise. Adjust based on actual needs"
},
"kb_agentic_mode": {
"description": "Agentic Knowledge Base Retrieval",
"hint": "When enabled, knowledge base retrieval becomes an LLM Tool, allowing the model to autonomously decide when to query the knowledge base. Requires the model to support function calling."
}
},
"websearch": {
"description": "Web Search",
"provider_settings": {
"web_search": {
"description": "Enable Web Search"
},
"websearch_provider": {
"description": "Web Search Provider"
},
"websearch_tavily_key": {
"description": "Tavily API Key",
"hint": "Multiple keys can be added for rotation."
},
"websearch_baidu_app_builder_key": {
"description": "Baidu Qianfan Smart Cloud APP Builder API Key",
"hint": "Reference: https://console.bce.baidu.com/iam/#/iam/apikey/list"
},
"web_search_link": {
"description": "Display Source Citations"
}
}
},
"file_extract": {
"description": "File Extract",
"provider_settings": {
"file_extract": {
"enable": {
"description": "Enable File Extract"
},
"provider": {
"description": "File Extract Provider"
},
"moonshotai_api_key": {
"description": "Moonshot AI API Key"
}
}
}
},
"others": {
"description": "Other Settings",
"provider_settings": {
"display_reasoning_text": {
"description": "Display Reasoning Content"
},
"identifier": {
"description": "User Identification",
"hint": "When enabled, user ID information will be included in the prompt."
},
"group_name_display": {
"description": "Display Group Name",
"hint": "When enabled, group name information will be included in the prompt on supported platforms (OneBot v11)."
},
"datetime_system_prompt": {
"description": "Real-world Time Awareness",
"hint": "When enabled, current time information will be appended to the system prompt."
},
"show_tool_use_status": {
"description": "Output Function Call Status"
},
"max_agent_step": {
"description": "Maximum Tool Call Rounds"
},
"tool_call_timeout": {
"description": "Tool Call Timeout (seconds)"
},
"streaming_response": {
"description": "Streaming Output"
},
"unsupported_streaming_strategy": {
"description": "Platforms Without Streaming Support",
"hint": "Select the handling method for platforms that don't support streaming responses. Real-time segmented reply sends content immediately when the system detects segment points like punctuation during streaming reception",
"labels": ["Real-time Segmented Reply", "Disable Streaming Response"]
},
"max_context_length": {
"description": "Maximum Conversation Rounds",
"hint": "Discards the oldest parts when this count is exceeded. One conversation round counts as 1, -1 means unlimited"
},
"dequeue_context_length": {
"description": "Dequeue Conversation Rounds",
"hint": "Number of conversation rounds to discard at once when maximum context length is exceeded"
},
"wake_prefix": {
"description": "Additional LLM Chat Wake Prefix",
"hint": "If the wake prefix is / and the additional chat wake prefix is chat, then /chat is required to trigger LLM requests"
},
"prompt_prefix": {
"description": "User Prompt",
"hint": "You can use {{prompt}} as a placeholder for user input. If no placeholder is provided, it will be added before the user input."
},
"reachability_check": {
"description": "Provider Reachability Check",
"hint": "When running the /provider command, test provider connectivity in parallel. This actively pings models and may consume extra tokens."
}
},
"provider_tts_settings": {
"dual_output": {
"description": "Output Both Voice and Text When TTS is Enabled"
}
}
}
},
"platform_group": {
"name": "Platform",
"general": {
"description": "General",
"admins_id": {
"description": "Administrator IDs"
},
"platform_settings": {
"unique_session": {
"description": "Isolate Sessions",
"hint": "When enabled, group members have independent contexts."
},
"friend_message_needs_wake_prefix": {
"description": "Private Messages Require Wake Word"
},
"reply_prefix": {
"description": "Reply Text Prefix"
},
"reply_with_mention": {
"description": "Mention Sender in Reply"
},
"reply_with_quote": {
"description": "Quote Sender's Message in Reply"
},
"forward_threshold": {
"description": "Forward Message Word Count Threshold"
},
"empty_mention_waiting": {
"description": "Trigger Waiting on Mention-only Messages"
}
},
"wake_prefix": {
"description": "Wake Word"
}
},
"whitelist": {
"description": "Whitelist",
"platform_settings": {
"enable_id_white_list": {
"description": "Enable Whitelist",
"hint": "When enabled, only sessions in the whitelist will be responded to."
},
"id_whitelist": {
"description": "Whitelist ID List",
"hint": "Use /sid to get IDs."
},
"id_whitelist_log": {
"description": "Output Logs",
"hint": "When enabled, INFO level logs will be output when a message doesn't pass the whitelist."
},
"wl_ignore_admin_on_group": {
"description": "Administrator Group Messages Bypass ID Whitelist"
},
"wl_ignore_admin_on_friend": {
"description": "Administrator Private Messages Bypass ID Whitelist"
}
}
},
"rate_limit": {
"description": "Rate Limiting",
"platform_settings": {
"rate_limit": {
"time": {
"description": "Message Rate Limit Time (seconds)"
},
"count": {
"description": "Message Rate Limit Count"
},
"strategy": {
"description": "Rate Limit Strategy"
}
}
}
},
"content_safety": {
"description": "Content Safety",
"content_safety": {
"also_use_in_response": {
"description": "Also Check Model Response Content"
},
"baidu_aip": {
"enable": {
"description": "Use Baidu Content Safety Moderation",
"hint": "You need to manually install the baidu-aip library."
},
"app_id": {
"description": "App ID"
},
"api_key": {
"description": "API Key"
},
"secret_key": {
"description": "Secret Key"
}
},
"internal_keywords": {
"enable": {
"description": "Keyword Check"
},
"extra_keywords": {
"description": "Additional Keywords",
"hint": "Additional keyword blocklist, supports regular expressions."
}
}
}
},
"t2i": {
"description": "Text-to-Image",
"t2i": {
"description": "Text-to-Image Output"
},
"t2i_word_threshold": {
"description": "Text-to-Image Word Count Threshold"
}
},
"others": {
"description": "Other Settings",
"platform_settings": {
"ignore_bot_self_message": {
"description": "Ignore Bot's Own Messages"
},
"ignore_at_all": {
"description": "Ignore @All Events"
},
"no_permission_reply": {
"description": "Reply When User Has Insufficient Permissions"
}
},
"platform_specific": {
"lark": {
"pre_ack_emoji": {
"enable": {
"description": "[Lark] Enable Pre-acknowledgment Emoji"
},
"emojis": {
"description": "Emoji List (Lark Emoji Enum Names)",
"hint": "Emoji enum names reference: https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce"
}
}
},
"telegram": {
"pre_ack_emoji": {
"enable": {
"description": "[Telegram] Enable Pre-acknowledgment Emoji"
},
"emojis": {
"description": "Emoji List (Unicode)",
"hint": "Telegram only supports a fixed reaction set, reference: https://gist.github.com/Soulter/3f22c8e5f9c7e152e967e8bc28c97fc9"
}
}
}
}
}
},
"plugin_group": {
"name": "Plugin",
"plugin": {
"description": "Plugins",
"plugin_set": {
"description": "Available Plugins",
"hint": "All non-disabled plugins are enabled by default. If a plugin is disabled on the plugins page, selections here will not take effect."
}
}
},
"ext_group": {
"name": "Ext.",
"segmented_reply": {
"description": "Segmented Reply",
"platform_settings": {
"segmented_reply": {
"enable": {
"description": "Enable Segmented Reply"
},
"only_llm_result": {
"description": "Segment Only LLM Results"
},
"interval_method": {
"description": "Interval Method"
},
"interval": {
"description": "Random Interval Time",
"hint": "Format: minimum,maximum (e.g., 1.5,3.5)"
},
"log_base": {
"description": "Logarithm Base",
"hint": "Base for logarithmic intervals, defaults to 2.0. Value range: 1.0-10.0."
},
"words_count_threshold": {
"description": "Segmented Reply Word Count Threshold"
},
"regex": {
"description": "Segmentation Regular Expression"
},
"content_cleanup_rule": {
"description": "Content Filtering Regular Expression",
"hint": "Remove specified content from segmented content. For example, `[。?!]` will remove all periods, question marks, and exclamation marks."
}
}
}
},
"ltm": {
"description": "Group Chat Context Awareness (formerly Chat Memory Enhancement)",
"provider_ltm_settings": {
"group_icl_enable": {
"description": "Enable Group Chat Context Awareness"
},
"group_message_max_cnt": {
"description": "Maximum Message Count"
},
"image_caption": {
"description": "Auto-understand Images",
"hint": "Requires setting a group chat image caption model."
},
"image_caption_provider_id": {
"description": "Group Chat Image Caption Model",
"hint": "Used for image understanding in group chat context awareness, configured separately from the default image caption model."
},
"active_reply": {
"enable": {
"description": "Active Reply"
},
"method": {
"description": "Active Reply Method"
},
"possibility_reply": {
"description": "Reply Probability",
"hint": "Value between 0.0-1.0"
},
"whitelist": {
"description": "Active Reply Whitelist",
"hint": "Whitelist filtering is disabled when empty. Use /sid to get IDs."
}
}
}
}
},
"system_group": {
"name": "System",
"system": {
"description": "System Settings",
"t2i_strategy": {
"description": "Text-to-Image Strategy",
"hint": "Text-to-image strategy. `remote` uses a remote HTML-based rendering service, `local` uses PIL for local rendering. When using local, place a TTF font named 'font.ttf' in the data/ directory to customize the font."
},
"t2i_endpoint": {
"description": "Text-to-Image Service API Endpoint",
"hint": "Uses AstrBot API service when empty"
},
"t2i_template": {
"description": "Text-to-Image Custom Template",
"hint": "When enabled, you can customize HTML templates for text-to-image rendering."
},
"t2i_active_template": {
"description": "Currently Active Text-to-Image Rendering Template",
"hint": "This value is maintained by the text-to-image template management page."
},
"log_level": {
"description": "Console Log Level",
"hint": "Log level for console output."
},
"pip_install_arg": {
"description": "Additional pip Installation Arguments",
"hint": "When installing plugin dependencies, Python's pip tool will be used. Additional arguments can be provided here, such as `--break-system-package`."
},
"pypi_index_url": {
"description": "PyPI Repository URL",
"hint": "PyPI repository URL for installing Python dependencies. Defaults to https://mirrors.aliyun.com/pypi/simple/"
},
"callback_api_base": {
"description": "Externally Accessible Callback API Address",
"hint": "External services may access AstrBot's backend through callback links generated by AstrBot (such as file download links). Since AstrBot cannot automatically determine the externally accessible host address in the deployment environment, this configuration item is needed to explicitly specify how external services should access AstrBot's address. Examples: http://localhost:6185, https://example.com, etc."
},
"timezone": {
"description": "Timezone",
"hint": "Timezone setting. Please enter an IANA timezone name, such as Asia/Shanghai. Uses system default timezone when empty. For all timezones, see: https://data.iana.org/time-zones/tzdb-2021a/zone1970.tab"
},
"http_proxy": {
"description": "HTTP Proxy",
"hint": "When enabled, proxy will be set by adding environment variables. Format: `http://ip:port`"
},
"no_proxy": {
"description": "Direct Connection Address List"
}
}
}
}

View File

@@ -62,33 +62,5 @@
"allowedHosts": "Allowed Hosts",
"rateLimit": "Rate Limit",
"encryption": "Encryption Settings"
},
"configSelection": {
"selectConfig": "Select Configuration",
"normalConfig": "Basic",
"systemConfig": "System"
},
"configManagement": {
"title": "Configuration Management",
"description": "AstrBot supports separate configuration files for different bots. The `default` configuration is used by default.",
"newConfig": "New Configuration",
"editConfig": "Edit Configuration",
"manageConfigs": "Manage Configurations...",
"configName": "Name",
"fillConfigName": "Enter configuration name",
"confirmDelete": "Are you sure you want to delete the configuration \"{name}\"? This action cannot be undone.",
"pleaseEnterName": "Please enter a configuration name",
"createFailed": "Failed to create new configuration",
"deleteFailed": "Failed to delete configuration",
"updateFailed": "Failed to update configuration"
},
"buttons": {
"cancel": "Cancel",
"create": "Create",
"update": "Update"
},
"codeEditor": {
"title": "Edit Configuration File"
}
}

View File

@@ -32,8 +32,7 @@
"actions": "Actions",
"back": "Back",
"selectFile": "Select File",
"refresh": "Refresh",
"updateAll": "Update All"
"refresh": "Refresh"
},
"status": {
"enabled": "Enabled",
@@ -142,9 +141,7 @@
"confirmDelete": "Are you sure you want to delete this extension?",
"fillUrlOrFile": "Please fill in extension URL or upload extension file",
"dontFillBoth": "Please don't fill in both extension URL and upload file",
"supportedFormats": "Supports .zip extension files",
"updateAllSuccess": "All upgradable extensions have been updated!",
"updateAllFailed": "{failed} of {total} extensions failed to update:"
"supportedFormats": "Supports .zip extension files"
},
"upload": {
"fromFile": "Install from File",

View File

@@ -9,7 +9,6 @@
"tabs": {
"all": "All",
"chatCompletion": "Chat Completion",
"agentRunner": "Agent Runner",
"speechToText": "Speech to Text",
"textToSpeech": "Text to Speech",
"embedding": "Embedding",
@@ -45,13 +44,12 @@
"title": "Service Provider",
"tabs": {
"basic": "Basic",
"agentRunner": "Agent Runner",
"speechToText": "Speech to Text",
"textToSpeech": "Text to Speech",
"embedding": "Embedding",
"rerank": "Rerank"
},
"noTemplates": "No this type provider templates available"
"noTemplates": "No {type} type provider templates available"
},
"config": {
"addTitle": "Add",

View File

@@ -1,110 +1,124 @@
{
"title": "Custom Rules",
"subtitle": "Set custom rules for specific sessions, which take priority over global settings",
"title": "Session Management",
"subtitle": "Manage active sessions and configurations",
"buttons": {
"refresh": "Refresh",
"edit": "Edit",
"editRule": "Edit Rules",
"deleteAllRules": "Delete All Rules",
"addRule": "Add Rule",
"apply": "Apply Batch Settings",
"editName": "Edit Session Name",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"clear": "Clear",
"next": "Next",
"editCustomName": "Edit Note",
"batchDelete": "Batch Delete"
"delete": "Delete"
},
"customRules": {
"title": "Custom Rules",
"rulesCount": "rules",
"hasRules": "Configured",
"noRules": "No Custom Rules",
"noRulesDesc": "Click 'Add Rule' to configure custom rules for specific sessions",
"serviceConfig": "Service Config",
"pluginConfig": "Plugin Config",
"kbConfig": "Knowledge Base",
"providerConfig": "Provider Config",
"configured": "Configured",
"noCustomName": "No note set"
},
"quickEditName": {
"title": "Edit Note"
"sessions": {
"activeSessions": "Active Sessions",
"sessionCount": "sessions",
"noActiveSessions": "No active sessions",
"noActiveSessionsDesc": "Sessions will appear here when users interact with the bot"
},
"search": {
"placeholder": "Search sessions..."
"placeholder": "Search sessions...",
"platformFilter": "Platform Filter"
},
"table": {
"headers": {
"umoInfo": "Unified Message Origin",
"rulesOverview": "Rules Overview",
"sessionStatus": "Session Status",
"sessionInfo": "Session Info",
"persona": "Persona",
"chatProvider": "Chat Provider",
"sttProvider": "STT Provider",
"ttsProvider": "TTS Provider",
"llmStatus": "LLM Status",
"ttsStatus": "TTS Status",
"knowledgeBase": "Knowledge Base",
"pluginManagement": "Plugin Management",
"actions": "Actions"
}
},
"status": {
"enabled": "Enabled",
"disabled": "Disabled"
},
"persona": {
"none": "Follow Config"
"none": "No Persona"
},
"provider": {
"followConfig": "Follow Config"
"batchOperations": {
"title": "Batch Operations",
"setPersona": "Batch Set Persona",
"setChatProvider": "Batch Set Chat Provider",
"setSttProvider": "Batch Set STT Provider",
"setTtsProvider": "Batch Set TTS Provider",
"setLlmStatus": "Batch Set LLM Status",
"setTtsStatus": "Batch Set TTS Status",
"noSttProvider": "No STT Provider Available",
"noTtsProvider": "No TTS Provider Available"
},
"addRule": {
"title": "Add Custom Rule",
"description": "Select a session (UMO) to configure custom rules. Custom rules take priority over global settings.",
"selectUmo": "Select Session",
"noUmos": "No sessions available"
"pluginManagement": {
"title": "Plugin Management",
"noPlugins": "No available plugins",
"noPluginsDesc": "Currently no active plugins",
"loading": "Loading plugin list...",
"author": "Author"
},
"ruleEditor": {
"title": "Edit Custom Rules",
"description": "Configure custom rules for this session. These rules take priority over global settings.",
"serviceConfig": {
"title": "Service Configuration",
"sessionEnabled": "Enable Session",
"llmEnabled": "Enable LLM",
"ttsEnabled": "Enable TTS",
"customName": "Custom Name"
},
"providerConfig": {
"title": "Provider Configuration",
"chatProvider": "Chat Provider",
"sttProvider": "STT Provider",
"ttsProvider": "TTS Provider"
},
"personaConfig": {
"title": "Persona Configuration",
"selectPersona": "Select Persona",
"hint": "Persona settings affect the conversation style and behavior of the LLM"
},
"pluginConfig": {
"title": "Plugin Configuration",
"disabledPlugins": "Disabled Plugins",
"hint": "Select plugins to disable for this session. Unselected plugins will remain enabled."
},
"kbConfig": {
"title": "Knowledge Base Configuration",
"selectKbs": "Select Knowledge Bases",
"topK": "Top K Results",
"enableRerank": "Enable Reranking"
}
"nameEditor": {
"title": "Edit Session Name",
"customName": "Custom Name",
"placeholder": "Enter custom session name (leave empty to use original name)",
"originalName": "Original Name",
"fullSessionId": "Full Session ID",
"hint": "Custom names help you easily identify sessions. The small information icon (!) will show the actual UMO when hovering."
},
"knowledgeBase": {
"title": "Knowledge Base Configuration",
"configure": "Configure",
"selectKB": "Select Knowledge Bases",
"selectMultiple": "You can select multiple knowledge bases",
"noKBAvailable": "No knowledge bases available",
"noKBDesc": "No knowledge bases have been created yet",
"createKB": "Create Knowledge Base",
"advancedSettings": "Advanced Settings",
"topK": "Result Count",
"topKHint": "Number of results to retrieve from knowledge base",
"enableRerank": "Enable Reranking",
"enableRerankHint": "Use reranking model to improve retrieval quality",
"clearConfig": "Clear Configuration",
"save": "Save",
"cancel": "Cancel",
"loading": "Loading knowledge base configuration...",
"description": "Configure knowledge bases for this session. The session will use configured knowledge bases to enhance conversation context.",
"saveSuccess": "Knowledge base configuration saved successfully",
"saveFailed": "Failed to save knowledge base configuration",
"loadFailed": "Failed to load knowledge base configuration",
"clearSuccess": "Knowledge base configuration cleared",
"clearFailed": "Failed to clear knowledge base configuration",
"clearConfirm": "Are you sure you want to clear the knowledge base configuration for this session?"
},
"list": {
"documents": "documents"
},
"deleteConfirm": {
"title": "Confirm Delete",
"message": "Are you sure you want to delete all custom rules for this session? Global settings will be used after deletion."
},
"batchDeleteConfirm": {
"title": "Confirm Batch Delete",
"message": "Are you sure you want to delete {count} selected rules? Global settings will be used after deletion."
"message": "Are you sure you want to delete session {sessionName}?",
"warning": "This action will permanently delete all chat history and preference settings for this session (except for data linked via plugins), and this cannot be undone. Continue?"
},
"messages": {
"refreshSuccess": "Data refreshed",
"loadError": "Failed to load data",
"saveSuccess": "Saved successfully",
"saveError": "Failed to save",
"clearSuccess": "Cleared successfully",
"clearError": "Failed to clear",
"deleteSuccess": "Deleted successfully",
"deleteError": "Failed to delete",
"noChanges": "No changes to save",
"batchDeleteSuccess": "Batch delete successful",
"batchDeleteError": "Batch delete failed"
"refreshSuccess": "Session list refreshed",
"personaUpdateSuccess": "Persona updated successfully",
"personaUpdateError": "Failed to update persona",
"providerUpdateSuccess": "Provider updated successfully",
"providerUpdateError": "Failed to update provider",
"sessionStatusSuccess": "Session {status}",
"llmStatusSuccess": "LLM {status}",
"ttsStatusSuccess": "TTS {status}",
"statusUpdateError": "Failed to update status",
"loadSessionsError": "Failed to load session list",
"batchUpdateSuccess": "Successfully batch updated {count} settings",
"batchUpdatePartial": "Batch update completed, {success} successful, {error} failed",
"loadPluginsError": "Failed to load plugin list",
"pluginStatusSuccess": "Plugin {name} {status}",
"pluginStatusError": "Failed to update plugin status",
"nameUpdateSuccess": "Session name updated successfully",
"nameUpdateError": "Failed to update session name",
"deleteSuccess": "Session deleted successfully",
"deleteError": "Failed to delete session"
}
}

View File

@@ -8,10 +8,11 @@
"config": "配置文件",
"chat": "聊天",
"conversation": "对话数据",
"sessionManagement": "自定义规则",
"sessionManagement": "会话管理",
"console": "控制台",
"alkaid": "Alkaid",
"knowledgeBase": "知识库",
"memory": "长期记忆",
"about": "关于",
"settings": "设置",
"documentation": "官方文档",

View File

@@ -1,45 +0,0 @@
{
"knowledgeBaseSelector": {
"notSelected": "未选择",
"buttonText": "选择知识库...",
"dialogTitle": "选择知识库",
"loading": "加载中...",
"noKnowledgeBases": "暂无知识库",
"createKnowledgeBase": "创建知识库",
"selectedCount": "已选择 {count} 个知识库",
"confirmSelection": "确认选择",
"cancelSelection": "取消",
"noDescription": "无描述",
"documentCount": "{count} 个文档",
"chunkCount": "{count} 个块"
},
"pluginSetSelector": {
"notSelected": "未启用任何插件",
"allPlugins": "启用所有插件 (*)",
"selectedCount": "已选择 {count} 个插件",
"buttonText": "选择插件集合...",
"dialogTitle": "选择插件集合",
"loading": "加载中...",
"enableAll": "启用所有插件",
"enableNone": "不启用任何插件",
"customSelect": "自定义选择",
"noPlugins": "暂无可用的插件",
"confirmSelection": "确认选择",
"cancelSelection": "取消",
"noDescription": "无描述",
"notActivated": "未激活",
"note": "*不显示系统插件和已经在插件页禁用的插件。"
},
"providerSelector": {
"notSelected": "未选择",
"buttonText": "选择提供商...",
"dialogTitle": "选择提供商",
"loading": "加载中...",
"noProviders": "暂无可用的提供商",
"confirmSelection": "确认选择",
"cancelSelection": "取消",
"clearSelection": "不选择",
"clearSelectionSubtitle": "清除当前选择",
"unknownType": "未知类型"
}
}

View File

@@ -51,8 +51,7 @@
"editDisplayName": "编辑会话名称",
"displayName": "会话名称",
"displayNameUpdated": "会话名称已更新",
"displayNameUpdateFailed": "更新会话名称失败",
"confirmDelete": "确定要删除“{name}”吗?此操作无法撤销。"
"displayNameUpdateFailed": "更新会话名称失败"
},
"modes": {
"darkMode": "切换到夜间模式",
@@ -85,9 +84,5 @@
"reconnected": "聊天连接已重新建立",
"failed": "连接失败,请刷新页面重试"
}
},
"errors": {
"sendMessageFailed": "发送消息失败,请重试",
"createSessionFailed": "创建会话失败,请刷新页面重试"
}
}

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