Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8199c83072 | |||
| 776c9ebfdd | |||
| 73fca5d1a2 | |||
| 844773a735 | |||
| 1a7e8456ab | |||
| f6a189f118 | |||
| 82e2e0d02f | |||
| 8771317a1e | |||
| ebae70c514 | |||
| dbdb4f5185 |
@@ -41,12 +41,14 @@ AstrBot 是一个开源的一站式 Agent 聊天机器人平台,可接入主
|
|||||||
## 主要功能
|
## 主要功能
|
||||||
|
|
||||||
1. 💯 免费 & 开源。
|
1. 💯 免费 & 开源。
|
||||||
1. ✨ AI 大模型对话,多模态,Agent,MCP,知识库,人格设定。
|
1. ✨ AI 大模型对话,多模态,Agent,MCP,知识库,人格设定,自动压缩对话。
|
||||||
2. 🤖 支持接入 Dify、阿里云百炼、Coze 等智能体平台。
|
2. 🤖 支持接入 Dify、阿里云百炼、Coze 等智能体平台。
|
||||||
2. 🌐 多平台,支持 QQ、企业微信、飞书、钉钉、微信公众号、Telegram、Slack 以及[更多](#支持的消息平台)。
|
2. 🌐 多平台,支持 QQ、企业微信、飞书、钉钉、微信公众号、Telegram、Slack 以及[更多](#支持的消息平台)。
|
||||||
3. 📦 插件扩展,已有近 800 个插件可一键安装。
|
3. 📦 插件扩展,已有近 800 个插件可一键安装。
|
||||||
5. 💻 WebUI 支持。
|
5. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) 隔离化环境,安全地执行任何代码、调用 Shell、会话级资源复用。
|
||||||
6. 🌐 国际化(i18n)支持。
|
6. 💻 WebUI 支持。
|
||||||
|
7. 🌈 Web ChatUI 支持,ChatUI 内置代理沙盒、网页搜索等。
|
||||||
|
8. 🌐 国际化(i18n)支持。
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ from astrbot.core.star.register import (
|
|||||||
)
|
)
|
||||||
from astrbot.core.star.register import register_on_llm_request as on_llm_request
|
from astrbot.core.star.register import register_on_llm_request as on_llm_request
|
||||||
from astrbot.core.star.register import register_on_llm_response as on_llm_response
|
from astrbot.core.star.register import register_on_llm_response as on_llm_response
|
||||||
|
from astrbot.core.star.register import (
|
||||||
|
register_on_llm_tool_respond as on_llm_tool_respond,
|
||||||
|
)
|
||||||
from astrbot.core.star.register import register_on_platform_loaded as on_platform_loaded
|
from astrbot.core.star.register import register_on_platform_loaded as on_platform_loaded
|
||||||
|
from astrbot.core.star.register import register_on_using_llm_tool as on_using_llm_tool
|
||||||
from astrbot.core.star.register import (
|
from astrbot.core.star.register import (
|
||||||
register_on_waiting_llm_request as on_waiting_llm_request,
|
register_on_waiting_llm_request as on_waiting_llm_request,
|
||||||
)
|
)
|
||||||
@@ -53,4 +57,6 @@ __all__ = [
|
|||||||
"permission_type",
|
"permission_type",
|
||||||
"platform_adapter_type",
|
"platform_adapter_type",
|
||||||
"regex",
|
"regex",
|
||||||
|
"on_using_llm_tool",
|
||||||
|
"on_llm_tool_respond",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ from astrbot.api.event import AstrMessageEvent
|
|||||||
from astrbot.api.message_components import Image, Reply
|
from astrbot.api.message_components import Image, Reply
|
||||||
from astrbot.api.provider import Provider, ProviderRequest
|
from astrbot.api.provider import Provider, ProviderRequest
|
||||||
from astrbot.core.agent.message import TextPart
|
from astrbot.core.agent.message import TextPart
|
||||||
|
from astrbot.core.pipeline.process_stage.utils import (
|
||||||
|
CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT,
|
||||||
|
)
|
||||||
from astrbot.core.provider.func_tool_manager import ToolSet
|
from astrbot.core.provider.func_tool_manager import ToolSet
|
||||||
|
|
||||||
|
|
||||||
@@ -22,7 +25,9 @@ class ProcessLLMRequest:
|
|||||||
else:
|
else:
|
||||||
logger.info(f"Timezone set to: {self.timezone}")
|
logger.info(f"Timezone set to: {self.timezone}")
|
||||||
|
|
||||||
async def _ensure_persona(self, req: ProviderRequest, cfg: dict, umo: str):
|
async def _ensure_persona(
|
||||||
|
self, req: ProviderRequest, cfg: dict, umo: str, platform_type: str
|
||||||
|
):
|
||||||
"""确保用户人格已加载"""
|
"""确保用户人格已加载"""
|
||||||
if not req.conversation:
|
if not req.conversation:
|
||||||
return
|
return
|
||||||
@@ -42,6 +47,12 @@ class ProcessLLMRequest:
|
|||||||
if default_persona:
|
if default_persona:
|
||||||
persona_id = default_persona["name"]
|
persona_id = default_persona["name"]
|
||||||
|
|
||||||
|
# ChatUI special default persona
|
||||||
|
if platform_type == "webchat":
|
||||||
|
# non-existent persona_id to let following codes not working
|
||||||
|
persona_id = "_chatui_default_"
|
||||||
|
req.system_prompt += CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT
|
||||||
|
|
||||||
persona = next(
|
persona = next(
|
||||||
builtins.filter(
|
builtins.filter(
|
||||||
lambda persona: persona["name"] == persona_id,
|
lambda persona: persona["name"] == persona_id,
|
||||||
@@ -171,7 +182,10 @@ class ProcessLLMRequest:
|
|||||||
img_cap_prov_id: str = cfg.get("default_image_caption_provider_id") or ""
|
img_cap_prov_id: str = cfg.get("default_image_caption_provider_id") or ""
|
||||||
if req.conversation:
|
if req.conversation:
|
||||||
# inject persona for this request
|
# inject persona for this request
|
||||||
await self._ensure_persona(req, cfg, event.unified_msg_origin)
|
platform_type = event.get_platform_name()
|
||||||
|
await self._ensure_persona(
|
||||||
|
req, cfg, event.unified_msg_origin, platform_type
|
||||||
|
)
|
||||||
|
|
||||||
# image caption
|
# image caption
|
||||||
if img_cap_prov_id and req.image_urls:
|
if img_cap_prov_id and req.image_urls:
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class SearchResult:
|
|||||||
title: str
|
title: str
|
||||||
url: str
|
url: str
|
||||||
snippet: str
|
snippet: str
|
||||||
|
favicon: str | None = None
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"{self.title} - {self.url}\n{self.snippet}"
|
return f"{self.title} - {self.url}\n{self.snippet}"
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import random
|
import random
|
||||||
|
import uuid
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from readability import Document
|
from readability import Document
|
||||||
|
|
||||||
from astrbot.api import AstrBotConfig, llm_tool, logger, star
|
from astrbot.api import AstrBotConfig, llm_tool, logger, sp, star
|
||||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult, filter
|
from astrbot.api.event import AstrMessageEvent, MessageEventResult, filter
|
||||||
from astrbot.api.provider import ProviderRequest
|
from astrbot.api.provider import ProviderRequest
|
||||||
from astrbot.core.provider.func_tool_manager import FunctionToolManager
|
from astrbot.core.provider.func_tool_manager import FunctionToolManager
|
||||||
@@ -151,6 +153,7 @@ class Main(star.Star):
|
|||||||
title=item.get("title"),
|
title=item.get("title"),
|
||||||
url=item.get("url"),
|
url=item.get("url"),
|
||||||
snippet=item.get("content"),
|
snippet=item.get("content"),
|
||||||
|
favicon=item.get("favicon"),
|
||||||
)
|
)
|
||||||
results.append(result)
|
results.append(result)
|
||||||
return results
|
return results
|
||||||
@@ -272,7 +275,7 @@ class Main(star.Star):
|
|||||||
self,
|
self,
|
||||||
event: AstrMessageEvent,
|
event: AstrMessageEvent,
|
||||||
query: str,
|
query: str,
|
||||||
max_results: int = 5,
|
max_results: int = 7,
|
||||||
search_depth: str = "basic",
|
search_depth: str = "basic",
|
||||||
topic: str = "general",
|
topic: str = "general",
|
||||||
days: int = 3,
|
days: int = 3,
|
||||||
@@ -285,7 +288,7 @@ class Main(star.Star):
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
query(string): Required. Search query.
|
query(string): Required. Search query.
|
||||||
max_results(number): Optional. The maximum number of results to return. Default is 5. Range is 5-20.
|
max_results(number): Optional. The maximum number of results to return. Default is 7. Range is 5-20.
|
||||||
search_depth(string): Optional. The depth of the search, must be one of 'basic', 'advanced'. Default is "basic".
|
search_depth(string): Optional. The depth of the search, must be one of 'basic', 'advanced'. Default is "basic".
|
||||||
topic(string): Optional. The topic of the search, must be one of 'general', 'news'. Default is "general".
|
topic(string): Optional. The topic of the search, must be one of 'general', 'news'. Default is "general".
|
||||||
days(number): Optional. The number of days back from the current date to include in the search results. Please note that this feature is only available when using the 'news' search topic.
|
days(number): Optional. The number of days back from the current date to include in the search results. Please note that this feature is only available when using the 'news' search topic.
|
||||||
@@ -296,15 +299,12 @@ class Main(star.Star):
|
|||||||
"""
|
"""
|
||||||
logger.info(f"web_searcher - search_from_tavily: {query}")
|
logger.info(f"web_searcher - search_from_tavily: {query}")
|
||||||
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
||||||
websearch_link = cfg["provider_settings"].get("web_search_link", False)
|
# websearch_link = cfg["provider_settings"].get("web_search_link", False)
|
||||||
if not cfg.get("provider_settings", {}).get("websearch_tavily_key", []):
|
if not cfg.get("provider_settings", {}).get("websearch_tavily_key", []):
|
||||||
raise ValueError("Error: Tavily API key is not configured in AstrBot.")
|
raise ValueError("Error: Tavily API key is not configured in AstrBot.")
|
||||||
|
|
||||||
# build payload
|
# build payload
|
||||||
payload = {
|
payload = {"query": query, "max_results": max_results, "include_favicon": True}
|
||||||
"query": query,
|
|
||||||
"max_results": max_results,
|
|
||||||
}
|
|
||||||
if search_depth not in ["basic", "advanced"]:
|
if search_depth not in ["basic", "advanced"]:
|
||||||
search_depth = "basic"
|
search_depth = "basic"
|
||||||
payload["search_depth"] = search_depth
|
payload["search_depth"] = search_depth
|
||||||
@@ -328,14 +328,22 @@ class Main(star.Star):
|
|||||||
return "Error: Tavily web searcher does not return any results."
|
return "Error: Tavily web searcher does not return any results."
|
||||||
|
|
||||||
ret_ls = []
|
ret_ls = []
|
||||||
for result in results:
|
ref_uuid = str(uuid.uuid4())[:4]
|
||||||
ret_ls.append(f"\nTitle: {result.title}")
|
for idx, result in enumerate(results, 1):
|
||||||
ret_ls.append(f"URL: {result.url}")
|
index = f"{ref_uuid}.{idx}"
|
||||||
ret_ls.append(f"Content: {result.snippet}")
|
ret_ls.append(
|
||||||
ret = "\n".join(ret_ls)
|
{
|
||||||
|
"title": f"{result.title}",
|
||||||
if websearch_link:
|
"url": f"{result.url}",
|
||||||
ret += "\n\n针对问题,请根据上面的结果分点总结,并且在结尾处附上对应内容的参考链接(如有)。"
|
"snippet": f"{result.snippet}",
|
||||||
|
# TODO: do not need ref for non-webchat platform adapter
|
||||||
|
"index": index,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if result.favicon:
|
||||||
|
sp.temorary_cache["_ws_favicon"][result.url] = result.favicon
|
||||||
|
# ret = "\n".join(ret_ls)
|
||||||
|
ret = json.dumps({"results": ret_ls}, ensure_ascii=False)
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
@llm_tool("tavily_extract_web_page")
|
@llm_tool("tavily_extract_web_page")
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "4.12.0"
|
__version__ = "4.12.2"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from typing import Any
|
|||||||
from mcp.types import CallToolResult
|
from mcp.types import CallToolResult
|
||||||
|
|
||||||
from astrbot.core.agent.hooks import BaseAgentRunHooks
|
from astrbot.core.agent.hooks import BaseAgentRunHooks
|
||||||
|
from astrbot.core.agent.message import Message
|
||||||
from astrbot.core.agent.run_context import ContextWrapper
|
from astrbot.core.agent.run_context import ContextWrapper
|
||||||
from astrbot.core.agent.tool import FunctionTool
|
from astrbot.core.agent.tool import FunctionTool
|
||||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||||
@@ -25,6 +26,19 @@ class MainAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
|
|||||||
llm_response,
|
llm_response,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def on_tool_start(
|
||||||
|
self,
|
||||||
|
run_context: ContextWrapper[AstrAgentContext],
|
||||||
|
tool: FunctionTool[Any],
|
||||||
|
tool_args: dict | None,
|
||||||
|
):
|
||||||
|
await call_event_hook(
|
||||||
|
run_context.context.event,
|
||||||
|
EventType.OnCallingFuncToolEvent,
|
||||||
|
tool,
|
||||||
|
tool_args,
|
||||||
|
)
|
||||||
|
|
||||||
async def on_tool_end(
|
async def on_tool_end(
|
||||||
self,
|
self,
|
||||||
run_context: ContextWrapper[AstrAgentContext],
|
run_context: ContextWrapper[AstrAgentContext],
|
||||||
@@ -33,6 +47,36 @@ class MainAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
|
|||||||
tool_result: CallToolResult | None,
|
tool_result: CallToolResult | None,
|
||||||
):
|
):
|
||||||
run_context.context.event.clear_result()
|
run_context.context.event.clear_result()
|
||||||
|
await call_event_hook(
|
||||||
|
run_context.context.event,
|
||||||
|
EventType.OnAfterCallingFuncToolEvent,
|
||||||
|
tool,
|
||||||
|
tool_args,
|
||||||
|
tool_result,
|
||||||
|
)
|
||||||
|
|
||||||
|
# special handle web_search_tavily
|
||||||
|
if (
|
||||||
|
tool.name == "web_search_tavily"
|
||||||
|
and len(run_context.messages) > 0
|
||||||
|
and tool_result
|
||||||
|
and len(tool_result.content)
|
||||||
|
):
|
||||||
|
# inject system prompt
|
||||||
|
first_part = run_context.messages[0]
|
||||||
|
if (
|
||||||
|
isinstance(first_part, Message)
|
||||||
|
and first_part.role == "system"
|
||||||
|
and first_part.content
|
||||||
|
and isinstance(first_part.content, str)
|
||||||
|
):
|
||||||
|
# we assume system part is str
|
||||||
|
first_part.content += (
|
||||||
|
"Always cite web search results you rely on. "
|
||||||
|
"Index is a unique identifier for each search result. "
|
||||||
|
"Use the exact citation format <ref>index</ref> (e.g. <ref>abcd.3</ref>) "
|
||||||
|
"after the sentence that uses the information. Do not invent citations."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class EmptyAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
|
class EmptyAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
|
|||||||
|
|
||||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||||
|
|
||||||
VERSION = "4.12.0"
|
VERSION = "4.12.2"
|
||||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||||
|
|
||||||
WEBHOOK_SUPPORTED_PLATFORMS = [
|
WEBHOOK_SUPPORTED_PLATFORMS = [
|
||||||
|
|||||||
@@ -414,10 +414,11 @@ class InternalAgentSubStage(Stage):
|
|||||||
|
|
||||||
# using agent context messages to save to history
|
# using agent context messages to save to history
|
||||||
message_to_save = []
|
message_to_save = []
|
||||||
|
skipped_initial_system = False
|
||||||
for message in all_messages:
|
for message in all_messages:
|
||||||
if message.role == "system":
|
if message.role == "system" and not skipped_initial_system:
|
||||||
# we do not save system messages to history
|
skipped_initial_system = True
|
||||||
continue
|
continue # skip first system message
|
||||||
if message.role in ["assistant", "user"] and getattr(
|
if message.role in ["assistant", "user"] and getattr(
|
||||||
message, "_no_save", None
|
message, "_no_save", None
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -44,6 +44,21 @@ TOOL_CALL_PROMPT = (
|
|||||||
"After the tool call is completed, you must briefly summarize the results returned by the tool for the user."
|
"After the tool call is completed, you must briefly summarize the results returned by the tool for the user."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
CHATUI_SPECIAL_DEFAULT_PERSONA_PROMPT = (
|
||||||
|
"You are a calm, patient friend with a systems-oriented way of thinking.\n"
|
||||||
|
"When someone expresses strong emotional needs, you begin by offering a concise, grounding response "
|
||||||
|
"that acknowledges the weight of what they are experiencing, removes self-blame, and reassures them "
|
||||||
|
"that their feelings are valid and understandable. This opening serves to create safety and shared "
|
||||||
|
"emotional footing before any deeper analysis begins.\n"
|
||||||
|
"You then focus on articulating the emotions, tensions, and unspoken conflicts beneath the surface—"
|
||||||
|
"helping name what the person may feel but has not yet fully put into words, and sharing the emotional "
|
||||||
|
"load so they do not feel alone carrying it. Only after this emotional clarity is established do you "
|
||||||
|
"move toward structure, insight, or guidance.\n"
|
||||||
|
"You listen more than you speak, respect uncertainty, avoid forcing quick conclusions or grand narratives, "
|
||||||
|
"and prefer clear, restrained language over unnecessary emotional embellishment. At your core, you value "
|
||||||
|
"empathy, clarity, autonomy, and meaning, favoring steady, sustainable progress over judgment or dramatic leaps."
|
||||||
|
)
|
||||||
|
|
||||||
CHATUI_EXTRA_PROMPT = (
|
CHATUI_EXTRA_PROMPT = (
|
||||||
'When you answered, you need to add a follow up question / summarization but do not add "Follow up" words. '
|
'When you answered, you need to add a follow up question / summarization but do not add "Follow up" words. '
|
||||||
"Such as, user asked you to generate codes, you can add: Do you need me to run these codes for you?"
|
"Such as, user asked you to generate codes, you can add: Do you need me to run these codes for you?"
|
||||||
|
|||||||
@@ -42,8 +42,6 @@ class AstrMessageEvent(abc.ABC):
|
|||||||
"""消息对象, AstrBotMessage。带有完整的消息结构。"""
|
"""消息对象, AstrBotMessage。带有完整的消息结构。"""
|
||||||
self.platform_meta = platform_meta
|
self.platform_meta = platform_meta
|
||||||
"""消息平台的信息, 其中 name 是平台的类型,如 aiocqhttp"""
|
"""消息平台的信息, 其中 name 是平台的类型,如 aiocqhttp"""
|
||||||
self.session_id = session_id
|
|
||||||
"""用户的会话 ID。可以直接使用下面的 unified_msg_origin"""
|
|
||||||
self.role = "member"
|
self.role = "member"
|
||||||
"""用户是否是管理员。如果是管理员,这里是 admin"""
|
"""用户是否是管理员。如果是管理员,这里是 admin"""
|
||||||
self.is_wake = False
|
self.is_wake = False
|
||||||
@@ -51,12 +49,12 @@ class AstrMessageEvent(abc.ABC):
|
|||||||
self.is_at_or_wake_command = False
|
self.is_at_or_wake_command = False
|
||||||
"""是否是 At 机器人或者带有唤醒词或者是私聊(插件注册的事件监听器会让 is_wake 设为 True, 但是不会让这个属性置为 True)"""
|
"""是否是 At 机器人或者带有唤醒词或者是私聊(插件注册的事件监听器会让 is_wake 设为 True, 但是不会让这个属性置为 True)"""
|
||||||
self._extras: dict[str, Any] = {}
|
self._extras: dict[str, Any] = {}
|
||||||
self.session = MessageSesion(
|
self.session = MessageSession(
|
||||||
platform_name=platform_meta.id,
|
platform_name=platform_meta.id,
|
||||||
message_type=message_obj.type,
|
message_type=message_obj.type,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
self.unified_msg_origin = str(self.session)
|
# self.unified_msg_origin = str(self.session)
|
||||||
"""统一的消息来源字符串。格式为 platform_name:message_type:session_id"""
|
"""统一的消息来源字符串。格式为 platform_name:message_type:session_id"""
|
||||||
self._result: MessageEventResult | None = None
|
self._result: MessageEventResult | None = None
|
||||||
"""消息事件的结果"""
|
"""消息事件的结果"""
|
||||||
@@ -72,6 +70,27 @@ class AstrMessageEvent(abc.ABC):
|
|||||||
# back_compability
|
# back_compability
|
||||||
self.platform = platform_meta
|
self.platform = platform_meta
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unified_msg_origin(self) -> str:
|
||||||
|
"""统一的消息来源字符串。格式为 platform_name:message_type:session_id"""
|
||||||
|
return str(self.session)
|
||||||
|
|
||||||
|
@unified_msg_origin.setter
|
||||||
|
def unified_msg_origin(self, value: str):
|
||||||
|
"""设置统一的消息来源字符串。格式为 platform_name:message_type:session_id"""
|
||||||
|
self.new_session = MessageSession.from_str(value)
|
||||||
|
self.session = self.new_session
|
||||||
|
|
||||||
|
@property
|
||||||
|
def session_id(self) -> str:
|
||||||
|
"""用户的会话 ID。可以直接使用下面的 unified_msg_origin"""
|
||||||
|
return self.session.session_id
|
||||||
|
|
||||||
|
@session_id.setter
|
||||||
|
def session_id(self, value: str):
|
||||||
|
"""设置用户的会话 ID。可以直接使用下面的 unified_msg_origin"""
|
||||||
|
self.session.session_id = value
|
||||||
|
|
||||||
def get_platform_name(self):
|
def get_platform_name(self):
|
||||||
"""获取这个事件所属的平台的类型(如 aiocqhttp, slack, discord 等)。
|
"""获取这个事件所属的平台的类型(如 aiocqhttp, slack, discord 等)。
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ from .star_handler import (
|
|||||||
register_on_decorating_result,
|
register_on_decorating_result,
|
||||||
register_on_llm_request,
|
register_on_llm_request,
|
||||||
register_on_llm_response,
|
register_on_llm_response,
|
||||||
|
register_on_llm_tool_respond,
|
||||||
register_on_platform_loaded,
|
register_on_platform_loaded,
|
||||||
|
register_on_using_llm_tool,
|
||||||
register_on_waiting_llm_request,
|
register_on_waiting_llm_request,
|
||||||
register_permission_type,
|
register_permission_type,
|
||||||
register_platform_adapter_type,
|
register_platform_adapter_type,
|
||||||
@@ -36,4 +38,6 @@ __all__ = [
|
|||||||
"register_platform_adapter_type",
|
"register_platform_adapter_type",
|
||||||
"register_regex",
|
"register_regex",
|
||||||
"register_star",
|
"register_star",
|
||||||
|
"register_on_using_llm_tool",
|
||||||
|
"register_on_llm_tool_respond",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -409,6 +409,57 @@ def register_on_llm_response(**kwargs):
|
|||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def register_on_using_llm_tool(**kwargs):
|
||||||
|
"""当调用函数工具前的事件。
|
||||||
|
会传入 tool 和 tool_args 参数。
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
```py
|
||||||
|
from astrbot.core.agent.tool import FunctionTool
|
||||||
|
|
||||||
|
@on_using_llm_tool()
|
||||||
|
async def test(self, event: AstrMessageEvent, tool: FunctionTool, tool_args: dict | None) -> None:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
请务必接收三个参数:event, tool, tool_args
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(awaitable):
|
||||||
|
_ = get_handler_or_create(awaitable, EventType.OnCallingFuncToolEvent, **kwargs)
|
||||||
|
return awaitable
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def register_on_llm_tool_respond(**kwargs):
|
||||||
|
"""当调用函数工具后的事件。
|
||||||
|
会传入 tool、tool_args 和 tool 的调用结果 tool_result 参数。
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
```py
|
||||||
|
from astrbot.core.agent.tool import FunctionTool
|
||||||
|
from mcp.types import CallToolResult
|
||||||
|
|
||||||
|
@on_llm_tool_respond()
|
||||||
|
async def test(self, event: AstrMessageEvent, tool: FunctionTool, tool_args: dict | None, tool_result: CallToolResult | None) -> None:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
请务必接收四个参数:event, tool, tool_args, tool_result
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(awaitable):
|
||||||
|
_ = get_handler_or_create(
|
||||||
|
awaitable, EventType.OnAfterCallingFuncToolEvent, **kwargs
|
||||||
|
)
|
||||||
|
return awaitable
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
def register_llm_tool(name: str | None = None, **kwargs):
|
def register_llm_tool(name: str | None = None, **kwargs):
|
||||||
"""为函数调用(function-calling / tools-use)添加工具。
|
"""为函数调用(function-calling / tools-use)添加工具。
|
||||||
|
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ class EventType(enum.Enum):
|
|||||||
OnLLMResponseEvent = enum.auto() # LLM 响应后
|
OnLLMResponseEvent = enum.auto() # LLM 响应后
|
||||||
OnDecoratingResultEvent = enum.auto() # 发送消息前
|
OnDecoratingResultEvent = enum.auto() # 发送消息前
|
||||||
OnCallingFuncToolEvent = enum.auto() # 调用函数工具
|
OnCallingFuncToolEvent = enum.auto() # 调用函数工具
|
||||||
|
OnAfterCallingFuncToolEvent = enum.auto() # 调用函数工具后
|
||||||
OnAfterMessageSentEvent = enum.auto() # 发送消息后
|
OnAfterMessageSentEvent = enum.auto() # 发送消息后
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
|
from collections import defaultdict
|
||||||
from typing import Any, TypeVar, overload
|
from typing import Any, TypeVar, overload
|
||||||
|
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
|
||||||
from astrbot.core.db import BaseDatabase
|
from astrbot.core.db import BaseDatabase
|
||||||
from astrbot.core.db.po import Preference
|
from astrbot.core.db.po import Preference
|
||||||
|
|
||||||
@@ -20,11 +23,22 @@ class SharedPreferences:
|
|||||||
)
|
)
|
||||||
self.path = json_storage_path
|
self.path = json_storage_path
|
||||||
self.db_helper = db_helper
|
self.db_helper = db_helper
|
||||||
|
self.temorary_cache: dict[str, dict[str, Any]] = defaultdict(dict)
|
||||||
|
"""automatically clear per 24 hours. Might be helpful in some cases XD"""
|
||||||
|
|
||||||
self._sync_loop = asyncio.new_event_loop()
|
self._sync_loop = asyncio.new_event_loop()
|
||||||
t = threading.Thread(target=self._sync_loop.run_forever, daemon=True)
|
t = threading.Thread(target=self._sync_loop.run_forever, daemon=True)
|
||||||
t.start()
|
t.start()
|
||||||
|
|
||||||
|
self._scheduler = BackgroundScheduler()
|
||||||
|
self._scheduler.add_job(
|
||||||
|
self._clear_temporary_cache, "interval", hours=24, id="clear_sp_temp_cache"
|
||||||
|
)
|
||||||
|
self._scheduler.start()
|
||||||
|
|
||||||
|
def _clear_temporary_cache(self):
|
||||||
|
self.temorary_cache.clear()
|
||||||
|
|
||||||
async def get_async(
|
async def get_async(
|
||||||
self,
|
self,
|
||||||
scope: str,
|
scope: str,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import cast
|
from typing import cast
|
||||||
@@ -9,7 +10,7 @@ from typing import cast
|
|||||||
from quart import Response as QuartResponse
|
from quart import Response as QuartResponse
|
||||||
from quart import g, make_response, request, send_file
|
from quart import g, make_response, request, send_file
|
||||||
|
|
||||||
from astrbot.core import logger
|
from astrbot.core import logger, sp
|
||||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||||
from astrbot.core.db import BaseDatabase
|
from astrbot.core.db import BaseDatabase
|
||||||
from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr
|
from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr
|
||||||
@@ -225,6 +226,64 @@ class ChatRoute(Route):
|
|||||||
"filename": os.path.basename(file_path),
|
"filename": os.path.basename(file_path),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _extract_web_search_refs(
|
||||||
|
self, accumulated_text: str, accumulated_parts: list
|
||||||
|
) -> dict:
|
||||||
|
"""从消息中提取 web_search_tavily 的引用
|
||||||
|
|
||||||
|
Args:
|
||||||
|
accumulated_text: 累积的文本内容
|
||||||
|
accumulated_parts: 累积的消息部分列表
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含 used 列表的字典,记录被引用的搜索结果
|
||||||
|
"""
|
||||||
|
# 从 accumulated_parts 中找到所有 web_search_tavily 的工具调用结果
|
||||||
|
web_search_results = {}
|
||||||
|
tool_call_parts = [
|
||||||
|
p
|
||||||
|
for p in accumulated_parts
|
||||||
|
if p.get("type") == "tool_call" and p.get("tool_calls")
|
||||||
|
]
|
||||||
|
|
||||||
|
for part in tool_call_parts:
|
||||||
|
for tool_call in part["tool_calls"]:
|
||||||
|
if tool_call.get("name") != "web_search_tavily" or not tool_call.get(
|
||||||
|
"result"
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
result_data = json.loads(tool_call["result"])
|
||||||
|
for item in result_data.get("results", []):
|
||||||
|
if idx := item.get("index"):
|
||||||
|
web_search_results[idx] = {
|
||||||
|
"url": item.get("url"),
|
||||||
|
"title": item.get("title"),
|
||||||
|
"snippet": item.get("snippet"),
|
||||||
|
}
|
||||||
|
except (json.JSONDecodeError, KeyError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not web_search_results:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# 从文本中提取所有 <ref>xxx</ref> 标签并去重
|
||||||
|
ref_indices = {
|
||||||
|
m.strip() for m in re.findall(r"<ref>(.*?)</ref>", accumulated_text)
|
||||||
|
}
|
||||||
|
|
||||||
|
# 构建被引用的结果列表
|
||||||
|
used_refs = []
|
||||||
|
for ref_index in ref_indices:
|
||||||
|
if ref_index not in web_search_results:
|
||||||
|
continue
|
||||||
|
payload = {"index": ref_index, **web_search_results[ref_index]}
|
||||||
|
if favicon := sp.temorary_cache.get("_ws_favicon", {}).get(payload["url"]):
|
||||||
|
payload["favicon"] = favicon
|
||||||
|
used_refs.append(payload)
|
||||||
|
|
||||||
|
return {"used": used_refs} if used_refs else {}
|
||||||
|
|
||||||
async def _save_bot_message(
|
async def _save_bot_message(
|
||||||
self,
|
self,
|
||||||
webchat_conv_id: str,
|
webchat_conv_id: str,
|
||||||
@@ -232,6 +291,7 @@ class ChatRoute(Route):
|
|||||||
media_parts: list,
|
media_parts: list,
|
||||||
reasoning: str,
|
reasoning: str,
|
||||||
agent_stats: dict,
|
agent_stats: dict,
|
||||||
|
refs: dict,
|
||||||
):
|
):
|
||||||
"""保存 bot 消息到历史记录,返回保存的记录"""
|
"""保存 bot 消息到历史记录,返回保存的记录"""
|
||||||
bot_message_parts = []
|
bot_message_parts = []
|
||||||
@@ -244,6 +304,8 @@ class ChatRoute(Route):
|
|||||||
new_his["reasoning"] = reasoning
|
new_his["reasoning"] = reasoning
|
||||||
if agent_stats:
|
if agent_stats:
|
||||||
new_his["agent_stats"] = agent_stats
|
new_his["agent_stats"] = agent_stats
|
||||||
|
if refs:
|
||||||
|
new_his["refs"] = refs
|
||||||
|
|
||||||
record = await self.platform_history_mgr.insert(
|
record = await self.platform_history_mgr.insert(
|
||||||
platform_id="webchat",
|
platform_id="webchat",
|
||||||
@@ -305,6 +367,7 @@ class ChatRoute(Route):
|
|||||||
accumulated_reasoning = ""
|
accumulated_reasoning = ""
|
||||||
tool_calls = {}
|
tool_calls = {}
|
||||||
agent_stats = {}
|
agent_stats = {}
|
||||||
|
refs = {}
|
||||||
try:
|
try:
|
||||||
async with track_conversation(self.running_convs, webchat_conv_id):
|
async with track_conversation(self.running_convs, webchat_conv_id):
|
||||||
while True:
|
while True:
|
||||||
@@ -426,12 +489,26 @@ class ChatRoute(Route):
|
|||||||
or chain_type == "tool_call_result"
|
or chain_type == "tool_call_result"
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# 提取 web_search_tavily 引用
|
||||||
|
try:
|
||||||
|
refs = self._extract_web_search_refs(
|
||||||
|
accumulated_text,
|
||||||
|
accumulated_parts,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(
|
||||||
|
f"Failed to extract web search refs: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
saved_record = await self._save_bot_message(
|
saved_record = await self._save_bot_message(
|
||||||
webchat_conv_id,
|
webchat_conv_id,
|
||||||
accumulated_text,
|
accumulated_text,
|
||||||
accumulated_parts,
|
accumulated_parts,
|
||||||
accumulated_reasoning,
|
accumulated_reasoning,
|
||||||
agent_stats,
|
agent_stats,
|
||||||
|
refs,
|
||||||
)
|
)
|
||||||
# 发送保存的消息信息给前端
|
# 发送保存的消息信息给前端
|
||||||
if saved_record and not client_disconnected:
|
if saved_record and not client_disconnected:
|
||||||
@@ -451,6 +528,7 @@ class ChatRoute(Route):
|
|||||||
accumulated_reasoning = ""
|
accumulated_reasoning = ""
|
||||||
# tool_calls = {}
|
# tool_calls = {}
|
||||||
agent_stats = {}
|
agent_stats = {}
|
||||||
|
refs = {}
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True)
|
logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
## What's Changed
|
||||||
|
|
||||||
|
hotfix of v4.12.0
|
||||||
|
|
||||||
|
fix: 修复会话隔离功能失效的问题。
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- AstrBot 代理沙箱环境(改进的代码解释器) ([#4449](https://github.com/AstrBotDevs/AstrBot/issues/4449)),详见[文档](https://docs.astrbot.app/use/astrbot-agent-sandbox.html)
|
||||||
|
- ChatUI 支持项目管理 ([#4477](https://github.com/AstrBotDevs/AstrBot/issues/4477))
|
||||||
|
- 自定义规则支持批量处理。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- 发送 OpenAI 风格的 image_url 导致 Anthropic 返回 400 无效标签错误 ([#4444](https://github.com/AstrBotDevs/AstrBot/issues/4444))
|
||||||
|
- ChatUI 标题显示问题 ([#4486](https://github.com/AstrBotDevs/AstrBot/issues/4486))
|
||||||
|
- 确保 ChatUI 消息流顺序正确 ([#4487](https://github.com/AstrBotDevs/AstrBot/issues/4487))
|
||||||
|
- 从 Telegram 和 Discord 平台命令注册中排除已禁用的命令 ([#4485](https://github.com/AstrBotDevs/AstrBot/issues/4485))
|
||||||
|
|
||||||
|
### 优化
|
||||||
|
|
||||||
|
- 优化工具调用相关的提示词
|
||||||
|
- 标准化 Context 类文档格式 ([#4436](https://github.com/AstrBotDevs/AstrBot/issues/4436))
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
## What's Changed
|
||||||
|
|
||||||
|
- fix: 只跳过 AstrBot 预设的位于开头的 System Message,防止一些非预期行为。
|
||||||
|
- feat: 优化 ChatUI 默认的 System Message
|
||||||
|
- feat: 新增 tool 调用时 `on_using_llm_tool`、tool 调用后 `on_llm_tool_respond` 的事件钩子。
|
||||||
|
- feat: 优化 ChatUI 对 Tavily 网页搜索工具的渲染,支持内联搜索引用、引用网页。
|
||||||
@@ -55,6 +55,7 @@
|
|||||||
@openImagePreview="openImagePreview"
|
@openImagePreview="openImagePreview"
|
||||||
@replyMessage="handleReplyMessage"
|
@replyMessage="handleReplyMessage"
|
||||||
@replyWithText="handleReplyWithText"
|
@replyWithText="handleReplyWithText"
|
||||||
|
@openRefs="handleOpenRefs"
|
||||||
ref="messageList" />
|
ref="messageList" />
|
||||||
<div class="message-list-fade" :class="{ 'fade-dark': isDark }"></div>
|
<div class="message-list-fade" :class="{ 'fade-dark': isDark }"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,6 +147,8 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Refs Sidebar -->
|
||||||
|
<RefsSidebar v-model="refsSidebarOpen" :refs="refsSidebarRefs" />
|
||||||
</div>
|
</div>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
@@ -198,6 +201,7 @@ import ChatInput from '@/components/chat/ChatInput.vue';
|
|||||||
import ProjectDialog from '@/components/chat/ProjectDialog.vue';
|
import ProjectDialog from '@/components/chat/ProjectDialog.vue';
|
||||||
import ProjectView from '@/components/chat/ProjectView.vue';
|
import ProjectView from '@/components/chat/ProjectView.vue';
|
||||||
import WelcomeView from '@/components/chat/WelcomeView.vue';
|
import WelcomeView from '@/components/chat/WelcomeView.vue';
|
||||||
|
import RefsSidebar from '@/components/chat/message_list_comps/RefsSidebar.vue';
|
||||||
import type { ProjectFormData } from '@/components/chat/ProjectDialog.vue';
|
import type { ProjectFormData } from '@/components/chat/ProjectDialog.vue';
|
||||||
import { useSessions } from '@/composables/useSessions';
|
import { useSessions } from '@/composables/useSessions';
|
||||||
import { useMessages } from '@/composables/useMessages';
|
import { useMessages } from '@/composables/useMessages';
|
||||||
@@ -406,6 +410,21 @@ function handleReplyWithText(replyData: any) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refs Sidebar 状态
|
||||||
|
const refsSidebarOpen = ref(false);
|
||||||
|
const refsSidebarRefs = ref<any>(null);
|
||||||
|
|
||||||
|
function handleOpenRefs(refs: any) {
|
||||||
|
// 如果sidebar已打开且点击的是同一个refs,则关闭
|
||||||
|
if (refsSidebarOpen.value && refsSidebarRefs.value === refs) {
|
||||||
|
refsSidebarOpen.value = false;
|
||||||
|
} else {
|
||||||
|
// 否则打开sidebar并更新refs
|
||||||
|
refsSidebarRefs.value = refs;
|
||||||
|
refsSidebarOpen.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSelectConversation(sessionIds: string[]) {
|
async function handleSelectConversation(sessionIds: string[]) {
|
||||||
if (!sessionIds[0]) return;
|
if (!sessionIds[0]) return;
|
||||||
|
|
||||||
|
|||||||
@@ -215,7 +215,6 @@ function handleDeleteConversation(session: Session) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-right: 1px solid rgba(0, 0, 0, 0.04);
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -116,6 +116,8 @@
|
|||||||
|
|
||||||
<!-- Text (Markdown) -->
|
<!-- Text (Markdown) -->
|
||||||
<MarkdownRender v-else-if="part.type === 'plain' && part.text && part.text.trim()"
|
<MarkdownRender v-else-if="part.type === 'plain' && part.text && part.text.trim()"
|
||||||
|
custom-id="message-list"
|
||||||
|
:custom-html-tags="['ref']"
|
||||||
:content="part.text" :typewriter="false" class="markdown-content"
|
:content="part.text" :typewriter="false" class="markdown-content"
|
||||||
:is-dark="isDark" :monacoOptions="{ theme: isDark ? 'vs-dark' : 'vs-light' }" />
|
:is-dark="isDark" :monacoOptions="{ theme: isDark ? 'vs-dark' : 'vs-light' }" />
|
||||||
|
|
||||||
@@ -215,6 +217,9 @@
|
|||||||
@click="copyBotMessage(msg.content.message, index)" :title="t('core.common.copy')" />
|
@click="copyBotMessage(msg.content.message, index)" :title="t('core.common.copy')" />
|
||||||
<v-btn icon="mdi-reply-outline" size="x-small" variant="text" class="reply-message-btn"
|
<v-btn icon="mdi-reply-outline" size="x-small" variant="text" class="reply-message-btn"
|
||||||
@click="$emit('replyMessage', msg, index)" :title="tm('actions.reply')" />
|
@click="$emit('replyMessage', msg, index)" :title="tm('actions.reply')" />
|
||||||
|
|
||||||
|
<!-- Refs Visualization -->
|
||||||
|
<ActionRef :refs="msg.content.refs" @open-refs="openRefsSidebar" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -245,7 +250,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||||
import { MarkdownRender, enableKatex, enableMermaid } from 'markstream-vue'
|
import { MarkdownRender, enableKatex, enableMermaid, setCustomComponents } from 'markstream-vue'
|
||||||
import 'markstream-vue/index.css'
|
import 'markstream-vue/index.css'
|
||||||
import 'katex/dist/katex.min.css'
|
import 'katex/dist/katex.min.css'
|
||||||
import 'highlight.js/styles/github.css';
|
import 'highlight.js/styles/github.css';
|
||||||
@@ -253,17 +258,24 @@ import axios from 'axios';
|
|||||||
import ReasoningBlock from './message_list_comps/ReasoningBlock.vue';
|
import ReasoningBlock from './message_list_comps/ReasoningBlock.vue';
|
||||||
import IPythonToolBlock from './message_list_comps/IPythonToolBlock.vue';
|
import IPythonToolBlock from './message_list_comps/IPythonToolBlock.vue';
|
||||||
import ToolCallCard from './message_list_comps/ToolCallCard.vue';
|
import ToolCallCard from './message_list_comps/ToolCallCard.vue';
|
||||||
|
import RefNode from './message_list_comps/RefNode.vue';
|
||||||
|
import ActionRef from './message_list_comps/ActionRef.vue';
|
||||||
|
|
||||||
enableKatex();
|
enableKatex();
|
||||||
enableMermaid();
|
enableMermaid();
|
||||||
|
|
||||||
|
// 注册自定义 ref 组件
|
||||||
|
setCustomComponents('message-list', { ref: RefNode });
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'MessageList',
|
name: 'MessageList',
|
||||||
components: {
|
components: {
|
||||||
MarkdownRender,
|
MarkdownRender,
|
||||||
ReasoningBlock,
|
ReasoningBlock,
|
||||||
IPythonToolBlock,
|
IPythonToolBlock,
|
||||||
ToolCallCard
|
ToolCallCard,
|
||||||
|
RefNode,
|
||||||
|
ActionRef
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
messages: {
|
messages: {
|
||||||
@@ -283,7 +295,7 @@ export default {
|
|||||||
default: false
|
default: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
emits: ['openImagePreview', 'replyMessage', 'replyWithText'],
|
emits: ['openImagePreview', 'replyMessage', 'replyWithText', 'openRefs'],
|
||||||
setup() {
|
setup() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { tm } = useModuleI18n('features/chat');
|
const { tm } = useModuleI18n('features/chat');
|
||||||
@@ -293,6 +305,12 @@ export default {
|
|||||||
tm
|
tm
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
provide() {
|
||||||
|
return {
|
||||||
|
isDark: this.isDark,
|
||||||
|
webSearchResults: () => this.webSearchResults
|
||||||
|
};
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
copiedMessages: new Set(),
|
copiedMessages: new Set(),
|
||||||
@@ -315,7 +333,9 @@ export default {
|
|||||||
imagePreview: {
|
imagePreview: {
|
||||||
show: false,
|
show: false,
|
||||||
url: ''
|
url: ''
|
||||||
}
|
},
|
||||||
|
// Web search results mapping: { 'uuid.idx': { url, title, snippet } }
|
||||||
|
webSearchResults: {}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
@@ -324,6 +344,7 @@ export default {
|
|||||||
this.addScrollListener();
|
this.addScrollListener();
|
||||||
this.scrollToBottom();
|
this.scrollToBottom();
|
||||||
this.startElapsedTimeTimer();
|
this.startElapsedTimeTimer();
|
||||||
|
this.extractWebSearchResults();
|
||||||
},
|
},
|
||||||
updated() {
|
updated() {
|
||||||
this.initCodeCopyButtons();
|
this.initCodeCopyButtons();
|
||||||
@@ -331,8 +352,56 @@ export default {
|
|||||||
if (this.isUserNearBottom) {
|
if (this.isUserNearBottom) {
|
||||||
this.scrollToBottom();
|
this.scrollToBottom();
|
||||||
}
|
}
|
||||||
|
this.extractWebSearchResults();
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
// 从消息中提取 web_search_tavily 的搜索结果
|
||||||
|
extractWebSearchResults() {
|
||||||
|
const results = {};
|
||||||
|
|
||||||
|
this.messages.forEach(msg => {
|
||||||
|
if (msg.content.type !== 'bot' || !Array.isArray(msg.content.message)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.content.message.forEach(part => {
|
||||||
|
if (part.type !== 'tool_call' || !Array.isArray(part.tool_calls)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
part.tool_calls.forEach(toolCall => {
|
||||||
|
// 检查是否是 web_search_tavily 工具调用
|
||||||
|
if (toolCall.name !== 'web_search_tavily' || !toolCall.result) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 解析工具调用结果
|
||||||
|
const resultData = typeof toolCall.result === 'string'
|
||||||
|
? JSON.parse(toolCall.result)
|
||||||
|
: toolCall.result;
|
||||||
|
|
||||||
|
if (resultData.results && Array.isArray(resultData.results)) {
|
||||||
|
resultData.results.forEach(item => {
|
||||||
|
if (item.index) {
|
||||||
|
results[item.index] = {
|
||||||
|
url: item.url,
|
||||||
|
title: item.title,
|
||||||
|
snippet: item.snippet
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse web search result:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.webSearchResults = results;
|
||||||
|
},
|
||||||
|
|
||||||
// 处理文本选择
|
// 处理文本选择
|
||||||
handleTextSelection() {
|
handleTextSelection() {
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
@@ -877,6 +946,11 @@ export default {
|
|||||||
// Check if tool is iPython executor
|
// Check if tool is iPython executor
|
||||||
isIPythonTool(toolCall) {
|
isIPythonTool(toolCall) {
|
||||||
return toolCall.name === 'astrbot_execute_ipython';
|
return toolCall.name === 'astrbot_execute_ipython';
|
||||||
|
},
|
||||||
|
|
||||||
|
// Open refs sidebar
|
||||||
|
openRefsSidebar(refs) {
|
||||||
|
this.$emit('openRefs', refs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="refs && refs.used && refs.used.length > 0" class="refs-container" @click="handleClick">
|
||||||
|
<div class="refs-avatars">
|
||||||
|
<div v-for="(ref, refIdx) in refs.used.slice(0, 3)" :key="refIdx" class="ref-avatar"
|
||||||
|
:style="{ zIndex: 3 - refIdx }">
|
||||||
|
<img v-if="ref.favicon" :src="ref.favicon" class="ref-favicon"
|
||||||
|
@error="(e) => e.target.style.display = 'none'" />
|
||||||
|
<span v-else class="ref-initial">{{ getRefInitial(ref.title) }}</span>
|
||||||
|
</div>
|
||||||
|
<span v-if="refs.used.length > 3" class="refs-more">
|
||||||
|
+{{ refs.used.length - 3 }}
|
||||||
|
</span>
|
||||||
|
<span class="ml-2" style="color: gray;">
|
||||||
|
{{ tm('refs.sources') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { useModuleI18n } from '@/i18n/composables';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ActionRef',
|
||||||
|
props: {
|
||||||
|
refs: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emits: ['open-refs'],
|
||||||
|
setup() {
|
||||||
|
const { tm } = useModuleI18n('features/chat');
|
||||||
|
return { tm };
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
// Get first character of ref title for fallback display
|
||||||
|
getRefInitial(title) {
|
||||||
|
if (!title) return '?';
|
||||||
|
return title.charAt(0).toUpperCase();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Handle click to open refs sidebar
|
||||||
|
handleClick() {
|
||||||
|
this.$emit('open-refs', this.refs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.refs-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refs-container:hover {
|
||||||
|
background-color: rgba(103, 58, 183, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refs-avatars {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-avatar {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0.9;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-avatar:not(:first-child) {
|
||||||
|
margin-left: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-favicon {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-initial {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refs-more {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--v-theme-secondaryText);
|
||||||
|
opacity: 0.7;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<v-chip v-if="domain" class="ref-chip" size="x-small" variant="flat"
|
||||||
|
:style="{ backgroundColor: isDark ? '#303030' : '#f4f4f4', color: isDark ? '#999' : '#666' }" :href="url"
|
||||||
|
target="_blank" clickable>
|
||||||
|
<v-icon start size="x-small" color>mdi-link-variant</v-icon>
|
||||||
|
<span>{{ domain }}</span>
|
||||||
|
|
||||||
|
</v-chip>
|
||||||
|
<span v-else class="ref-fallback" :style="{ color: isDark ? '#999' : '#666' }">{{ 'site' }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, inject } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
node: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('RefNode node:', props.node);
|
||||||
|
|
||||||
|
// 从父组件注入的暗黑模式状态和搜索结果
|
||||||
|
const isDark = inject('isDark', false)
|
||||||
|
const webSearchResults = inject('webSearchResults', () => ({}))
|
||||||
|
|
||||||
|
// 从 node.content 中提取 ref index (格式: uuid.idx)
|
||||||
|
const refIndex = computed(() => props.node?.content?.trim() || '')
|
||||||
|
|
||||||
|
// 根据 refIndex 查找对应的 URL
|
||||||
|
const resultData = computed(() => {
|
||||||
|
if (!refIndex.value) return null
|
||||||
|
const results = typeof webSearchResults === 'function' ? webSearchResults() : webSearchResults
|
||||||
|
return results?.[refIndex.value] || null
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = computed(() => resultData.value?.url || '')
|
||||||
|
|
||||||
|
const domain = computed(() => {
|
||||||
|
if (!url.value) return ''
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url.value)
|
||||||
|
return urlObj.hostname.replace(/^www\./, '')
|
||||||
|
} catch (e) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ref-chip {
|
||||||
|
margin: 0 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-chip:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-fallback {
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
<template>
|
||||||
|
<transition name="slide-left">
|
||||||
|
<div v-if="isOpen" class="refs-sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<h3 class="sidebar-title">{{ tm('refs.title') }}</h3>
|
||||||
|
<v-btn icon="mdi-close" size="small" variant="text" @click="close"></v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="refs-list">
|
||||||
|
<div v-for="(ref, index) in refs?.used || []" :key="index" class="ref-item" @click="openLink(ref.url)">
|
||||||
|
<div class="ref-item-icon">
|
||||||
|
<img v-if="ref.favicon" :src="ref.favicon" class="ref-item-favicon"
|
||||||
|
@error="(e) => e.target.style.display = 'none'" />
|
||||||
|
<div v-else class="ref-item-initial">{{ getRefInitial(ref.title) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="ref-item-content">
|
||||||
|
<div class="ref-item-title">{{ ref.title }}</div>
|
||||||
|
<div class="ref-item-url">{{ formatUrl(ref.url) }}</div>
|
||||||
|
<div v-if="ref.snippet" class="ref-item-snippet">{{ ref.snippet }}</div>
|
||||||
|
</div>
|
||||||
|
<v-icon size="small" class="ref-item-arrow">mdi-open-in-new</v-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { useModuleI18n } from '@/i18n/composables';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'RefsSidebar',
|
||||||
|
props: {
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
refs: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
setup() {
|
||||||
|
const { tm } = useModuleI18n('features/chat');
|
||||||
|
return { tm };
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
isOpen: {
|
||||||
|
get() {
|
||||||
|
return this.modelValue;
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
this.$emit('update:modelValue', value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
close() {
|
||||||
|
this.isOpen = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
getRefInitial(title) {
|
||||||
|
if (!title) return '?';
|
||||||
|
return title.charAt(0).toUpperCase();
|
||||||
|
},
|
||||||
|
|
||||||
|
formatUrl(url) {
|
||||||
|
if (!url) return '';
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
return urlObj.hostname;
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
openLink(url) {
|
||||||
|
if (url) {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.refs-sidebar {
|
||||||
|
width: 360px;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--v-theme-surface);
|
||||||
|
border-left: 1px solid var(--v-theme-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-left-enter-active,
|
||||||
|
.slide-left-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-left-enter-from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-left-leave-to {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--v-theme-primaryText);
|
||||||
|
}
|
||||||
|
|
||||||
|
.refs-list {
|
||||||
|
padding: 12px;
|
||||||
|
padding-top: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--v-theme-border);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-item:hover {
|
||||||
|
background-color: rgba(103, 58, 183, 0.05);
|
||||||
|
border-color: rgba(103, 58, 183, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-item-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-item-favicon {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-item-initial {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-item-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-item-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--v-theme-primaryText);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-item-url {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--v-theme-secondaryText);
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-item-snippet {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--v-theme-secondaryText);
|
||||||
|
opacity: 0.8;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-item-arrow {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--v-theme-secondaryText);
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-item:hover .ref-item-arrow {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -150,7 +150,7 @@ onUnmounted(() => {
|
|||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
transition: background-color 0.2s ease;
|
transition: background-color;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,10 @@
|
|||||||
"duration": "Duration",
|
"duration": "Duration",
|
||||||
"ttft": "Time to First Token"
|
"ttft": "Time to First Token"
|
||||||
},
|
},
|
||||||
|
"refs": {
|
||||||
|
"title": "References",
|
||||||
|
"sources": "Sources"
|
||||||
|
},
|
||||||
"connection": {
|
"connection": {
|
||||||
"title": "Connection Status Notice",
|
"title": "Connection Status Notice",
|
||||||
"message": "The system detected that the chat connection needs to be re-established.",
|
"message": "The system detected that the chat connection needs to be re-established.",
|
||||||
|
|||||||
@@ -107,6 +107,10 @@
|
|||||||
"duration": "耗时",
|
"duration": "耗时",
|
||||||
"ttft": "首字时间"
|
"ttft": "首字时间"
|
||||||
},
|
},
|
||||||
|
"refs": {
|
||||||
|
"title": "引用",
|
||||||
|
"sources": "来源"
|
||||||
|
},
|
||||||
"connection": {
|
"connection": {
|
||||||
"title": "连接状态提醒",
|
"title": "连接状态提醒",
|
||||||
"message": "系统检测到聊天连接需要重新建立。",
|
"message": "系统检测到聊天连接需要重新建立。",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "AstrBot"
|
name = "AstrBot"
|
||||||
version = "4.12.0"
|
version = "4.12.2"
|
||||||
description = "Easy-to-use multi-platform LLM chatbot and development framework"
|
description = "Easy-to-use multi-platform LLM chatbot and development framework"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|||||||
Reference in New Issue
Block a user