Compare commits

..

12 Commits

Author SHA1 Message Date
Soulter 5f54becbe2 chore: bump version to 4.12.3 2026-01-17 19:11:05 +08:00
Soulter 317b6fa475 refactor: update event types for LLM tool usage and response 2026-01-17 19:09:49 +08:00
Soulter 8199c83072 docs: update 4.12.2 changelog 2026-01-17 18:12:08 +08:00
Soulter 776c9ebfdd chore: bump version to 4.12.2 2026-01-17 18:07:54 +08:00
Soulter 73fca5d1a2 fix: clarify logic for skipping initial system messages in conversation 2026-01-17 18:02:31 +08:00
Soulter 844773a735 feat: skip saving head system messages in history (#4538)
* feat: skip saving the first system message in history

* fix: rename variable for clarity in system message handling

* fix: update logic to skip all system messages until the first non-system message
2026-01-17 17:57:11 +08:00
Soulter 1a7e8456ab chore: update readme
Added '自动压缩对话' feature and updated features list.
2026-01-16 17:57:49 +08:00
Soulter f6a189f118 feat: add event hooks for tool usage and response handling (#4516)
* feat: add event hooks for tool usage and response handling

* fix: update decorator for LLM tool response handling
2026-01-16 16:51:35 +08:00
Soulter 82e2e0d02f feat: add web search references feature with sidebar and extraction logic (#4515)
* feat: add web search references feature with sidebar and extraction logic

* fix: reorder import statements for consistency

* chore: remove log
2026-01-16 16:49:48 +08:00
Soulter 8771317a1e perf: chatui default persona (#4502) 2026-01-16 16:46:39 +08:00
Soulter ebae70c514 chore: bump version to 4.12.1 2026-01-15 22:20:52 +08:00
Soulter dbdb4f5185 fix: unique session not working (#4490)
* fix: unique session not working

* fix: correct session initialization and update unified_msg_origin setter

* fix: update session ID assignment in WakingCheckStage
2026-01-15 22:16:21 +08:00
27 changed files with 793 additions and 34 deletions
+5 -3
View File
@@ -41,12 +41,14 @@ AstrBot 是一个开源的一站式 Agent 聊天机器人平台,可接入主
## 主要功能
1. 💯 免费 & 开源。
1. ✨ AI 大模型对话,多模态,Agent,MCP,知识库,人格设定。
1. ✨ AI 大模型对话,多模态,Agent,MCP,知识库,人格设定,自动压缩对话
2. 🤖 支持接入 Dify、阿里云百炼、Coze 等智能体平台。
2. 🌐 多平台,支持 QQ、企业微信、飞书、钉钉、微信公众号、Telegram、Slack 以及[更多](#支持的消息平台)。
3. 📦 插件扩展,已有近 800 个插件可一键安装。
5. 💻 WebUI 支持
6. 🌐 国际化(i18n支持。
5. 🛡️ [Agent Sandbox](https://docs.astrbot.app/use/astrbot-agent-sandbox.html) 隔离化环境,安全地执行任何代码、调用 Shell、会话级资源复用
6. 💻 WebUI 支持。
7. 🌈 Web ChatUI 支持,ChatUI 内置代理沙盒、网页搜索等。
8. 🌐 国际化(i18n)支持。
## 快速开始
+6
View File
@@ -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_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_using_llm_tool as on_using_llm_tool
from astrbot.core.star.register import (
register_on_waiting_llm_request as on_waiting_llm_request,
)
@@ -53,4 +57,6 @@ __all__ = [
"permission_type",
"platform_adapter_type",
"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.provider import Provider, ProviderRequest
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
@@ -22,7 +25,9 @@ class ProcessLLMRequest:
else:
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:
return
@@ -42,6 +47,12 @@ class ProcessLLMRequest:
if default_persona:
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(
builtins.filter(
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 ""
if req.conversation:
# 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
if img_cap_prov_id and req.image_urls:
@@ -32,6 +32,7 @@ class SearchResult:
title: str
url: str
snippet: str
favicon: str | None = None
def __str__(self) -> str:
return f"{self.title} - {self.url}\n{self.snippet}"
+24 -16
View File
@@ -1,11 +1,13 @@
import asyncio
import json
import random
import uuid
import aiohttp
from bs4 import BeautifulSoup
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.provider import ProviderRequest
from astrbot.core.provider.func_tool_manager import FunctionToolManager
@@ -151,6 +153,7 @@ class Main(star.Star):
title=item.get("title"),
url=item.get("url"),
snippet=item.get("content"),
favicon=item.get("favicon"),
)
results.append(result)
return results
@@ -272,7 +275,7 @@ class Main(star.Star):
self,
event: AstrMessageEvent,
query: str,
max_results: int = 5,
max_results: int = 7,
search_depth: str = "basic",
topic: str = "general",
days: int = 3,
@@ -285,7 +288,7 @@ class Main(star.Star):
Args:
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".
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.
@@ -296,15 +299,12 @@ class Main(star.Star):
"""
logger.info(f"web_searcher - search_from_tavily: {query}")
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", []):
raise ValueError("Error: Tavily API key is not configured in AstrBot.")
# build payload
payload = {
"query": query,
"max_results": max_results,
}
payload = {"query": query, "max_results": max_results, "include_favicon": True}
if search_depth not in ["basic", "advanced"]:
search_depth = "basic"
payload["search_depth"] = search_depth
@@ -328,14 +328,22 @@ class Main(star.Star):
return "Error: Tavily web searcher does not return any results."
ret_ls = []
for result in results:
ret_ls.append(f"\nTitle: {result.title}")
ret_ls.append(f"URL: {result.url}")
ret_ls.append(f"Content: {result.snippet}")
ret = "\n".join(ret_ls)
if websearch_link:
ret += "\n\n针对问题,请根据上面的结果分点总结,并且在结尾处附上对应内容的参考链接(如有)。"
ref_uuid = str(uuid.uuid4())[:4]
for idx, result in enumerate(results, 1):
index = f"{ref_uuid}.{idx}"
ret_ls.append(
{
"title": f"{result.title}",
"url": f"{result.url}",
"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
@llm_tool("tavily_extract_web_page")
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.12.1"
__version__ = "4.12.3"
+44
View File
@@ -3,6 +3,7 @@ from typing import Any
from mcp.types import CallToolResult
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.tool import FunctionTool
from astrbot.core.astr_agent_context import AstrAgentContext
@@ -25,6 +26,19 @@ class MainAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
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.OnUsingLLMToolEvent,
tool,
tool_args,
)
async def on_tool_end(
self,
run_context: ContextWrapper[AstrAgentContext],
@@ -33,6 +47,36 @@ class MainAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
tool_result: CallToolResult | None,
):
run_context.context.event.clear_result()
await call_event_hook(
run_context.context.event,
EventType.OnLLMToolRespondEvent,
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]):
+1 -1
View File
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.12.1"
VERSION = "4.12.3"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -414,10 +414,11 @@ class InternalAgentSubStage(Stage):
# using agent context messages to save to history
message_to_save = []
skipped_initial_system = False
for message in all_messages:
if message.role == "system":
# we do not save system messages to history
continue
if message.role == "system" and not skipped_initial_system:
skipped_initial_system = True
continue # skip first system message
if message.role in ["assistant", "user"] and getattr(
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."
)
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 = (
'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?"
+4
View File
@@ -11,7 +11,9 @@ from .star_handler import (
register_on_decorating_result,
register_on_llm_request,
register_on_llm_response,
register_on_llm_tool_respond,
register_on_platform_loaded,
register_on_using_llm_tool,
register_on_waiting_llm_request,
register_permission_type,
register_platform_adapter_type,
@@ -36,4 +38,6 @@ __all__ = [
"register_platform_adapter_type",
"register_regex",
"register_star",
"register_on_using_llm_tool",
"register_on_llm_tool_respond",
]
@@ -409,6 +409,57 @@ def register_on_llm_response(**kwargs):
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.OnUsingLLMToolEvent, **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.OnLLMToolRespondEvent, **kwargs
)
return awaitable
return decorator
def register_llm_tool(name: str | None = None, **kwargs):
"""为函数调用(function-calling / tools-use)添加工具。
+2
View File
@@ -189,6 +189,8 @@ class EventType(enum.Enum):
OnLLMResponseEvent = enum.auto() # LLM 响应后
OnDecoratingResultEvent = enum.auto() # 发送消息前
OnCallingFuncToolEvent = enum.auto() # 调用函数工具
OnUsingLLMToolEvent = enum.auto() # 使用 LLM 工具
OnLLMToolRespondEvent = enum.auto() # 调用函数工具后
OnAfterMessageSentEvent = enum.auto() # 发送消息后
+14
View File
@@ -1,8 +1,11 @@
import asyncio
import os
import threading
from collections import defaultdict
from typing import Any, TypeVar, overload
from apscheduler.schedulers.background import BackgroundScheduler
from astrbot.core.db import BaseDatabase
from astrbot.core.db.po import Preference
@@ -20,11 +23,22 @@ class SharedPreferences:
)
self.path = json_storage_path
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()
t = threading.Thread(target=self._sync_loop.run_forever, daemon=True)
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(
self,
scope: str,
+79 -1
View File
@@ -2,6 +2,7 @@ import asyncio
import json
import mimetypes
import os
import re
import uuid
from contextlib import asynccontextmanager
from typing import cast
@@ -9,7 +10,7 @@ from typing import cast
from quart import Response as QuartResponse
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.db import BaseDatabase
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),
}
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(
self,
webchat_conv_id: str,
@@ -232,6 +291,7 @@ class ChatRoute(Route):
media_parts: list,
reasoning: str,
agent_stats: dict,
refs: dict,
):
"""保存 bot 消息到历史记录,返回保存的记录"""
bot_message_parts = []
@@ -244,6 +304,8 @@ class ChatRoute(Route):
new_his["reasoning"] = reasoning
if agent_stats:
new_his["agent_stats"] = agent_stats
if refs:
new_his["refs"] = refs
record = await self.platform_history_mgr.insert(
platform_id="webchat",
@@ -305,6 +367,7 @@ class ChatRoute(Route):
accumulated_reasoning = ""
tool_calls = {}
agent_stats = {}
refs = {}
try:
async with track_conversation(self.running_convs, webchat_conv_id):
while True:
@@ -426,12 +489,26 @@ class ChatRoute(Route):
or chain_type == "tool_call_result"
):
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(
webchat_conv_id,
accumulated_text,
accumulated_parts,
accumulated_reasoning,
agent_stats,
refs,
)
# 发送保存的消息信息给前端
if saved_record and not client_disconnected:
@@ -451,6 +528,7 @@ class ChatRoute(Route):
accumulated_reasoning = ""
# tool_calls = {}
agent_stats = {}
refs = {}
except BaseException as e:
logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True)
+6
View File
@@ -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 网页搜索工具的渲染,支持内联搜索引用、引用网页。
+12
View File
@@ -0,0 +1,12 @@
## 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 网页搜索工具的渲染,支持内联搜索引用、引用网页。
hotfix of 4.12.2
- fix: tool call error in some cases
+19
View File
@@ -55,6 +55,7 @@
@openImagePreview="openImagePreview"
@replyMessage="handleReplyMessage"
@replyWithText="handleReplyWithText"
@openRefs="handleOpenRefs"
ref="messageList" />
<div class="message-list-fade" :class="{ 'fade-dark': isDark }"></div>
</div>
@@ -146,6 +147,8 @@
/>
</div>
<!-- Refs Sidebar -->
<RefsSidebar v-model="refsSidebarOpen" :refs="refsSidebarRefs" />
</div>
</v-card-text>
</v-card>
@@ -198,6 +201,7 @@ import ChatInput from '@/components/chat/ChatInput.vue';
import ProjectDialog from '@/components/chat/ProjectDialog.vue';
import ProjectView from '@/components/chat/ProjectView.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 { useSessions } from '@/composables/useSessions';
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[]) {
if (!sessionIds[0]) return;
@@ -215,7 +215,6 @@ function handleDeleteConversation(session: Session) {
display: flex;
flex-direction: column;
padding: 0;
border-right: 1px solid rgba(0, 0, 0, 0.04);
height: 100%;
max-height: 100%;
position: relative;
+78 -4
View File
@@ -116,6 +116,8 @@
<!-- Text (Markdown) -->
<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"
: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')" />
<v-btn icon="mdi-reply-outline" size="x-small" variant="text" class="reply-message-btn"
@click="$emit('replyMessage', msg, index)" :title="tm('actions.reply')" />
<!-- Refs Visualization -->
<ActionRef :refs="msg.content.refs" @open-refs="openRefsSidebar" />
</div>
</div>
</div>
@@ -245,7 +250,7 @@
<script>
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 'katex/dist/katex.min.css'
import 'highlight.js/styles/github.css';
@@ -253,17 +258,24 @@ import axios from 'axios';
import ReasoningBlock from './message_list_comps/ReasoningBlock.vue';
import IPythonToolBlock from './message_list_comps/IPythonToolBlock.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();
enableMermaid();
// 注册自定义 ref 组件
setCustomComponents('message-list', { ref: RefNode });
export default {
name: 'MessageList',
components: {
MarkdownRender,
ReasoningBlock,
IPythonToolBlock,
ToolCallCard
ToolCallCard,
RefNode,
ActionRef
},
props: {
messages: {
@@ -283,7 +295,7 @@ export default {
default: false
}
},
emits: ['openImagePreview', 'replyMessage', 'replyWithText'],
emits: ['openImagePreview', 'replyMessage', 'replyWithText', 'openRefs'],
setup() {
const { t } = useI18n();
const { tm } = useModuleI18n('features/chat');
@@ -293,6 +305,12 @@ export default {
tm
};
},
provide() {
return {
isDark: this.isDark,
webSearchResults: () => this.webSearchResults
};
},
data() {
return {
copiedMessages: new Set(),
@@ -315,7 +333,9 @@ export default {
imagePreview: {
show: false,
url: ''
}
},
// Web search results mapping: { 'uuid.idx': { url, title, snippet } }
webSearchResults: {}
};
},
async mounted() {
@@ -324,6 +344,7 @@ export default {
this.addScrollListener();
this.scrollToBottom();
this.startElapsedTimeTimer();
this.extractWebSearchResults();
},
updated() {
this.initCodeCopyButtons();
@@ -331,8 +352,56 @@ export default {
if (this.isUserNearBottom) {
this.scrollToBottom();
}
this.extractWebSearchResults();
},
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() {
const selection = window.getSelection();
@@ -877,6 +946,11 @@ export default {
// Check if tool is iPython executor
isIPythonTool(toolCall) {
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;
cursor: pointer;
user-select: none;
transition: background-color 0.2s ease;
transition: background-color;
gap: 8px;
}
@@ -105,6 +105,10 @@
"duration": "Duration",
"ttft": "Time to First Token"
},
"refs": {
"title": "References",
"sources": "Sources"
},
"connection": {
"title": "Connection Status Notice",
"message": "The system detected that the chat connection needs to be re-established.",
@@ -107,6 +107,10 @@
"duration": "耗时",
"ttft": "首字时间"
},
"refs": {
"title": "引用",
"sources": "来源"
},
"connection": {
"title": "连接状态提醒",
"message": "系统检测到聊天连接需要重新建立。",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "4.12.1"
version = "4.12.3"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
requires-python = ">=3.10"