Compare commits

..

37 Commits

Author SHA1 Message Date
Soulter 71d4357ca7 chore: remove skills prompt 2026-01-15 16:08:58 +08:00
Soulter 97081bf543 feat: add sandbox mode prompt for enhanced user guidance in executing commands 2026-01-15 16:05:56 +08:00
Soulter 9c9239073e bugfixes 2026-01-15 14:29:46 +08:00
Soulter c78ac6acd7 feat: enhance iPython tool rendering with Shiki syntax highlighting 2026-01-15 14:01:43 +08:00
Soulter ac427af3c8 fix: ensure message stream order (#4487) 2026-01-15 13:11:49 +08:00
Soulter b7160c9c58 Merge remote-tracking branch 'origin/master' into feat/astrbot-agent-mode 2026-01-15 12:48:58 +08:00
Soulter 86715813ad project list 2026-01-15 12:46:22 +08:00
Soulter a363a2ddcd refactor: remove unused file tools and update PythonTool output handling 2026-01-15 01:50:48 +08:00
Soulter f2af8e58e2 fix: update description for command parameter in ExecuteShellTool 2026-01-15 00:58:34 +08:00
Soulter 937f0b7f32 fix: handle empty output case in PythonTool execution 2026-01-15 00:48:50 +08:00
Li-shi-ling c52ab1346b docs: standardize Context class documentation formatting (#4436)
* docs: standardize Context class documentation formatting

- Unified all method docstrings to standard format
- Fixed mixed language and formatting issues
- Added complete parameter and return descriptions
- Enhanced developer experience for plugin creators
- Fixes #4429

* docs: fix Context class documentation issues per review

- Restored Sphinx directives for versionadded notes
- Fixed MessageSesion typo to MessageSession throughout file
- Added clarification for kwargs propagation in tool_loop_agent
- Unified deprecation marker format
- Fixes #4429

* Convert developer API comments to English

* chore: revise comments

---------

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2026-01-14 23:56:18 +08:00
Soulter c8fca4e6a0 fix: title saving logic and update project sessions on changes 2026-01-14 23:56:18 +08:00
Soulter 45397e941d feat: chatui project (#4477)
* feat: chatui-project

* fix: remove console log from getProjects function
2026-01-14 23:56:18 +08:00
時壹 ce0a024757 fix: sending OpenAI-style image_url causes Anthropic 400 invalid tag error (#4444) 2026-01-14 23:56:18 +08:00
Soulter 792e348076 feat: add file download functionality and update shipyard SDK version 2026-01-13 21:28:56 +08:00
Soulter 068094708e feat: add shipyard session configuration options and update related tools 2026-01-13 20:32:31 +08:00
Soulter 661bcfd890 feat: add availability check for sandbox in Shipyard and base booters 2026-01-13 20:02:18 +08:00
Soulter 0a58eaecdd fix 2026-01-13 19:45:14 +08:00
Soulter d564926e6b Merge remote-tracking branch 'origin/master' into feat/astrbot-agent-mode 2026-01-13 19:40:09 +08:00
Soulter 7d1709667e docs: refine EULA 2026-01-13 19:39:13 +08:00
Soulter ebdecf8dce feat: implement localStorage persistence for showReservedPlugins state 2026-01-13 19:39:13 +08:00
Soulter 3698b771dd chore: makes world better 2026-01-13 19:39:13 +08:00
Soulter 12b4ee0a2b remove 2026-01-13 19:36:36 +08:00
Soulter 9de0fe304c uv lock 2026-01-13 19:35:58 +08:00
Soulter e5cac2684f beta 2026-01-13 19:35:12 +08:00
Soulter 1646547cb4 fix 2026-01-13 19:34:07 +08:00
Soulter dca88d8ab8 feat: sandbox 2026-01-13 19:29:22 +08:00
Soulter 2e2da4b4ce feat: implement singleton pattern for ShipyardSandboxClient and add FileUploadTool for file uploads 2026-01-13 18:26:39 +08:00
Soulter 6df966e9a2 fix: remove 'boxlite' option from booter and handle error in PythonTool execution 2026-01-13 16:38:04 +08:00
Soulter a89e7b3f55 update 2026-01-13 01:48:45 +08:00
Soulter ea7c387fcb fix 2026-01-12 22:22:35 +08:00
Soulter 350c18b741 Merge remote-tracking branch 'origin/master' into feat/astrbot-agent-mode 2026-01-12 22:19:26 +08:00
Soulter fdbed75ce4 Merge remote-tracking branch 'origin/master' into feat/astrbot-agent-mode 2025-10-29 15:56:54 +08:00
Soulter 9fec29c1a3 feat: file upload 2025-10-04 23:42:59 +08:00
Soulter 972b5ffb86 fix: update tool call logging to include tool call IDs and enhance sandbox ship creation parameters 2025-10-03 00:46:02 +08:00
Soulter 33e67bf925 Merge remote-tracking branch 'origin/master' into feat/astrbot-agent-mode 2025-10-03 00:45:30 +08:00
Soulter 185501d1b5 stage 2025-10-02 14:06:23 +08:00
24 changed files with 417 additions and 1886 deletions
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.12.1"
__version__ = "4.11.4"
@@ -227,8 +227,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
encrypted=llm_resp.reasoning_signature,
)
)
if llm_resp.completion_text:
parts.append(TextPart(text=llm_resp.completion_text))
parts.append(TextPart(text=llm_resp.completion_text or "*No response*"))
self.run_context.messages.append(Message(role="assistant", content=parts))
# call the on_agent_done hook
@@ -278,8 +277,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
encrypted=llm_resp.reasoning_signature,
)
)
if llm_resp.completion_text:
parts.append(TextPart(text=llm_resp.completion_text))
parts.append(TextPart(text=llm_resp.completion_text or "*No response*"))
tool_calls_result = ToolCallsResult(
tool_calls_info=AssistantMessageSegment(
tool_calls=llm_resp.to_openai_to_calls_model(),
@@ -363,7 +361,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content=f"error: Tool {func_tool_name} not found.",
content=f"error: 未找到工具 {func_tool_name}",
),
)
continue
@@ -429,7 +427,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content="The tool has successfully returned an image and sent directly to the user. You can describe it in your next response.",
content="返回了图片(已直接发送给用户)",
),
)
yield MessageChain(type="tool_direct_result").base64_image(
@@ -454,7 +452,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content="The tool has successfully returned an image and sent directly to the user. You can describe it in your next response.",
content="返回了图片(已直接发送给用户)",
),
)
yield MessageChain(
@@ -465,7 +463,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content="The tool has returned a data type that is not supported.",
content="返回的数据类型不受支持",
),
)
@@ -482,7 +480,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content="The tool has no return value, or has sent the result directly to the user.",
content="*工具没有返回值或者将结果直接发送给了用户*",
),
)
else:
@@ -494,7 +492,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content="*The tool has returned an unsupported type. Please tell the user to check the definition and implementation of this tool.*",
content="*工具返回了不支持的类型,请告诉用户检查这个工具的定义和实现。*",
),
)
+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.11.4"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -36,7 +36,6 @@ from .....astr_agent_tool_exec import FunctionToolExecutor
from ....context import PipelineContext, call_event_hook
from ...stage import Stage
from ...utils import (
CHATUI_EXTRA_PROMPT,
EXECUTE_SHELL_TOOL,
FILE_DOWNLOAD_TOOL,
FILE_UPLOAD_TOOL,
@@ -44,7 +43,6 @@ from ...utils import (
LLM_SAFETY_MODE_SYSTEM_PROMPT,
PYTHON_TOOL,
SANDBOX_MODE_PROMPT,
TOOL_CALL_PROMPT,
decoded_blocked,
retrieve_knowledge_base,
)
@@ -659,14 +657,6 @@ class InternalAgentSubStage(Stage):
if event.get_platform_name() == "webchat":
asyncio.create_task(self._handle_webchat(event, req, provider))
# 注入 ChatUI 额外 prompt
# 比如 follow-up questions 提示等
req.system_prompt += f"\n{CHATUI_EXTRA_PROMPT}\n"
# 注入基本 prompt
if req.func_tool and req.func_tool.tools:
req.system_prompt += f"\n{TOOL_CALL_PROMPT}\n"
await agent_runner.reset(
provider=provider,
request=req,
+3 -11
View File
@@ -36,17 +36,9 @@ SANDBOX_MODE_PROMPT = (
# "Use `cat /app/skills/{skill_name}/SKILL.md` to read the documentation of a specific skill."
# "SKILL.md might be large, you can read the description first, which is located in the YAML frontmatter of the file."
# "Use shell commands such as grep, sed, awk to extract relevant information from the documentation as needed.\n"
)
TOOL_CALL_PROMPT = (
"You MUST NOT return an empty response, especially after invoking a tool."
"Before calling any tool, provide a brief explanatory message to the user stating the purpose of the tool call."
"After the tool call is completed, you must briefly summarize the results returned by the tool for the user."
)
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?"
"Note:\n"
"1. If you use shell, your command will always runs in the /home/<username>/workspace directory.\n"
"2. If you use IPython, you would better use absolute paths when dealing with files to avoid confusion.\n"
)
+4 -23
View File
@@ -42,6 +42,8 @@ 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
@@ -49,12 +51,12 @@ class AstrMessageEvent(abc.ABC):
self.is_at_or_wake_command = False
"""是否是 At 机器人或者带有唤醒词或者是私聊(插件注册的事件监听器会让 is_wake 设为 True, 但是不会让这个属性置为 True)"""
self._extras: dict[str, Any] = {}
self.session = MessageSession(
self.session = MessageSesion(
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
"""消息事件的结果"""
@@ -70,27 +72,6 @@ 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 等)。
@@ -370,8 +370,6 @@ class DiscordPlatformAdapter(Platform):
for handler_md in star_handlers_registry:
if not star_map[handler_md.handler_module_path].activated:
continue
if not handler_md.enabled:
continue
for event_filter in handler_md.event_filters:
cmd_info = self._extract_command_info(event_filter, handler_md)
if not cmd_info:
@@ -161,8 +161,6 @@ class TelegramPlatformAdapter(Platform):
handler_metadata = handler_md
if not star_map[handler_metadata.handler_module_path].activated:
continue
if not handler_metadata.enabled:
continue
for event_filter in handler_metadata.event_filters:
cmd_info = self._extract_command_info(
event_filter,
@@ -35,14 +35,6 @@ 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
@@ -399,540 +391,3 @@ 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
@@ -1,19 +0,0 @@
## 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
@@ -1,23 +0,0 @@
## 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))
-47
View File
@@ -1,47 +0,0 @@
version: '3.8'
# 当接入 QQ NapCat 时,请使用这个 compose 文件一键部署: https://github.com/NapNeko/NapCat-Docker/blob/main/compose/astrbot.yml
services:
astrbot:
image: soulter/astrbot:latest
container_name: astrbot
restart: always
ports: # mappings description: https://github.com/AstrBotDevs/AstrBot/issues/497
- "6185:6185" # 必选,AstrBot WebUI 端口
- "6199:6199" # 可选, QQ 个人号 WebSocket 端口
environment:
- TZ=Asia/Shanghai
volumes:
- ${PWD}/data:/AstrBot/data
# - /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
networks:
- astrbot_network
shipyard:
image: soulter/shipyard-bay:latest
container_name: astrbot_shipyard
# ports:
# - "8156:8156"
environment:
- PORT=8156
- DATABASE_URL=sqlite+aiosqlite:///./data/bay.db
- ACCESS_TOKEN=secret-token
- MAX_SHIP_NUM=10
- BEHAVIOR_AFTER_MAX_SHIP=reject
- DOCKER_IMAGE=soulter/shipyard-ship:latest
- DOCKER_NETWORK=astrbot_network
- SHIP_DATA_DIR=${PWD}/data/shipyard/ship_mnt_data
- DEFAULT_SHIP_CPUS=1.0
- DEFAULT_SHIP_MEMORY=512m
volumes:
- ${PWD}/data/shipyard/bay_data:/app/data
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- astrbot_network
networks:
astrbot_network:
name: astrbot_network
driver: bridge
+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.13",
"stream-monaco": "^0.0.15",
"stream-markdown": "^0.0.11",
"stream-monaco": "^0.0.8",
"vee-validate": "4.11.3",
"vite-plugin-vuetify": "1.0.2",
"vue": "3.3.4",
+390 -45
View File
@@ -90,28 +90,103 @@
<template v-else>
<!-- Reasoning Block (Collapsible) - 放在最前面 -->
<ReasoningBlock v-if="msg.content.reasoning && msg.content.reasoning.trim()"
:reasoning="msg.content.reasoning" :is-dark="isDark"
:initial-expanded="isReasoningExpanded(index)" />
<div v-if="msg.content.reasoning && msg.content.reasoning.trim()"
class="reasoning-container" :class="{ 'is-dark': isDark }"
:style="isDark ? { backgroundColor: 'rgba(103, 58, 183, 0.08)' } : {}">
<div class="reasoning-header" :class="{ 'is-dark': isDark }"
@click="toggleReasoning(index)">
<v-icon size="small" class="reasoning-icon">
{{ isReasoningExpanded(index) ? 'mdi-chevron-down' : 'mdi-chevron-right' }}
</v-icon>
<span class="reasoning-label">{{ tm('reasoning.thinking') }}</span>
</div>
<div v-if="isReasoningExpanded(index)" class="reasoning-content">
<MarkdownRender :content="msg.content.reasoning"
class="reasoning-text markdown-content" :typewriter="false"
:style="isDark ? { opacity: '0.85' } : {}" :is-dark="isDark" />
</div>
</div>
<!-- 遍历 message parts (保持顺序) -->
<template v-for="(part, partIndex) in msg.content.message" :key="partIndex">
<!-- iPython Tool Special Block -->
<template v-if="part.type === 'tool_call' && part.tool_calls && part.tool_calls.length > 0">
<template v-for="(toolCall, tcIndex) in part.tool_calls" :key="toolCall.id">
<IPythonToolBlock v-if="isIPythonTool(toolCall)" :tool-call="toolCall" style="margin: 8px 0;"
:is-dark="isDark"
:initial-expanded="isIPythonToolExpanded(index, partIndex, tcIndex)" />
</template>
</template>
<!-- Regular Tool Calls Block (for non-iPython tools) -->
<div v-if="part.type === 'tool_call' && part.tool_calls && part.tool_calls.some(tc => !isIPythonTool(tc))"
class="flex flex-col gap-2">
<div class="font-medium opacity-70" style="font-size: 13px; margin-bottom: 16px;">{{ tm('actions.toolsUsed') }}</div>
<ToolCallCard v-for="(toolCall, tcIndex) in part.tool_calls.filter(tc => !isIPythonTool(tc))"
:key="toolCall.id" :tool-call="toolCall" :is-dark="isDark"
:initial-expanded="isToolCallExpanded(index, partIndex, tcIndex)" />
<!-- Tool Calls Block -->
<div v-if="part.type === 'tool_call' && part.tool_calls && part.tool_calls.length > 0"
class="tool-calls-container">
<div class="tool-calls-label">{{ tm('actions.toolsUsed') }}</div>
<div v-for="(toolCall, tcIndex) in part.tool_calls" :key="toolCall.id"
class="tool-call-card" :class="{ 'is-dark': isDark, 'expanded': isToolCallExpanded(index, partIndex, tcIndex) }" :style="isDark ? {
backgroundColor: 'rgba(40, 60, 100, 0.4)',
borderColor: 'rgba(100, 140, 200, 0.4)'
} : {}">
<div class="tool-call-header" :class="{ 'is-dark': isDark }"
@click="toggleToolCall(index, partIndex, tcIndex)">
<v-icon size="small" class="tool-call-expand-icon">
{{ isToolCallExpanded(index, partIndex, tcIndex) ?
'mdi-chevron-down' : 'mdi-chevron-right' }}
</v-icon>
<v-icon size="small" class="tool-call-icon">mdi-wrench-outline</v-icon>
<div class="tool-call-info">
<span class="tool-call-name">{{ toolCall.name }}</span>
</div>
<span class="tool-call-status"
:class="{ 'status-running': !toolCall.finished_ts, 'status-finished': toolCall.finished_ts }">
<template v-if="toolCall.finished_ts">
<v-icon size="x-small"
class="status-icon">mdi-check-circle</v-icon>
{{ formatDuration(toolCall.finished_ts - toolCall.ts) }}
</template>
<template v-else>
<v-icon size="x-small"
class="status-icon spinning">mdi-loading</v-icon>
{{ getElapsedTime(toolCall.ts) }}
</template>
</span>
</div>
<div v-if="isToolCallExpanded(index, partIndex, tcIndex)"
class="tool-call-details" :style="isDark ? {
borderTopColor: 'rgba(100, 140, 200, 0.3)',
backgroundColor: 'rgba(30, 45, 70, 0.5)'
} : {}">
<!-- Special rendering for iPython tool -->
<template v-if="isIPythonTool(toolCall)">
<div class="ipython-code-container">
<!-- <div class="detail-label ipython-label">Code:</div> -->
<div v-if="shikiReady && getIPythonCode(toolCall)"
class="ipython-code-highlighted"
v-html="highlightIPythonCode(getIPythonCode(toolCall))"></div>
<pre v-else class="detail-value detail-json"
:style="isDark ? { backgroundColor: 'transparent' } : {}">{{ getIPythonCode(toolCall) || 'No code available' }}</pre>
</div>
<div v-if="toolCall.result" class="tool-call-detail-row">
<span class="detail-label">Result:</span>
<pre class="detail-value detail-json detail-result"
:style="isDark ? { backgroundColor: 'transparent' } : {}">{{ formatToolResult(toolCall.result) }}</pre>
</div>
</template>
<!-- Default rendering for other tools -->
<template v-else>
<div class="tool-call-detail-row">
<span class="detail-label">ID:</span>
<code class="detail-value"
:style="isDark ? { backgroundColor: 'transparent' } : {}">{{ toolCall.id
}}</code>
</div>
<div class="tool-call-detail-row">
<span class="detail-label">Args:</span>
<pre class="detail-value detail-json"
:style="isDark ? { backgroundColor: 'transparent' } : {}">{{
JSON.stringify(toolCall.args, null, 2) }}</pre>
</div>
<div v-if="toolCall.result" class="tool-call-detail-row">
<span class="detail-label">Result:</span>
<pre class="detail-value detail-json detail-result"
:style="isDark ? { backgroundColor: 'transparent' } : {}">{{ formatToolResult(toolCall.result) }}
</pre>
</div>
</template>
</div>
</div>
</div>
<!-- Text (Markdown) -->
@@ -250,9 +325,7 @@ import 'markstream-vue/index.css'
import 'katex/dist/katex.min.css'
import 'highlight.js/styles/github.css';
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 { createHighlighter } from 'shiki';
enableKatex();
enableMermaid();
@@ -260,10 +333,7 @@ enableMermaid();
export default {
name: 'MessageList',
components: {
MarkdownRender,
ReasoningBlock,
IPythonToolBlock,
ToolCallCard
MarkdownRender
},
props: {
messages: {
@@ -302,7 +372,6 @@ export default {
expandedReasoning: new Set(), // Track which reasoning blocks are expanded
downloadingFiles: new Set(), // Track which files are being downloaded
expandedToolCalls: new Set(), // Track which tool call cards are expanded
expandedIPythonTools: new Set(), // Track which iPython tools are expanded
elapsedTimeTimer: null, // Timer for updating elapsed time
currentTime: Date.now() / 1000, // Current time for elapsed time calculation
// 选中文本相关状态
@@ -315,7 +384,10 @@ export default {
imagePreview: {
show: false,
url: ''
}
},
// Shiki highlighter
shikiHighlighter: null,
shikiReady: false
};
},
async mounted() {
@@ -324,6 +396,7 @@ export default {
this.addScrollListener();
this.scrollToBottom();
this.startElapsedTimeTimer();
await this.initShiki();
},
updated() {
this.initCodeCopyButtons();
@@ -472,23 +545,6 @@ export default {
return this.expandedReasoning.has(messageIndex);
},
// Toggle iPython tool expansion state
toggleIPythonTool(messageIndex, partIndex, toolCallIndex) {
const key = `${messageIndex}-${partIndex}-${toolCallIndex}`;
if (this.expandedIPythonTools.has(key)) {
this.expandedIPythonTools.delete(key);
} else {
this.expandedIPythonTools.add(key);
}
// Force reactivity
this.expandedIPythonTools = new Set(this.expandedIPythonTools);
},
// Check if iPython tool is expanded
isIPythonToolExpanded(messageIndex, partIndex, toolCallIndex) {
return this.expandedIPythonTools.has(`${messageIndex}-${partIndex}-${toolCallIndex}`);
},
// 下载文件
async downloadFile(file) {
if (!file.attachment_id) return;
@@ -874,9 +930,50 @@ export default {
}, 300);
},
// Initialize Shiki highlighter
async initShiki() {
try {
this.shikiHighlighter = await createHighlighter({
themes: ['nord', 'github-light'],
langs: ['python']
});
this.shikiReady = true;
} catch (err) {
console.error('Failed to initialize Shiki:', err);
}
},
// Check if tool is iPython executor
isIPythonTool(toolCall) {
return toolCall.name === 'astrbot_execute_ipython';
},
// Get iPython code from tool args
getIPythonCode(toolCall) {
try {
if (toolCall.args && toolCall.args.code) {
return toolCall.args.code;
}
} catch (err) {
console.error('Failed to get iPython code:', err);
}
return null;
},
// Highlight iPython code with Shiki
highlightIPythonCode(code) {
if (!this.shikiReady || !this.shikiHighlighter || !code) {
return '';
}
try {
return this.shikiHighlighter.codeToHtml(code, {
lang: 'python',
theme: this.isDark ? 'nord' : 'github-light'
});
} catch (err) {
console.error('Failed to highlight code:', err);
return `<pre><code>${code}</code></pre>`;
}
}
}
}
@@ -1346,6 +1443,167 @@ export default {
animation: fadeIn 0.3s ease-in-out;
}
/* Reasoning 区块样式 */
.reasoning-container {
margin-bottom: 12px;
margin-top: 6px;
border: 1px solid var(--v-theme-border);
border-radius: 20px;
overflow: hidden;
width: fit-content;
}
.reasoning-header {
display: inline-flex;
align-items: center;
padding: 8px 8px;
cursor: pointer;
user-select: none;
transition: background-color 0.2s ease;
border-radius: 20px;
}
.reasoning-header:hover {
background-color: rgba(103, 58, 183, 0.08);
}
.reasoning-header.is-dark:hover {
background-color: rgba(103, 58, 183, 0.15);
}
.reasoning-icon {
margin-right: 6px;
color: var(--v-theme-secondary);
transition: transform 0.2s ease;
}
.reasoning-label {
font-size: 13px;
font-weight: 500;
color: var(--v-theme-secondary);
letter-spacing: 0.3px;
}
.reasoning-content {
padding: 0px 12px;
border-top: 1px solid var(--v-theme-border);
color: gray;
animation: fadeIn 0.2s ease-in-out;
font-style: italic;
}
.reasoning-text {
font-size: 14px;
line-height: 1.6;
color: var(--v-theme-secondaryText);
}
/* Tool Call Card Styles */
.tool-calls-container {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
margin-top: 6px;
}
.tool-calls-label {
font-size: 13px;
font-weight: 500;
color: var(--v-theme-secondaryText);
opacity: 0.7;
margin-bottom: 4px;
}
.tool-call-card {
border-radius: 8px;
overflow: hidden;
background-color: #eff3f6;
margin: 8px 0px;
width: fit-content;
min-width: 320px;
max-width: 100%;
transition: all 0.1s ease;
}
.tool-call-card.expanded {
width: 100%;
}
.tool-call-header {
display: flex;
align-items: center;
padding: 10px 12px;
cursor: pointer;
user-select: none;
transition: background-color 0.2s ease;
gap: 8px;
}
.tool-call-header:hover {
background-color: rgba(169, 194, 219, 0.15);
}
.tool-call-header.is-dark:hover {
background-color: rgba(100, 150, 200, 0.2);
}
.tool-call-expand-icon {
color: var(--v-theme-secondary);
transition: transform 0.2s ease;
flex-shrink: 0;
}
.tool-call-icon {
color: var(--v-theme-secondary);
flex-shrink: 0;
}
.tool-call-info {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.tool-call-name {
font-size: 13px;
font-weight: 600;
color: var(--v-theme-secondary);
}
.tool-call-id {
font-size: 11px;
color: var(--v-theme-secondaryText);
opacity: 0.7;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tool-call-status {
margin-left: 8px;
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 500;
flex-shrink: 0;
}
.tool-call-status.status-running {
color: #ff9800;
}
.tool-call-status.status-finished {
color: #4caf50;
}
.tool-call-status .status-icon {
font-size: 14px;
}
/* 浮动引用按钮样式 */
.selection-quote-button {
position: fixed;
@@ -1356,6 +1614,7 @@ export default {
pointer-events: all;
}
.quote-btn {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
font-size: 14px;
@@ -1375,8 +1634,94 @@ export default {
color: #ffffff !important;
}
.tool-call-status .status-icon.spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.tool-call-details {
padding: 12px;
background-color: rgba(255, 255, 255, 0.5);
animation: fadeIn 0.2s ease-in-out;
}
.tool-call-detail-row {
display: flex;
flex-direction: column;
margin-bottom: 8px;
}
.tool-call-detail-row:last-child {
margin-bottom: 0;
}
.detail-label {
font-size: 11px;
font-weight: 600;
color: var(--v-theme-secondaryText);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.detail-value {
font-size: 12px;
color: var(--v-theme-primaryText);
background-color: transparent;
padding: 4px 8px;
border-radius: 4px;
word-break: break-all;
}
.detail-json {
font-family: 'Fira Code', 'Consolas', monospace;
white-space: pre-wrap;
max-height: 200px;
overflow-y: auto;
margin: 0;
}
.detail-result {
max-height: 300px;
background-color: transparent;
}
/* iPython Tool Special Styles */
.ipython-code-container {
margin-bottom: 12px;
}
.ipython-label {
margin-bottom: 8px;
}
.ipython-code-highlighted {
border-radius: 6px;
overflow-x: auto;
font-size: 13px;
line-height: 1.5;
}
.ipython-code-highlighted :deep(pre) {
margin: 0;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
}
.ipython-code-highlighted :deep(code) {
font-family: 'Fira Code', 'Consolas', monospace;
font-size: 13px;
}
</style>
<style>
@@ -1,220 +0,0 @@
<template>
<div class="mb-3 mt-1.5">
<div class="ipython-header" :class="{ 'expanded': isExpanded }" @click="toggleExpanded">
<span class="ipython-label">
{{ tm('actions.pythonCodeAnalysis') }}
</span>
<v-icon size="small" class="ipython-icon" :class="{ 'rotated': isExpanded }">
mdi-chevron-right
</v-icon>
</div>
<div v-if="isExpanded" class="py-3 animate-fade-in">
<!-- Code Section -->
<div class="code-section">
<div v-if="shikiReady && code" class="code-highlighted"
v-html="highlightedCode"></div>
<pre v-else class="code-fallback"
:class="{ 'dark-theme': isDark }">{{ code || 'No code available' }}</pre>
</div>
<!-- Result Section -->
<div v-if="result" class="result-section">
<div class="result-label">
{{ tm('ipython.output') }}:
</div>
<pre class="result-content"
:class="{ 'dark-theme': isDark }">{{ formattedResult }}</pre>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import { createHighlighter } from 'shiki';
const props = defineProps({
toolCall: {
type: Object,
required: true
},
isDark: {
type: Boolean,
default: false
},
initialExpanded: {
type: Boolean,
default: false
}
});
const { tm } = useModuleI18n('features/chat');
const isExpanded = ref(props.initialExpanded);
const shikiHighlighter = ref(null);
const shikiReady = ref(false);
const code = computed(() => {
try {
if (props.toolCall.args && props.toolCall.args.code) {
return props.toolCall.args.code;
}
} catch (err) {
console.error('Failed to get iPython code:', err);
}
return null;
});
const result = computed(() => props.toolCall.result);
const formattedResult = computed(() => {
if (!result.value) return '';
try {
const parsed = JSON.parse(result.value);
return JSON.stringify(parsed, null, 2);
} catch {
return result.value;
}
});
const highlightedCode = computed(() => {
if (!shikiReady.value || !shikiHighlighter.value || !code.value) {
return '';
}
try {
return shikiHighlighter.value.codeToHtml(code.value, {
lang: 'python',
theme: props.isDark ? 'min-dark' : 'github-light'
});
} catch (err) {
console.error('Failed to highlight code:', err);
return `<pre><code>${code.value}</code></pre>`;
}
});
const toggleExpanded = () => {
isExpanded.value = !isExpanded.value;
};
onMounted(async () => {
try {
shikiHighlighter.value = await createHighlighter({
themes: ['min-dark', 'github-light'],
langs: ['python']
});
shikiReady.value = true;
} catch (err) {
console.error('Failed to initialize Shiki:', err);
}
});
</script>
<style scoped>
.mb-3 {
margin-bottom: 12px;
}
.mt-1\.5 {
margin-top: 6px;
}
.ipython-header {
display: inline-flex;
align-items: center;
cursor: pointer;
user-select: none;
border-radius: 20px;
opacity: 0.7;
transition: opacity;
}
.ipython-header:hover,
.ipython-header.expanded {
opacity: 1;
}
.ipython-label {
font-size: 16px;
}
.ipython-icon {
margin-left: 6px;
transition: transform 0.2s ease;
}
.ipython-icon.rotated {
transform: rotate(90deg);
}
.py-3 {
padding-top: 12px;
padding-bottom: 12px;
}
.code-section {
margin-bottom: 12px;
}
.code-highlighted {
border-radius: 6px;
overflow: hidden;
font-size: 14px;
line-height: 1.5;
}
.code-fallback {
margin: 0;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
font-size: 13px;
line-height: 1.5;
background-color: #f5f5f5;
}
.code-fallback.dark-theme {
background-color: transparent;
}
.result-section {
margin-top: 12px;
}
.result-label {
font-size: 12px;
font-weight: 600;
color: var(--v-theme-secondaryText);
margin-bottom: 6px;
opacity: 0.8;
}
.result-content {
margin: 0;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
font-size: 13px;
line-height: 1.5;
background-color: #f5f5f5;
max-height: 300px;
overflow-y: auto;
}
.result-content.dark-theme {
background-color: transparent;
}
.animate-fade-in {
animation: fadeIn 0.2s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>
@@ -1,73 +0,0 @@
<template>
<div class="mb-3 mt-1.5 border border-gray-200 dark:border-gray-700 rounded-2xl overflow-hidden w-fit"
:class="{ 'dark:bg-purple-900/8': isDark, 'bg-purple-50/50': !isDark }">
<div class="inline-flex items-center px-2 py-2 cursor-pointer select-none rounded-2xl transition-colors hover:bg-purple-50/80 dark:hover:bg-purple-900/15"
@click="toggleExpanded">
<v-icon size="small" class="mr-1.5 text-purple-600 dark:text-purple-400 transition-transform"
:class="{ 'rotate-90': isExpanded }">
mdi-chevron-right
</v-icon>
<span class="text-sm font-medium text-purple-600 dark:text-purple-400 tracking-wide">
{{ tm('reasoning.thinking') }}
</span>
</div>
<div v-if="isExpanded" class="px-3 border-t border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 animate-fade-in italic">
<MarkdownRender :content="reasoning" class="reasoning-text markdown-content text-sm leading-relaxed"
:typewriter="false" :is-dark="isDark" :style="isDark ? { opacity: '0.85' } : {}" />
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useModuleI18n } from '@/i18n/composables';
import { MarkdownRender } from 'markstream-vue';
const props = defineProps({
reasoning: {
type: String,
required: true
},
isDark: {
type: Boolean,
default: false
},
initialExpanded: {
type: Boolean,
default: false
}
});
const { tm } = useModuleI18n('features/chat');
const isExpanded = ref(props.initialExpanded);
const toggleExpanded = () => {
isExpanded.value = !isExpanded.value;
};
</script>
<style scoped>
.animate-fade-in {
animation: fadeIn 0.2s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.rotate-90 {
transform: rotate(90deg);
}
.reasoning-text {
font-size: 14px;
line-height: 1.6;
color: var(--v-theme-secondaryText);
}
</style>
@@ -1,290 +0,0 @@
<template>
<div class="tool-call-card" :class="{ 'is-dark': isDark, 'expanded': isExpanded }" :style="isDark ? {
backgroundColor: 'rgba(40, 60, 100, 0.4)',
borderColor: 'rgba(100, 140, 200, 0.4)'
} : {}">
<!-- Header -->
<div class="tool-call-header" :class="{ 'is-dark': isDark }" @click="toggleExpanded">
<v-icon size="small" class="tool-call-expand-icon" :class="{ 'expanded': isExpanded }">
mdi-chevron-right
</v-icon>
<v-icon size="small" class="tool-call-icon">mdi-wrench-outline</v-icon>
<div class="tool-call-info">
<span class="tool-call-name">{{ toolCall.name }}</span>
</div>
<span class="tool-call-status"
:class="{ 'status-running': !toolCall.finished_ts, 'status-finished': toolCall.finished_ts }">
<template v-if="toolCall.finished_ts">
<v-icon size="x-small" class="status-icon">mdi-check-circle</v-icon>
{{ formatDuration(toolCall.finished_ts - toolCall.ts) }}
</template>
<template v-else>
<v-icon size="x-small" class="status-icon spinning">mdi-loading</v-icon>
{{ elapsedTime }}
</template>
</span>
</div>
<!-- Details -->
<div v-if="isExpanded" class="tool-call-details" :style="isDark ? {
borderTopColor: 'rgba(100, 140, 200, 0.3)',
backgroundColor: 'rgba(30, 45, 70, 0.5)'
} : {}">
<!-- ID -->
<div class="tool-call-detail-row">
<span class="detail-label">ID:</span>
<code class="detail-value" :style="isDark ? { backgroundColor: 'transparent' } : {}">
{{ toolCall.id }}
</code>
</div>
<!-- Args -->
<div class="tool-call-detail-row">
<span class="detail-label">Args:</span>
<pre class="detail-value detail-json" :style="isDark ? { backgroundColor: 'transparent' } : {}">{{
JSON.stringify(toolCall.args, null, 2) }}</pre>
</div>
<!-- Result -->
<div v-if="toolCall.result" class="tool-call-detail-row">
<span class="detail-label">Result:</span>
<pre class="detail-value detail-json detail-result"
:style="isDark ? { backgroundColor: 'transparent' } : {}">{{
formattedResult }}</pre>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
const props = defineProps({
toolCall: {
type: Object,
required: true
},
isDark: {
type: Boolean,
default: false
},
initialExpanded: {
type: Boolean,
default: false
}
});
const isExpanded = ref(props.initialExpanded);
const currentTime = ref(Date.now() / 1000);
let timer = null;
const elapsedTime = computed(() => {
if (props.toolCall.finished_ts) return '';
const elapsed = currentTime.value - props.toolCall.ts;
return formatDuration(elapsed);
});
const formattedResult = computed(() => {
if (!props.toolCall.result) return '';
try {
const parsed = JSON.parse(props.toolCall.result);
return JSON.stringify(parsed, null, 2);
} catch {
return props.toolCall.result;
}
});
const formatDuration = (seconds) => {
if (seconds < 1) {
return `${Math.round(seconds * 1000)}ms`;
} else if (seconds < 60) {
return `${seconds.toFixed(1)}s`;
} else {
const minutes = Math.floor(seconds / 60);
const secs = Math.round(seconds % 60);
return `${minutes}m ${secs}s`;
}
};
const toggleExpanded = () => {
isExpanded.value = !isExpanded.value;
};
const updateTime = () => {
currentTime.value = Date.now() / 1000;
};
onMounted(() => {
// Update time periodically if tool call is running
if (!props.toolCall.finished_ts) {
timer = setInterval(updateTime, 100);
}
});
onUnmounted(() => {
if (timer) {
clearInterval(timer);
}
});
</script>
<style scoped>
.tool-call-card {
border-radius: 8px;
overflow: hidden;
background-color: #eff3f6;
margin: 8px 0px;
width: fit-content;
min-width: 320px;
max-width: 100%;
transition: all 0.1s ease;
}
.tool-call-card.expanded {
width: 100%;
}
.tool-call-header {
display: flex;
align-items: center;
padding: 10px 12px;
cursor: pointer;
user-select: none;
transition: background-color 0.2s ease;
gap: 8px;
}
.tool-call-header:hover {
background-color: rgba(169, 194, 219, 0.15);
}
.tool-call-header.is-dark:hover {
background-color: rgba(100, 150, 200, 0.2);
}
.tool-call-expand-icon {
color: var(--v-theme-secondary);
transition: transform 0.2s ease;
flex-shrink: 0;
}
.tool-call-expand-icon.expanded {
transform: rotate(90deg);
}
.tool-call-icon {
color: var(--v-theme-secondary);
flex-shrink: 0;
}
.tool-call-info {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.tool-call-name {
font-size: 13px;
font-weight: 600;
color: var(--v-theme-secondary);
}
.tool-call-status {
margin-left: 8px;
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 500;
flex-shrink: 0;
}
.tool-call-status.status-running {
color: #ff9800;
}
.tool-call-status.status-finished {
color: #4caf50;
}
.tool-call-status .status-icon {
font-size: 14px;
}
.tool-call-status .status-icon.spinning {
animation: spin 1s linear infinite;
}
.tool-call-details {
padding: 12px;
background-color: rgba(255, 255, 255, 0.5);
animation: fadeIn 0.2s ease-in-out;
}
.tool-call-detail-row {
display: flex;
flex-direction: column;
margin-bottom: 8px;
}
.tool-call-detail-row:last-child {
margin-bottom: 0;
}
.detail-label {
font-size: 11px;
font-weight: 600;
color: var(--v-theme-secondaryText);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.detail-value {
font-size: 12px;
color: var(--v-theme-primaryText);
background-color: transparent;
padding: 4px 8px;
border-radius: 4px;
word-break: break-all;
}
.detail-json {
font-family: 'Fira Code', 'Consolas', monospace;
white-space: pre-wrap;
max-height: 200px;
overflow-y: auto;
margin: 0;
}
.detail-result {
max-height: 300px;
background-color: transparent;
}
.animate-fade-in {
animation: fadeIn 0.2s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
@@ -43,11 +43,7 @@
"exitFullscreen": "Exit Fullscreen",
"reply": "Reply",
"providerConfig": "AI Configuration",
"toolsUsed": "Tool Used",
"pythonCodeAnalysis": "Python Code Analysis Used"
},
"ipython": {
"output": "Output"
"toolsUsed": "Tool Used"
},
"conversation": {
"newConversation": "New Conversation",
@@ -1,4 +1,4 @@
{
{
"title": "Custom Rules",
"subtitle": "Set custom rules for specific sessions, which take priority over global settings",
"buttons": {
@@ -93,42 +93,6 @@
"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",
@@ -141,8 +105,6 @@
"deleteError": "Failed to delete",
"noChanges": "No changes to save",
"batchDeleteSuccess": "Batch delete successful",
"batchDeleteError": "Batch delete failed",
"batchUpdateError": "Batch update failed",
"batchUpdateSuccess": "Batch update success"
"batchDeleteError": "Batch delete failed"
}
}
@@ -43,11 +43,7 @@
"exitFullscreen": "退出全屏",
"reply": "引用回复",
"providerConfig": "AI 配置",
"toolsUsed": "已使用工具",
"pythonCodeAnalysis": "已使用 Python 代码分析"
},
"ipython": {
"output": "输出"
"toolsUsed": "已使用工具"
},
"conversation": {
"newConversation": "新的聊天",
@@ -1,4 +1,4 @@
{
{
"title": "自定义规则",
"subtitle": "为特定会话设置自定义规则,优先级高于全局配置",
"buttons": {
@@ -94,24 +94,6 @@
"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": "加载数据失败",
+1 -490
View File
@@ -1,4 +1,4 @@
<template>
<template>
<div class="session-management-page">
<v-container fluid class="pa-0">
<v-card flat>
@@ -111,160 +111,6 @@
</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">
@@ -608,29 +454,6 @@ 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,
@@ -706,65 +529,6 @@ 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: {
@@ -783,7 +547,6 @@ export default {
mounted() {
this.loadData()
this.loadGroups()
},
beforeUnmount() {
@@ -1308,242 +1071,6 @@ 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>
@@ -1560,20 +1087,4 @@ 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.12.1"
version = "4.11.4"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
requires-python = ">=3.10"
+1 -2
View File
@@ -52,5 +52,4 @@ rank-bm25>=0.2.2
jieba>=0.42.1
markitdown-no-magika[docx,xls,xlsx]>=0.1.2
xinference-client
tenacity>=9.1.2
shipyard-python-sdk>=0.2.4
tenacity>=9.1.2