Compare commits

..

14 Commits

Author SHA1 Message Date
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
Soulter af2b3b3bfc fix: update stream-monaco dependency to version 0.0.15 2026-01-15 19:53:58 +08:00
Soulter 6497d9a46f fix: update stream-markdown dependency to version 0.0.13 2026-01-15 19:52:23 +08:00
Soulter 8f4a62a2cb chore: bump version to 4.12.0 2026-01-15 19:47:53 +08:00
Soulter acbe83a2e2 chore: bump version to 4.12.0 2026-01-15 19:47:25 +08:00
34 changed files with 1941 additions and 44 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.11.4"
__version__ = "4.12.2"
+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.OnCallingFuncToolEvent,
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.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]):
+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.11.4"
VERSION = "4.12.2"
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?"
+23 -4
View File
@@ -42,8 +42,6 @@ class AstrMessageEvent(abc.ABC):
"""消息对象, AstrBotMessage。带有完整的消息结构。"""
self.platform_meta = platform_meta
"""消息平台的信息, 其中 name 是平台的类型,如 aiocqhttp"""
self.session_id = session_id
"""用户的会话 ID。可以直接使用下面的 unified_msg_origin"""
self.role = "member"
"""用户是否是管理员。如果是管理员,这里是 admin"""
self.is_wake = False
@@ -51,12 +49,12 @@ class AstrMessageEvent(abc.ABC):
self.is_at_or_wake_command = False
"""是否是 At 机器人或者带有唤醒词或者是私聊(插件注册的事件监听器会让 is_wake 设为 True, 但是不会让这个属性置为 True)"""
self._extras: dict[str, Any] = {}
self.session = MessageSesion(
self.session = MessageSession(
platform_name=platform_meta.id,
message_type=message_obj.type,
session_id=session_id,
)
self.unified_msg_origin = str(self.session)
# self.unified_msg_origin = str(self.session)
"""统一的消息来源字符串。格式为 platform_name:message_type:session_id"""
self._result: MessageEventResult | None = None
"""消息事件的结果"""
@@ -72,6 +70,27 @@ class AstrMessageEvent(abc.ABC):
# back_compability
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):
"""获取这个事件所属的平台的类型(如 aiocqhttp, slack, discord 等)。
+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.OnCallingFuncToolEvent, **kwargs)
return awaitable
return decorator
def register_on_llm_tool_respond(**kwargs):
"""当调用函数工具后的事件。
会传入 tooltool_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):
"""为函数调用(function-calling / tools-use)添加工具。
+1
View File
@@ -189,6 +189,7 @@ class EventType(enum.Enum):
OnLLMResponseEvent = enum.auto() # LLM 响应后
OnDecoratingResultEvent = enum.auto() # 发送消息前
OnCallingFuncToolEvent = enum.auto() # 调用函数工具
OnAfterCallingFuncToolEvent = 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)
@@ -35,6 +35,14 @@ class SessionManagementRoute(Route):
"/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-all-with-status": ("GET", self.list_all_umos_with_status),
"/session/batch-update-service": ("POST", self.batch_update_service),
"/session/batch-update-provider": ("POST", self.batch_update_provider),
# 分组管理 API
"/session/groups": ("GET", self.list_groups),
"/session/group/create": ("POST", self.create_group),
"/session/group/update": ("POST", self.update_group),
"/session/group/delete": ("POST", self.delete_group),
}
self.conv_mgr = core_lifecycle.conversation_manager
self.core_lifecycle = core_lifecycle
@@ -391,3 +399,540 @@ class SessionManagementRoute(Route):
except Exception as e:
logger.error(f"获取 UMO 列表失败: {e!s}")
return Response().error(f"获取 UMO 列表失败: {e!s}").__dict__
async def list_all_umos_with_status(self):
"""获取所有有对话记录的 UMO 及其服务状态(支持分页、搜索、筛选)
Query 参数:
page: 页码默认为 1
page_size: 每页数量默认为 20
search: 搜索关键词
message_type: 筛选消息类型 (group/private/all)
platform: 筛选平台
"""
try:
page = request.args.get("page", 1, type=int)
page_size = request.args.get("page_size", 20, type=int)
search = request.args.get("search", "", type=str).strip()
message_type = request.args.get("message_type", "all", type=str)
platform = request.args.get("platform", "", type=str)
if page < 1:
page = 1
if page_size < 1:
page_size = 20
if page_size > 100:
page_size = 100
# 从 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)
)
all_umos = [row[0] for row in result.fetchall()]
# 获取所有 umo 的规则配置
umo_rules, _ = await self._get_umo_rules(page=1, page_size=99999, search="")
# 构建带状态的 umo 列表
umos_with_status = []
for umo in all_umos:
parts = umo.split(":")
umo_platform = parts[0] if len(parts) >= 1 else "unknown"
umo_message_type = parts[1] if len(parts) >= 2 else "unknown"
umo_session_id = parts[2] if len(parts) >= 3 else umo
# 筛选消息类型
if message_type != "all":
if message_type == "group" and umo_message_type not in [
"group",
"GroupMessage",
]:
continue
if message_type == "private" and umo_message_type not in [
"private",
"FriendMessage",
"friend",
]:
continue
# 筛选平台
if platform and umo_platform != platform:
continue
# 获取服务配置
rules = umo_rules.get(umo, {})
svc_config = rules.get("session_service_config", {})
custom_name = svc_config.get("custom_name", "") if svc_config else ""
session_enabled = (
svc_config.get("session_enabled", True) if svc_config else True
)
llm_enabled = (
svc_config.get("llm_enabled", True) if svc_config else True
)
tts_enabled = (
svc_config.get("tts_enabled", True) if svc_config else True
)
# 搜索过滤
if search:
search_lower = search.lower()
if (
search_lower not in umo.lower()
and search_lower not in custom_name.lower()
):
continue
# 获取 provider 配置
chat_provider_key = (
f"provider_perf_{ProviderType.CHAT_COMPLETION.value}"
)
tts_provider_key = f"provider_perf_{ProviderType.TEXT_TO_SPEECH.value}"
stt_provider_key = f"provider_perf_{ProviderType.SPEECH_TO_TEXT.value}"
umos_with_status.append(
{
"umo": umo,
"platform": umo_platform,
"message_type": umo_message_type,
"session_id": umo_session_id,
"custom_name": custom_name,
"session_enabled": session_enabled,
"llm_enabled": llm_enabled,
"tts_enabled": tts_enabled,
"has_rules": umo in umo_rules,
"chat_provider": rules.get(chat_provider_key),
"tts_provider": rules.get(tts_provider_key),
"stt_provider": rules.get(stt_provider_key),
}
)
# 分页
total = len(umos_with_status)
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
paginated = umos_with_status[start_idx:end_idx]
# 获取可用的平台列表
platforms = list({u["platform"] for u in umos_with_status})
# 获取可用的 providers
provider_manager = self.core_lifecycle.provider_manager
available_chat_providers = [
{"id": p.meta().id, "name": p.meta().id, "model": p.meta().model}
for p in provider_manager.provider_insts
]
available_tts_providers = [
{"id": p.meta().id, "name": p.meta().id, "model": p.meta().model}
for p in provider_manager.tts_provider_insts
]
available_stt_providers = [
{"id": p.meta().id, "name": p.meta().id, "model": p.meta().model}
for p in provider_manager.stt_provider_insts
]
return (
Response()
.ok(
{
"sessions": paginated,
"total": total,
"page": page,
"page_size": page_size,
"platforms": platforms,
"available_chat_providers": available_chat_providers,
"available_tts_providers": available_tts_providers,
"available_stt_providers": available_stt_providers,
}
)
.__dict__
)
except Exception as e:
logger.error(f"获取会话状态列表失败: {e!s}")
return Response().error(f"获取会话状态列表失败: {e!s}").__dict__
async def batch_update_service(self):
"""批量更新多个 UMO 的服务状态 (LLM/TTS/Session)
请求体:
{
"umos": ["平台:消息类型:会话ID", ...], // 可选如果不传则根据 scope 筛选
"scope": "all" | "group" | "private" | "custom_group", // 可选批量范围
"group_id": "分组ID", // scope custom_group 时必填
"llm_enabled": true/false/null, // 可选null表示不修改
"tts_enabled": true/false/null, // 可选
"session_enabled": true/false/null // 可选
}
"""
try:
data = await request.get_json()
umos = data.get("umos", [])
scope = data.get("scope", "")
group_id = data.get("group_id", "")
llm_enabled = data.get("llm_enabled")
tts_enabled = data.get("tts_enabled")
session_enabled = data.get("session_enabled")
# 如果没有任何修改
if llm_enabled is None and tts_enabled is None and session_enabled is None:
return Response().error("至少需要指定一个要修改的状态").__dict__
# 如果指定了 scope,获取符合条件的所有 umo
if scope and not umos:
# 如果是自定义分组
if scope == "custom_group":
if not group_id:
return Response().error("请指定分组 ID").__dict__
groups = self._get_groups()
if group_id not in groups:
return Response().error(f"分组 '{group_id}' 不存在").__dict__
umos = groups[group_id].get("umos", [])
else:
async with self.db_helper.get_db() as session:
session: AsyncSession
result = await session.execute(
select(ConversationV2.user_id).distinct()
)
all_umos = [row[0] for row in result.fetchall()]
if scope == "group":
umos = [
u
for u in all_umos
if ":group:" in u.lower() or ":groupmessage:" in u.lower()
]
elif scope == "private":
umos = [
u
for u in all_umos
if ":private:" in u.lower() or ":friend" in u.lower()
]
elif scope == "all":
umos = all_umos
if not umos:
return Response().error("没有找到符合条件的会话").__dict__
# 批量更新
success_count = 0
failed_umos = []
for umo in umos:
try:
# 获取现有配置
session_config = (
sp.get("session_service_config", {}, scope="umo", scope_id=umo)
or {}
)
# 更新状态
if llm_enabled is not None:
session_config["llm_enabled"] = llm_enabled
if tts_enabled is not None:
session_config["tts_enabled"] = tts_enabled
if session_enabled is not None:
session_config["session_enabled"] = session_enabled
# 保存
sp.put(
"session_service_config",
session_config,
scope="umo",
scope_id=umo,
)
success_count += 1
except Exception as e:
logger.error(f"更新 {umo} 服务状态失败: {e!s}")
failed_umos.append(umo)
status_changes = []
if llm_enabled is not None:
status_changes.append(f"LLM={'启用' if llm_enabled else '禁用'}")
if tts_enabled is not None:
status_changes.append(f"TTS={'启用' if tts_enabled else '禁用'}")
if session_enabled is not None:
status_changes.append(f"会话={'启用' if session_enabled else '禁用'}")
return (
Response()
.ok(
{
"message": f"已更新 {success_count} 个会话 ({', '.join(status_changes)})",
"success_count": success_count,
"failed_count": len(failed_umos),
"failed_umos": failed_umos,
}
)
.__dict__
)
except Exception as e:
logger.error(f"批量更新服务状态失败: {e!s}")
return Response().error(f"批量更新服务状态失败: {e!s}").__dict__
async def batch_update_provider(self):
"""批量更新多个 UMO 的 Provider 配置
请求体:
{
"umos": ["平台:消息类型:会话ID", ...], // 可选
"scope": "all" | "group" | "private", // 可选
"provider_type": "chat_completion" | "text_to_speech" | "speech_to_text",
"provider_id": "provider_id"
}
"""
try:
data = await request.get_json()
umos = data.get("umos", [])
scope = data.get("scope", "")
provider_type = data.get("provider_type")
provider_id = data.get("provider_id")
if not provider_type or not provider_id:
return (
Response()
.error("缺少必要参数: provider_type, provider_id")
.__dict__
)
# 转换 provider_type
provider_type_map = {
"chat_completion": ProviderType.CHAT_COMPLETION,
"text_to_speech": ProviderType.TEXT_TO_SPEECH,
"speech_to_text": ProviderType.SPEECH_TO_TEXT,
}
if provider_type not in provider_type_map:
return (
Response()
.error(f"不支持的 provider_type: {provider_type}")
.__dict__
)
provider_type_enum = provider_type_map[provider_type]
# 如果指定了 scope,获取符合条件的所有 umo
group_id = data.get("group_id", "")
if scope and not umos:
# 如果是自定义分组
if scope == "custom_group":
if not group_id:
return Response().error("请指定分组 ID").__dict__
groups = self._get_groups()
if group_id not in groups:
return Response().error(f"分组 '{group_id}' 不存在").__dict__
umos = groups[group_id].get("umos", [])
else:
async with self.db_helper.get_db() as session:
session: AsyncSession
result = await session.execute(
select(ConversationV2.user_id).distinct()
)
all_umos = [row[0] for row in result.fetchall()]
if scope == "group":
umos = [
u
for u in all_umos
if ":group:" in u.lower() or ":groupmessage:" in u.lower()
]
elif scope == "private":
umos = [
u
for u in all_umos
if ":private:" in u.lower() or ":friend" in u.lower()
]
elif scope == "all":
umos = all_umos
if not umos:
return Response().error("没有找到符合条件的会话").__dict__
# 批量更新
success_count = 0
failed_umos = []
provider_manager = self.core_lifecycle.provider_manager
for umo in umos:
try:
await provider_manager.set_provider(
provider_id=provider_id,
provider_type=provider_type_enum,
umo=umo,
)
success_count += 1
except Exception as e:
logger.error(f"更新 {umo} Provider 失败: {e!s}")
failed_umos.append(umo)
return (
Response()
.ok(
{
"message": f"已更新 {success_count} 个会话的 {provider_type}{provider_id}",
"success_count": success_count,
"failed_count": len(failed_umos),
"failed_umos": failed_umos,
}
)
.__dict__
)
except Exception as e:
logger.error(f"批量更新 Provider 失败: {e!s}")
return Response().error(f"批量更新 Provider 失败: {e!s}").__dict__
# ==================== 分组管理 API ====================
def _get_groups(self) -> dict:
"""获取所有分组"""
return sp.get("session_groups", {})
def _save_groups(self, groups: dict) -> None:
"""保存分组"""
sp.put("session_groups", groups)
async def list_groups(self):
"""获取所有分组列表"""
try:
groups = self._get_groups()
# 转换为列表格式,方便前端使用
groups_list = []
for group_id, group_data in groups.items():
groups_list.append(
{
"id": group_id,
"name": group_data.get("name", ""),
"umos": group_data.get("umos", []),
"umo_count": len(group_data.get("umos", [])),
}
)
return Response().ok({"groups": groups_list}).__dict__
except Exception as e:
logger.error(f"获取分组列表失败: {e!s}")
return Response().error(f"获取分组列表失败: {e!s}").__dict__
async def create_group(self):
"""创建新分组"""
try:
data = await request.json
name = data.get("name", "").strip()
umos = data.get("umos", [])
if not name:
return Response().error("分组名称不能为空").__dict__
groups = self._get_groups()
# 生成唯一 ID
import uuid
group_id = str(uuid.uuid4())[:8]
groups[group_id] = {
"name": name,
"umos": umos,
}
self._save_groups(groups)
return (
Response()
.ok(
{
"message": f"分组 '{name}' 创建成功",
"group": {
"id": group_id,
"name": name,
"umos": umos,
"umo_count": len(umos),
},
}
)
.__dict__
)
except Exception as e:
logger.error(f"创建分组失败: {e!s}")
return Response().error(f"创建分组失败: {e!s}").__dict__
async def update_group(self):
"""更新分组(改名、增删成员)"""
try:
data = await request.json
group_id = data.get("id")
name = data.get("name")
umos = data.get("umos")
add_umos = data.get("add_umos", [])
remove_umos = data.get("remove_umos", [])
if not group_id:
return Response().error("分组 ID 不能为空").__dict__
groups = self._get_groups()
if group_id not in groups:
return Response().error(f"分组 '{group_id}' 不存在").__dict__
group = groups[group_id]
# 更新名称
if name is not None:
group["name"] = name.strip()
# 直接设置 umos 列表
if umos is not None:
group["umos"] = umos
else:
# 增量更新
current_umos = set(group.get("umos", []))
if add_umos:
current_umos.update(add_umos)
if remove_umos:
current_umos.difference_update(remove_umos)
group["umos"] = list(current_umos)
self._save_groups(groups)
return (
Response()
.ok(
{
"message": f"分组 '{group['name']}' 更新成功",
"group": {
"id": group_id,
"name": group["name"],
"umos": group["umos"],
"umo_count": len(group["umos"]),
},
}
)
.__dict__
)
except Exception as e:
logger.error(f"更新分组失败: {e!s}")
return Response().error(f"更新分组失败: {e!s}").__dict__
async def delete_group(self):
"""删除分组"""
try:
data = await request.json
group_id = data.get("id")
if not group_id:
return Response().error("分组 ID 不能为空").__dict__
groups = self._get_groups()
if group_id not in groups:
return Response().error(f"分组 '{group_id}' 不存在").__dict__
group_name = groups[group_id].get("name", group_id)
del groups[group_id]
self._save_groups(groups)
return Response().ok({"message": f"分组 '{group_name}' 已删除"}).__dict__
except Exception as e:
logger.error(f"删除分组失败: {e!s}")
return Response().error(f"删除分组失败: {e!s}").__dict__
+19
View File
@@ -0,0 +1,19 @@
## What's Changed
### 新增
- 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))
+23
View File
@@ -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))
+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 网页搜索工具的渲染,支持内联搜索引用、引用网页。
+2 -2
View File
@@ -34,8 +34,8 @@
"pinyin-pro": "^3.26.0",
"remixicon": "3.5.0",
"shiki": "^3.20.0",
"stream-markdown": "^0.0.11",
"stream-monaco": "^0.0.8",
"stream-markdown": "^0.0.13",
"stream-monaco": "^0.0.15",
"vee-validate": "4.11.3",
"vite-plugin-vuetify": "1.0.2",
"vue": "3.3.4",
+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) {
// sidebarrefs
if (refsSidebarOpen.value && refsSidebarRefs.value === refs) {
refsSidebarOpen.value = false;
} else {
// sidebarrefs
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.",
@@ -1,4 +1,4 @@
{
{
"title": "Custom Rules",
"subtitle": "Set custom rules for specific sessions, which take priority over global settings",
"buttons": {
@@ -93,6 +93,42 @@
"batchDeleteConfirm": {
"title": "Confirm Batch Delete",
"message": "Are you sure you want to delete {count} selected rules? Global settings will be used after deletion."
},
"batchOperations": {
"title": "Batch Operations",
"hint": "Quick batch modify session settings",
"scope": "Apply to",
"scopeSelected": "Selected sessions",
"scopeAll": "All sessions",
"scopeGroup": "All groups",
"scopePrivate": "All private chats",
"llmStatus": "LLM Status",
"ttsStatus": "TTS Status",
"chatProvider": "Chat Model",
"ttsProvider": "TTS Model",
"apply": "Apply Changes"
},
"status": {
"enabled": "Enabled",
"disabled": "Disabled"
},
"batchOperations": {
"title": "Batch Operations",
"hint": "Quick batch modify session settings",
"scope": "Apply to",
"scopeSelected": "Selected sessions",
"scopeAll": "All sessions",
"scopeGroup": "All groups",
"scopePrivate": "All private chats",
"llmStatus": "LLM Status",
"ttsStatus": "TTS Status",
"chatProvider": "Chat Model",
"ttsProvider": "TTS Model",
"apply": "Apply Changes"
},
"status": {
"enabled": "Enabled",
"disabled": "Disabled"
},
"messages": {
"refreshSuccess": "Data refreshed",
@@ -105,6 +141,8 @@
"deleteError": "Failed to delete",
"noChanges": "No changes to save",
"batchDeleteSuccess": "Batch delete successful",
"batchDeleteError": "Batch delete failed"
"batchDeleteError": "Batch delete failed",
"batchUpdateError": "Batch update failed",
"batchUpdateSuccess": "Batch update success"
}
}
@@ -107,6 +107,10 @@
"duration": "耗时",
"ttft": "首字时间"
},
"refs": {
"title": "引用",
"sources": "来源"
},
"connection": {
"title": "连接状态提醒",
"message": "系统检测到聊天连接需要重新建立。",
@@ -1,4 +1,4 @@
{
{
"title": "自定义规则",
"subtitle": "为特定会话设置自定义规则,优先级高于全局配置",
"buttons": {
@@ -94,6 +94,24 @@
"title": "确认批量删除",
"message": "确定要删除选中的 {count} 条规则吗?删除后将恢复使用全局配置。"
},
"batchOperations": {
"title": "批量操作",
"hint": "快速批量修改会话配置",
"scope": "应用范围",
"scopeSelected": "选中的会话",
"scopeAll": "所有会话",
"scopeGroup": "所有群聊",
"scopePrivate": "所有私聊",
"llmStatus": "LLM 状态",
"ttsStatus": "TTS 状态",
"chatProvider": "聊天模型",
"ttsProvider": "TTS 模型",
"apply": "应用更改"
},
"status": {
"enabled": "启用",
"disabled": "禁用"
},
"messages": {
"refreshSuccess": "数据已刷新",
"loadError": "加载数据失败",
+490 -1
View File
@@ -1,4 +1,4 @@
<template>
<template>
<div class="session-management-page">
<v-container fluid class="pa-0">
<v-card flat>
@@ -111,6 +111,160 @@
</v-data-table-server>
</v-card-text>
</v-card>
<!-- 批量操作面板 -->
<v-card flat class="mt-4">
<v-card-title class="d-flex align-center py-3 px-4">
<span class="text-h6">{{ tm('batchOperations.title') }}</span>
<v-chip size="small" class="ml-2" color="info" variant="outlined">
{{ tm('batchOperations.hint') }}
</v-chip>
</v-card-title>
<v-card-text>
<v-row dense>
<v-col cols="12" md="6" lg="3">
<v-select v-model="batchScope" :items="batchScopeOptions" item-title="label" item-value="value"
:label="tm('batchOperations.scope')" hide-details variant="solo-filled" flat density="comfortable">
</v-select>
</v-col>
<v-col cols="12" md="6" lg="3">
<v-select v-model="batchLlmStatus" :items="statusOptions" item-title="label" item-value="value"
:label="tm('batchOperations.llmStatus')" hide-details clearable variant="solo-filled" flat density="comfortable">
</v-select>
</v-col>
<v-col cols="12" md="6" lg="3">
<v-select v-model="batchTtsStatus" :items="statusOptions" item-title="label" item-value="value"
:label="tm('batchOperations.ttsStatus')" hide-details clearable variant="solo-filled" flat density="comfortable">
</v-select>
</v-col>
<v-col cols="12" md="6" lg="3">
<v-select v-model="batchChatProvider" :items="chatProviderOptions" item-title="label" item-value="value"
:label="tm('batchOperations.chatProvider')" hide-details clearable variant="solo-filled" flat density="comfortable">
</v-select>
</v-col>
</v-row>
<v-row dense class="mt-3">
<v-col cols="12" class="d-flex justify-end">
<v-btn color="primary" variant="tonal" size="large" @click="applyBatchChanges"
:disabled="!canApplyBatch" :loading="batchUpdating" prepend-icon="mdi-check-all">
{{ tm('batchOperations.apply') }}
</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- 分组管理面板 -->
<v-card flat class="mt-4">
<v-card-title class="d-flex align-center py-3 px-4">
<span class="text-h6">分组管理</span>
<v-chip size="small" class="ml-2" color="secondary" variant="outlined">
{{ groups.length }} 个分组
</v-chip>
<v-spacer></v-spacer>
<v-btn v-if="selectedItems.length > 0 && groups.length > 0" color="info" variant="tonal" size="small" class="mr-2">
<v-icon start>mdi-folder-plus</v-icon>
添加到分组
<v-menu activator="parent">
<v-list density="compact">
<v-list-item v-for="g in groups" :key="g.id" @click="addSelectedToGroup(g.id)">
<v-list-item-title>{{ g.name }} ({{ g.umo_count }})</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-btn>
<v-btn color="success" variant="tonal" size="small" @click="openCreateGroupDialog" prepend-icon="mdi-folder-plus">
新建分组
</v-btn>
</v-card-title>
<v-card-text v-if="groups.length > 0">
<v-row dense>
<v-col v-for="group in groups" :key="group.id" cols="12" sm="6" md="4" lg="3">
<v-card variant="outlined" class="pa-3">
<div class="d-flex align-center justify-space-between">
<div>
<div class="font-weight-bold">{{ group.name }}</div>
<div class="text-caption text-grey">{{ group.umo_count }} 个会话</div>
</div>
<div>
<v-btn icon size="small" variant="text" @click="openEditGroupDialog(group)">
<v-icon size="small">mdi-pencil</v-icon>
</v-btn>
<v-btn icon size="small" variant="text" color="error" @click="deleteGroup(group)">
<v-icon size="small">mdi-delete</v-icon>
</v-btn>
</div>
</div>
</v-card>
</v-col>
</v-row>
</v-card-text>
<v-card-text v-else class="text-center text-grey py-6">
暂无分组点击新建分组创建
</v-card-text>
</v-card>
<!-- 分组编辑对话框 -->
<v-dialog v-model="groupDialog" max-width="800" @after-enter="loadAvailableUmos">
<v-card>
<v-card-title class="py-3 px-4">
{{ groupDialogMode === 'create' ? '新建分组' : '编辑分组' }}
</v-card-title>
<v-card-text>
<v-text-field v-model="editingGroup.name" label="分组名称" variant="outlined" hide-details class="mb-4"></v-text-field>
<v-row dense>
<!-- 左侧可选会话 -->
<v-col cols="5">
<div class="text-subtitle-2 mb-2">可选会话 ({{ unselectedUmos.length }})</div>
<v-text-field v-model="groupMemberSearch" placeholder="搜索..." variant="outlined" density="compact" hide-details class="mb-2" clearable prepend-inner-icon="mdi-magnify"></v-text-field>
<v-list density="compact" class="transfer-list" lines="one">
<v-list-item v-for="umo in filteredUnselectedUmos" :key="umo" @click="addToGroup(umo)" class="transfer-item">
<template v-slot:prepend>
<v-icon size="small" color="grey">mdi-plus</v-icon>
</template>
<v-list-item-title class="text-caption">{{ formatUmoShort(umo) }}</v-list-item-title>
</v-list-item>
<v-list-item v-if="filteredUnselectedUmos.length === 0 && !loadingUmos">
<v-list-item-title class="text-caption text-grey text-center">无匹配项</v-list-item-title>
</v-list-item>
<v-list-item v-if="loadingUmos">
<v-list-item-title class="text-center"><v-progress-circular indeterminate size="20"></v-progress-circular></v-list-item-title>
</v-list-item>
</v-list>
</v-col>
<!-- 中间操作按钮 -->
<v-col cols="2" class="d-flex flex-column align-center justify-center">
<v-btn icon size="small" variant="tonal" color="primary" class="mb-2" @click="addAllToGroup" :disabled="unselectedUmos.length === 0">
<v-icon>mdi-chevron-double-right</v-icon>
</v-btn>
<v-btn icon size="small" variant="tonal" color="error" @click="removeAllFromGroup" :disabled="editingGroup.umos.length === 0">
<v-icon>mdi-chevron-double-left</v-icon>
</v-btn>
</v-col>
<!-- 右侧已选会话 -->
<v-col cols="5">
<div class="text-subtitle-2 mb-2">已选会话 ({{ editingGroup.umos.length }})</div>
<v-text-field v-model="groupSelectedSearch" placeholder="搜索..." variant="outlined" density="compact" hide-details class="mb-2" clearable prepend-inner-icon="mdi-magnify"></v-text-field>
<v-list density="compact" class="transfer-list" lines="one">
<v-list-item v-for="umo in filteredSelectedUmos" :key="umo" @click="removeFromGroup(umo)" class="transfer-item">
<template v-slot:prepend>
<v-icon size="small" color="error">mdi-minus</v-icon>
</template>
<v-list-item-title class="text-caption">{{ formatUmoShort(umo) }}</v-list-item-title>
</v-list-item>
<v-list-item v-if="editingGroup.umos.length === 0">
<v-list-item-title class="text-caption text-grey text-center">暂无成员</v-list-item-title>
</v-list-item>
</v-list>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="px-4 pb-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="groupDialog = false">取消</v-btn>
<v-btn color="primary" variant="tonal" @click="saveGroup">保存</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 添加规则对话框 - 选择 UMO -->
<v-dialog v-model="addRuleDialog" max-width="600">
@@ -454,6 +608,29 @@ export default {
quickEditNameDialog: false,
quickEditNameTarget: null,
quickEditNameValue: '',
//
batchScope: 'selected',
batchGroupId: null,
batchLlmStatus: null,
batchTtsStatus: null,
batchChatProvider: null,
batchTtsProvider: null,
batchUpdating: false,
//
groups: [],
groupsLoading: false,
groupDialog: false,
groupDialogMode: 'create',
editingGroup: {
id: null,
name: '',
umos: [],
},
groupMemberDialog: false,
groupMemberTarget: null,
groupMemberSearch: '',
groupSelectedSearch: '',
//
snackbar: false,
@@ -529,6 +706,65 @@ export default {
value: kb.kb_id
}))
},
batchScopeOptions() {
const options = [
{ label: this.tm('batchOperations.scopeSelected'), value: 'selected' },
{ label: this.tm('batchOperations.scopeAll'), value: 'all' },
{ label: this.tm('batchOperations.scopeGroup'), value: 'group' },
{ label: this.tm('batchOperations.scopePrivate'), value: 'private' },
]
//
if (this.groups.length > 0) {
options.push({ label: '── 自定义分组 ──', value: '_divider', disabled: true })
this.groups.forEach(g => {
options.push({ label: `📁 ${g.name} (${g.umo_count})`, value: `custom_group:${g.id}` })
})
}
return options
},
groupOptions() {
return this.groups.map(g => ({
label: `${g.name} (${g.umo_count} 个会话)`,
value: g.id
}))
},
statusOptions() {
return [
{ label: this.tm('status.enabled'), value: true },
{ label: this.tm('status.disabled'), value: false },
]
},
canApplyBatch() {
const hasChanges = this.batchLlmStatus !== null || this.batchTtsStatus !== null ||
this.batchChatProvider !== null || this.batchTtsProvider !== null
if (this.batchScope === 'selected') {
return hasChanges && this.selectedItems.length > 0
}
return hasChanges
},
// 穿UMO
unselectedUmos() {
const selected = new Set(this.editingGroup.umos || [])
return this.availableUmos.filter(u => !selected.has(u))
},
// 穿
filteredUnselectedUmos() {
if (!this.groupMemberSearch) return this.unselectedUmos
const search = this.groupMemberSearch.toLowerCase()
return this.unselectedUmos.filter(u => u.toLowerCase().includes(search))
},
// 穿
filteredSelectedUmos() {
if (!this.groupSelectedSearch) return this.editingGroup.umos || []
const search = this.groupSelectedSearch.toLowerCase()
return (this.editingGroup.umos || []).filter(u => u.toLowerCase().includes(search))
},
},
watch: {
@@ -547,6 +783,7 @@ export default {
mounted() {
this.loadData()
this.loadGroups()
},
beforeUnmount() {
@@ -1071,6 +1308,242 @@ export default {
}
this.saving = false
},
async applyBatchChanges() {
this.batchUpdating = true
try {
let scope = this.batchScope
let groupId = null
let umos = []
//
if (scope.startsWith('custom_group:')) {
groupId = scope.split(':')[1]
scope = 'custom_group'
}
if (scope === 'selected') {
umos = this.selectedItems.map(item => item.umo)
if (umos.length === 0) {
this.showError('请先选择要操作的会话')
this.batchUpdating = false
return
}
}
const tasks = []
if (this.batchLlmStatus !== null || this.batchTtsStatus !== null) {
const serviceData = { scope, umos, group_id: groupId }
if (this.batchLlmStatus !== null) {
serviceData.llm_enabled = this.batchLlmStatus
}
if (this.batchTtsStatus !== null) {
serviceData.tts_enabled = this.batchTtsStatus
}
tasks.push(axios.post('/api/session/batch-update-service', serviceData))
}
if (this.batchChatProvider !== null) {
tasks.push(axios.post('/api/session/batch-update-provider', {
scope,
umos,
group_id: groupId,
provider_type: 'chat_completion',
provider_id: this.batchChatProvider || null
}))
}
if (this.batchTtsProvider !== null) {
tasks.push(axios.post('/api/session/batch-update-provider', {
scope,
umos,
group_id: groupId,
provider_type: 'text_to_speech',
provider_id: this.batchTtsProvider || null
}))
}
if (tasks.length === 0) {
this.showError('请至少选择一项要修改的配置')
this.batchUpdating = false
return
}
const results = await Promise.all(tasks)
const allOk = results.every(r => r.data.status === 'ok')
if (allOk) {
this.showSuccess('批量更新成功')
this.batchLlmStatus = null
this.batchTtsStatus = null
this.batchChatProvider = null
this.batchTtsProvider = null
await this.loadData()
} else {
this.showError('部分更新失败')
}
} catch (error) {
this.showError(error.response?.data?.message || '批量更新失败')
}
this.batchUpdating = false
},
// ==================== ====================
async loadGroups() {
this.groupsLoading = true
try {
const response = await axios.get('/api/session/groups')
if (response.data.status === 'ok') {
this.groups = response.data.data.groups || []
}
} catch (error) {
console.error('加载分组失败:', error)
}
this.groupsLoading = false
},
async loadAvailableUmos() {
if (this.availableUmos.length > 0) return
this.loadingUmos = true
try {
const response = await axios.get('/api/session/active-umos')
if (response.data.status === 'ok') {
this.availableUmos = response.data.data.umos || []
}
} catch (error) {
console.error('加载会话列表失败:', error)
}
this.loadingUmos = false
},
openCreateGroupDialog() {
this.groupDialogMode = 'create'
this.editingGroup = { id: null, name: '', umos: [] }
this.groupMemberSearch = ''
this.groupSelectedSearch = ''
this.groupDialog = true
},
openEditGroupDialog(group) {
this.groupDialogMode = 'edit'
this.editingGroup = { ...group, umos: [...(group.umos || [])] }
this.groupMemberSearch = ''
this.groupSelectedSearch = ''
this.groupDialog = true
},
// 穿
addToGroup(umo) {
if (!this.editingGroup.umos.includes(umo)) {
this.editingGroup.umos.push(umo)
}
},
removeFromGroup(umo) {
const idx = this.editingGroup.umos.indexOf(umo)
if (idx > -1) {
this.editingGroup.umos.splice(idx, 1)
}
},
addAllToGroup() {
this.unselectedUmos.forEach(umo => {
if (!this.editingGroup.umos.includes(umo)) {
this.editingGroup.umos.push(umo)
}
})
},
removeAllFromGroup() {
this.editingGroup.umos = []
},
formatUmoShort(umo) {
// ::ID -> ID
const parts = umo.split(':')
if (parts.length >= 3) {
return `${parts[0]}:${parts[2]}`
}
return umo
},
async saveGroup() {
if (!this.editingGroup.name.trim()) {
this.showError('分组名称不能为空')
return
}
try {
let response
if (this.groupDialogMode === 'create') {
response = await axios.post('/api/session/group/create', {
name: this.editingGroup.name,
umos: this.editingGroup.umos
})
} else {
response = await axios.post('/api/session/group/update', {
id: this.editingGroup.id,
name: this.editingGroup.name,
umos: this.editingGroup.umos
})
}
if (response.data.status === 'ok') {
this.showSuccess(response.data.data.message)
this.groupDialog = false
await this.loadGroups()
} else {
this.showError(response.data.message)
}
} catch (error) {
this.showError(error.response?.data?.message || '保存分组失败')
}
},
async deleteGroup(group) {
if (!confirm(`确定要删除分组 "${group.name}" 吗?`)) return
try {
const response = await axios.post('/api/session/group/delete', { id: group.id })
if (response.data.status === 'ok') {
this.showSuccess(response.data.data.message)
await this.loadGroups()
} else {
this.showError(response.data.message)
}
} catch (error) {
this.showError(error.response?.data?.message || '删除分组失败')
}
},
openGroupMemberDialog(group) {
this.groupMemberTarget = { ...group }
this.groupMemberDialog = true
},
async addSelectedToGroup(groupId) {
if (this.selectedItems.length === 0) {
this.showError('请先选择要添加的会话')
return
}
try {
const response = await axios.post('/api/session/group/update', {
id: groupId,
add_umos: this.selectedItems.map(item => item.umo)
})
if (response.data.status === 'ok') {
this.showSuccess(`已添加 ${this.selectedItems.length} 个会话到分组`)
await this.loadGroups()
} else {
this.showError(response.data.message)
}
} catch (error) {
this.showError(error.response?.data?.message || '添加失败')
}
},
},
}
</script>
@@ -1087,4 +1560,20 @@ code {
border-radius: 4px;
font-size: 12px;
}
.transfer-list {
max-height: 280px;
overflow-y: auto;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 4px;
}
.transfer-item {
cursor: pointer;
transition: background-color 0.15s;
}
.transfer-item:hover {
background-color: rgba(0, 0, 0, 0.04);
}
</style>
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "4.11.4"
version = "4.12.2"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
requires-python = ">=3.10"