mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-08 05:50:12 +08:00
Compare commits
11 Commits
refactor--
...
fix/remove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d00309d70 | ||
|
|
533a0bde6a | ||
|
|
35ce281cbe | ||
|
|
80c7ebae8a | ||
|
|
5f0178bc73 | ||
|
|
6131386893 | ||
|
|
3b2435875c | ||
|
|
2a229c4beb | ||
|
|
d1913b5950 | ||
|
|
7172281436 | ||
|
|
bd08273640 |
@@ -1,9 +1,12 @@
|
||||
from astrbot.api import sp, star
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.agent.runners.deerflow.constants import (
|
||||
DEERFLOW_AGENT_RUNNER_PROVIDER_ID_KEY,
|
||||
DEERFLOW_PROVIDER_TYPE,
|
||||
DEERFLOW_THREAD_ID_KEY,
|
||||
)
|
||||
from astrbot.core.agent.runners.deerflow.deerflow_api_client import DeerFlowAPIClient
|
||||
from astrbot.core.utils.active_event_registry import active_event_registry
|
||||
|
||||
from .utils.rst_scene import RstScene
|
||||
@@ -17,6 +20,85 @@ THIRD_PARTY_AGENT_RUNNER_KEY = {
|
||||
THIRD_PARTY_AGENT_RUNNER_STR = ", ".join(THIRD_PARTY_AGENT_RUNNER_KEY.keys())
|
||||
|
||||
|
||||
async def _cleanup_deerflow_thread_if_present(
|
||||
context: star.Context,
|
||||
umo: str,
|
||||
) -> None:
|
||||
try:
|
||||
thread_id = await sp.get_async(
|
||||
scope="umo",
|
||||
scope_id=umo,
|
||||
key=DEERFLOW_THREAD_ID_KEY,
|
||||
default="",
|
||||
)
|
||||
if not thread_id:
|
||||
return
|
||||
|
||||
cfg = context.get_config(umo=umo)
|
||||
provider_id = cfg["provider_settings"].get(
|
||||
DEERFLOW_AGENT_RUNNER_PROVIDER_ID_KEY,
|
||||
"",
|
||||
)
|
||||
if not provider_id:
|
||||
return
|
||||
|
||||
merged_provider_config = context.provider_manager.get_provider_config_by_id(
|
||||
provider_id,
|
||||
merged=True,
|
||||
)
|
||||
if not merged_provider_config:
|
||||
logger.warning(
|
||||
"Failed to resolve DeerFlow provider config for remote thread cleanup: provider_id=%s",
|
||||
provider_id,
|
||||
)
|
||||
return
|
||||
|
||||
client = DeerFlowAPIClient(
|
||||
api_base=merged_provider_config.get(
|
||||
"deerflow_api_base",
|
||||
"http://127.0.0.1:2026",
|
||||
),
|
||||
api_key=merged_provider_config.get("deerflow_api_key", ""),
|
||||
auth_header=merged_provider_config.get("deerflow_auth_header", ""),
|
||||
proxy=merged_provider_config.get("proxy", ""),
|
||||
)
|
||||
try:
|
||||
await client.delete_thread(thread_id)
|
||||
finally:
|
||||
try:
|
||||
await client.close()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to close DeerFlow API client after thread cleanup: %s",
|
||||
e,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to clean up DeerFlow thread for session %s: %s",
|
||||
umo,
|
||||
e,
|
||||
)
|
||||
|
||||
|
||||
async def _clear_third_party_agent_runner_state(
|
||||
context: star.Context,
|
||||
umo: str,
|
||||
agent_runner_type: str,
|
||||
) -> None:
|
||||
session_key = THIRD_PARTY_AGENT_RUNNER_KEY.get(agent_runner_type)
|
||||
if not session_key:
|
||||
return
|
||||
|
||||
if agent_runner_type == DEERFLOW_PROVIDER_TYPE:
|
||||
await _cleanup_deerflow_thread_if_present(context, umo)
|
||||
|
||||
await sp.remove_async(
|
||||
scope="umo",
|
||||
scope_id=umo,
|
||||
key=session_key,
|
||||
)
|
||||
|
||||
|
||||
class ConversationCommands:
|
||||
def __init__(self, context: star.Context) -> None:
|
||||
self.context = context
|
||||
@@ -65,10 +147,10 @@ class ConversationCommands:
|
||||
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
||||
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
||||
active_event_registry.stop_all(umo, exclude=message)
|
||||
await sp.remove_async(
|
||||
scope="umo",
|
||||
scope_id=umo,
|
||||
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
|
||||
await _clear_third_party_agent_runner_state(
|
||||
self.context,
|
||||
umo,
|
||||
agent_runner_type,
|
||||
)
|
||||
message.set_result(
|
||||
MessageEventResult().message("✅ Conversation reset successfully.")
|
||||
@@ -139,10 +221,10 @@ class ConversationCommands:
|
||||
agent_runner_type = cfg["provider_settings"]["agent_runner_type"]
|
||||
if agent_runner_type in THIRD_PARTY_AGENT_RUNNER_KEY:
|
||||
active_event_registry.stop_all(message.unified_msg_origin, exclude=message)
|
||||
await sp.remove_async(
|
||||
scope="umo",
|
||||
scope_id=message.unified_msg_origin,
|
||||
key=THIRD_PARTY_AGENT_RUNNER_KEY[agent_runner_type],
|
||||
await _clear_third_party_agent_runner_state(
|
||||
self.context,
|
||||
message.unified_msg_origin,
|
||||
agent_runner_type,
|
||||
)
|
||||
message.set_result(
|
||||
MessageEventResult().message("✅ New conversation created.")
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.23.0-beta.1"
|
||||
__version__ = "4.23.0"
|
||||
|
||||
@@ -410,18 +410,20 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]):
|
||||
)
|
||||
return messages
|
||||
|
||||
def _build_runtime_context(self, thread_id: str) -> dict[str, T.Any]:
|
||||
runtime_context: dict[str, T.Any] = {
|
||||
def _build_runtime_configurable(self, thread_id: str) -> dict[str, T.Any]:
|
||||
runtime_configurable: dict[str, T.Any] = {
|
||||
"thread_id": thread_id,
|
||||
"thinking_enabled": self.thinking_enabled,
|
||||
"is_plan_mode": self.plan_mode,
|
||||
"subagent_enabled": self.subagent_enabled,
|
||||
}
|
||||
if self.subagent_enabled:
|
||||
runtime_context["max_concurrent_subagents"] = self.max_concurrent_subagents
|
||||
runtime_configurable["max_concurrent_subagents"] = (
|
||||
self.max_concurrent_subagents
|
||||
)
|
||||
if self.model_name:
|
||||
runtime_context["model_name"] = self.model_name
|
||||
return runtime_context
|
||||
runtime_configurable["model_name"] = self.model_name
|
||||
return runtime_configurable
|
||||
|
||||
def _build_payload(
|
||||
self,
|
||||
@@ -430,16 +432,19 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]):
|
||||
image_urls: list[str],
|
||||
system_prompt: str | None,
|
||||
) -> dict[str, T.Any]:
|
||||
runtime_configurable = self._build_runtime_configurable(thread_id)
|
||||
return {
|
||||
"assistant_id": self.assistant_id,
|
||||
"input": {
|
||||
"messages": self._build_messages(prompt, image_urls, system_prompt),
|
||||
},
|
||||
"stream_mode": ["values", "messages-tuple", "custom"],
|
||||
# LangGraph 0.6+ prefers context instead of configurable.
|
||||
"context": self._build_runtime_context(thread_id),
|
||||
# DeerFlow 2.0 consumes runtime overrides from config.configurable.
|
||||
# Keep the legacy context mirror for older compat paths.
|
||||
"context": dict(runtime_configurable),
|
||||
"config": {
|
||||
"recursion_limit": self.recursion_limit,
|
||||
"configurable": runtime_configurable,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,33 @@ from astrbot.core import logger
|
||||
SSE_MAX_BUFFER_CHARS = 1_048_576
|
||||
|
||||
|
||||
class DeerFlowAPIError(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
operation: str,
|
||||
status: int,
|
||||
body: str,
|
||||
url: str,
|
||||
thread_id: str | None = None,
|
||||
) -> None:
|
||||
self.operation = operation
|
||||
self.status = status
|
||||
self.body = body
|
||||
self.url = url
|
||||
self.thread_id = thread_id
|
||||
|
||||
message = (
|
||||
f"DeerFlow {operation} failed: status={status}, url={url}, body={body}"
|
||||
)
|
||||
if thread_id is not None:
|
||||
message = (
|
||||
f"DeerFlow {operation} failed: thread_id={thread_id}, "
|
||||
f"status={status}, url={url}, body={body}"
|
||||
)
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
def _normalize_sse_newlines(text: str) -> str:
|
||||
"""Normalize CRLF/CR to LF so SSE block splitting works reliably."""
|
||||
return text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
@@ -152,11 +179,33 @@ class DeerFlowAPIClient:
|
||||
) as resp:
|
||||
if resp.status not in (200, 201):
|
||||
text = await resp.text()
|
||||
raise Exception(
|
||||
f"DeerFlow create thread failed: {resp.status}. {text}",
|
||||
raise DeerFlowAPIError(
|
||||
operation="create thread",
|
||||
status=resp.status,
|
||||
body=text,
|
||||
url=url,
|
||||
)
|
||||
return await resp.json()
|
||||
|
||||
async def delete_thread(self, thread_id: str, timeout: float = 20) -> None:
|
||||
session = self._get_session()
|
||||
url = f"{self.api_base}/api/threads/{thread_id}"
|
||||
async with session.delete(
|
||||
url,
|
||||
headers=self.headers,
|
||||
timeout=timeout,
|
||||
proxy=self.proxy,
|
||||
) as resp:
|
||||
if resp.status not in (200, 202, 204, 404):
|
||||
text = await resp.text()
|
||||
raise DeerFlowAPIError(
|
||||
operation="delete thread",
|
||||
status=resp.status,
|
||||
body=text,
|
||||
url=url,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
async def stream_run(
|
||||
self,
|
||||
thread_id: str,
|
||||
@@ -200,8 +249,12 @@ class DeerFlowAPIClient:
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
raise Exception(
|
||||
f"DeerFlow runs/stream request failed: {resp.status}. {text}",
|
||||
raise DeerFlowAPIError(
|
||||
operation="runs/stream request",
|
||||
status=resp.status,
|
||||
body=text,
|
||||
url=url,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
async for event in _stream_sse(resp):
|
||||
yield event
|
||||
|
||||
@@ -9,8 +9,6 @@ import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from python_ripgrep import search
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.core.computer.file_read_utils import (
|
||||
detect_text_encoding,
|
||||
@@ -221,15 +219,57 @@ class LocalFileSystemComponent(FileSystemComponent):
|
||||
before_context: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
def _run() -> dict[str, Any]:
|
||||
results = search(
|
||||
patterns=[pattern],
|
||||
paths=[path] if path else None,
|
||||
globs=[glob] if glob else None,
|
||||
after_context=after_context,
|
||||
before_context=before_context,
|
||||
line_number=True,
|
||||
)
|
||||
return {"success": True, "content": _truncate_long_lines("".join(results))}
|
||||
search_path = path if path else "."
|
||||
|
||||
# Try ripgrep first, fallback to grep
|
||||
if shutil.which("rg"):
|
||||
cmd = ["rg", "--line-number", "--color=never"]
|
||||
if glob:
|
||||
cmd.extend(["--glob", glob])
|
||||
if after_context:
|
||||
cmd.extend(["--after-context", str(after_context)])
|
||||
if before_context:
|
||||
cmd.extend(["--before-context", str(before_context)])
|
||||
cmd.extend([pattern, search_path])
|
||||
elif shutil.which("grep"):
|
||||
cmd = ["grep", "-rn", "--color=never"]
|
||||
if after_context:
|
||||
cmd.extend(["-A", str(after_context)])
|
||||
if before_context:
|
||||
cmd.extend(["-B", str(before_context)])
|
||||
# grep doesn't support glob directly, use include if available
|
||||
if glob and shutil.which("grep"):
|
||||
# Try to use --include if grep supports it (GNU grep)
|
||||
cmd.extend(["--include", glob])
|
||||
cmd.extend([pattern, search_path])
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Neither ripgrep (rg) nor grep is available on the system",
|
||||
}
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
# grep returns exit code 1 when no matches found, which is not an error
|
||||
if result.returncode not in (0, 1):
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Search command failed with exit code {result.returncode}: {result.stderr}",
|
||||
}
|
||||
output = result.stdout if result.stdout else ""
|
||||
return {"success": True, "content": _truncate_long_lines(output)}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Search command timed out after 30 seconds",
|
||||
}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Search failed: {str(e)}"}
|
||||
|
||||
return await asyncio.to_thread(_run)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
VERSION = "4.23.0-beta.1"
|
||||
VERSION = "4.23.0"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||
PERSONAL_WECHAT_CONFIG_METADATA = {
|
||||
"weixin_oc_base_url": {
|
||||
@@ -2671,12 +2671,12 @@ CONFIG_METADATA_2 = {
|
||||
"deerflow_assistant_id": {
|
||||
"description": "Assistant ID",
|
||||
"type": "string",
|
||||
"hint": "LangGraph assistant_id,默认为 lead_agent。",
|
||||
"hint": "DeerFlow 2.0 LangGraph assistant_id,默认为 lead_agent。",
|
||||
},
|
||||
"deerflow_model_name": {
|
||||
"description": "模型名称覆盖",
|
||||
"type": "string",
|
||||
"hint": "可选。覆盖 DeerFlow 默认模型(对应 runtime context 的 model_name)。",
|
||||
"hint": "可选。覆盖 DeerFlow 默认模型(对应运行时 configurable 的 model_name)。",
|
||||
},
|
||||
"deerflow_thinking_enabled": {
|
||||
"description": "启用思考模式",
|
||||
@@ -2685,17 +2685,17 @@ CONFIG_METADATA_2 = {
|
||||
"deerflow_plan_mode": {
|
||||
"description": "启用计划模式",
|
||||
"type": "bool",
|
||||
"hint": "对应 DeerFlow 的 is_plan_mode。",
|
||||
"hint": "对应 DeerFlow 2.0 运行时 configurable 的 is_plan_mode。",
|
||||
},
|
||||
"deerflow_subagent_enabled": {
|
||||
"description": "启用子智能体",
|
||||
"type": "bool",
|
||||
"hint": "对应 DeerFlow 的 subagent_enabled。",
|
||||
"hint": "对应 DeerFlow 2.0 运行时 configurable 的 subagent_enabled。",
|
||||
},
|
||||
"deerflow_max_concurrent_subagents": {
|
||||
"description": "子智能体最大并发数",
|
||||
"type": "int",
|
||||
"hint": "对应 DeerFlow 的 max_concurrent_subagents。仅在启用子智能体时生效,默认 3。",
|
||||
"hint": "对应 DeerFlow 2.0 运行时 configurable 的 max_concurrent_subagents。仅在启用子智能体时生效,默认 3。",
|
||||
},
|
||||
"deerflow_recursion_limit": {
|
||||
"description": "递归深度上限",
|
||||
|
||||
@@ -505,6 +505,26 @@ class ProviderManager:
|
||||
pc = merged_config
|
||||
return pc
|
||||
|
||||
def get_provider_config_by_id(
|
||||
self,
|
||||
provider_id: str,
|
||||
*,
|
||||
merged: bool = False,
|
||||
) -> dict | None:
|
||||
"""Get a provider config by id.
|
||||
|
||||
Args:
|
||||
provider_id: Provider id to resolve.
|
||||
merged: Whether to merge provider_source config into the provider config.
|
||||
"""
|
||||
for provider_config in self.providers_config:
|
||||
if provider_config.get("id") != provider_id:
|
||||
continue
|
||||
if merged:
|
||||
return self.get_merged_provider_config(provider_config)
|
||||
return copy.deepcopy(provider_config)
|
||||
return None
|
||||
|
||||
def _resolve_env_key_list(self, provider_config: dict) -> dict:
|
||||
keys = provider_config.get("key", [])
|
||||
if not isinstance(keys, list):
|
||||
|
||||
125
changelogs/v4.23.0.md
Normal file
125
changelogs/v4.23.0.md
Normal file
@@ -0,0 +1,125 @@
|
||||
- [更新日志(简体中文)](#chinese)
|
||||
- [Changelog(English)](#english)
|
||||
|
||||
<a id="chinese"></a>
|
||||
|
||||
## What's Changed
|
||||
|
||||
### 新增
|
||||
|
||||
- 为电脑使用能力支持文件读取(read)、写入(write)、编辑(edit)、Grep 搜索(ripgrep)与按会话隔离的 workspace。([#7402](https://github.com/AstrBotDevs/AstrBot/pull/7402))
|
||||
- 微信个人号适配器支持引用消息的解析。([#7380](https://github.com/AstrBotDevs/AstrBot/pull/7380))
|
||||
- 新增 Brave Search 网页搜索工具,移除旧的默认网页搜索实现。([#6847](https://github.com/AstrBotDevs/AstrBot/pull/6847))
|
||||
- 新增 Mattermost 平台适配器支持。([#7369](https://github.com/AstrBotDevs/AstrBot/pull/7369))
|
||||
- 新增 NVIDIA Rerank Provider。([#7227](https://github.com/AstrBotDevs/AstrBot/pull/7227))
|
||||
- 新增 LongCat LLM Provider。([#7360](https://github.com/AstrBotDevs/AstrBot/pull/7360))
|
||||
- 新增 OpenAI、Gemini 音频输入模态支持,并修复 ChatUI 录音相关问题。([#7378](https://github.com/AstrBotDevs/AstrBot/pull/7378))
|
||||
- Discord 平台新增 Bot 消息过滤配置,允许控制是否接收其他 Bot 的消息。([#6505](https://github.com/AstrBotDevs/AstrBot/pull/6505))
|
||||
- 新增 LLM 对重复工具调用的引导能力,减少模型陷入重复调用工具的情况。([#7388](https://github.com/AstrBotDevs/AstrBot/pull/7388))
|
||||
- QQ 官方 API 文件上传新增重试机制,提升文件发送稳定性。([#7430](https://github.com/AstrBotDevs/AstrBot/pull/7430))
|
||||
|
||||
### 优化
|
||||
|
||||
- 重构 ChatUI 样式、消息渲染与输入体验,改善聊天界面的整体可维护性与交互一致性。([#7485](https://github.com/AstrBotDevs/AstrBot/pull/7485))
|
||||
- 合并 Cron 相关工具为统一管理工具,并新增 Cron 任务编辑能力。([#7445](https://github.com/AstrBotDevs/AstrBot/pull/7445))
|
||||
- 重构内置工具管理逻辑,改善 AstrBot 内置工具注册与调用维护性。([#7418](https://github.com/AstrBotDevs/AstrBot/pull/7418))
|
||||
- 移除了大部分低频内置命令并整合命令功能,将这些指令挪至 [builtin-command-extension](https://github.com/AstrBotDevs/builtin_commands_extension) 插件,同时更新文档。([#7478](https://github.com/AstrBotDevs/AstrBot/pull/7478))
|
||||
- 移除默认网页搜索实现,改由新的搜索工具链路提供能力。([#7416](https://github.com/AstrBotDevs/AstrBot/pull/7416))
|
||||
- 移除 `lxml` 与 `beautifulsoup4` 依赖,降低安装体积与依赖复杂度。([#7449](https://github.com/AstrBotDevs/AstrBot/pull/7449))
|
||||
- 新增 MCP stdio 配置校验,降低无效配置导致的启动失败与排查成本。([#7477](https://github.com/AstrBotDevs/AstrBot/pull/7477))
|
||||
- Docker 运行配置启用 `no-new-privileges`,提升容器默认安全性。([commit](https://github.com/AstrBotDevs/AstrBot/commit/68a195e12))
|
||||
- 降低 MCP Server 状态轮询频率,减少后台请求开销。([#7399](https://github.com/AstrBotDevs/AstrBot/pull/7399))
|
||||
- 帮助命令忽略 `dashboard_update` 等内部有效命令,减少帮助列表噪音。([commit](https://github.com/AstrBotDevs/AstrBot/commit/baaad2a69))
|
||||
- 修正文档中的路径拼接示例,避免插件开发存储文档误导使用者。([#7448](https://github.com/AstrBotDevs/AstrBot/pull/7448))
|
||||
- 补充 Matrix 平台常量、平台图标与相关文档。([#7368](https://github.com/AstrBotDevs/AstrBot/pull/7368))
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复 STT 或 TTS Provider 在配置中禁用时仍可能被取用的问题。([#7363](https://github.com/AstrBotDevs/AstrBot/pull/7363))
|
||||
- 修复 Gemini 空模型输出误触发错误处理的问题。([#7377](https://github.com/AstrBotDevs/AstrBot/pull/7377))
|
||||
- 修复 Gemini FunctionResponse 中不支持的 `id` 字段导致请求失败的问题。([#7386](https://github.com/AstrBotDevs/AstrBot/pull/7386))
|
||||
- 修复仅存在原生工具时仍传递 `FunctionCallingConfig` 的问题。([#7407](https://github.com/AstrBotDevs/AstrBot/pull/7407))
|
||||
- 修复工具结果断言与动态阈值不一致的问题。([commit](https://github.com/AstrBotDevs/AstrBot/commit/8c6c00ae6))
|
||||
- 修复 Bailian Rerank 对不同 URL 端点返回格式的兼容性问题。([#7250](https://github.com/AstrBotDevs/AstrBot/pull/7250))
|
||||
- 修复 QQ 官方 WebSocket 关闭流程清理不完整的问题。([#7395](https://github.com/AstrBotDevs/AstrBot/pull/7395))
|
||||
- 修复 Telegram 网络异常后的轮询恢复问题,并补充相关测试。([#7468](https://github.com/AstrBotDevs/AstrBot/pull/7468))
|
||||
- 修复 Telegram 长消息最终分段过长的问题。([#7432](https://github.com/AstrBotDevs/AstrBot/pull/7432))
|
||||
- 修复 Telegram `sendMessageDraft` 发送空文本导致 400 错误刷屏的问题。([#7398](https://github.com/AstrBotDevs/AstrBot/pull/7398))
|
||||
- 修复 Telegram 收集命令时插件 handler 不在 `star_map` 中导致 `KeyError` 的问题。([#7405](https://github.com/AstrBotDevs/AstrBot/pull/7405))
|
||||
- 修复 Discord Slash Command 未及时 defer 导致 `10062 Unknown interaction` 的问题。([#7474](https://github.com/AstrBotDevs/AstrBot/pull/7474))
|
||||
- 修复微信个人号适配器缺少上下文 token 时的警告信息。([commit](https://github.com/AstrBotDevs/AstrBot/commit/dfca5cdb7))
|
||||
- 修复 `group_icl_enable` 在消息处理时未使用 UMO 绑定配置的问题。([#7397](https://github.com/AstrBotDevs/AstrBot/pull/7397))
|
||||
- 修复插件函数工具模块路径与插件主模块路径不一致的问题。([#7462](https://github.com/AstrBotDevs/AstrBot/pull/7462))
|
||||
- 修复插件版本检查逻辑对预发布版本的支持问题。([commit](https://github.com/AstrBotDevs/AstrBot/commit/5f95bbc42))
|
||||
- 修复 Shell 命令执行 JSON 响应中非 ASCII 字符被转义的问题。([#7475](https://github.com/AstrBotDevs/AstrBot/pull/7475))
|
||||
- 修复 Windows 桌面端插件依赖加载不安全或失败的问题。([#7446](https://github.com/AstrBotDevs/AstrBot/pull/7446))
|
||||
- 修复 `faiss` 在启动阶段过早导入导致部分环境启动失败的问题。([#7400](https://github.com/AstrBotDevs/AstrBot/pull/7400))
|
||||
- 修复 WebUI 暗色模式渲染与多处交互问题。([#7173](https://github.com/AstrBotDevs/AstrBot/pull/7173))
|
||||
- 修复 ChatUI 项目常量缺失,并补充相关测试用例。([#7414](https://github.com/AstrBotDevs/AstrBot/pull/7414))
|
||||
- 修复页面切换时浮动按钮跳动的问题。([#7214](https://github.com/AstrBotDevs/AstrBot/pull/7214))
|
||||
- 修复对话管理页依赖的 `MessageList.vue` 缺失导致 Linux 环境 Dashboard 构建失败的问题。([commit](https://github.com/AstrBotDevs/AstrBot/commit/717228143))
|
||||
- 修复工具调用图标使用了 MDI 子集缺失图标导致的显示问题。([commit](https://github.com/AstrBotDevs/AstrBot/commit/d1913b595))
|
||||
- 修复 Dashboard store 中 `defineStore` 的类型用法问题。([#7490](https://github.com/AstrBotDevs/AstrBot/pull/7490))
|
||||
- 修复 compose 文件中的 Docker 镜像名称错误。([#7488](https://github.com/AstrBotDevs/AstrBot/pull/7488))
|
||||
|
||||
<a id="english"></a>
|
||||
|
||||
## What's Changed (EN)
|
||||
|
||||
### New Features
|
||||
|
||||
- Added the LongCat LLM Provider. ([#7360](https://github.com/AstrBotDevs/AstrBot/pull/7360))
|
||||
- Added missing Matrix platform constants, platform logo, and documentation updates. ([#7368](https://github.com/AstrBotDevs/AstrBot/pull/7368))
|
||||
- Added local Computer Use filesystem tools, including file read, write, edit, Grep search, and per-session workspace support. ([#7402](https://github.com/AstrBotDevs/AstrBot/pull/7402))
|
||||
- Added the Brave Search web search tool, replacing the old default web search implementation. ([#6847](https://github.com/AstrBotDevs/AstrBot/pull/6847))
|
||||
- Added Mattermost platform support. ([#7369](https://github.com/AstrBotDevs/AstrBot/pull/7369))
|
||||
- Added the NVIDIA Rerank Provider. ([#7227](https://github.com/AstrBotDevs/AstrBot/pull/7227))
|
||||
- Added audio input support across OpenAI and Gemini providers, and fixed ChatUI recording issues. ([#7378](https://github.com/AstrBotDevs/AstrBot/pull/7378))
|
||||
- Added reply component support for the Weixin OC adapter. ([#7380](https://github.com/AstrBotDevs/AstrBot/pull/7380))
|
||||
- Added configurable Discord bot-message filtering, including support for receiving messages from other bots. ([#6505](https://github.com/AstrBotDevs/AstrBot/pull/6505))
|
||||
- Added LLM guidance for repeated tool calls to reduce repetitive tool-call loops. ([#7388](https://github.com/AstrBotDevs/AstrBot/pull/7388))
|
||||
- Added retry handling for QQ Official API file uploads to improve file-send reliability. ([#7430](https://github.com/AstrBotDevs/AstrBot/pull/7430))
|
||||
|
||||
### Improvements
|
||||
|
||||
- Refactored ChatUI styling, message rendering, and input interactions for better maintainability and UI consistency. ([#7485](https://github.com/AstrBotDevs/AstrBot/pull/7485))
|
||||
- Merged Cron tools into a unified management tool and added Cron task editing. ([#7445](https://github.com/AstrBotDevs/AstrBot/pull/7445))
|
||||
- Refactored built-in tool management to improve registration and maintenance. ([#7418](https://github.com/AstrBotDevs/AstrBot/pull/7418))
|
||||
- Removed rarely used built-in commands, consolidated command functionality, and updated command documentation. ([#7478](https://github.com/AstrBotDevs/AstrBot/pull/7478))
|
||||
- Removed the old default web search implementation in favor of the new search tool flow. ([#7416](https://github.com/AstrBotDevs/AstrBot/pull/7416))
|
||||
- Removed `lxml` and `beautifulsoup4` dependencies to reduce dependency weight and installation complexity. ([#7449](https://github.com/AstrBotDevs/AstrBot/pull/7449))
|
||||
- Added MCP stdio configuration validation to reduce startup failures caused by invalid configs. ([#7477](https://github.com/AstrBotDevs/AstrBot/pull/7477))
|
||||
- Enabled `no-new-privileges` in Docker runtime configuration to improve default container security. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/68a195e12))
|
||||
- Reduced MCP Server status polling frequency to lower background request overhead. ([#7399](https://github.com/AstrBotDevs/AstrBot/pull/7399))
|
||||
- Ignored internal effective commands such as `dashboard_update` in HelpCommand to reduce help-list noise. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/baaad2a69))
|
||||
- Fixed a path concatenation example in the plugin storage docs. ([#7448](https://github.com/AstrBotDevs/AstrBot/pull/7448))
|
||||
- Updated the README logo. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/e34d9504e))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Returned `None` when STT or TTS providers are disabled in config instead of still resolving them. ([#7363](https://github.com/AstrBotDevs/AstrBot/pull/7363))
|
||||
- Fixed empty model output handling that could misfire when using Gemini. ([#7377](https://github.com/AstrBotDevs/AstrBot/pull/7377))
|
||||
- Removed the unsupported `id` field from Gemini FunctionResponse payloads. ([#7386](https://github.com/AstrBotDevs/AstrBot/pull/7386))
|
||||
- Skipped `FunctionCallingConfig` when only native tools are present. ([#7407](https://github.com/AstrBotDevs/AstrBot/pull/7407))
|
||||
- Updated tool-result assertions to match dynamic threshold values. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/8c6c00ae6))
|
||||
- Fixed Bailian Rerank compatibility with different response formats based on URL endpoint. ([#7250](https://github.com/AstrBotDevs/AstrBot/pull/7250))
|
||||
- Cleaned up QQ Official WebSocket shutdown handling. ([#7395](https://github.com/AstrBotDevs/AstrBot/pull/7395))
|
||||
- Fixed Telegram polling recovery after network failures and added related tests. ([#7468](https://github.com/AstrBotDevs/AstrBot/pull/7468))
|
||||
- Fixed overly long final Telegram message segments. ([#7432](https://github.com/AstrBotDevs/AstrBot/pull/7432))
|
||||
- Fixed Telegram `sendMessageDraft` 400 spam caused by empty text. ([#7398](https://github.com/AstrBotDevs/AstrBot/pull/7398))
|
||||
- Fixed a `KeyError` in Telegram command collection when a plugin handler is missing from `star_map`. ([#7405](https://github.com/AstrBotDevs/AstrBot/pull/7405))
|
||||
- Fixed Discord `10062 Unknown interaction` errors by deferring slash commands immediately. ([#7474](https://github.com/AstrBotDevs/AstrBot/pull/7474))
|
||||
- Improved the missing-context-token warning message in the Weixin OC adapter. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/dfca5cdb7))
|
||||
- Fixed `group_icl_enable` to use UMO-bound config during message handling. ([#7397](https://github.com/AstrBotDevs/AstrBot/pull/7397))
|
||||
- Aligned function tool module paths with plugin main module paths. ([#7462](https://github.com/AstrBotDevs/AstrBot/pull/7462))
|
||||
- Fixed plugin version checks for pre-release versions. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/5f95bbc42))
|
||||
- Preserved non-ASCII characters in JSON responses from shell command execution. ([#7475](https://github.com/AstrBotDevs/AstrBot/pull/7475))
|
||||
- Fixed safer desktop plugin dependency loading on Windows. ([#7446](https://github.com/AstrBotDevs/AstrBot/pull/7446))
|
||||
- Deferred `faiss` imports during startup to avoid startup failures in some environments. ([#7400](https://github.com/AstrBotDevs/AstrBot/pull/7400))
|
||||
- Fixed WebUI dark-mode rendering and multiple interaction bugs. ([#7173](https://github.com/AstrBotDevs/AstrBot/pull/7173))
|
||||
- Added missing ChatUI project constants and related tests. ([#7414](https://github.com/AstrBotDevs/AstrBot/pull/7414))
|
||||
- Prevented floating buttons from jumping during page transitions. ([#7214](https://github.com/AstrBotDevs/AstrBot/pull/7214))
|
||||
- Restored `MessageList.vue` for the conversation management page to fix Dashboard production builds on Linux. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/717228143))
|
||||
- Updated tool-call icons to use an icon included in the MDI subset. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/d1913b595))
|
||||
- Fixed `defineStore` type usage in Dashboard stores. ([#7490](https://github.com/AstrBotDevs/AstrBot/pull/7490))
|
||||
- Fixed the Docker image name in the compose file. ([#7488](https://github.com/AstrBotDevs/AstrBot/pull/7488))
|
||||
@@ -4,10 +4,7 @@ version: '3.8'
|
||||
|
||||
services:
|
||||
astrbot:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: astrbot:kimi-code
|
||||
image: soulter/astrbot:latest
|
||||
container_name: astrbot
|
||||
restart: always
|
||||
ports: # mappings description: https://github.com/AstrBotDevs/AstrBot/issues/497
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
||||
},
|
||||
"dependencies": {
|
||||
"qrcode": "^1.5.4",
|
||||
"@guolao/vue-monaco-editor": "^1.5.4",
|
||||
"@tiptap/starter-kit": "2.1.7",
|
||||
"@tiptap/vue-3": "2.1.7",
|
||||
@@ -25,7 +24,6 @@
|
||||
"date-fns": "2.30.0",
|
||||
"dompurify": "^3.3.2",
|
||||
"event-source-polyfill": "^1.0.31",
|
||||
"highlight.js": "^11.11.1",
|
||||
"js-md5": "^0.8.3",
|
||||
"katex": "^0.16.27",
|
||||
"lodash": "4.17.23",
|
||||
@@ -35,6 +33,7 @@
|
||||
"monaco-editor": "^0.52.2",
|
||||
"pinia": "2.1.6",
|
||||
"pinyin-pro": "^3.26.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"shiki": "^3.20.0",
|
||||
"stream-markdown": "^0.0.13",
|
||||
"vee-validate": "4.11.3",
|
||||
|
||||
22
dashboard/pnpm-lock.yaml
generated
22
dashboard/pnpm-lock.yaml
generated
@@ -42,9 +42,6 @@ importers:
|
||||
event-source-polyfill:
|
||||
specifier: ^1.0.31
|
||||
version: 1.0.31
|
||||
highlight.js:
|
||||
specifier: ^11.11.1
|
||||
version: 11.11.1
|
||||
js-md5:
|
||||
specifier: ^0.8.3
|
||||
version: 0.8.3
|
||||
@@ -540,79 +537,66 @@ packages:
|
||||
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
|
||||
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.59.0':
|
||||
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.59.0':
|
||||
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.59.0':
|
||||
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
|
||||
@@ -1919,10 +1903,6 @@ packages:
|
||||
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
|
||||
hasBin: true
|
||||
|
||||
highlight.js@11.11.1:
|
||||
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
hookified@1.15.1:
|
||||
resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==}
|
||||
|
||||
@@ -4933,8 +4913,6 @@ snapshots:
|
||||
|
||||
he@1.2.0: {}
|
||||
|
||||
highlight.js@11.11.1: {}
|
||||
|
||||
hookified@1.15.1: {}
|
||||
|
||||
html-void-elements@3.0.0: {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Auto-generated MDI subset – 256 icons */
|
||||
/* Auto-generated MDI subset – 247 icons */
|
||||
/* Do not edit manually. Run: pnpm run subset-icons */
|
||||
|
||||
@font-face {
|
||||
@@ -180,10 +180,6 @@
|
||||
content: "\F0132";
|
||||
}
|
||||
|
||||
.mdi-checkbox-multiple-marked-outline::before {
|
||||
content: "\F0139";
|
||||
}
|
||||
|
||||
.mdi-chevron-double-left::before {
|
||||
content: "\F013D";
|
||||
}
|
||||
@@ -440,10 +436,6 @@
|
||||
content: "\F0A4D";
|
||||
}
|
||||
|
||||
.mdi-file-upload-outline::before {
|
||||
content: "\F0A4E";
|
||||
}
|
||||
|
||||
.mdi-file-word-box::before {
|
||||
content: "\F022D";
|
||||
}
|
||||
@@ -456,14 +448,6 @@
|
||||
content: "\F0236";
|
||||
}
|
||||
|
||||
.mdi-flash::before {
|
||||
content: "\F0241";
|
||||
}
|
||||
|
||||
.mdi-flash-off::before {
|
||||
content: "\F0243";
|
||||
}
|
||||
|
||||
.mdi-folder::before {
|
||||
content: "\F024B";
|
||||
}
|
||||
@@ -576,18 +560,10 @@
|
||||
content: "\F0309";
|
||||
}
|
||||
|
||||
.mdi-keyboard-outline::before {
|
||||
content: "\F097B";
|
||||
}
|
||||
|
||||
.mdi-label::before {
|
||||
content: "\F0315";
|
||||
}
|
||||
|
||||
.mdi-lan-connect::before {
|
||||
content: "\F0318";
|
||||
}
|
||||
|
||||
.mdi-language-markdown::before {
|
||||
content: "\F0354";
|
||||
}
|
||||
@@ -652,10 +628,6 @@
|
||||
content: "\F035F";
|
||||
}
|
||||
|
||||
.mdi-message-off-outline::before {
|
||||
content: "\F164E";
|
||||
}
|
||||
|
||||
.mdi-message-outline::before {
|
||||
content: "\F0365";
|
||||
}
|
||||
@@ -664,10 +636,6 @@
|
||||
content: "\F0369";
|
||||
}
|
||||
|
||||
.mdi-message-text-outline::before {
|
||||
content: "\F036A";
|
||||
}
|
||||
|
||||
.mdi-microphone::before {
|
||||
content: "\F036C";
|
||||
}
|
||||
@@ -1036,10 +1004,6 @@
|
||||
content: "\F05B7";
|
||||
}
|
||||
|
||||
.mdi-wrench-outline::before {
|
||||
content: "\F0BE0";
|
||||
}
|
||||
|
||||
.mdi-zip-box::before {
|
||||
content: "\F05C4";
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,696 +0,0 @@
|
||||
<template>
|
||||
<div class="sidebar-panel"
|
||||
:class="{
|
||||
'sidebar-collapsed': sidebarCollapsed && !isMobile,
|
||||
'mobile-sidebar-open': isMobile && mobileMenuOpen,
|
||||
'mobile-sidebar': isMobile
|
||||
}"
|
||||
:style="{ backgroundColor: sidebarCollapsed && !isMobile ? 'rgb(var(--v-theme-surface))' : 'rgb(var(--v-theme-mcpCardBg))' }">
|
||||
|
||||
<div class="sidebar-collapse-btn-container" v-if="!isMobile">
|
||||
<v-btn icon class="sidebar-collapse-btn" @click="toggleSidebar" variant="text" color="deep-purple">
|
||||
<v-icon>{{ sidebarCollapsed ? 'mdi-chevron-right' : 'mdi-chevron-left' }}</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-collapse-btn-container" v-if="isMobile">
|
||||
<v-btn icon class="sidebar-collapse-btn" @click="$emit('closeMobileSidebar')" variant="text"
|
||||
color="deep-purple">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div style="padding: 8px; opacity: 0.6;">
|
||||
<div class="new-chat-row" v-if="!sidebarCollapsed || isMobile">
|
||||
<v-btn block variant="text" class="new-chat-btn" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
|
||||
prepend-icon="mdi-square-edit-outline">{{ tm('actions.newChat') }}</v-btn>
|
||||
<v-btn v-if="sessions.length > 0" icon size="small" variant="text" @click="toggleBatchMode"
|
||||
:color="batchMode ? 'primary' : undefined">
|
||||
<v-icon>mdi-checkbox-multiple-marked-outline</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
<v-btn icon="mdi-square-edit-outline" rounded="xl" @click="$emit('newChat')" :disabled="!currSessionId && !selectedProjectId"
|
||||
v-if="sidebarCollapsed && !isMobile" elevation="0"></v-btn>
|
||||
</div>
|
||||
|
||||
<!-- Batch action bar -->
|
||||
<div v-if="batchMode && (!sidebarCollapsed || isMobile)" class="batch-action-bar">
|
||||
<v-btn size="x-small" variant="text" @click="toggleSelectAll">
|
||||
{{ isAllSelected ? tm('batch.deselectAll') : tm('batch.selectAll') }}
|
||||
</v-btn>
|
||||
<span class="batch-selected-count">{{ tm('batch.selected', { count: batchSelected.length }) }}</span>
|
||||
<v-spacer />
|
||||
<v-btn size="x-small" variant="text" color="error" :disabled="batchSelected.length === 0"
|
||||
@click="handleBatchDelete">
|
||||
{{ tm('batch.delete') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- 项目列表组件 -->
|
||||
<ProjectList
|
||||
v-if="!sidebarCollapsed || isMobile"
|
||||
:projects="projects"
|
||||
@selectProject="$emit('selectProject', $event)"
|
||||
@createProject="$emit('createProject')"
|
||||
@editProject="$emit('editProject', $event)"
|
||||
@deleteProject="$emit('deleteProject', $event)"
|
||||
/>
|
||||
|
||||
<div style="overflow-y: auto; flex-grow: 1; overscroll-behavior-y: contain;"
|
||||
v-if="!sidebarCollapsed || isMobile">
|
||||
<v-card v-if="sessions.length > 0" flat style="background-color: transparent;">
|
||||
<v-list density="compact" nav class="conversation-list"
|
||||
style="background-color: transparent;" :selected="batchMode ? [] : selectedSessions"
|
||||
@update:selected="handleListSelect">
|
||||
<v-list-item v-for="item in sessions" :key="item.session_id" :value="item.session_id"
|
||||
rounded="lg" class="conversation-item" active-color="secondary"
|
||||
@click="batchMode ? toggleBatchItem(item.session_id) : undefined">
|
||||
|
||||
<template v-slot:prepend>
|
||||
<div class="batch-checkbox-slot" :class="{ 'batch-checkbox-slot--active': batchMode }">
|
||||
<v-checkbox-btn
|
||||
:model-value="batchSelected.includes(item.session_id)"
|
||||
@update:model-value="toggleBatchItem(item.session_id)"
|
||||
@click.stop
|
||||
density="compact"
|
||||
hide-details
|
||||
class="batch-checkbox"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<v-list-item-title v-if="!sidebarCollapsed || isMobile" class="conversation-title"
|
||||
:style="{ color: 'rgb(var(--v-theme-primaryText))' }">
|
||||
{{ item.display_name || tm('conversation.newConversation') }}
|
||||
</v-list-item-title>
|
||||
<!-- <v-list-item-subtitle v-if="!sidebarCollapsed || isMobile" class="timestamp">
|
||||
{{ new Date(item.updated_at).toLocaleString() }}
|
||||
</v-list-item-subtitle> -->
|
||||
|
||||
<template v-if="!batchMode && (!sidebarCollapsed || isMobile)" v-slot:append>
|
||||
<div class="conversation-actions">
|
||||
<v-btn icon="mdi-pencil" size="x-small" variant="text"
|
||||
class="edit-title-btn"
|
||||
@click.stop="$emit('editTitle', item.session_id, item.display_name ?? '')" />
|
||||
<v-btn icon="mdi-delete" size="x-small" variant="text"
|
||||
class="delete-conversation-btn" color="error"
|
||||
@click.stop="handleDeleteConversation(item)" />
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
|
||||
<v-fade-transition>
|
||||
<div class="no-conversations" v-if="sessions.length === 0">
|
||||
<v-icon icon="mdi-message-text-outline" size="large" color="grey-lighten-1"></v-icon>
|
||||
<div class="no-conversations-text" v-if="!sidebarCollapsed || isMobile">
|
||||
{{ tm('conversation.noHistory') }}
|
||||
</div>
|
||||
</div>
|
||||
</v-fade-transition>
|
||||
</div>
|
||||
|
||||
<!-- 收起时的占位元素 -->
|
||||
<div class="sidebar-spacer" v-if="sidebarCollapsed && !isMobile"></div>
|
||||
|
||||
<!-- 底部设置按钮 -->
|
||||
<div class="sidebar-footer">
|
||||
<StyledMenu location="top" :close-on-content-click="false">
|
||||
<template v-slot:activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
v-bind="menuProps"
|
||||
:icon="sidebarCollapsed && !isMobile"
|
||||
:block="!sidebarCollapsed || isMobile"
|
||||
variant="text"
|
||||
class="settings-btn"
|
||||
:class="{ 'settings-btn-collapsed': sidebarCollapsed && !isMobile }"
|
||||
:prepend-icon="(!sidebarCollapsed || isMobile) ? 'mdi-cog-outline' : undefined"
|
||||
>
|
||||
<v-icon v-if="sidebarCollapsed && !isMobile">mdi-cog-outline</v-icon>
|
||||
<template v-if="!sidebarCollapsed || isMobile">{{ t('core.common.settings') }}</template>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<!-- 语言切换(分组) -->
|
||||
<v-menu
|
||||
:open-on-hover="!isMobile"
|
||||
:open-on-click="isMobile"
|
||||
:open-delay="!isMobile ? 60 : 0"
|
||||
:close-delay="!isMobile ? 120 : 0"
|
||||
:location="isMobile ? 'bottom' : 'end center'"
|
||||
offset="8"
|
||||
close-on-content-click
|
||||
>
|
||||
<template v-slot:activator="{ props: languageMenuProps }">
|
||||
<v-list-item
|
||||
v-bind="languageMenuProps"
|
||||
class="styled-menu-item chat-settings-group-trigger"
|
||||
rounded="md"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-translate</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ t('core.common.language') }}</v-list-item-title>
|
||||
<template v-slot:append>
|
||||
<span class="chat-settings-group-current">{{ currentLanguage?.flag }}</span>
|
||||
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<v-card class="styled-menu-card" style="min-width: 180px;" elevation="8" rounded="lg">
|
||||
<v-list density="compact" class="styled-menu-list pa-1">
|
||||
<v-list-item
|
||||
v-for="lang in languages"
|
||||
:key="lang.code"
|
||||
:value="lang.code"
|
||||
@click="changeLanguage(lang.code)"
|
||||
:class="{ 'styled-menu-item-active': currentLocale === lang.code }"
|
||||
class="styled-menu-item"
|
||||
rounded="md"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<span class="language-flag">{{ lang.flag }}</span>
|
||||
</template>
|
||||
<v-list-item-title>{{ lang.name }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
|
||||
<!-- 主题切换 -->
|
||||
<v-list-item class="styled-menu-item" @click="$emit('toggleTheme')">
|
||||
<template v-slot:prepend>
|
||||
<v-icon>{{ isDark ? 'mdi-weather-night' : 'mdi-white-balance-sunny' }}</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ isDark ? tm('modes.lightMode') : tm('modes.darkMode') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<!-- 通信传输模式(分组) -->
|
||||
<v-menu
|
||||
:open-on-hover="!isMobile"
|
||||
:open-on-click="isMobile"
|
||||
:open-delay="!isMobile ? 60 : 0"
|
||||
:close-delay="!isMobile ? 120 : 0"
|
||||
:location="isMobile ? 'bottom' : 'end center'"
|
||||
offset="8"
|
||||
close-on-content-click
|
||||
>
|
||||
<template v-slot:activator="{ props: transportMenuProps }">
|
||||
<v-list-item
|
||||
v-bind="transportMenuProps"
|
||||
class="styled-menu-item chat-settings-group-trigger"
|
||||
rounded="md"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-lan-connect</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ tm('transport.title') }}</v-list-item-title>
|
||||
<template v-slot:append>
|
||||
<span class="chat-settings-group-current chat-settings-transport-current">{{ currentTransportLabel }}</span>
|
||||
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<v-card class="styled-menu-card" style="min-width: 220px;" elevation="8" rounded="lg">
|
||||
<v-list density="compact" class="styled-menu-list pa-1">
|
||||
<v-list-item
|
||||
v-for="opt in transportOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
@click="handleTransportModeChange(opt.value)"
|
||||
:class="{ 'styled-menu-item-active': transportMode === opt.value }"
|
||||
class="styled-menu-item"
|
||||
rounded="md"
|
||||
>
|
||||
<v-list-item-title>{{ opt.label }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
|
||||
<!-- 发送快捷键(分组) -->
|
||||
<v-menu
|
||||
:open-on-hover="!isMobile"
|
||||
:open-on-click="isMobile"
|
||||
:open-delay="!isMobile ? 60 : 0"
|
||||
:close-delay="!isMobile ? 120 : 0"
|
||||
:location="isMobile ? 'bottom' : 'end center'"
|
||||
offset="8"
|
||||
close-on-content-click
|
||||
>
|
||||
<template v-slot:activator="{ props: sendShortcutMenuProps }">
|
||||
<v-list-item
|
||||
v-bind="sendShortcutMenuProps"
|
||||
class="styled-menu-item chat-settings-group-trigger"
|
||||
rounded="md"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-keyboard-outline</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ tm('shortcuts.sendKey.title') }}</v-list-item-title>
|
||||
<template v-slot:append>
|
||||
<span class="chat-settings-group-current chat-settings-transport-current">{{ currentSendShortcutLabel }}</span>
|
||||
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<v-card class="styled-menu-card" style="min-width: 220px;" elevation="8" rounded="lg">
|
||||
<v-list density="compact" class="styled-menu-list pa-1">
|
||||
<v-list-item
|
||||
v-for="opt in sendShortcutOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
@click="handleSendShortcutChange(opt.value)"
|
||||
:class="{ 'styled-menu-item-active': props.sendShortcut === opt.value }"
|
||||
class="styled-menu-item"
|
||||
rounded="md"
|
||||
>
|
||||
<v-list-item-title>{{ opt.label }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
|
||||
<!-- 全屏/退出全屏 -->
|
||||
<v-list-item class="styled-menu-item" @click="$emit('toggleFullscreen')">
|
||||
<template v-slot:prepend>
|
||||
<v-icon>{{ chatboxMode ? 'mdi-fullscreen-exit' : 'mdi-fullscreen' }}</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ chatboxMode ? tm('actions.exitFullscreen') : tm('actions.fullscreen') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
|
||||
<!-- 提供商配置 -->
|
||||
<v-list-item class="styled-menu-item" @click="showProviderConfigDialog = true">
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-creation</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ tm('actions.providerConfig') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</StyledMenu>
|
||||
</div>
|
||||
|
||||
<!-- 提供商配置对话框 -->
|
||||
<ProviderConfigDialog v-model="showProviderConfigDialog" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||
import type { Session } from '@/composables/useSessions';
|
||||
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog';
|
||||
import StyledMenu from '@/components/shared/StyledMenu.vue';
|
||||
import ProviderConfigDialog from '@/components/chat/ProviderConfigDialog.vue';
|
||||
import ProjectList from '@/components/chat/ProjectList.vue';
|
||||
import type { Project } from '@/components/chat/ProjectList.vue';
|
||||
import { useLanguageSwitcher } from '@/i18n/composables';
|
||||
import type { Locale } from '@/i18n/types';
|
||||
|
||||
interface Props {
|
||||
sessions: Session[];
|
||||
selectedSessions: string[];
|
||||
currSessionId: string;
|
||||
selectedProjectId?: string | null;
|
||||
transportMode: 'sse' | 'websocket';
|
||||
isDark: boolean;
|
||||
chatboxMode: boolean;
|
||||
isMobile: boolean;
|
||||
mobileMenuOpen: boolean;
|
||||
projects?: Project[];
|
||||
sendShortcut: 'enter' | 'shift_enter';
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
projects: () => []
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
newChat: [];
|
||||
selectConversation: [sessionIds: string[]];
|
||||
editTitle: [sessionId: string, title: string];
|
||||
deleteConversation: [sessionId: string];
|
||||
batchDeleteConversations: [sessionIds: string[]];
|
||||
closeMobileSidebar: [];
|
||||
toggleTheme: [];
|
||||
toggleFullscreen: [];
|
||||
selectProject: [projectId: string];
|
||||
createProject: [];
|
||||
editProject: [project: Project];
|
||||
deleteProject: [projectId: string];
|
||||
updateTransportMode: [mode: 'sse' | 'websocket'];
|
||||
updateSendShortcut: [mode: 'enter' | 'shift_enter'];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { tm } = useModuleI18n('features/chat');
|
||||
|
||||
const confirmDialog = useConfirmDialog();
|
||||
|
||||
const sidebarCollapsed = ref(true);
|
||||
const showProviderConfigDialog = ref(false);
|
||||
|
||||
// Batch mode state
|
||||
const batchMode = ref(false);
|
||||
const batchSelected = ref<string[]>([]);
|
||||
|
||||
const isAllSelected = computed(() =>
|
||||
props.sessions.length > 0 && batchSelected.value.length === props.sessions.length
|
||||
);
|
||||
|
||||
function toggleBatchMode() {
|
||||
batchMode.value = !batchMode.value;
|
||||
batchSelected.value = [];
|
||||
}
|
||||
|
||||
function toggleBatchItem(sessionId: string) {
|
||||
const idx = batchSelected.value.indexOf(sessionId);
|
||||
if (idx >= 0) {
|
||||
batchSelected.value.splice(idx, 1);
|
||||
} else {
|
||||
batchSelected.value.push(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (isAllSelected.value) {
|
||||
batchSelected.value = [];
|
||||
} else {
|
||||
batchSelected.value = props.sessions.map(s => s.session_id);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
const count = batchSelected.value.length;
|
||||
if (count === 0) return;
|
||||
const message = tm('batch.confirmDelete', { count });
|
||||
if (await askForConfirmation(message, confirmDialog)) {
|
||||
emit('batchDeleteConversations', [...batchSelected.value]);
|
||||
batchSelected.value = [];
|
||||
batchMode.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleListSelect(sessionIds: string[]) {
|
||||
if (!batchMode.value) {
|
||||
emit('selectConversation', sessionIds);
|
||||
}
|
||||
}
|
||||
const transportOptions = [
|
||||
{ label: tm('transport.sse'), value: 'sse' as const },
|
||||
{ label: tm('transport.websocket'), value: 'websocket' as const }
|
||||
];
|
||||
const sendShortcutOptions = [
|
||||
{ label: tm('shortcuts.sendKey.enterToSend'), value: 'enter' as const },
|
||||
{ label: tm('shortcuts.sendKey.shiftEnterToSend'), value: 'shift_enter' as const }
|
||||
];
|
||||
|
||||
// Language switcher
|
||||
const { languageOptions, currentLanguage, switchLanguage, locale } = useLanguageSwitcher();
|
||||
const languages = computed(() =>
|
||||
languageOptions.value.map(lang => ({
|
||||
code: lang.value,
|
||||
name: lang.label,
|
||||
flag: lang.flag
|
||||
}))
|
||||
);
|
||||
const currentLocale = computed(() => locale.value);
|
||||
const changeLanguage = async (langCode: string) => {
|
||||
await switchLanguage(langCode as Locale);
|
||||
};
|
||||
|
||||
const currentTransportLabel = computed(() => {
|
||||
const found = transportOptions.find(opt => opt.value === props.transportMode);
|
||||
return found?.label ?? '';
|
||||
});
|
||||
const currentSendShortcutLabel = computed(() => {
|
||||
const found = sendShortcutOptions.find(opt => opt.value === props.sendShortcut);
|
||||
return found?.label ?? '';
|
||||
});
|
||||
|
||||
// 从 localStorage 读取侧边栏折叠状态
|
||||
const savedCollapsedState = localStorage.getItem('sidebarCollapsed');
|
||||
if (savedCollapsedState !== null) {
|
||||
sidebarCollapsed.value = JSON.parse(savedCollapsedState);
|
||||
} else {
|
||||
sidebarCollapsed.value = true;
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value;
|
||||
localStorage.setItem('sidebarCollapsed', JSON.stringify(sidebarCollapsed.value));
|
||||
}
|
||||
|
||||
async function handleDeleteConversation(session: Session) {
|
||||
const sessionTitle = session.display_name || tm('conversation.newConversation');
|
||||
const message = tm('conversation.confirmDelete', { name: sessionTitle });
|
||||
if (await askForConfirmation(message, confirmDialog)) {
|
||||
emit('deleteConversation', session.session_id);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTransportModeChange(mode: string | null) {
|
||||
if (mode === 'sse' || mode === 'websocket') {
|
||||
emit('updateTransportMode', mode);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSendShortcutChange(mode: string | null) {
|
||||
if (mode === 'enter' || mode === 'shift_enter') {
|
||||
emit('updateSendShortcut', mode);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar-panel {
|
||||
max-width: 270px;
|
||||
min-width: 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-collapsed {
|
||||
max-width: 60px;
|
||||
min-width: 60px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.mobile-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
max-width: 280px !important;
|
||||
min-width: 280px !important;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.mobile-sidebar-open {
|
||||
transform: translateX(0) !important;
|
||||
}
|
||||
|
||||
.sidebar-collapse-btn-container {
|
||||
margin: 8px;
|
||||
margin-bottom: 0px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.sidebar-collapse-btn {
|
||||
opacity: 0.6;
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.new-chat-btn {
|
||||
justify-content: flex-start;
|
||||
background-color: transparent !important;
|
||||
border-radius: 20px;
|
||||
padding: 8px 16px !important;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
/* margin-bottom: 4px; */
|
||||
border-radius: 20px !important;
|
||||
height: auto !important;
|
||||
/* min-height: 56px; */
|
||||
padding: 0px 16px !important;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.conversation-item:hover {
|
||||
background-color: rgba(var(--v-theme-primary), 0.05);
|
||||
}
|
||||
|
||||
.conversation-item:hover .conversation-actions {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.conversation-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.conversation-actions {
|
||||
opacity: 1 !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-title-btn,
|
||||
.delete-conversation-btn {
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.edit-title-btn:hover,
|
||||
.delete-conversation-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.conversation-title {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
margin-bottom: 2px;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-size: 11px;
|
||||
color: var(--v-theme-secondaryText);
|
||||
line-height: 1;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.no-conversations {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 150px;
|
||||
opacity: 0.6;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.no-conversations-text {
|
||||
font-size: 14px;
|
||||
color: var(--v-theme-secondaryText);
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.sidebar-spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 8px 8px;
|
||||
padding-bottom: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-btn {
|
||||
opacity: 0.6;
|
||||
justify-content: flex-start;
|
||||
padding: 8px 16px !important;
|
||||
border-radius: 20px !important;
|
||||
}
|
||||
|
||||
.settings-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.settings-btn-collapsed {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.chat-settings-group-trigger :deep(.v-list-item__append) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-settings-group-current {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.chat-settings-transport-current {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat-settings-group-arrow {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.language-flag {
|
||||
font-size: 16px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.new-chat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.new-chat-row .new-chat-btn {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.batch-action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.batch-selected-count {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.batch-checkbox {
|
||||
flex: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.batch-checkbox-slot {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
transform: translateX(-8px);
|
||||
transition: width 0.2s ease, opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.batch-checkbox-slot--active {
|
||||
width: 28px;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateX(0);
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
1462
dashboard/src/components/chat/MessageListDEPRECATED.vue
Normal file
1462
dashboard/src/components/chat/MessageListDEPRECATED.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,162 +1,228 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 项目按钮 -->
|
||||
<div style="padding: 0 8px 0px 8px; opacity: 0.6;">
|
||||
<v-btn block variant="text" class="project-btn" @click="toggleExpanded" prepend-icon="mdi-folder-outline">
|
||||
{{ tm('project.title') }}
|
||||
<template v-slot:append>
|
||||
<v-icon size="small">{{ expanded ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
|
||||
</template>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- 项目列表 -->
|
||||
<v-expand-transition>
|
||||
<div v-show="expanded" style="padding: 0 8px;">
|
||||
<v-list density="compact" nav class="project-list" style="background-color: transparent;">
|
||||
<v-list-item @click="$emit('createProject')" class="create-project-item" rounded="lg">
|
||||
<template v-slot:prepend>
|
||||
<span class="project-emoji"><v-icon size="small">mdi-plus</v-icon></span>
|
||||
</template>
|
||||
<v-list-item-title style="font-size: 13px;">{{ tm('project.create') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item v-for="project in projects" :key="project.project_id"
|
||||
@click="$emit('selectProject', project.project_id)" rounded="lg" class="project-item">
|
||||
<template v-slot:prepend>
|
||||
<span class="project-emoji">{{ project.emoji || '📁' }}</span>
|
||||
</template>
|
||||
<v-list-item-title class="project-title">{{ project.title }}</v-list-item-title>
|
||||
<template v-slot:append>
|
||||
<div class="project-actions">
|
||||
<v-btn icon="mdi-pencil" size="x-small" variant="text" class="edit-project-btn"
|
||||
@click.stop="$emit('editProject', project)" />
|
||||
<v-btn icon="mdi-delete" size="x-small" variant="text" class="delete-project-btn"
|
||||
color="error" @click.stop="handleDeleteProject(project)" />
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
<div class="project-list-shell">
|
||||
<!-- 项目按钮 -->
|
||||
<div class="project-button-wrap">
|
||||
<v-btn block variant="text" class="project-btn" @click="toggleExpanded">
|
||||
<v-icon size="20" class="project-action-icon mr-2">
|
||||
mdi-folder-outline
|
||||
</v-icon>
|
||||
<span class="project-btn-title">{{ tm("project.title") }}</span>
|
||||
<v-spacer />
|
||||
<v-icon size="18" class="project-toggle-icon">
|
||||
{{ expanded ? "mdi-chevron-up" : "mdi-chevron-down" }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- 项目列表 -->
|
||||
<v-expand-transition>
|
||||
<div v-show="expanded" class="project-list-wrap">
|
||||
<button
|
||||
class="project-row create-project-item"
|
||||
type="button"
|
||||
@click="$emit('createProject')"
|
||||
>
|
||||
<span class="project-emoji">
|
||||
<v-icon size="18">mdi-plus</v-icon>
|
||||
</span>
|
||||
<span class="project-title">{{ tm("project.create") }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-for="project in projects"
|
||||
:key="project.project_id"
|
||||
class="project-row project-item"
|
||||
:class="{ active: selectedProjectId === project.project_id }"
|
||||
type="button"
|
||||
@click="$emit('selectProject', project.project_id)"
|
||||
>
|
||||
<span class="project-emoji">{{ project.emoji || "📁" }}</span>
|
||||
<span class="project-title">{{ project.title }}</span>
|
||||
<span class="project-actions">
|
||||
<v-btn
|
||||
icon="mdi-pencil"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
class="edit-project-btn"
|
||||
@click.stop="$emit('editProject', project)"
|
||||
/>
|
||||
<v-btn
|
||||
icon="mdi-delete"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
class="delete-project-btn"
|
||||
color="error"
|
||||
@click.stop="handleDeleteProject(project)"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog';
|
||||
import { ref } from "vue";
|
||||
import { useModuleI18n } from "@/i18n/composables";
|
||||
import { askForConfirmation, useConfirmDialog } from "@/utils/confirmDialog";
|
||||
|
||||
export interface Project {
|
||||
project_id: string;
|
||||
title: string;
|
||||
emoji?: string;
|
||||
description?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
project_id: string;
|
||||
title: string;
|
||||
emoji?: string;
|
||||
description?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
projects: Project[];
|
||||
initialExpanded?: boolean;
|
||||
projects: Project[];
|
||||
initialExpanded?: boolean;
|
||||
selectedProjectId?: string | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
initialExpanded: false
|
||||
initialExpanded: false,
|
||||
selectedProjectId: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectProject: [projectId: string];
|
||||
createProject: [];
|
||||
editProject: [project: Project];
|
||||
deleteProject: [projectId: string];
|
||||
selectProject: [projectId: string];
|
||||
createProject: [];
|
||||
editProject: [project: Project];
|
||||
deleteProject: [projectId: string];
|
||||
}>();
|
||||
|
||||
const { tm } = useModuleI18n('features/chat');
|
||||
const { tm } = useModuleI18n("features/chat");
|
||||
|
||||
const confirmDialog = useConfirmDialog();
|
||||
|
||||
const expanded = ref(props.initialExpanded);
|
||||
|
||||
// 从 localStorage 读取项目展开状态
|
||||
const savedProjectsExpandedState = localStorage.getItem('projectsExpanded');
|
||||
const savedProjectsExpandedState = localStorage.getItem("projectsExpanded");
|
||||
if (savedProjectsExpandedState !== null) {
|
||||
expanded.value = JSON.parse(savedProjectsExpandedState);
|
||||
expanded.value = JSON.parse(savedProjectsExpandedState);
|
||||
}
|
||||
|
||||
function toggleExpanded() {
|
||||
expanded.value = !expanded.value;
|
||||
localStorage.setItem('projectsExpanded', JSON.stringify(expanded.value));
|
||||
expanded.value = !expanded.value;
|
||||
localStorage.setItem("projectsExpanded", JSON.stringify(expanded.value));
|
||||
}
|
||||
|
||||
async function handleDeleteProject(project: Project) {
|
||||
const message = tm('project.confirmDelete', { title: project.title });
|
||||
if (await askForConfirmation(message, confirmDialog)) {
|
||||
emit('deleteProject', project.project_id);
|
||||
}
|
||||
const message = tm("project.confirmDelete", { title: project.title });
|
||||
if (await askForConfirmation(message, confirmDialog)) {
|
||||
emit("deleteProject", project.project_id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.project-list-shell {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.project-button-wrap {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.project-btn {
|
||||
justify-content: flex-start;
|
||||
background-color: transparent !important;
|
||||
border-radius: 20px;
|
||||
padding: 8px 16px !important;
|
||||
text-transform: none;
|
||||
justify-content: flex-start;
|
||||
background-color: transparent !important;
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px !important;
|
||||
text-transform: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.project-item {
|
||||
border-radius: 16px !important;
|
||||
padding: 4px 12px !important;
|
||||
margin-bottom: 2px;
|
||||
.project-action-icon {
|
||||
color: currentcolor;
|
||||
}
|
||||
|
||||
.project-item:hover {
|
||||
background-color: rgba(103, 58, 183, 0.05);
|
||||
.project-btn-title {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-toggle-icon {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.project-list-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.project-row {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.project-row:hover,
|
||||
.project-row.active {
|
||||
background: var(--chat-session-active-bg);
|
||||
}
|
||||
|
||||
.project-item:hover .project-actions {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.project-emoji {
|
||||
font-size: 16px;
|
||||
margin-right: 6px;
|
||||
width: 20px;
|
||||
flex: 0 0 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.project-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.project-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.edit-project-btn,
|
||||
.delete-project-btn {
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s ease;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.edit-project-btn:hover,
|
||||
.delete-project-btn:hover {
|
||||
opacity: 1;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.create-project-item {
|
||||
border-radius: 16px !important;
|
||||
padding: 4px 12px !important;
|
||||
opacity: 0.7;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.create-project-item:hover {
|
||||
background-color: rgba(103, 58, 183, 0.08);
|
||||
opacity: 1;
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,189 +1,214 @@
|
||||
<template>
|
||||
<div class="project-sessions-container fade-in">
|
||||
<div class="project-header">
|
||||
<div class="project-header-info">
|
||||
<span class="project-header-emoji">{{ project?.emoji || '📁' }}</span>
|
||||
<h2 class="project-header-title">{{ project?.title }}</h2>
|
||||
</div>
|
||||
<p class="project-header-description" v-if="project?.description">
|
||||
{{ project.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="project-input-slot">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<v-card flat class="project-sessions-list">
|
||||
<v-list v-if="sessions.length > 0">
|
||||
<v-list-item v-for="session in sessions" :key="session.session_id"
|
||||
@click="$emit('selectSession', session.session_id)" class="project-session-item" rounded="lg">
|
||||
<v-list-item-title>
|
||||
{{ session.display_name || tm('conversation.newConversation') }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
{{ formatDate(session.updated_at) }}
|
||||
</v-list-item-subtitle>
|
||||
<template v-slot:append>
|
||||
<div class="session-actions">
|
||||
<v-btn icon="mdi-pencil" size="x-small" variant="text"
|
||||
class="edit-session-btn"
|
||||
@click.stop="$emit('editSessionTitle', session.session_id, session.display_name ?? '')" />
|
||||
<v-btn icon="mdi-delete" size="x-small" variant="text"
|
||||
class="delete-session-btn" color="error"
|
||||
@click.stop="handleDeleteSession(session)" />
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<div v-else class="no-sessions-in-project">
|
||||
<v-icon icon="mdi-message-off-outline" size="large" color="grey-lighten-1"></v-icon>
|
||||
<p>{{ tm('project.noSessions') }}</p>
|
||||
</div>
|
||||
</v-card>
|
||||
<div class="project-sessions-container fade-in">
|
||||
<div class="project-header">
|
||||
<div class="project-header-info">
|
||||
<span class="project-header-emoji">{{ project?.emoji || "📁" }}</span>
|
||||
<h2 class="project-header-title">{{ project?.title }}</h2>
|
||||
</div>
|
||||
<p class="project-header-description" v-if="project?.description">
|
||||
{{ project.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="project-input-slot">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<v-card flat class="project-sessions-list">
|
||||
<v-list v-if="sessions.length > 0">
|
||||
<v-list-item
|
||||
v-for="session in sessions"
|
||||
:key="session.session_id"
|
||||
@click="$emit('selectSession', session.session_id)"
|
||||
class="project-session-item"
|
||||
rounded="lg"
|
||||
>
|
||||
<v-list-item-title>
|
||||
{{ session.display_name || tm("conversation.newConversation") }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
{{ formatDate(session.updated_at) }}
|
||||
</v-list-item-subtitle>
|
||||
<template v-slot:append>
|
||||
<div class="session-actions">
|
||||
<v-btn
|
||||
icon="mdi-pencil"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
class="edit-session-btn"
|
||||
@click.stop="
|
||||
$emit(
|
||||
'editSessionTitle',
|
||||
session.session_id,
|
||||
session.display_name ?? '',
|
||||
)
|
||||
"
|
||||
/>
|
||||
<v-btn
|
||||
icon="mdi-delete"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
class="delete-session-btn"
|
||||
color="error"
|
||||
@click.stop="handleDeleteSession(session)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<div v-else class="no-sessions-in-project">
|
||||
<v-icon
|
||||
icon="mdi-message-outline"
|
||||
size="large"
|
||||
color="grey-lighten-1"
|
||||
></v-icon>
|
||||
<p>{{ tm("project.noSessions") }}</p>
|
||||
</div>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import type { Project } from '@/components/chat/ProjectList.vue';
|
||||
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog';
|
||||
import { useModuleI18n } from "@/i18n/composables";
|
||||
import type { Project } from "@/components/chat/ProjectList.vue";
|
||||
import { askForConfirmation, useConfirmDialog } from "@/utils/confirmDialog";
|
||||
|
||||
interface Session {
|
||||
session_id: string;
|
||||
display_name?: string;
|
||||
updated_at: string;
|
||||
session_id: string;
|
||||
display_name?: string | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
project?: Project | null;
|
||||
sessions: Session[];
|
||||
project?: Project | null;
|
||||
sessions: Session[];
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectSession: [sessionId: string];
|
||||
editSessionTitle: [sessionId: string, title: string];
|
||||
deleteSession: [sessionId: string];
|
||||
selectSession: [sessionId: string];
|
||||
editSessionTitle: [sessionId: string, title: string];
|
||||
deleteSession: [sessionId: string];
|
||||
}>();
|
||||
|
||||
const { tm } = useModuleI18n('features/chat');
|
||||
const { tm } = useModuleI18n("features/chat");
|
||||
|
||||
const confirmDialog = useConfirmDialog();
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleString();
|
||||
return new Date(dateString).toLocaleString();
|
||||
}
|
||||
|
||||
async function handleDeleteSession(session: Session) {
|
||||
const sessionTitle = session.display_name || tm('conversation.newConversation');
|
||||
const message = tm('conversation.confirmDelete', { name: sessionTitle });
|
||||
if (await askForConfirmation(message, confirmDialog)) {
|
||||
emit('deleteSession', session.session_id);
|
||||
}
|
||||
const sessionTitle =
|
||||
session.display_name || tm("conversation.newConversation");
|
||||
const message = tm("conversation.confirmDelete", { name: sessionTitle });
|
||||
if (await askForConfirmation(message, confirmDialog)) {
|
||||
emit("deleteSession", session.session_id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.project-sessions-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 32px;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 32px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.project-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
max-width: 600px;
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.project-header-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.project-header-emoji {
|
||||
font-size: 48px;
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
.project-header-title {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.project-header-description {
|
||||
font-size: 14px;
|
||||
color: var(--v-theme-secondaryText);
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--v-theme-secondaryText);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.project-input-slot {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin-bottom: 24px;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.project-sessions-list {
|
||||
width: 100%;
|
||||
max-width: 680px;
|
||||
background-color: transparent !important;
|
||||
width: 100%;
|
||||
max-width: 680px;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.project-session-item {
|
||||
margin-bottom: 8px;
|
||||
border-radius: 12px !important;
|
||||
cursor: pointer;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 12px !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.project-session-item:hover {
|
||||
background-color: rgba(103, 58, 183, 0.05);
|
||||
background-color: rgba(103, 58, 183, 0.05);
|
||||
}
|
||||
|
||||
.project-session-item:hover .session-actions {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.session-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
opacity: 1;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.no-sessions-in-project {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
opacity: 0.6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.no-sessions-in-project p {
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,356 +1,617 @@
|
||||
<template>
|
||||
<v-card class="standalone-chat-card" elevation="0" rounded="0">
|
||||
<v-card-text class="standalone-chat-container">
|
||||
<div class="chat-layout">
|
||||
<!-- 聊天内容区域 -->
|
||||
<div class="chat-content-panel">
|
||||
<MessageList v-if="messages && messages.length > 0" :messages="messages" :isDark="isDark"
|
||||
:isStreaming="isStreaming || isConvRunning" @openImagePreview="openImagePreview"
|
||||
ref="messageList" />
|
||||
<div class="welcome-container fade-in" v-else>
|
||||
<div class="welcome-title">
|
||||
<span>Hello, I'm</span>
|
||||
<span class="bot-name">AstrBot ⭐</span>
|
||||
</div>
|
||||
<p class="text-caption text-medium-emphasis mt-2">
|
||||
测试配置: {{ configId || 'default' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="standalone-chat">
|
||||
<section ref="messagesContainer" class="standalone-messages">
|
||||
<div v-if="initializing" class="standalone-state">
|
||||
<v-progress-circular indeterminate size="28" width="3" />
|
||||
</div>
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<ChatInput
|
||||
v-model:prompt="prompt"
|
||||
:stagedImagesUrl="stagedImagesUrl"
|
||||
:stagedAudioUrl="stagedAudioUrl"
|
||||
:disabled="false"
|
||||
:is-running="isStreaming || isConvRunning"
|
||||
:enableStreaming="enableStreaming"
|
||||
:isRecording="isRecording"
|
||||
:session-id="currSessionId || null"
|
||||
:current-session="getCurrentSession"
|
||||
:config-id="configId"
|
||||
@send="handleSendMessage"
|
||||
@stop="handleStopMessage"
|
||||
@toggleStreaming="toggleStreaming"
|
||||
@removeImage="removeImage"
|
||||
@removeAudio="removeAudio"
|
||||
@startRecording="handleStartRecording"
|
||||
@stopRecording="handleStopRecording"
|
||||
@pasteImage="handlePaste"
|
||||
@fileSelect="handleFileSelect"
|
||||
@openLiveMode=""
|
||||
ref="chatInputRef"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="!activeMessages.length" class="standalone-state">
|
||||
<div class="welcome-title">{{ tm("welcome.title") }}</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="message-list">
|
||||
<div
|
||||
v-for="(msg, msgIndex) in activeMessages"
|
||||
:key="msg.id || `${msgIndex}-${msg.created_at || ''}`"
|
||||
class="message-row"
|
||||
:class="isUserMessage(msg) ? 'from-user' : 'from-bot'"
|
||||
>
|
||||
<div class="message-stack">
|
||||
<div
|
||||
class="message-bubble"
|
||||
:class="{ user: isUserMessage(msg), bot: !isUserMessage(msg) }"
|
||||
>
|
||||
<div v-if="messageContent(msg).isLoading" class="loading-message">
|
||||
{{ tm("message.loading") }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<ReasoningBlock
|
||||
v-if="messageContent(msg).reasoning"
|
||||
:reasoning="messageContent(msg).reasoning || ''"
|
||||
:is-dark="isDark"
|
||||
:initial-expanded="false"
|
||||
:is-streaming="isMessageStreaming(msg, msgIndex)"
|
||||
:has-non-reasoning-content="hasNonReasoningContent(msg)"
|
||||
/>
|
||||
|
||||
<template
|
||||
v-for="(part, partIndex) in messageParts(msg)"
|
||||
:key="`${msgIndex}-${partIndex}-${part.type}`"
|
||||
>
|
||||
<div
|
||||
v-if="part.type === 'plain' && isUserMessage(msg)"
|
||||
class="plain-content"
|
||||
>
|
||||
{{ part.text || "" }}
|
||||
</div>
|
||||
|
||||
<MarkdownMessagePart
|
||||
v-else-if="part.type === 'plain'"
|
||||
:content="part.text || ''"
|
||||
:refs="messageRefs(msg)"
|
||||
:is-dark="isDark"
|
||||
:custom-html-tags="customMarkdownTags"
|
||||
/>
|
||||
|
||||
<button
|
||||
v-else-if="part.type === 'image'"
|
||||
class="image-part"
|
||||
type="button"
|
||||
@click="openImage(partUrl(part))"
|
||||
>
|
||||
<img :src="partUrl(part)" :alt="part.filename || 'image'" />
|
||||
</button>
|
||||
|
||||
<audio
|
||||
v-else-if="part.type === 'record'"
|
||||
class="audio-part"
|
||||
controls
|
||||
:src="partUrl(part)"
|
||||
/>
|
||||
|
||||
<video
|
||||
v-else-if="part.type === 'video'"
|
||||
class="video-part"
|
||||
controls
|
||||
:src="partUrl(part)"
|
||||
/>
|
||||
|
||||
<div v-else-if="part.type === 'file'" class="file-part">
|
||||
<v-icon size="20">mdi-file-document-outline</v-icon>
|
||||
<span>{{ part.filename || "file" }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="part.type === 'tool_call'"
|
||||
class="tool-call-block"
|
||||
>
|
||||
<template
|
||||
v-for="tool in part.tool_calls || []"
|
||||
:key="tool.id || tool.name"
|
||||
>
|
||||
<ToolCallItem
|
||||
v-if="isIPythonToolCall(tool)"
|
||||
:is-dark="isDark"
|
||||
>
|
||||
<template #label>
|
||||
<v-icon size="16">mdi-code-json</v-icon>
|
||||
<span>{{ tool.name || "python" }}</span>
|
||||
<span class="tool-call-inline-status">
|
||||
{{ toolCallStatusText(tool) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #details>
|
||||
<IPythonToolBlock
|
||||
:tool-call="normalizeToolCall(tool)"
|
||||
:is-dark="isDark"
|
||||
:show-header="false"
|
||||
:force-expanded="true"
|
||||
/>
|
||||
</template>
|
||||
</ToolCallItem>
|
||||
<ToolCallCard
|
||||
v-else
|
||||
:tool-call="normalizeToolCall(tool)"
|
||||
:is-dark="isDark"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<pre v-else class="unknown-part">{{ formatJson(part) }}</pre>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 图片预览对话框 -->
|
||||
<v-dialog v-model="imagePreviewDialog" max-width="90vw" max-height="90vh">
|
||||
<v-card class="image-preview-card" elevation="8">
|
||||
<v-card-title class="d-flex justify-space-between align-center pa-4">
|
||||
<span>{{ t('core.common.imagePreview') }}</span>
|
||||
<v-btn icon="mdi-close" variant="text" @click="imagePreviewDialog = false" />
|
||||
</v-card-title>
|
||||
<v-card-text class="text-center pa-4">
|
||||
<img :src="previewImageUrl" class="preview-image-large" />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
<section class="standalone-composer">
|
||||
<ChatInput
|
||||
ref="inputRef"
|
||||
v-model:prompt="draft"
|
||||
:staged-images-url="stagedImagesUrl"
|
||||
:staged-audio-url="stagedAudioUrl"
|
||||
:staged-files="stagedNonImageFiles"
|
||||
:disabled="sending || initializing"
|
||||
:enable-streaming="enableStreaming"
|
||||
:is-recording="false"
|
||||
:is-running="Boolean(currSessionId && isSessionRunning(currSessionId))"
|
||||
:session-id="currSessionId || null"
|
||||
:current-session="currentSession"
|
||||
:config-id="configId || 'default'"
|
||||
send-shortcut="enter"
|
||||
@send="sendCurrentMessage"
|
||||
@stop="stopCurrentSession"
|
||||
@toggle-streaming="enableStreaming = !enableStreaming"
|
||||
@remove-image="removeImage"
|
||||
@remove-audio="removeAudio"
|
||||
@remove-file="removeFile"
|
||||
@paste-image="handlePaste"
|
||||
@file-select="handleFilesSelected"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<v-overlay
|
||||
v-model="imagePreview.visible"
|
||||
class="image-preview-overlay"
|
||||
scrim="rgba(0, 0, 0, 0.86)"
|
||||
@click="closeImage"
|
||||
>
|
||||
<img class="preview-image" :src="imagePreview.url" alt="preview" />
|
||||
</v-overlay>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useCustomizerStore } from '@/stores/customizer';
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||
import { useTheme } from 'vuetify';
|
||||
import MessageList from '@/components/chat/MessageList.vue';
|
||||
import ChatInput from '@/components/chat/ChatInput.vue';
|
||||
import { useMessages } from '@/composables/useMessages';
|
||||
import { useMediaHandling } from '@/composables/useMediaHandling';
|
||||
import { useRecording } from '@/composables/useRecording';
|
||||
import { useToast } from '@/utils/toast';
|
||||
import { buildWebchatUmoDetails } from '@/utils/chatConfigBinding';
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
reactive,
|
||||
ref,
|
||||
} from "vue";
|
||||
import axios from "axios";
|
||||
import { setCustomComponents } from "markstream-vue";
|
||||
import "markstream-vue/index.css";
|
||||
import ChatInput from "@/components/chat/ChatInput.vue";
|
||||
import IPythonToolBlock from "@/components/chat/message_list_comps/IPythonToolBlock.vue";
|
||||
import MarkdownMessagePart from "@/components/chat/message_list_comps/MarkdownMessagePart.vue";
|
||||
import ReasoningBlock from "@/components/chat/message_list_comps/ReasoningBlock.vue";
|
||||
import RefNode from "@/components/chat/message_list_comps/RefNode.vue";
|
||||
import ToolCallCard from "@/components/chat/message_list_comps/ToolCallCard.vue";
|
||||
import ToolCallItem from "@/components/chat/message_list_comps/ToolCallItem.vue";
|
||||
import ThemeAwareMarkdownCodeBlock from "@/components/shared/ThemeAwareMarkdownCodeBlock.vue";
|
||||
import { useMediaHandling } from "@/composables/useMediaHandling";
|
||||
import {
|
||||
useMessages,
|
||||
type ChatRecord,
|
||||
type MessagePart,
|
||||
type TransportMode,
|
||||
} from "@/composables/useMessages";
|
||||
import type { Session } from "@/composables/useSessions";
|
||||
import { useModuleI18n } from "@/i18n/composables";
|
||||
import { useCustomizerStore } from "@/stores/customizer";
|
||||
import { buildWebchatUmoDetails } from "@/utils/chatConfigBinding";
|
||||
|
||||
interface Props {
|
||||
configId?: string | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
configId: null
|
||||
const props = withDefaults(defineProps<{ configId?: string | null }>(), {
|
||||
configId: "default",
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const { error: showError } = useToast();
|
||||
setCustomComponents("chat-message", {
|
||||
ref: RefNode,
|
||||
code_block: ThemeAwareMarkdownCodeBlock,
|
||||
});
|
||||
|
||||
const { tm } = useModuleI18n("features/chat");
|
||||
const customizer = useCustomizerStore();
|
||||
const currSessionId = ref("");
|
||||
const currentSession = ref<Session | null>(null);
|
||||
const draft = ref("");
|
||||
const initializing = ref(false);
|
||||
const enableStreaming = ref(true);
|
||||
const shouldStickToBottom = ref(true);
|
||||
const messagesContainer = ref<HTMLElement | null>(null);
|
||||
const inputRef = ref<InstanceType<typeof ChatInput> | null>(null);
|
||||
const imagePreview = reactive({ visible: false, url: "" });
|
||||
|
||||
// UI 状态
|
||||
const imagePreviewDialog = ref(false);
|
||||
const previewImageUrl = ref('');
|
||||
|
||||
// 会话管理(不使用 useSessions 避免路由跳转)
|
||||
const currSessionId = ref('');
|
||||
const getCurrentSession = computed(() => null); // 独立测试模式不需要会话信息
|
||||
|
||||
async function bindConfigToSession(sessionId: string) {
|
||||
const confId = (props.configId || '').trim();
|
||||
if (!confId || confId === 'default') {
|
||||
return;
|
||||
}
|
||||
|
||||
const umoDetails = buildWebchatUmoDetails(sessionId, false);
|
||||
|
||||
await axios.post('/api/config/umo_abconf_route/update', {
|
||||
umo: umoDetails.umo,
|
||||
conf_id: confId
|
||||
});
|
||||
}
|
||||
|
||||
async function newSession() {
|
||||
try {
|
||||
const response = await axios.get('/api/chat/new_session');
|
||||
const sessionId = response.data.data.session_id;
|
||||
|
||||
try {
|
||||
await bindConfigToSession(sessionId);
|
||||
} catch (err) {
|
||||
console.error('Failed to bind config to session', err);
|
||||
}
|
||||
|
||||
currSessionId.value = sessionId;
|
||||
|
||||
return sessionId;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function updateSessionTitle(sessionId: string, title: string) {
|
||||
// 独立模式不需要更新会话标题
|
||||
}
|
||||
|
||||
function getSessions() {
|
||||
// 独立模式不需要加载会话列表
|
||||
}
|
||||
const isDark = computed(() => customizer.uiTheme === "PurpleThemeDark");
|
||||
const customMarkdownTags = ["ref"];
|
||||
|
||||
const {
|
||||
stagedImagesUrl,
|
||||
stagedAudioUrl,
|
||||
stagedFiles,
|
||||
getMediaFile,
|
||||
processAndUploadImage,
|
||||
handlePaste,
|
||||
removeImage,
|
||||
removeAudio,
|
||||
clearStaged,
|
||||
cleanupMediaCache
|
||||
stagedFiles,
|
||||
stagedImagesUrl,
|
||||
stagedAudioUrl,
|
||||
stagedNonImageFiles,
|
||||
processAndUploadImage,
|
||||
processAndUploadFile,
|
||||
handlePaste,
|
||||
removeImage,
|
||||
removeAudio,
|
||||
removeFile,
|
||||
clearStaged,
|
||||
cleanupMediaCache,
|
||||
} = useMediaHandling();
|
||||
|
||||
const { isRecording, startRecording: startRec, stopRecording: stopRec } = useRecording();
|
||||
|
||||
const {
|
||||
messages,
|
||||
isStreaming,
|
||||
isConvRunning,
|
||||
enableStreaming,
|
||||
getSessionMessages: getSessionMsg,
|
||||
sendMessage: sendMsg,
|
||||
stopMessage: stopMsg,
|
||||
toggleStreaming
|
||||
} = useMessages(currSessionId, getMediaFile, updateSessionTitle, getSessions);
|
||||
|
||||
// 组件引用
|
||||
const messageList = ref<InstanceType<typeof MessageList> | null>(null);
|
||||
const chatInputRef = ref<InstanceType<typeof ChatInput> | null>(null);
|
||||
|
||||
// 输入状态
|
||||
const prompt = ref('');
|
||||
|
||||
const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark');
|
||||
|
||||
function openImagePreview(imageUrl: string) {
|
||||
previewImageUrl.value = imageUrl;
|
||||
imagePreviewDialog.value = true;
|
||||
}
|
||||
|
||||
async function handleStartRecording() {
|
||||
await startRec();
|
||||
}
|
||||
|
||||
async function handleStopRecording() {
|
||||
const audioFilename = await stopRec();
|
||||
stagedAudioUrl.value = audioFilename;
|
||||
}
|
||||
|
||||
async function handleFileSelect(files: FileList) {
|
||||
for (const file of files) {
|
||||
await processAndUploadImage(file);
|
||||
sending,
|
||||
activeMessages,
|
||||
isSessionRunning,
|
||||
isMessageStreaming,
|
||||
isUserMessage,
|
||||
messageContent,
|
||||
messageParts,
|
||||
createLocalExchange,
|
||||
sendMessageStream,
|
||||
stopSession,
|
||||
} = useMessages({
|
||||
currentSessionId: currSessionId,
|
||||
onStreamUpdate: () => {
|
||||
if (shouldStickToBottom.value) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function handleSendMessage() {
|
||||
if (!prompt.value.trim() && stagedFiles.value.length === 0 && !stagedAudioUrl.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!currSessionId.value) {
|
||||
await newSession();
|
||||
}
|
||||
|
||||
const promptToSend = prompt.value.trim();
|
||||
const audioNameToSend = stagedAudioUrl.value;
|
||||
const filesToSend = stagedFiles.value.map(f => ({
|
||||
attachment_id: f.attachment_id,
|
||||
url: f.url,
|
||||
original_name: f.original_name,
|
||||
type: f.type
|
||||
}));
|
||||
|
||||
// 清空输入和附件
|
||||
prompt.value = '';
|
||||
clearStaged();
|
||||
|
||||
// 获取选择的提供商和模型
|
||||
const selection = chatInputRef.value?.getCurrentSelection();
|
||||
const selectedProviderId = selection?.providerId || '';
|
||||
const selectedModelName = selection?.modelName || '';
|
||||
|
||||
await sendMsg(
|
||||
promptToSend,
|
||||
filesToSend,
|
||||
audioNameToSend,
|
||||
selectedProviderId,
|
||||
selectedModelName
|
||||
);
|
||||
|
||||
// 滚动到底部
|
||||
nextTick(() => {
|
||||
messageList.value?.scrollToBottom();
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to send message:', err);
|
||||
showError(t('features.chat.errors.sendMessageFailed'));
|
||||
// 恢复输入内容,让用户可以重试
|
||||
// 注意:附件已经上传到服务器,所以不恢复附件
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStopMessage() {
|
||||
await stopMsg();
|
||||
}
|
||||
const transportMode = computed<TransportMode>(() =>
|
||||
(localStorage.getItem("chat.transportMode") as TransportMode) === "websocket"
|
||||
? "websocket"
|
||||
: "sse",
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
// 独立模式在挂载时创建新会话
|
||||
try {
|
||||
await newSession();
|
||||
} catch (err) {
|
||||
console.error('Failed to create initial session:', err);
|
||||
showError(t('features.chat.errors.createSessionFailed'));
|
||||
}
|
||||
await ensureSession();
|
||||
inputRef.value?.focusInput();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupMediaCache();
|
||||
cleanupMediaCache();
|
||||
});
|
||||
|
||||
async function ensureSession() {
|
||||
if (currSessionId.value) return currSessionId.value;
|
||||
initializing.value = true;
|
||||
try {
|
||||
const response = await axios.get("/api/chat/new_session");
|
||||
const session = response.data?.data as Session;
|
||||
currSessionId.value = session.session_id;
|
||||
currentSession.value = session;
|
||||
await bindConfigToSession(session.session_id);
|
||||
return session.session_id;
|
||||
} finally {
|
||||
initializing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function bindConfigToSession(sessionId: string) {
|
||||
const confId = props.configId || "default";
|
||||
const umo = buildWebchatUmoDetails(sessionId, false).umo;
|
||||
await axios.post("/api/config/umo_abconf_route/update", {
|
||||
umo,
|
||||
conf_id: confId,
|
||||
});
|
||||
}
|
||||
|
||||
async function sendCurrentMessage() {
|
||||
if (!draft.value.trim() && !stagedFiles.value.length) return;
|
||||
const sessionId = await ensureSession();
|
||||
const text = draft.value.trim();
|
||||
const parts = buildOutgoingParts(text);
|
||||
const messageId = crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`;
|
||||
const selection = inputRef.value?.getCurrentSelection();
|
||||
const { botRecord } = createLocalExchange({ sessionId, messageId, parts });
|
||||
|
||||
draft.value = "";
|
||||
clearStaged();
|
||||
scrollToBottom();
|
||||
|
||||
sendMessageStream({
|
||||
sessionId,
|
||||
messageId,
|
||||
parts,
|
||||
transport: transportMode.value,
|
||||
enableStreaming: enableStreaming.value,
|
||||
selectedProvider: selection?.providerId || "",
|
||||
selectedModel: selection?.modelName || "",
|
||||
botRecord,
|
||||
});
|
||||
}
|
||||
|
||||
function buildOutgoingParts(text: string): MessagePart[] {
|
||||
const parts: MessagePart[] = [];
|
||||
if (text) {
|
||||
parts.push({ type: "plain", text });
|
||||
}
|
||||
stagedFiles.value.forEach((file) => {
|
||||
parts.push({
|
||||
type: file.type,
|
||||
attachment_id: file.attachment_id,
|
||||
filename: file.filename,
|
||||
embedded_url: file.url,
|
||||
});
|
||||
});
|
||||
return parts;
|
||||
}
|
||||
|
||||
function hasNonReasoningContent(message: ChatRecord) {
|
||||
return messageParts(message).some((part) => {
|
||||
if (part.type === "reply") return false;
|
||||
if (part.type === "plain") return Boolean(String(part.text || "").trim());
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function stopCurrentSession() {
|
||||
if (!currSessionId.value) return;
|
||||
await stopSession(currSessionId.value);
|
||||
}
|
||||
|
||||
async function handleFilesSelected(files: FileList) {
|
||||
const selectedFiles = Array.from(files || []);
|
||||
for (const file of selectedFiles) {
|
||||
if (file.type.startsWith("image/")) {
|
||||
await processAndUploadImage(file);
|
||||
} else {
|
||||
await processAndUploadFile(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
nextTick(() => {
|
||||
const container = messagesContainer.value;
|
||||
if (!container) return;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
shouldStickToBottom.value = true;
|
||||
});
|
||||
}
|
||||
|
||||
function messageRefs(message: ChatRecord) {
|
||||
const refs = messageContent(message).refs;
|
||||
if (refs && typeof refs === "object" && Array.isArray(refs.used)) {
|
||||
return refs as { used?: Array<Record<string, unknown>> };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function partUrl(part: MessagePart) {
|
||||
if (part.embedded_url) return part.embedded_url;
|
||||
if (part.embedded_file?.url) return part.embedded_file.url;
|
||||
if (part.attachment_id)
|
||||
return `/api/chat/get_attachment?attachment_id=${encodeURIComponent(
|
||||
part.attachment_id,
|
||||
)}`;
|
||||
if (part.filename)
|
||||
return `/api/chat/get_file?filename=${encodeURIComponent(part.filename)}`;
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizeToolCall(tool: Record<string, unknown>) {
|
||||
const normalized = { ...tool };
|
||||
normalized.args = parseJsonSafe(normalized.args || normalized.arguments);
|
||||
normalized.result = parseJsonSafe(normalized.result);
|
||||
if (!normalized.ts) normalized.ts = Date.now() / 1000;
|
||||
if (normalized.result && typeof normalized.result === "object") {
|
||||
normalized.result = JSON.stringify(normalized.result, null, 2);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isIPythonToolCall(tool: Record<string, unknown>) {
|
||||
const name = String(tool.name || "").toLowerCase();
|
||||
return name.includes("python") || name.includes("ipython");
|
||||
}
|
||||
|
||||
function toolCallStatusText(tool: Record<string, unknown>) {
|
||||
if (tool.finished_ts) return tm("toolStatus.done");
|
||||
return tm("toolStatus.running");
|
||||
}
|
||||
|
||||
function formatJson(value: unknown) {
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonSafe(value: unknown) {
|
||||
if (typeof value !== "string") return value;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function openImage(url: string) {
|
||||
imagePreview.url = url;
|
||||
imagePreview.visible = true;
|
||||
}
|
||||
|
||||
function closeImage() {
|
||||
imagePreview.visible = false;
|
||||
imagePreview.url = "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 基础动画 */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.standalone-chat {
|
||||
--standalone-muted: rgba(var(--v-theme-on-surface), 0.62);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgb(var(--v-theme-background));
|
||||
}
|
||||
|
||||
.standalone-chat-card {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
.standalone-messages {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 20px 22px 14px;
|
||||
}
|
||||
|
||||
.standalone-chat-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-layout {
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-content-panel {
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.conversation-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
padding-left: 16px;
|
||||
border-bottom: 1px solid var(--v-theme-border);
|
||||
width: 100%;
|
||||
padding-right: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.conversation-header-info h4 {
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.conversation-header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.welcome-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
.standalone-state {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 28px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bot-name {
|
||||
font-weight: 700;
|
||||
margin-left: 8px;
|
||||
color: var(--v-theme-secondary);
|
||||
.message-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
.message-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.preview-image-large {
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
object-fit: contain;
|
||||
.message-row.from-user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.message-stack {
|
||||
max-width: 88%;
|
||||
}
|
||||
|
||||
.from-user .message-stack {
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
line-height: 1.65;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.message-bubble.user {
|
||||
padding: 12px 18px;
|
||||
border-radius: 1.5rem;
|
||||
background: rgba(var(--v-theme-primary), 0.12);
|
||||
}
|
||||
|
||||
.message-bubble.bot {
|
||||
padding-left: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.plain-content {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.loading-message,
|
||||
.tool-call-inline-status {
|
||||
color: var(--standalone-muted);
|
||||
}
|
||||
|
||||
.image-part {
|
||||
display: block;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin-top: 8px;
|
||||
background: transparent;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.image-part img {
|
||||
max-width: min(360px, 100%);
|
||||
max-height: 320px;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.audio-part,
|
||||
.video-part {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.video-part {
|
||||
max-height: 320px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.file-part {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.tool-call-block {
|
||||
margin: 8px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.message-bubble.bot
|
||||
> .tool-call-block:first-child
|
||||
:deep(.tool-call-card:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.unknown-part {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
background: rgba(var(--v-theme-on-surface), 0.06);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.standalone-composer {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding-bottom: 10px;
|
||||
background: rgb(var(--v-theme-background));
|
||||
}
|
||||
|
||||
.standalone-composer::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: -32px;
|
||||
height: 32px;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(var(--v-theme-background), 0),
|
||||
rgb(var(--v-theme-background))
|
||||
);
|
||||
}
|
||||
|
||||
.standalone-composer :deep(.input-area) {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.image-preview-overlay {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
max-width: min(92vw, 1000px);
|
||||
max-height: 88vh;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
<template>
|
||||
<div class="welcome-container fade-in">
|
||||
<div v-if="isLoading" class="loading-overlay-welcome">
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
size="48"
|
||||
width="4"
|
||||
color="primary"
|
||||
></v-progress-circular>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="welcome-content">
|
||||
<div class="welcome-title">
|
||||
<span class="bot-name-container">
|
||||
<span class="bot-name-text">
|
||||
Hello, I'm <span class="highlight-name">AstrBot</span>
|
||||
</span>
|
||||
<span class="bot-name-star">⭐</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="welcome-input">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
isLoading: false
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.welcome-content {
|
||||
padding: 24px 0px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 28px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.welcome-input {
|
||||
width: 75%;
|
||||
}
|
||||
|
||||
.loading-overlay-welcome {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bot-name-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.highlight-name {
|
||||
color: var(--v-theme-secondary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bot-name-text {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
animation: revealText 1.2s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.bot-name-star {
|
||||
margin-left: 0;
|
||||
display: inline-block;
|
||||
transform-origin: center;
|
||||
animation: rotateStar 1.2s cubic-bezier(0.34, 1, 0.64, 1) forwards;
|
||||
animation-delay: 0.2s;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
@keyframes revealText {
|
||||
from {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
width: 9.2em;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotateStar {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.welcome-input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,109 +1,121 @@
|
||||
<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
|
||||
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';
|
||||
import { useModuleI18n } from "@/i18n/composables";
|
||||
|
||||
export default {
|
||||
name: 'ActionRef',
|
||||
props: {
|
||||
refs: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
name: "ActionRef",
|
||||
props: {
|
||||
refs: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
emits: ['open-refs'],
|
||||
setup() {
|
||||
const { tm } = useModuleI18n('features/chat');
|
||||
return { tm };
|
||||
},
|
||||
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();
|
||||
},
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
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);
|
||||
background-color: rgba(103, 58, 183, 0.08);
|
||||
}
|
||||
|
||||
.refs-avatars {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
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;
|
||||
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;
|
||||
margin-left: -8px;
|
||||
}
|
||||
|
||||
.ref-favicon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.ref-initial {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
user-select: none;
|
||||
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;
|
||||
margin-left: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--v-theme-secondaryText);
|
||||
opacity: 0.7;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import { createHighlighter } from 'shiki';
|
||||
import { ensureShikiLanguages, escapeHtml, renderShikiCode } from '@/utils/shiki';
|
||||
|
||||
const props = defineProps({
|
||||
toolCall: {
|
||||
@@ -82,13 +82,15 @@ const highlightedCode = computed(() => {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
return shikiHighlighter.value.codeToHtml(code.value, {
|
||||
lang: 'python',
|
||||
theme: props.isDark ? 'min-dark' : 'github-light'
|
||||
});
|
||||
return renderShikiCode(
|
||||
shikiHighlighter.value,
|
||||
code.value,
|
||||
'python',
|
||||
props.isDark ? 'dark' : 'light'
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to highlight code:', err);
|
||||
return `<pre><code>${code.value}</code></pre>`;
|
||||
return `<pre><code>${escapeHtml(code.value)}</code></pre>`;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -101,10 +103,7 @@ const displayExpanded = computed(() => {
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
shikiHighlighter.value = await createHighlighter({
|
||||
themes: ['min-dark', 'github-light'],
|
||||
langs: ['python']
|
||||
});
|
||||
shikiHighlighter.value = await ensureShikiLanguages(['python']);
|
||||
shikiReady.value = true;
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize Shiki:', err);
|
||||
@@ -139,6 +138,20 @@ onMounted(async () => {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
:deep(.code-highlighted pre.shiki) {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
:deep(.code-highlighted pre.shiki code) {
|
||||
display: block;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.code-fallback {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="markdown-content">
|
||||
<MarkdownRender
|
||||
custom-id="chat-message"
|
||||
:content="content"
|
||||
:custom-html-tags="customHtmlTags"
|
||||
:is-dark="isDark"
|
||||
:typewriter="false"
|
||||
:max-live-nodes="0"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, provide } from "vue";
|
||||
import { MarkdownRender } from "markstream-vue";
|
||||
|
||||
const props = defineProps<{
|
||||
content: string;
|
||||
refs: { used?: Array<Record<string, unknown>> } | null;
|
||||
isDark: boolean;
|
||||
customHtmlTags: string[];
|
||||
}>();
|
||||
|
||||
const isDarkRef = computed(() => props.isDark);
|
||||
const refsByIndex = computed(() => {
|
||||
const messageRefs = props.refs;
|
||||
const refs =
|
||||
messageRefs && Array.isArray(messageRefs.used) ? messageRefs.used : [];
|
||||
return refs.reduce<Record<string, Record<string, unknown>>>((acc, item) => {
|
||||
if (item.index != null) {
|
||||
acc[String(item.index)] = item;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
});
|
||||
|
||||
provide("isDark", isDarkRef);
|
||||
provide("webSearchResults", () => refsByIndex.value);
|
||||
</script>
|
||||
@@ -1,433 +0,0 @@
|
||||
<template>
|
||||
<template v-for="(renderPart, renderIndex) in getRenderParts(parts)" :key="renderPart.key">
|
||||
<!-- Grouped Tool Calls (consecutive tool_call parts) -->
|
||||
<div v-if="renderPart.type === 'tool_group'" class="tool-call-compact">
|
||||
<transition-group name="tool-call-item" tag="div" class="tool-call-items">
|
||||
<ToolCallItem v-for="(toolCall, tcIndex) in renderPart.toolCalls" :key="toolCall.id" :is-dark="isDark">
|
||||
<template #label="{ expanded }">
|
||||
<v-icon size="x-small" v-if="toolCall.name.includes('web_search') || toolCall.name.includes('tavily')">
|
||||
mdi-web
|
||||
</v-icon>
|
||||
<v-icon size="x-small" v-else-if="toolCall.name === 'astrbot_execute_shell'">
|
||||
mdi-console-line
|
||||
</v-icon>
|
||||
<v-icon size="x-small" v-else>
|
||||
mdi-wrench
|
||||
</v-icon>
|
||||
{{ tm('actions.toolCallUsed', { name: toolCall.name }) }}
|
||||
<span style="opacity: 0.6;">{{ toolCall.finished_ts ? formatDuration(toolCall.finished_ts -
|
||||
toolCall.ts) : getElapsedTime(toolCall.ts) }}</span>
|
||||
<v-icon size="x-small" class="tool-call-chevron" :class="{ rotated: expanded }">
|
||||
mdi-chevron-right
|
||||
</v-icon>
|
||||
</template>
|
||||
<template #details>
|
||||
<div class="tool-call-detail-row">
|
||||
<span class="detail-label">ID:</span>
|
||||
<code class="detail-value">{{ toolCall.id }}</code>
|
||||
</div>
|
||||
<div class="tool-call-detail-row">
|
||||
<span class="detail-label">Args:</span>
|
||||
<pre class="detail-value detail-json">{{ formatToolArgs(toolCall.args) }}</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">{{ formatToolResult(toolCall.result) }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
</ToolCallItem>
|
||||
</transition-group>
|
||||
</div>
|
||||
|
||||
<!-- iPython Tool Block -->
|
||||
<ToolCallItem v-else-if="renderPart.type === 'ipython'" :is-dark="isDark" style="margin: 8px 0 4px;">
|
||||
<template #label="{ expanded }">
|
||||
<v-icon size="x-small">
|
||||
mdi-code-json
|
||||
</v-icon>
|
||||
<span class="ipython-label">{{ tm('actions.pythonCodeAnalysis') }}</span>
|
||||
<span style="opacity: 0.6;">{{ renderPart.toolCall.finished_ts ?
|
||||
formatDuration(renderPart.toolCall.finished_ts -
|
||||
renderPart.toolCall.ts) : getElapsedTime(renderPart.toolCall.ts) }}</span>
|
||||
<v-icon size="small" class="ipython-icon" :class="{ rotated: expanded }">
|
||||
mdi-chevron-right
|
||||
</v-icon>
|
||||
</template>
|
||||
<template #details>
|
||||
<IPythonToolBlock :tool-call="renderPart.toolCall" :is-dark="isDark" :show-header="false"
|
||||
:force-expanded="true" />
|
||||
</template>
|
||||
</ToolCallItem>
|
||||
|
||||
<!-- Text (Markdown) -->
|
||||
<MarkdownRender
|
||||
v-else-if="renderPart.part.type === 'plain' && renderPart.part.text && renderPart.part.text.trim()"
|
||||
custom-id="message-list" :custom-html-tags="['ref']"
|
||||
:content="normalizeMarkdownContent(renderPart.part.text)" :typewriter="false"
|
||||
class="markdown-content" :is-dark="isDark" :monacoOptions="{ theme: isDark ? 'vs-dark' : 'vs-light' }"
|
||||
:key="`${renderPart.key}-${isDark ? 'dark' : 'light'}`"/>
|
||||
|
||||
<!-- Image -->
|
||||
<div v-else-if="renderPart.part.type === 'image' && renderPart.part.embedded_url" class="embedded-images">
|
||||
<div class="embedded-image">
|
||||
<img :src="renderPart.part.embedded_url" class="bot-embedded-image"
|
||||
@click="emitOpenImage(renderPart.part.embedded_url)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audio -->
|
||||
<div v-else-if="renderPart.part.type === 'record' && renderPart.part.embedded_url" class="embedded-audio">
|
||||
<audio controls class="audio-player">
|
||||
<source :src="renderPart.part.embedded_url" type="audio/wav">
|
||||
{{ t('messages.errors.browser.audioNotSupported') }}
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
<!-- Files -->
|
||||
<div v-else-if="renderPart.part.type === 'file' && renderPart.part.embedded_file" class="embedded-files">
|
||||
<div class="embedded-file">
|
||||
<a v-if="renderPart.part.embedded_file.url" :href="renderPart.part.embedded_file.url"
|
||||
:download="renderPart.part.embedded_file.filename" class="file-link" :class="{ 'is-dark': isDark }"
|
||||
:style="isDark ? {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
color: 'var(--v-theme-secondary)'
|
||||
} : {}">
|
||||
<v-icon size="small" class="file-icon"
|
||||
:style="isDark ? { color: 'var(--v-theme-secondary)' } : {}">mdi-file-document-outline</v-icon>
|
||||
<span class="file-name">{{ renderPart.part.embedded_file.filename }}</span>
|
||||
</a>
|
||||
<a v-else @click="emitDownloadFile(renderPart.part.embedded_file)" class="file-link file-link-download"
|
||||
:class="{ 'is-dark': isDark }" :style="isDark ? {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
color: 'var(--v-theme-secondary)'
|
||||
} : {}">
|
||||
<v-icon size="small" class="file-icon"
|
||||
:style="isDark ? { color: 'var(--v-theme-secondary)' } : {}">mdi-file-document-outline</v-icon>
|
||||
<span class="file-name">{{ renderPart.part.embedded_file.filename }}</span>
|
||||
<v-icon v-if="downloadingFiles?.has(renderPart.part.embedded_file.attachment_id)" size="small"
|
||||
class="download-icon">mdi-loading mdi-spin</v-icon>
|
||||
<v-icon v-else size="small" class="download-icon">mdi-download</v-icon>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||
import { MarkdownRender } from 'markstream-vue';
|
||||
import IPythonToolBlock from './IPythonToolBlock.vue';
|
||||
import ToolCallItem from './ToolCallItem.vue';
|
||||
|
||||
const props = defineProps({
|
||||
parts: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
isDark: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
currentTime: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
downloadingFiles: {
|
||||
type: Object,
|
||||
default: () => new Set()
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['open-image-preview', 'download-file']);
|
||||
const { t } = useI18n();
|
||||
const { tm } = useModuleI18n('features/chat');
|
||||
|
||||
const emitOpenImage = (url) => {
|
||||
emit('open-image-preview', url);
|
||||
};
|
||||
|
||||
const emitDownloadFile = (file) => {
|
||||
emit('download-file', file);
|
||||
};
|
||||
|
||||
const isMarkdownCodeFence = (text) => /^(```|~~~)/.test(text.trim());
|
||||
|
||||
const looksLikeStandaloneHtml = (text) => {
|
||||
const normalized = text.trim();
|
||||
if (!normalized) return false;
|
||||
if (!/(<!doctype\s+html|<html\b|<head\b|<body\b)/i.test(normalized)) return false;
|
||||
return /(<\/html>|<\/body>|<\/head>|<form\b|<input\b|<button\b)/i.test(normalized);
|
||||
};
|
||||
|
||||
const normalizeMarkdownContent = (text) => {
|
||||
if (typeof text !== 'string') return text;
|
||||
if (isMarkdownCodeFence(text) || !looksLikeStandaloneHtml(text)) return text;
|
||||
return `\`\`\`\`html\n${text}\n\`\`\`\``;
|
||||
};
|
||||
|
||||
const formatDuration = (seconds) => {
|
||||
if (seconds < 1) {
|
||||
return `${Math.round(seconds * 1000)}ms`;
|
||||
}
|
||||
if (seconds < 60) {
|
||||
return `${seconds.toFixed(1)}s`;
|
||||
}
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const secs = Math.round(seconds % 60);
|
||||
return `${minutes}m ${secs}s`;
|
||||
};
|
||||
|
||||
const getElapsedTime = (startTs) => {
|
||||
const elapsed = props.currentTime - startTs;
|
||||
return formatDuration(elapsed);
|
||||
};
|
||||
|
||||
const formatToolResult = (result) => {
|
||||
if (!result) return '';
|
||||
if (typeof result === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(result);
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return JSON.stringify(result, null, 2);
|
||||
};
|
||||
|
||||
const formatToolArgs = (args) => {
|
||||
if (!args) return '';
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(args);
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
} catch {
|
||||
return args;
|
||||
}
|
||||
}
|
||||
return JSON.stringify(args, null, 2);
|
||||
};
|
||||
|
||||
const isIPythonTool = (toolCall) => {
|
||||
return toolCall.name === 'astrbot_execute_ipython' || toolCall.name === 'astrbot_execute_python';
|
||||
};
|
||||
|
||||
const getRenderParts = (messageParts) => {
|
||||
if (!Array.isArray(messageParts)) return [];
|
||||
const rendered = [];
|
||||
let pendingToolCalls = [];
|
||||
let groupIndex = 0;
|
||||
|
||||
const flushPending = (endIndex) => {
|
||||
if (!pendingToolCalls.length) return;
|
||||
rendered.push({
|
||||
type: 'tool_group',
|
||||
toolCalls: pendingToolCalls,
|
||||
key: `tool-group-${groupIndex}-${endIndex}`
|
||||
});
|
||||
pendingToolCalls = [];
|
||||
groupIndex += 1;
|
||||
};
|
||||
|
||||
messageParts.forEach((part, idx) => {
|
||||
if (part?.type === 'tool_call' && Array.isArray(part.tool_calls) && part.tool_calls.length) {
|
||||
part.tool_calls.forEach((toolCall, tcIndex) => {
|
||||
if (isIPythonTool(toolCall)) {
|
||||
flushPending(idx - 1);
|
||||
rendered.push({
|
||||
type: 'ipython',
|
||||
toolCall,
|
||||
key: `ipython-${idx}-${tcIndex}`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
pendingToolCalls.push(toolCall);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
flushPending(idx - 1);
|
||||
rendered.push({
|
||||
type: 'part',
|
||||
part,
|
||||
key: `part-${idx}`
|
||||
});
|
||||
});
|
||||
|
||||
flushPending(messageParts.length - 1);
|
||||
return rendered;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tool-call-compact {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
|
||||
.tool-call-group-title {
|
||||
font-size: 13px;
|
||||
color: var(--v-theme-secondaryText);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tool-call-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tool-call-detail-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.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: 2px 6px;
|
||||
border-radius: 4px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.detail-json {
|
||||
font-family: 'Fira Code', 'Consolas', monospace;
|
||||
white-space: pre-wrap;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.detail-result {
|
||||
max-height: 320px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.tool-call-item-enter-active,
|
||||
.tool-call-item-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tool-call-item-enter-from,
|
||||
.tool-call-item-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.ipython-icon,
|
||||
.tool-call-chevron {
|
||||
margin-left: 6px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.ipython-icon.rotated {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.tool-call-chevron.rotated {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
|
||||
.embedded-images {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.embedded-image {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.bot-embedded-image {
|
||||
max-width: 55%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.embedded-audio {
|
||||
width: 300px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.embedded-audio .audio-player {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
/* 文件附件样式 */
|
||||
.file-attachments,
|
||||
.embedded-files {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.file-attachment,
|
||||
.embedded-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
|
||||
/* 文件附件样式 */
|
||||
.file-attachments,
|
||||
.embedded-files {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.file-attachment,
|
||||
.embedded-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.file-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
border: 1px solid rgba(var(--v-theme-primary), 0.2);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s ease;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.file-link-download {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -1,122 +1,288 @@
|
||||
<template>
|
||||
<div class="reasoning-block" :class="{ 'reasoning-block--dark': isDark }">
|
||||
<div class="reasoning-header" @click="toggleExpanded">
|
||||
<v-icon size="small" class="reasoning-icon" :class="{ 'rotate-90': isExpanded }">
|
||||
mdi-chevron-right
|
||||
</v-icon>
|
||||
<span class="reasoning-title">
|
||||
{{ tm('reasoning.thinking') }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="isExpanded" class="reasoning-content animate-fade-in">
|
||||
<MarkdownRender :key="`reasoning-${isDark ? 'dark' : 'light'}`" :content="reasoning" class="reasoning-text markdown-content"
|
||||
:typewriter="false" :is-dark="isDark" :style="isDark ? { opacity: '0.85' } : {}" />
|
||||
</div>
|
||||
<div class="reasoning-block" :class="{ 'reasoning-block--dark': isDark }">
|
||||
<button class="reasoning-header" type="button" @click="toggleExpanded">
|
||||
<span class="reasoning-title">
|
||||
{{ tm("reasoning.thinking") }}
|
||||
</span>
|
||||
<v-icon
|
||||
size="22"
|
||||
class="reasoning-icon"
|
||||
:class="{ 'rotate-90': isExpanded }"
|
||||
>
|
||||
mdi-chevron-right
|
||||
</v-icon>
|
||||
</button>
|
||||
<div v-if="isExpanded" class="reasoning-content animate-fade-in">
|
||||
<MarkdownRender
|
||||
:key="`reasoning-${isDark ? 'dark' : 'light'}`"
|
||||
:content="reasoning"
|
||||
class="reasoning-text markdown-content"
|
||||
:typewriter="false"
|
||||
:is-dark="isDark"
|
||||
/>
|
||||
</div>
|
||||
<transition :name="previewTransitionName" mode="out-in">
|
||||
<div
|
||||
v-if="showStreamingPreview"
|
||||
:key="previewKey"
|
||||
class="reasoning-preview"
|
||||
>
|
||||
{{ previewText }}
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import { MarkdownRender } from 'markstream-vue';
|
||||
import { computed, onBeforeUnmount, ref, watch } 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
|
||||
}
|
||||
reasoning: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isDark: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
initialExpanded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isStreaming: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hasNonReasoningContent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { tm } = useModuleI18n('features/chat');
|
||||
const { tm } = useModuleI18n("features/chat");
|
||||
const isExpanded = ref(props.initialExpanded);
|
||||
const previewText = ref("");
|
||||
const previewKey = ref(0);
|
||||
let previewTimer = null;
|
||||
let previewStartTimer = null;
|
||||
|
||||
const showStreamingPreview = computed(
|
||||
() =>
|
||||
props.isStreaming &&
|
||||
!isExpanded.value &&
|
||||
!props.hasNonReasoningContent &&
|
||||
previewText.value,
|
||||
);
|
||||
const previewTransitionName = computed(() =>
|
||||
props.hasNonReasoningContent
|
||||
? "reasoning-preview-collapse"
|
||||
: "reasoning-preview-fade",
|
||||
);
|
||||
|
||||
const toggleExpanded = () => {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
isExpanded.value = !isExpanded.value;
|
||||
};
|
||||
|
||||
const latestReasoningPreview = () => {
|
||||
const lines = props.reasoning
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
return lines.slice(-3).join("\n");
|
||||
};
|
||||
|
||||
const updatePreviewLine = () => {
|
||||
const nextText = latestReasoningPreview();
|
||||
if (!nextText || nextText === previewText.value) return;
|
||||
previewText.value = nextText;
|
||||
previewKey.value += 1;
|
||||
};
|
||||
|
||||
const stopPreviewTimer = () => {
|
||||
if (!previewTimer) return;
|
||||
clearInterval(previewTimer);
|
||||
previewTimer = null;
|
||||
};
|
||||
|
||||
const stopPreviewStartTimer = () => {
|
||||
if (!previewStartTimer) return;
|
||||
clearTimeout(previewStartTimer);
|
||||
previewStartTimer = null;
|
||||
};
|
||||
|
||||
const startPreviewTimer = () => {
|
||||
updatePreviewLine();
|
||||
if (!previewTimer) {
|
||||
previewTimer = setInterval(updatePreviewLine, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const syncPreviewTimer = () => {
|
||||
if (props.isStreaming && !isExpanded.value && !props.hasNonReasoningContent) {
|
||||
if (!previewTimer && !previewStartTimer) {
|
||||
previewStartTimer = setTimeout(() => {
|
||||
previewStartTimer = null;
|
||||
if (
|
||||
props.isStreaming &&
|
||||
!isExpanded.value &&
|
||||
!props.hasNonReasoningContent
|
||||
) {
|
||||
startPreviewTimer();
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
stopPreviewStartTimer();
|
||||
stopPreviewTimer();
|
||||
if (!props.isStreaming) {
|
||||
previewText.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [props.isStreaming, isExpanded.value, props.hasNonReasoningContent],
|
||||
syncPreviewTimer,
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPreviewStartTimer();
|
||||
stopPreviewTimer();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
|
||||
/* 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-block {
|
||||
margin: 6px 0;
|
||||
max-width: 100%;
|
||||
color: rgba(var(--v-theme-on-surface), 0.7);
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.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;
|
||||
max-width: 100%;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.reasoning-header:hover {
|
||||
background-color: rgba(103, 58, 183, 0.08);
|
||||
}
|
||||
|
||||
.reasoning-header.is-dark:hover {
|
||||
background-color: rgba(103, 58, 183, 0.15);
|
||||
color: rgba(var(--v-theme-on-surface), 0.88);
|
||||
}
|
||||
|
||||
.reasoning-icon {
|
||||
margin-right: 6px;
|
||||
color: var(--v-theme-secondary);
|
||||
transition: transform 0.2s ease;
|
||||
color: currentcolor;
|
||||
transition: transform 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.reasoning-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--v-theme-secondary);
|
||||
letter-spacing: 0.3px;
|
||||
.reasoning-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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;
|
||||
margin-top: 8px;
|
||||
padding: 0;
|
||||
color: rgba(var(--v-theme-on-surface), 0.7);
|
||||
animation: fadeIn 0.2s ease-in-out;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.reasoning-preview {
|
||||
max-width: 100%;
|
||||
margin-top: 4px;
|
||||
color: rgba(var(--v-theme-on-surface), 0.52);
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
white-space: pre-line;
|
||||
font: inherit;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.reasoning-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--v-theme-secondaryText);
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.2s ease-in-out;
|
||||
animation: fadeIn 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.rotate-90 {
|
||||
transform: rotate(90deg);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.reasoning-preview-fade-enter-active {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.reasoning-preview-fade-leave-active {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.reasoning-preview-fade-enter-from,
|
||||
.reasoning-preview-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.reasoning-preview-collapse-enter-active {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.reasoning-preview-collapse-leave-active {
|
||||
overflow: hidden;
|
||||
transition:
|
||||
max-height 0.45s cubic-bezier(0.55, 0, 1, 0.45),
|
||||
margin-top 0.45s cubic-bezier(0.55, 0, 1, 0.45),
|
||||
opacity 0.35s ease-in,
|
||||
transform 0.45s cubic-bezier(0.55, 0, 1, 0.45);
|
||||
}
|
||||
|
||||
.reasoning-preview-collapse-enter-from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.reasoning-preview-collapse-leave-from {
|
||||
max-height: 5rem;
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.reasoning-preview-collapse-leave-to {
|
||||
max-height: 0;
|
||||
margin-top: 0;
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,76 +1,91 @@
|
||||
<template>
|
||||
<v-chip v-if="domain" class="ref-chip" size="x-small" variant="flat"
|
||||
:style="chipStyle" :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="fallbackStyle">{{ 'site' }}</span>
|
||||
<v-chip
|
||||
v-if="resultData"
|
||||
class="ref-chip"
|
||||
size="x-small"
|
||||
variant="flat"
|
||||
:style="chipStyle"
|
||||
:href="url"
|
||||
target="_blank"
|
||||
clickable
|
||||
>
|
||||
<v-icon start size="x-small" color>mdi-link-variant</v-icon>
|
||||
<span>{{ domain || resultData.title || refIndex }}</span>
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, inject } from 'vue'
|
||||
import { computed, inject, unref, useSlots } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
node: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
node: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('RefNode node:', props.node);
|
||||
const slots = useSlots();
|
||||
const injectedIsDark = inject("isDark", false);
|
||||
const webSearchResults = inject("webSearchResults", () => ({}));
|
||||
|
||||
// 从父组件注入的暗黑模式状态和搜索结果
|
||||
const isDark = inject('isDark', false)
|
||||
const webSearchResults = inject('webSearchResults', () => ({}))
|
||||
const isDark = computed(() => Boolean(unref(injectedIsDark)));
|
||||
const refIndex = computed(() => {
|
||||
const nodeContent = props.node?.content?.trim();
|
||||
if (nodeContent) return nodeContent;
|
||||
return slotText(slots.default?.()).trim();
|
||||
});
|
||||
|
||||
// 从 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
|
||||
})
|
||||
if (!refIndex.value) return null;
|
||||
const results =
|
||||
typeof webSearchResults === "function"
|
||||
? webSearchResults()
|
||||
: webSearchResults;
|
||||
return results?.[refIndex.value] || null;
|
||||
});
|
||||
|
||||
const url = computed(() => resultData.value?.url || '')
|
||||
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 ''
|
||||
}
|
||||
})
|
||||
if (!url.value) return "";
|
||||
try {
|
||||
const urlObj = new URL(url.value);
|
||||
return urlObj.hostname.replace(/^www\./, "");
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
const chipStyle = computed(() => ({
|
||||
backgroundColor: isDark ? 'rgba(var(--v-theme-on-surface), 0.08)' : 'rgba(var(--v-theme-on-surface), 0.04)',
|
||||
color: isDark ? 'rgba(var(--v-theme-on-surface), 0.62)' : 'rgba(var(--v-theme-on-surface), 0.72)'
|
||||
}))
|
||||
backgroundColor: isDark.value
|
||||
? "rgba(var(--v-theme-on-surface), 0.08)"
|
||||
: "rgba(var(--v-theme-on-surface), 0.04)",
|
||||
color: isDark.value
|
||||
? "rgba(var(--v-theme-on-surface), 0.62)"
|
||||
: "rgba(var(--v-theme-on-surface), 0.72)",
|
||||
}));
|
||||
|
||||
const fallbackStyle = computed(() => ({
|
||||
color: isDark ? 'rgba(var(--v-theme-on-surface), 0.62)' : 'rgba(var(--v-theme-on-surface), 0.72)'
|
||||
}))
|
||||
function slotText(nodes = []) {
|
||||
return nodes
|
||||
.map((node) => {
|
||||
if (typeof node.children === "string") return node.children;
|
||||
if (Array.isArray(node.children)) return slotText(node.children);
|
||||
return "";
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ref-chip {
|
||||
margin: 0 2px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: opacity;
|
||||
margin-left: 4px;
|
||||
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;
|
||||
opacity: 0.8;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,225 +1,262 @@
|
||||
<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>
|
||||
<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 class="refs-list">
|
||||
<div
|
||||
v-for="(ref, index) in normalizedRefs"
|
||||
:key="ref.index || 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>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import { useModuleI18n } from "@/i18n/composables";
|
||||
|
||||
export default {
|
||||
name: 'RefsSidebar',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
refs: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
name: "RefsSidebar",
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup() {
|
||||
const { tm } = useModuleI18n('features/chat');
|
||||
return { tm };
|
||||
refs: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
computed: {
|
||||
isOpen: {
|
||||
get() {
|
||||
return this.modelValue;
|
||||
},
|
||||
set(value) {
|
||||
this.$emit('update:modelValue', value);
|
||||
}
|
||||
}
|
||||
},
|
||||
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();
|
||||
},
|
||||
normalizedRefs() {
|
||||
const used = Array.isArray(this.refs?.used)
|
||||
? this.refs.used
|
||||
: Array.isArray(this.refs)
|
||||
? this.refs
|
||||
: [];
|
||||
|
||||
formatUrl(url) {
|
||||
if (!url) return '';
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return urlObj.hostname;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
},
|
||||
return used
|
||||
.map((ref) => ({
|
||||
index: ref?.index,
|
||||
title: ref?.title || ref?.url || "Reference",
|
||||
url: ref?.url,
|
||||
snippet: ref?.snippet,
|
||||
favicon: ref?.favicon,
|
||||
}))
|
||||
.filter((ref) => ref.url);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
},
|
||||
|
||||
openLink(url) {
|
||||
if (url) {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
width: 360px;
|
||||
height: 100%;
|
||||
background-color: rgb(var(--v-theme-surface));
|
||||
border-left: 1px solid rgba(var(--v-border-color), 0.16);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
|
||||
.slide-left-enter-active,
|
||||
.slide-left-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-left-enter-from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-left-leave-to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
flex-shrink: 0;
|
||||
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);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--v-theme-primaryText);
|
||||
}
|
||||
|
||||
.refs-list {
|
||||
padding: 12px;
|
||||
padding-top: 0;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
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;
|
||||
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);
|
||||
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%);
|
||||
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;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.ref-item-initial {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.ref-item-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,290 +1,271 @@
|
||||
<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>
|
||||
<div class="tool-call-card" :class="{ expanded: isExpanded }">
|
||||
<button class="tool-call-header" type="button" @click="toggleExpanded">
|
||||
<v-icon size="16" class="tool-call-icon">{{ toolCallIcon }}</v-icon>
|
||||
<span class="tool-call-title">
|
||||
{{ tm("actions.toolCallUsed", { name: displayToolName }) }}
|
||||
</span>
|
||||
<span class="tool-call-duration">{{ toolCallDuration }}</span>
|
||||
<v-icon
|
||||
size="22"
|
||||
class="tool-call-expand-icon"
|
||||
:class="{ expanded: isExpanded }"
|
||||
>
|
||||
mdi-chevron-right
|
||||
</v-icon>
|
||||
</button>
|
||||
|
||||
<!-- 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 }}
|
||||
<div v-if="isExpanded" class="tool-call-details">
|
||||
<div v-if="toolCall.id" class="tool-call-detail-row">
|
||||
<span class="detail-label">ID:</span>
|
||||
<code class="detail-value">
|
||||
{{ toolCall.id }}
|
||||
</code>
|
||||
</div>
|
||||
</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>
|
||||
<div class="tool-call-detail-row">
|
||||
<span class="detail-label">Args:</span>
|
||||
<pre class="detail-value detail-json">{{
|
||||
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 v-if="toolCall.result" class="tool-call-detail-row">
|
||||
<span class="detail-label">Result:</span>
|
||||
<pre class="detail-value detail-json detail-result">{{
|
||||
formattedResult
|
||||
}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import { useModuleI18n } from "@/i18n/composables";
|
||||
|
||||
const props = defineProps({
|
||||
toolCall: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
isDark: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
initialExpanded: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
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 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);
|
||||
if (props.toolCall.finished_ts) return "";
|
||||
const startTime = Number(props.toolCall.ts);
|
||||
if (!Number.isFinite(startTime) || startTime <= 0) return "";
|
||||
return formatDuration(currentTime.value - startTime);
|
||||
});
|
||||
|
||||
const displayToolName = computed(() => props.toolCall.name || "tool");
|
||||
|
||||
const toolCallIcon = computed(() => {
|
||||
const name = String(props.toolCall.name || "");
|
||||
if (name === "astrbot_execute_ipython" || name === "astrbot_execute_python") {
|
||||
return "mdi-code-json";
|
||||
}
|
||||
if (name.includes("web_search") || name.includes("tavily")) {
|
||||
return "mdi-web";
|
||||
}
|
||||
if (name === "astrbot_execute_shell") {
|
||||
return "mdi-console-line";
|
||||
}
|
||||
return "mdi-wrench";
|
||||
});
|
||||
|
||||
const toolCallDuration = computed(() => {
|
||||
const startTime = Number(props.toolCall.ts);
|
||||
if (!Number.isFinite(startTime) || startTime <= 0) return "";
|
||||
if (props.toolCall.finished_ts) {
|
||||
return formatDuration(Number(props.toolCall.finished_ts) - startTime);
|
||||
}
|
||||
return elapsedTime.value;
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
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`;
|
||||
}
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "";
|
||||
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;
|
||||
isExpanded.value = !isExpanded.value;
|
||||
};
|
||||
|
||||
const updateTime = () => {
|
||||
currentTime.value = Date.now() / 1000;
|
||||
currentTime.value = Date.now() / 1000;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// Update time periodically if tool call is running
|
||||
if (!props.toolCall.finished_ts) {
|
||||
timer = setInterval(updateTime, 100);
|
||||
}
|
||||
if (!props.toolCall.finished_ts) {
|
||||
timer = setInterval(updateTime, 100);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
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;
|
||||
margin: 6px 0;
|
||||
max-width: 100%;
|
||||
color: rgba(var(--v-theme-on-surface), 0.7);
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.tool-call-card.expanded {
|
||||
width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tool-call-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background-color;
|
||||
gap: 8px;
|
||||
max-width: 100%;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.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);
|
||||
color: rgba(var(--v-theme-on-surface), 0.88);
|
||||
}
|
||||
|
||||
.tool-call-expand-icon {
|
||||
color: var(--v-theme-secondary);
|
||||
transition: transform 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
color: currentcolor;
|
||||
transition: transform 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-call-expand-icon.expanded {
|
||||
transform: rotate(90deg);
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.tool-call-icon {
|
||||
color: var(--v-theme-secondary);
|
||||
flex-shrink: 0;
|
||||
color: currentcolor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tool-call-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
.tool-call-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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-duration {
|
||||
flex-shrink: 0;
|
||||
color: rgba(var(--v-theme-on-surface), 0.48);
|
||||
}
|
||||
|
||||
.tool-call-details {
|
||||
padding: 12px;
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
animation: fadeIn 0.2s ease-in-out;
|
||||
margin-top: 8px;
|
||||
padding-left: 26px;
|
||||
animation: fadeIn 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.tool-call-detail-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tool-call-detail-row:last-child {
|
||||
margin-bottom: 0;
|
||||
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;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: rgba(var(--v-theme-on-surface), 0.55);
|
||||
text-transform: uppercase;
|
||||
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;
|
||||
font-size: 12px;
|
||||
color: rgba(var(--v-theme-on-surface), 0.8);
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
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;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
white-space: pre-wrap;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.detail-result {
|
||||
max-height: 300px;
|
||||
background-color: transparent;
|
||||
max-height: 300px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.2s ease-in-out;
|
||||
animation: fadeIn 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,6 @@ import axios from 'axios';
|
||||
import { MarkdownRender, enableKatex, enableMermaid } from 'markstream-vue';
|
||||
import 'markstream-vue/index.css';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import 'highlight.js/styles/github.css';
|
||||
|
||||
enableKatex();
|
||||
enableMermaid();
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
<script setup>
|
||||
import { ref, watch, computed, onUnmounted } from "vue";
|
||||
import { useTheme } from "vuetify";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import hljs from "highlight.js";
|
||||
import axios from "axios";
|
||||
import DOMPurify from "dompurify";
|
||||
import "highlight.js/styles/github.css";
|
||||
import { useI18n } from "@/i18n/composables";
|
||||
import {
|
||||
escapeHtml,
|
||||
ensureShikiLanguages,
|
||||
normalizeShikiLanguage,
|
||||
renderShikiCode,
|
||||
} from "@/utils/shiki";
|
||||
|
||||
// 1. 在 setup 作用域创建 MarkdownIt 实例
|
||||
const md = new MarkdownIt({
|
||||
@@ -41,6 +46,7 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(["update:show"]);
|
||||
const { t, locale } = useI18n();
|
||||
const theme = useTheme();
|
||||
|
||||
const content = ref(null);
|
||||
const error = ref(null);
|
||||
@@ -48,7 +54,103 @@ const loading = ref(false);
|
||||
const isEmpty = ref(false);
|
||||
const copyFeedbackTimer = ref(null);
|
||||
const lastRequestId = ref(0);
|
||||
const lastRenderId = ref(0);
|
||||
const scrollContainer = ref(null);
|
||||
const renderedHtml = ref("");
|
||||
const isDark = computed(() => theme.global.current.value.dark);
|
||||
|
||||
const MARKDOWN_SANITIZE_OPTIONS = {
|
||||
ALLOWED_TAGS: [
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"p",
|
||||
"br",
|
||||
"hr",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"blockquote",
|
||||
"pre",
|
||||
"code",
|
||||
"a",
|
||||
"img",
|
||||
"table",
|
||||
"thead",
|
||||
"tbody",
|
||||
"tr",
|
||||
"th",
|
||||
"td",
|
||||
"strong",
|
||||
"em",
|
||||
"del",
|
||||
"s",
|
||||
"details",
|
||||
"summary",
|
||||
"div",
|
||||
"span",
|
||||
"input",
|
||||
"button",
|
||||
"svg",
|
||||
"rect",
|
||||
"path",
|
||||
"polyline",
|
||||
],
|
||||
ALLOWED_ATTR: [
|
||||
"href",
|
||||
"src",
|
||||
"alt",
|
||||
"title",
|
||||
"class",
|
||||
"id",
|
||||
"target",
|
||||
"rel",
|
||||
"type",
|
||||
"checked",
|
||||
"disabled",
|
||||
"open",
|
||||
"align",
|
||||
"width",
|
||||
"height",
|
||||
"viewBox",
|
||||
"fill",
|
||||
"stroke",
|
||||
"stroke-width",
|
||||
"points",
|
||||
"d",
|
||||
"x",
|
||||
"y",
|
||||
"rx",
|
||||
"ry",
|
||||
"data-code-block-index",
|
||||
],
|
||||
};
|
||||
|
||||
const CODE_BLOCK_SANITIZE_OPTIONS = {
|
||||
ALLOWED_TAGS: ["div", "span", "button", "svg", "rect", "path", "polyline", "pre", "code"],
|
||||
ALLOWED_ATTR: [
|
||||
"class",
|
||||
"title",
|
||||
"type",
|
||||
"width",
|
||||
"height",
|
||||
"viewBox",
|
||||
"fill",
|
||||
"stroke",
|
||||
"stroke-width",
|
||||
"points",
|
||||
"d",
|
||||
"x",
|
||||
"y",
|
||||
"rx",
|
||||
"ry",
|
||||
"style",
|
||||
"tabindex",
|
||||
],
|
||||
};
|
||||
|
||||
function slugifyHeading(text, slugCounts) {
|
||||
const base = (text || "")
|
||||
@@ -71,104 +173,62 @@ onUnmounted(() => {
|
||||
if (copyFeedbackTimer.value) clearTimeout(copyFeedbackTimer.value);
|
||||
});
|
||||
|
||||
// 渲染后的 HTML
|
||||
const renderedHtml = computed(() => {
|
||||
// 强制依赖 locale,确保语言切换时重新渲染
|
||||
const _ = locale?.value;
|
||||
if (!content.value) return "";
|
||||
function sanitizeHighlightedBlock(html) {
|
||||
return DOMPurify.sanitize(html, CODE_BLOCK_SANITIZE_OPTIONS);
|
||||
}
|
||||
|
||||
async function updateRenderedHtml() {
|
||||
const source = content.value;
|
||||
const renderId = ++lastRenderId.value;
|
||||
void locale?.value;
|
||||
|
||||
if (!source) {
|
||||
renderedHtml.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
let highlighter = null;
|
||||
const env = {};
|
||||
const tokens = md.parse(source, env);
|
||||
|
||||
try {
|
||||
const languages = tokens
|
||||
.filter((token) => token.type === "fence")
|
||||
.map((token) => normalizeShikiLanguage(token.info));
|
||||
highlighter = await ensureShikiLanguages(languages);
|
||||
} catch (err) {
|
||||
console.error("Failed to initialize Shiki for README dialog:", err);
|
||||
}
|
||||
|
||||
if (renderId !== lastRenderId.value) return;
|
||||
|
||||
const highlightedBlocks = [];
|
||||
|
||||
// 设置 fence 规则,直接使用当前作用域的 t 函数
|
||||
md.renderer.rules.fence = (tokens, idx) => {
|
||||
const token = tokens[idx];
|
||||
const lang = token.info.trim() || "";
|
||||
const lang = normalizeShikiLanguage(token.info);
|
||||
const code = token.content;
|
||||
|
||||
const highlighted =
|
||||
lang && hljs.getLanguage(lang)
|
||||
? hljs.highlight(code, { language: lang }).value
|
||||
: md.utils.escapeHtml(code);
|
||||
|
||||
return `<div class="code-block-wrapper">
|
||||
${lang ? `<span class="code-lang-label">${lang}</span>` : ""}
|
||||
const escapedLangLabel =
|
||||
lang && lang !== "text" ? escapeHtml(lang) : "";
|
||||
const highlighted = highlighter
|
||||
? renderShikiCode(highlighter, code, lang, isDark.value ? "dark" : "light")
|
||||
: `<pre class="shiki shiki-fallback"><code>${escapeHtml(code)}</code></pre>`;
|
||||
const html = sanitizeHighlightedBlock(`<div class="code-block-wrapper">
|
||||
${escapedLangLabel ? `<span class="code-lang-label">${escapedLangLabel}</span>` : ""}
|
||||
<button class="copy-code-btn" title="${t("core.common.copy")}">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
|
||||
</button>
|
||||
<pre class="hljs"><code class="language-${lang}">${highlighted}</code></pre>
|
||||
</div>`;
|
||||
${highlighted}
|
||||
</div>`);
|
||||
|
||||
const placeholderIndex = highlightedBlocks.push(html) - 1;
|
||||
return `<div data-code-block-index="${placeholderIndex}"></div>`;
|
||||
};
|
||||
|
||||
const rawHtml = md.render(content.value);
|
||||
const rawHtml = md.renderer.render(tokens, md.options, env);
|
||||
|
||||
const cleanHtml = DOMPurify.sanitize(rawHtml, {
|
||||
ALLOWED_TAGS: [
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"p",
|
||||
"br",
|
||||
"hr",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"blockquote",
|
||||
"pre",
|
||||
"code",
|
||||
"a",
|
||||
"img",
|
||||
"table",
|
||||
"thead",
|
||||
"tbody",
|
||||
"tr",
|
||||
"th",
|
||||
"td",
|
||||
"strong",
|
||||
"em",
|
||||
"del",
|
||||
"s",
|
||||
"details",
|
||||
"summary",
|
||||
"div",
|
||||
"span",
|
||||
"input",
|
||||
"button",
|
||||
"svg",
|
||||
"rect",
|
||||
"path",
|
||||
"polyline",
|
||||
],
|
||||
ALLOWED_ATTR: [
|
||||
"href",
|
||||
"src",
|
||||
"alt",
|
||||
"title",
|
||||
"class",
|
||||
"id",
|
||||
"target",
|
||||
"rel",
|
||||
"type",
|
||||
"checked",
|
||||
"disabled",
|
||||
"open",
|
||||
"align",
|
||||
"width",
|
||||
"height",
|
||||
"viewBox",
|
||||
"fill",
|
||||
"stroke",
|
||||
"stroke-width",
|
||||
"points",
|
||||
"d",
|
||||
"x",
|
||||
"y",
|
||||
"rx",
|
||||
"ry",
|
||||
],
|
||||
});
|
||||
const cleanHtml = DOMPurify.sanitize(rawHtml, MARKDOWN_SANITIZE_OPTIONS);
|
||||
|
||||
// 3. 后处理方案:完全隔离,安全性最高
|
||||
const tempDiv = document.createElement("div");
|
||||
tempDiv.innerHTML = cleanHtml;
|
||||
|
||||
@@ -185,15 +245,21 @@ const renderedHtml = computed(() => {
|
||||
|
||||
tempDiv.querySelectorAll("a").forEach((link) => {
|
||||
const href = link.getAttribute("href");
|
||||
// 强制所有外部链接使用安全的 _blank 策略
|
||||
if (href && (href.startsWith("http") || href.startsWith("//"))) {
|
||||
link.setAttribute("target", "_blank");
|
||||
link.setAttribute("rel", "noopener noreferrer");
|
||||
}
|
||||
});
|
||||
|
||||
return tempDiv.innerHTML;
|
||||
});
|
||||
tempDiv.querySelectorAll("[data-code-block-index]").forEach((placeholder) => {
|
||||
const index = Number(placeholder.getAttribute("data-code-block-index"));
|
||||
placeholder.outerHTML = highlightedBlocks[index] || "";
|
||||
});
|
||||
|
||||
if (renderId === lastRenderId.value) {
|
||||
renderedHtml.value = tempDiv.innerHTML;
|
||||
}
|
||||
}
|
||||
|
||||
const modeConfig = computed(() => {
|
||||
if (props.mode === "changelog") {
|
||||
@@ -279,6 +345,10 @@ watch(
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch([content, locale, isDark], () => {
|
||||
updateRenderedHtml();
|
||||
}, { immediate: true });
|
||||
|
||||
function handleContainerClick(event) {
|
||||
const btn = event.target.closest(".copy-code-btn");
|
||||
if (btn) {
|
||||
@@ -549,22 +619,32 @@ const showActionArea = computed(() => {
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
}
|
||||
|
||||
:deep(.markdown-body pre.hljs) {
|
||||
:deep(.markdown-body pre.shiki) {
|
||||
padding: 16px;
|
||||
padding-top: 32px;
|
||||
overflow: auto;
|
||||
font-size: 85%;
|
||||
line-height: 1.45;
|
||||
background-color: #0d1117;
|
||||
border-radius: 6px;
|
||||
margin: 0;
|
||||
border: 1px solid rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
:deep(.markdown-body pre.hljs code) {
|
||||
:deep(.markdown-body pre.shiki code) {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
color: #c9d1d9;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
:deep(.markdown-body pre.shiki .line) {
|
||||
display: block;
|
||||
min-height: 1.45em;
|
||||
}
|
||||
|
||||
:deep(.markdown-body pre.shiki.shiki-fallback) {
|
||||
background-color: #f6f8fa;
|
||||
color: #24292f;
|
||||
}
|
||||
:deep(.markdown-body ul),
|
||||
:deep(.markdown-body ol) {
|
||||
@@ -679,13 +759,4 @@ const showActionArea = computed(() => {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
:deep(.markdown-body .hljs-keyword),
|
||||
:deep(.markdown-body .hljs-selector-tag),
|
||||
:deep(.markdown-body .hljs-title),
|
||||
:deep(.markdown-body .hljs-section),
|
||||
:deep(.markdown-body .hljs-doctag),
|
||||
:deep(.markdown-body .hljs-name),
|
||||
:deep(.markdown-body .hljs-strong) {
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<MarkdownCodeBlockNode
|
||||
:key="themeRenderKey"
|
||||
v-bind="forwardedBindings"
|
||||
>
|
||||
<template
|
||||
v-for="(_, slotName) in $slots"
|
||||
#[slotName]="slotProps"
|
||||
>
|
||||
<slot :name="slotName" v-bind="slotProps || {}" />
|
||||
</template>
|
||||
</MarkdownCodeBlockNode>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { MarkdownCodeBlockNode } from "markstream-vue";
|
||||
import { useAttrs } from "vue";
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
node: Record<string, unknown>;
|
||||
isDark?: boolean;
|
||||
}>(),
|
||||
{
|
||||
isDark: false,
|
||||
},
|
||||
);
|
||||
|
||||
const attrs = useAttrs();
|
||||
const forwardedBindings = computed(() => ({
|
||||
...attrs,
|
||||
...props,
|
||||
}));
|
||||
const themeRenderKey = computed(() => (props.isDark ? "dark" : "light"));
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -56,6 +56,10 @@
|
||||
"ipython": {
|
||||
"output": "Output"
|
||||
},
|
||||
"toolStatus": {
|
||||
"done": "Done",
|
||||
"running": "Running"
|
||||
},
|
||||
"conversation": {
|
||||
"newConversation": "New Conversation",
|
||||
"noHistory": "No conversation history",
|
||||
@@ -158,4 +162,4 @@
|
||||
"partialFailure": "{failed} of {total} conversations failed to delete",
|
||||
"requestFailed": "Failed to delete conversations. Please try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,151 +1,155 @@
|
||||
{
|
||||
"title": "Давай пообщаемся!",
|
||||
"subtitle": "Общение с AI-помощником",
|
||||
"input": {
|
||||
"placeholder": "Введите сообщение...",
|
||||
"send": "Отправить",
|
||||
"clear": "Очистить",
|
||||
"upload": "Загрузить файл",
|
||||
"voice": "Голосовой ввод",
|
||||
"recordingPrompt": "Запись... говорите",
|
||||
"chatPrompt": "Давай пообщаемся!",
|
||||
"dropToUpload": "Отпустите, чтобы загрузить файл",
|
||||
"stopGenerating": "Остановить генерацию"
|
||||
},
|
||||
"message": {
|
||||
"user": "Вы",
|
||||
"assistant": "Ассистент",
|
||||
"system": "Система",
|
||||
"error": "Ошибка в сообщении",
|
||||
"loading": "Думаю..."
|
||||
},
|
||||
"voice": {
|
||||
"start": "Начать запись",
|
||||
"stop": "Стоп",
|
||||
"recording": "Запись",
|
||||
"processing": "Обработка...",
|
||||
"error": "Ошибка записи",
|
||||
"listening": "Слушаю...",
|
||||
"speaking": "Говорю",
|
||||
"startRecording": "Начать голосовой ввод",
|
||||
"liveMode": "Общение в реальном времени"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Добро пожаловать в AstrBot",
|
||||
"subtitle": "Ваш умный помощник",
|
||||
"quickActions": "Быстрые действия",
|
||||
"examples": "Примеры вопросов"
|
||||
},
|
||||
"actions": {
|
||||
"copy": "Копировать",
|
||||
"regenerate": "Перегенерировать",
|
||||
"like": "Нравится",
|
||||
"dislike": "Не нравится",
|
||||
"share": "Поделиться",
|
||||
"newChat": "Новый чат",
|
||||
"deleteChat": "Удалить чат",
|
||||
"editTitle": "Изменить заголовок",
|
||||
"fullscreen": "На весь экран",
|
||||
"exitFullscreen": "Выход из полноэкранного режима",
|
||||
"reply": "Ответить",
|
||||
"providerConfig": "Настройки AI",
|
||||
"toolsUsed": "Использованные инструменты",
|
||||
"toolCallUsed": "Использован инструмент {name}",
|
||||
"pythonCodeAnalysis": "Использован анализ кода Python"
|
||||
},
|
||||
"ipython": {
|
||||
"output": "Вывод"
|
||||
},
|
||||
"conversation": {
|
||||
"newConversation": "Новый чат",
|
||||
"noHistory": "История диалогов пуста",
|
||||
"systemStatus": "Статус системы",
|
||||
"llmService": "Сервис LLM",
|
||||
"speechToText": "Преобразование речи",
|
||||
"editDisplayName": "Изменить имя чата",
|
||||
"displayName": "Имя чата",
|
||||
"displayNameUpdated": "Имя чата обновлено",
|
||||
"displayNameUpdateFailed": "Не удалось обновить имя чата",
|
||||
"confirmDelete": "Вы уверены, что хотите удалить «{name}»? Это действие необратимо."
|
||||
},
|
||||
"modes": {
|
||||
"darkMode": "Темная тема",
|
||||
"lightMode": "Светлая тема"
|
||||
},
|
||||
"shortcuts": {
|
||||
"help": "Справка",
|
||||
"voiceRecord": "Запись голоса",
|
||||
"pasteImage": "Вставить изображение",
|
||||
"sendKey": {
|
||||
"title": "Клавиша отправки",
|
||||
"enterToSend": "Enter для отправки",
|
||||
"shiftEnterToSend": "Shift+Enter для отправки"
|
||||
}
|
||||
},
|
||||
"streaming": {
|
||||
"enabled": "Потоковый ответ включен",
|
||||
"disabled": "Потоковый ответ выключен",
|
||||
"on": "Поток",
|
||||
"off": "Обычный"
|
||||
},
|
||||
"transport": {
|
||||
"title": "Протокол передачи",
|
||||
"sse": "SSE",
|
||||
"websocket": "WebSocket"
|
||||
},
|
||||
"config": {
|
||||
"title": "Конфигурация"
|
||||
},
|
||||
"reasoning": {
|
||||
"thinking": "Рассуждение"
|
||||
},
|
||||
"reply": {
|
||||
"replyTo": "В ответ на",
|
||||
"notFound": "Сообщение не найдено"
|
||||
},
|
||||
"project": {
|
||||
"title": "Проект",
|
||||
"create": "Создать проект",
|
||||
"edit": "Изменить проект",
|
||||
"name": "Имя проекта",
|
||||
"emoji": "Иконка (Emoji)",
|
||||
"description": "Описание проекта (опционально)",
|
||||
"noSessions": "В этом проекте пока нет диалогов",
|
||||
"confirmDelete": "Вы уверены, что хотите удалить проект «{title}»? Диалоги внутри проекта не будут удалены."
|
||||
},
|
||||
"time": {
|
||||
"today": "Сегодня",
|
||||
"yesterday": "Вчера"
|
||||
},
|
||||
"stats": {
|
||||
"tokens": "Токены",
|
||||
"inputTokens": "Входящие",
|
||||
"outputTokens": "Исходящие",
|
||||
"cachedTokens": "Кэшированные",
|
||||
"duration": "Время",
|
||||
"ttft": "Время до первого токена"
|
||||
},
|
||||
"refs": {
|
||||
"title": "Ссылки",
|
||||
"sources": "Источники"
|
||||
},
|
||||
"connection": {
|
||||
"title": "Статус подключения",
|
||||
"message": "Системе необходимо переустановить соединение с чатом.",
|
||||
"reasons": "Это может быть вызвано следующими причинами:",
|
||||
"reasonWindowResize": "Изменение размера окна (нормально)",
|
||||
"reasonMultipleTabs": "Страница чата открыта в другой вкладке",
|
||||
"reasonNetworkIssue": "Временная проблема с сетью",
|
||||
"notice": "Примечание: для стабильной работы допускается только одно активное соединение. Если вы используете чат в нескольких вкладках, рекомендуем оставить только одну.",
|
||||
"understand": "Понятно",
|
||||
"status": {
|
||||
"reconnecting": "Переподключение...",
|
||||
"reconnected": "Соединение восстановлено",
|
||||
"failed": "Ошибка подключения, обновите страницу"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"sendMessageFailed": "Ошибка отправки сообщения, попробуйте еще раз",
|
||||
"createSessionFailed": "Ошибка создания сессии, обновите страницу"
|
||||
"title": "Давай пообщаемся!",
|
||||
"subtitle": "Общение с AI-помощником",
|
||||
"input": {
|
||||
"placeholder": "Введите сообщение...",
|
||||
"send": "Отправить",
|
||||
"clear": "Очистить",
|
||||
"upload": "Загрузить файл",
|
||||
"voice": "Голосовой ввод",
|
||||
"recordingPrompt": "Запись... говорите",
|
||||
"chatPrompt": "Давай пообщаемся!",
|
||||
"dropToUpload": "Отпустите, чтобы загрузить файл",
|
||||
"stopGenerating": "Остановить генерацию"
|
||||
},
|
||||
"message": {
|
||||
"user": "Вы",
|
||||
"assistant": "Ассистент",
|
||||
"system": "Система",
|
||||
"error": "Ошибка в сообщении",
|
||||
"loading": "Думаю..."
|
||||
},
|
||||
"voice": {
|
||||
"start": "Начать запись",
|
||||
"stop": "Стоп",
|
||||
"recording": "Запись",
|
||||
"processing": "Обработка...",
|
||||
"error": "Ошибка записи",
|
||||
"listening": "Слушаю...",
|
||||
"speaking": "Говорю",
|
||||
"startRecording": "Начать голосовой ввод",
|
||||
"liveMode": "Общение в реальном времени"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Добро пожаловать в AstrBot",
|
||||
"subtitle": "Ваш умный помощник",
|
||||
"quickActions": "Быстрые действия",
|
||||
"examples": "Примеры вопросов"
|
||||
},
|
||||
"actions": {
|
||||
"copy": "Копировать",
|
||||
"regenerate": "Перегенерировать",
|
||||
"like": "Нравится",
|
||||
"dislike": "Не нравится",
|
||||
"share": "Поделиться",
|
||||
"newChat": "Новый чат",
|
||||
"deleteChat": "Удалить чат",
|
||||
"editTitle": "Изменить заголовок",
|
||||
"fullscreen": "На весь экран",
|
||||
"exitFullscreen": "Выход из полноэкранного режима",
|
||||
"reply": "Ответить",
|
||||
"providerConfig": "Настройки AI",
|
||||
"toolsUsed": "Использованные инструменты",
|
||||
"toolCallUsed": "Использован инструмент {name}",
|
||||
"pythonCodeAnalysis": "Использован анализ кода Python"
|
||||
},
|
||||
"ipython": {
|
||||
"output": "Вывод"
|
||||
},
|
||||
"toolStatus": {
|
||||
"done": "Готово",
|
||||
"running": "Выполняется"
|
||||
},
|
||||
"conversation": {
|
||||
"newConversation": "Новый чат",
|
||||
"noHistory": "История диалогов пуста",
|
||||
"systemStatus": "Статус системы",
|
||||
"llmService": "Сервис LLM",
|
||||
"speechToText": "Преобразование речи",
|
||||
"editDisplayName": "Изменить имя чата",
|
||||
"displayName": "Имя чата",
|
||||
"displayNameUpdated": "Имя чата обновлено",
|
||||
"displayNameUpdateFailed": "Не удалось обновить имя чата",
|
||||
"confirmDelete": "Вы уверены, что хотите удалить «{name}»? Это действие необратимо."
|
||||
},
|
||||
"modes": {
|
||||
"darkMode": "Темная тема",
|
||||
"lightMode": "Светлая тема"
|
||||
},
|
||||
"shortcuts": {
|
||||
"help": "Справка",
|
||||
"voiceRecord": "Запись голоса",
|
||||
"pasteImage": "Вставить изображение",
|
||||
"sendKey": {
|
||||
"title": "Клавиша отправки",
|
||||
"enterToSend": "Enter для отправки",
|
||||
"shiftEnterToSend": "Shift+Enter для отправки"
|
||||
}
|
||||
},
|
||||
"streaming": {
|
||||
"enabled": "Потоковый ответ включен",
|
||||
"disabled": "Потоковый ответ выключен",
|
||||
"on": "Поток",
|
||||
"off": "Обычный"
|
||||
},
|
||||
"transport": {
|
||||
"title": "Протокол передачи",
|
||||
"sse": "SSE",
|
||||
"websocket": "WebSocket"
|
||||
},
|
||||
"config": {
|
||||
"title": "Конфигурация"
|
||||
},
|
||||
"reasoning": {
|
||||
"thinking": "Рассуждение"
|
||||
},
|
||||
"reply": {
|
||||
"replyTo": "В ответ на",
|
||||
"notFound": "Сообщение не найдено"
|
||||
},
|
||||
"project": {
|
||||
"title": "Проект",
|
||||
"create": "Создать проект",
|
||||
"edit": "Изменить проект",
|
||||
"name": "Имя проекта",
|
||||
"emoji": "Иконка (Emoji)",
|
||||
"description": "Описание проекта (опционально)",
|
||||
"noSessions": "В этом проекте пока нет диалогов",
|
||||
"confirmDelete": "Вы уверены, что хотите удалить проект «{title}»? Диалоги внутри проекта не будут удалены."
|
||||
},
|
||||
"time": {
|
||||
"today": "Сегодня",
|
||||
"yesterday": "Вчера"
|
||||
},
|
||||
"stats": {
|
||||
"tokens": "Токены",
|
||||
"inputTokens": "Входящие",
|
||||
"outputTokens": "Исходящие",
|
||||
"cachedTokens": "Кэшированные",
|
||||
"duration": "Время",
|
||||
"ttft": "Время до первого токена"
|
||||
},
|
||||
"refs": {
|
||||
"title": "Ссылки",
|
||||
"sources": "Источники"
|
||||
},
|
||||
"connection": {
|
||||
"title": "Статус подключения",
|
||||
"message": "Системе необходимо переустановить соединение с чатом.",
|
||||
"reasons": "Это может быть вызвано следующими причинами:",
|
||||
"reasonWindowResize": "Изменение размера окна (нормально)",
|
||||
"reasonMultipleTabs": "Страница чата открыта в другой вкладке",
|
||||
"reasonNetworkIssue": "Временная проблема с сетью",
|
||||
"notice": "Примечание: для стабильной работы допускается только одно активное соединение. Если вы используете чат в нескольких вкладках, рекомендуем оставить только одну.",
|
||||
"understand": "Понятно",
|
||||
"status": {
|
||||
"reconnecting": "Переподключение...",
|
||||
"reconnected": "Соединение восстановлено",
|
||||
"failed": "Ошибка подключения, обновите страницу"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"sendMessageFailed": "Ошибка отправки сообщения, попробуйте еще раз",
|
||||
"createSessionFailed": "Ошибка создания сессии, обновите страницу"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,10 @@
|
||||
"ipython": {
|
||||
"output": "输出"
|
||||
},
|
||||
"toolStatus": {
|
||||
"done": "已完成",
|
||||
"running": "运行中"
|
||||
},
|
||||
"conversation": {
|
||||
"newConversation": "新的聊天",
|
||||
"noHistory": "暂无对话历史",
|
||||
@@ -158,4 +162,4 @@
|
||||
"partialFailure": "{total} 个对话中有 {failed} 个删除失败",
|
||||
"requestFailed": "删除对话失败,请重试。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1604,26 +1604,26 @@
|
||||
},
|
||||
"deerflow_assistant_id": {
|
||||
"description": "Assistant ID",
|
||||
"hint": "LangGraph assistant_id,默认为 lead_agent。"
|
||||
"hint": "DeerFlow 2.0 LangGraph assistant_id,默认为 lead_agent。"
|
||||
},
|
||||
"deerflow_model_name": {
|
||||
"description": "模型名称覆盖",
|
||||
"hint": "可选。覆盖 DeerFlow 默认模型(对应 runtime context 的 model_name)。"
|
||||
"hint": "可选。覆盖 DeerFlow 默认模型(对应运行时 configurable 的 model_name)。"
|
||||
},
|
||||
"deerflow_thinking_enabled": {
|
||||
"description": "启用思考模式"
|
||||
},
|
||||
"deerflow_plan_mode": {
|
||||
"description": "启用计划模式",
|
||||
"hint": "对应 DeerFlow 的 is_plan_mode。"
|
||||
"hint": "对应 DeerFlow 2.0 运行时 configurable 的 is_plan_mode。"
|
||||
},
|
||||
"deerflow_subagent_enabled": {
|
||||
"description": "启用子智能体",
|
||||
"hint": "对应 DeerFlow 的 subagent_enabled。"
|
||||
"hint": "对应 DeerFlow 2.0 运行时 configurable 的 subagent_enabled。"
|
||||
},
|
||||
"deerflow_max_concurrent_subagents": {
|
||||
"description": "子智能体最大并发数",
|
||||
"hint": "对应 DeerFlow 的 max_concurrent_subagents。仅在启用子智能体时生效,默认 3。"
|
||||
"hint": "对应 DeerFlow 2.0 运行时 configurable 的 max_concurrent_subagents。仅在启用子智能体时生效,默认 3。"
|
||||
},
|
||||
"deerflow_recursion_limit": {
|
||||
"description": "递归深度上限",
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useCommonStore } from '@/stores/common';
|
||||
import { MarkdownRender, enableKatex, enableMermaid } from 'markstream-vue';
|
||||
import 'markstream-vue/index.css';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import 'highlight.js/styles/github.css';
|
||||
import { useI18n } from '@/i18n/composables';
|
||||
import { router } from '@/router';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
19
dashboard/src/scss/components/_CodeBlockDark.scss
Normal file
19
dashboard/src/scss/components/_CodeBlockDark.scss
Normal file
@@ -0,0 +1,19 @@
|
||||
.v-theme--PurpleThemeDark {
|
||||
.shiki.shiki-themes,
|
||||
.shiki.shiki-themes span {
|
||||
color: var(--shiki-dark) !important;
|
||||
}
|
||||
|
||||
.shiki.shiki-themes {
|
||||
background-color: var(--shiki-dark-bg) !important;
|
||||
}
|
||||
|
||||
.markstream-vue {
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
// highlight.js dark mode overrides
|
||||
// Scoped to the dark Vuetify theme so the default github.css light styles work in light mode.
|
||||
.v-theme--PurpleThemeDark {
|
||||
.hljs {
|
||||
background: transparent;
|
||||
color: #adbac7;
|
||||
}
|
||||
|
||||
.hljs-doctag,
|
||||
.hljs-keyword,
|
||||
.hljs-meta .hljs-keyword,
|
||||
.hljs-template-tag,
|
||||
.hljs-template-variable,
|
||||
.hljs-type,
|
||||
.hljs-variable.language_ {
|
||||
color: #f47067;
|
||||
}
|
||||
|
||||
.hljs-title,
|
||||
.hljs-title.class_,
|
||||
.hljs-title.class_.inherited__,
|
||||
.hljs-title.function_ {
|
||||
color: #dcbdfb;
|
||||
}
|
||||
|
||||
.hljs-attr,
|
||||
.hljs-attribute,
|
||||
.hljs-literal,
|
||||
.hljs-meta,
|
||||
.hljs-number,
|
||||
.hljs-operator,
|
||||
.hljs-selector-attr,
|
||||
.hljs-selector-class,
|
||||
.hljs-selector-id,
|
||||
.hljs-variable {
|
||||
color: #6cb6ff;
|
||||
}
|
||||
|
||||
.hljs-meta .hljs-string,
|
||||
.hljs-regexp,
|
||||
.hljs-string {
|
||||
color: #96d0ff;
|
||||
}
|
||||
|
||||
.hljs-built_in,
|
||||
.hljs-symbol {
|
||||
color: #f69d50;
|
||||
}
|
||||
|
||||
.hljs-code,
|
||||
.hljs-comment,
|
||||
.hljs-formula {
|
||||
color: #768390;
|
||||
}
|
||||
|
||||
.hljs-name,
|
||||
.hljs-quote,
|
||||
.hljs-selector-pseudo,
|
||||
.hljs-selector-tag {
|
||||
color: #8ddb8c;
|
||||
}
|
||||
|
||||
.hljs-subst {
|
||||
color: #adbac7;
|
||||
}
|
||||
|
||||
.hljs-section {
|
||||
color: #316dca;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.hljs-bullet {
|
||||
color: #eac55f;
|
||||
}
|
||||
|
||||
.hljs-emphasis {
|
||||
color: #adbac7;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-strong {
|
||||
color: #adbac7;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.hljs-addition {
|
||||
color: #b4f1b4;
|
||||
background-color: #1b4721;
|
||||
}
|
||||
|
||||
.hljs-deletion {
|
||||
color: #ffd8d3;
|
||||
background-color: #78191b;
|
||||
}
|
||||
|
||||
// markstream-vue dark mode variables override
|
||||
// markstream-vue expects a `.dark` ancestor to activate its dark palette,
|
||||
// but Vuetify uses `.v-theme--PurpleThemeDark` instead.
|
||||
.markstream-vue {
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,12 @@ html {
|
||||
overflow-y: auto;
|
||||
}
|
||||
.v-main {
|
||||
margin-right: 20px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.top-header {
|
||||
border-bottom: 1px solid rgba(var(--v-theme-borderLight), 0.5);
|
||||
}
|
||||
@media (max-width: 1279px) {
|
||||
.v-main {
|
||||
margin: 0 10px;
|
||||
}
|
||||
}
|
||||
.spacer {
|
||||
padding: 100px 0;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
@import './components/VTextField';
|
||||
@import './components/VTabs';
|
||||
@import './components/VScrollbar';
|
||||
@import './components/HljsDark';
|
||||
@import './components/CodeBlockDark';
|
||||
|
||||
@import './pages/dashboards';
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@ import { defineStore } from 'pinia';
|
||||
import { router } from '@/router';
|
||||
import axios from 'axios';
|
||||
|
||||
export const useAuthStore = defineStore({
|
||||
id: 'auth',
|
||||
export const useAuthStore = defineStore("auth", {
|
||||
state: () => ({
|
||||
// @ts-ignore
|
||||
username: '',
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import axios from 'axios';
|
||||
|
||||
export const useCommonStore = defineStore({
|
||||
id: 'common',
|
||||
export const useCommonStore = defineStore("common", {
|
||||
state: () => ({
|
||||
// @ts-ignore
|
||||
eventSource: null,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import config from '@/config';
|
||||
|
||||
export const useCustomizerStore = defineStore({
|
||||
id: 'customizer',
|
||||
export const useCustomizerStore = defineStore("customizer", {
|
||||
state: () => ({
|
||||
Sidebar_drawer: config.Sidebar_drawer,
|
||||
Customizer_drawer: config.Customizer_drawer,
|
||||
|
||||
@@ -43,8 +43,7 @@ export interface ReorderItem {
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export const usePersonaStore = defineStore({
|
||||
id: 'persona',
|
||||
export const usePersonaStore = defineStore("persona", {
|
||||
state: () => ({
|
||||
folderTree: [] as FolderTreeNode[],
|
||||
currentFolderId: null as string | null,
|
||||
|
||||
91
dashboard/src/utils/shiki.js
Normal file
91
dashboard/src/utils/shiki.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import { getSingletonHighlighter } from "shiki";
|
||||
|
||||
export const SHIKI_THEMES = {
|
||||
light: "github-light",
|
||||
dark: "github-dark",
|
||||
};
|
||||
|
||||
let highlighterPromise;
|
||||
|
||||
function normalizeLanguage(language) {
|
||||
const normalized = (language || "text").trim().split(/\s+/, 1)[0].toLowerCase();
|
||||
return normalized || "text";
|
||||
}
|
||||
|
||||
export function escapeHtml(value = "") {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
export async function getShikiHighlighter() {
|
||||
if (!highlighterPromise) {
|
||||
highlighterPromise = getSingletonHighlighter({
|
||||
themes: Object.values(SHIKI_THEMES),
|
||||
langs: ["text"],
|
||||
});
|
||||
}
|
||||
|
||||
return highlighterPromise;
|
||||
}
|
||||
|
||||
export async function ensureShikiLanguages(languages = []) {
|
||||
const highlighter = await getShikiHighlighter();
|
||||
const languagesToLoad = [...new Set(languages.map(normalizeLanguage))].filter(
|
||||
(language) => language !== "text",
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
languagesToLoad.map((language) =>
|
||||
highlighter.loadLanguage(language).catch((err) => {
|
||||
console.warn(`Failed to load Shiki language "${language}".`, err);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return highlighter;
|
||||
}
|
||||
|
||||
export function renderShikiCode(highlighter, code, language, colorMode = "auto") {
|
||||
const normalizedLanguage = normalizeLanguage(language);
|
||||
const options =
|
||||
colorMode === "dark"
|
||||
? { lang: normalizedLanguage, theme: SHIKI_THEMES.dark }
|
||||
: colorMode === "light"
|
||||
? { lang: normalizedLanguage, theme: SHIKI_THEMES.light }
|
||||
: { lang: normalizedLanguage, themes: SHIKI_THEMES };
|
||||
|
||||
try {
|
||||
return highlighter.codeToHtml(code, options);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`Failed to render code with Shiki language "${normalizedLanguage}". Falling back to plain text.`,
|
||||
err,
|
||||
);
|
||||
|
||||
const fallbackOptions =
|
||||
colorMode === "dark"
|
||||
? { lang: "text", theme: SHIKI_THEMES.dark }
|
||||
: colorMode === "light"
|
||||
? { lang: "text", theme: SHIKI_THEMES.light }
|
||||
: { lang: "text", themes: SHIKI_THEMES };
|
||||
|
||||
return highlighter.codeToHtml(code, fallbackOptions);
|
||||
}
|
||||
}
|
||||
|
||||
export function collectMarkdownFenceLanguages(markdownIt, markdown) {
|
||||
if (!markdown) return [];
|
||||
|
||||
return markdownIt
|
||||
.parse(markdown, {})
|
||||
.filter((token) => token.type === "fence")
|
||||
.map((token) => normalizeLanguage(token.info));
|
||||
}
|
||||
|
||||
export function normalizeShikiLanguage(language) {
|
||||
return normalizeLanguage(language);
|
||||
}
|
||||
@@ -200,7 +200,6 @@ import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||
import { useToast } from '@/utils/toast';
|
||||
import { MarkdownRender } from 'markstream-vue';
|
||||
import 'markstream-vue/index.css';
|
||||
import 'highlight.js/styles/github.css';
|
||||
|
||||
type StepState = 'pending' | 'completed' | 'skipped';
|
||||
type ComputerAccessRuntime = 'local' | 'none';
|
||||
|
||||
@@ -1288,7 +1288,7 @@ export const useExtensionPage = () => {
|
||||
const checkAlreadyInstalled = () => {
|
||||
const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
|
||||
const installedRepos = new Set(data.map((ext) => ext.repo?.toLowerCase()));
|
||||
const installedNames = new Set(data.map((ext) => ext.name));
|
||||
const installedNames = new Set(data.map((ext) => normalizeStr(ext.name).replace(/_/g, '-')));//统一格式,以防下面的匹配不生效
|
||||
const installedByRepo = new Map(
|
||||
data
|
||||
.filter((ext) => ext.repo)
|
||||
@@ -1315,10 +1315,10 @@ export const useExtensionPage = () => {
|
||||
plugin.astrbot_version = matchedInstalled.astrbot_version;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
plugin.installed =
|
||||
installedRepos.has(plugin.repo?.toLowerCase()) ||
|
||||
installedNames.has(plugin.name);
|
||||
installedNames.has(normalizeStr(plugin.name).replace(/_/g, '-'));//统一格式,防止匹配失败
|
||||
}
|
||||
|
||||
let installed = [];
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
在 v4.19.2 及之后,AstrBot 支持接入 [DeerFlow](https://github.com/bytedance/deer-flow) Agent Runner。
|
||||
|
||||
当前适配面向 DeerFlow **2.0 `main` 分支**。DeerFlow 官方已将原始 Deep Research 框架迁移到 `main-1.x` 分支持续维护,因此如果你使用的是 2.0,请以 `main` 分支文档和后端 API 为准。
|
||||
|
||||
## 预备工作:部署 DeerFlow
|
||||
|
||||
如果你还没有部署 DeerFlow,请先参考 DeerFlow 官方文档完成安装和启动:
|
||||
@@ -25,12 +27,12 @@
|
||||
- `API Base URL`:DeerFlow API 网关地址,默认为 `http://127.0.0.1:2026`
|
||||
- `DeerFlow API Key`:可选。若你的 DeerFlow 网关使用 Bearer 鉴权,可在此填写
|
||||
- `Authorization Header`:可选。自定义 Authorization 请求头,优先级高于 `DeerFlow API Key`
|
||||
- `Assistant ID`:对应 LangGraph 的 `assistant_id`,默认为 `lead_agent`
|
||||
- `Assistant ID`:对应 DeerFlow 2.0 LangGraph 的 `assistant_id`,默认为 `lead_agent`
|
||||
- `模型名称覆盖`:可选。覆盖 DeerFlow 默认模型
|
||||
- `启用思考模式`:是否启用 DeerFlow 的思考模式
|
||||
- `启用计划模式`:对应 DeerFlow 的 `is_plan_mode`
|
||||
- `启用子智能体`:对应 DeerFlow 的 `subagent_enabled`
|
||||
- `子智能体最大并发数`:对应 `max_concurrent_subagents`,仅在启用子智能体时生效,默认 `3`
|
||||
- `启用计划模式`:对应 DeerFlow 2.0 运行时 `config.configurable.is_plan_mode`
|
||||
- `启用子智能体`:对应 DeerFlow 2.0 运行时 `config.configurable.subagent_enabled`
|
||||
- `子智能体最大并发数`:对应 DeerFlow 2.0 运行时 `config.configurable.max_concurrent_subagents`,仅在启用子智能体时生效,默认 `3`
|
||||
- `递归深度上限`:对应 LangGraph 的 `recursion_limit`,默认 `1000`
|
||||
|
||||
填写完成后点击「保存」。
|
||||
@@ -38,6 +40,7 @@
|
||||
> [!TIP]
|
||||
> - 如果 DeerFlow 侧已经配置了默认模型,可以将 `模型名称覆盖` 留空。
|
||||
> - 只有在 DeerFlow 侧已经启用了相应能力时,才建议开启 `计划模式` 或 `子智能体` 相关选项。
|
||||
> - AstrBot 会同时发送 DeerFlow 2.0 推荐的 `config.configurable` 运行时参数,并保留兼容字段,便于对接上游近期版本。
|
||||
|
||||
## 选择 Agent 执行器
|
||||
|
||||
@@ -51,3 +54,4 @@
|
||||
- `API Base URL` 是否能从 AstrBot 所在环境访问
|
||||
- 鉴权配置是否填写正确
|
||||
- `Assistant ID` 是否与 DeerFlow 中实际可用的 assistant 一致
|
||||
- 如果通过 `/reset`、`/new`、`/del` 重置 DeerFlow 会话,AstrBot 会尝试同步清理 DeerFlow 远端 thread;若 DeerFlow 网关不可达,则只会清理 AstrBot 本地会话标识
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "AstrBot"
|
||||
version = "4.23.0-beta.1"
|
||||
version = "4.23.0"
|
||||
description = "Easy-to-use multi-platform LLM chatbot and development framework"
|
||||
readme = "README.md"
|
||||
license = { text = "AGPL-3.0-or-later" }
|
||||
@@ -64,7 +64,6 @@ dependencies = [
|
||||
"python-socks>=2.8.0",
|
||||
"pysocks>=1.7.1",
|
||||
"packaging>=24.2",
|
||||
"python-ripgrep==0.0.9",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -52,5 +52,4 @@ tenacity>=9.1.2
|
||||
shipyard-python-sdk>=0.2.4
|
||||
shipyard-neo-sdk>=0.2.0
|
||||
packaging>=24.2
|
||||
qrcode>=8.2
|
||||
python-ripgrep==0.0.9
|
||||
qrcode>=8.2
|
||||
181
tests/test_conversation_commands.py
Normal file
181
tests/test_conversation_commands.py
Normal file
@@ -0,0 +1,181 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from astrbot.builtin_stars.builtin_commands.commands import (
|
||||
conversation as conversation_module,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_third_party_agent_runner_state_deletes_deerflow_thread_before_local_state(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
calls: list[object] = []
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, **kwargs):
|
||||
calls.append(("init", kwargs))
|
||||
|
||||
async def delete_thread(self, thread_id: str, timeout: float = 20):
|
||||
calls.append(("delete", thread_id, timeout))
|
||||
|
||||
async def close(self):
|
||||
calls.append(("close",))
|
||||
|
||||
async def fake_get_async(*args, **kwargs):
|
||||
_ = args, kwargs
|
||||
return "thread-123"
|
||||
|
||||
async def fake_remove_async(*args, **kwargs):
|
||||
calls.append(("remove", kwargs["scope"], kwargs["scope_id"], kwargs["key"]))
|
||||
|
||||
context = SimpleNamespace(
|
||||
get_config=lambda **kwargs: {
|
||||
"provider_settings": {"deerflow_agent_runner_provider_id": "deerflow-runner"}
|
||||
},
|
||||
provider_manager=SimpleNamespace(
|
||||
get_provider_config_by_id=lambda provider_id, merged=False: {
|
||||
"id": provider_id,
|
||||
"deerflow_api_base": "http://127.0.0.1:2026",
|
||||
"deerflow_api_key": "token",
|
||||
"deerflow_auth_header": "",
|
||||
"proxy": "",
|
||||
}
|
||||
if merged
|
||||
else {"id": provider_id},
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(conversation_module, "DeerFlowAPIClient", FakeClient)
|
||||
monkeypatch.setattr(conversation_module.sp, "get_async", fake_get_async)
|
||||
monkeypatch.setattr(conversation_module.sp, "remove_async", fake_remove_async)
|
||||
|
||||
await conversation_module._clear_third_party_agent_runner_state(
|
||||
context,
|
||||
"umo-1",
|
||||
conversation_module.DEERFLOW_PROVIDER_TYPE,
|
||||
)
|
||||
|
||||
assert ("delete", "thread-123", 20) in calls
|
||||
assert (
|
||||
"remove",
|
||||
"umo",
|
||||
"umo-1",
|
||||
conversation_module.DEERFLOW_THREAD_ID_KEY,
|
||||
) in calls
|
||||
assert calls.index(("delete", "thread-123", 20)) < calls.index(
|
||||
("remove", "umo", "umo-1", conversation_module.DEERFLOW_THREAD_ID_KEY)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_third_party_agent_runner_state_removes_local_state_when_deerflow_cleanup_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
calls: list[object] = []
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, **kwargs):
|
||||
_ = kwargs
|
||||
|
||||
async def delete_thread(self, thread_id: str, timeout: float = 20):
|
||||
_ = thread_id, timeout
|
||||
raise RuntimeError("gateway down")
|
||||
|
||||
async def close(self):
|
||||
calls.append(("close",))
|
||||
|
||||
async def fake_get_async(*args, **kwargs):
|
||||
_ = args, kwargs
|
||||
return "thread-456"
|
||||
|
||||
async def fake_remove_async(*args, **kwargs):
|
||||
calls.append(("remove", kwargs["scope"], kwargs["scope_id"], kwargs["key"]))
|
||||
|
||||
context = SimpleNamespace(
|
||||
get_config=lambda **kwargs: {
|
||||
"provider_settings": {"deerflow_agent_runner_provider_id": "deerflow-runner"}
|
||||
},
|
||||
provider_manager=SimpleNamespace(
|
||||
get_provider_config_by_id=lambda provider_id, merged=False: {
|
||||
"id": provider_id,
|
||||
"deerflow_api_base": "http://127.0.0.1:2026",
|
||||
"deerflow_api_key": "",
|
||||
"deerflow_auth_header": "",
|
||||
"proxy": "",
|
||||
}
|
||||
if merged
|
||||
else {"id": provider_id},
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(conversation_module, "DeerFlowAPIClient", FakeClient)
|
||||
monkeypatch.setattr(conversation_module.sp, "get_async", fake_get_async)
|
||||
monkeypatch.setattr(conversation_module.sp, "remove_async", fake_remove_async)
|
||||
|
||||
await conversation_module._clear_third_party_agent_runner_state(
|
||||
context,
|
||||
"umo-2",
|
||||
conversation_module.DEERFLOW_PROVIDER_TYPE,
|
||||
)
|
||||
|
||||
assert (
|
||||
"remove",
|
||||
"umo",
|
||||
"umo-2",
|
||||
conversation_module.DEERFLOW_THREAD_ID_KEY,
|
||||
) in calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_third_party_agent_runner_state_removes_local_state_when_deerflow_client_init_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
calls: list[object] = []
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, **kwargs):
|
||||
_ = kwargs
|
||||
raise RuntimeError("invalid deerflow config")
|
||||
|
||||
async def fake_get_async(*args, **kwargs):
|
||||
_ = args, kwargs
|
||||
return "thread-789"
|
||||
|
||||
async def fake_remove_async(*args, **kwargs):
|
||||
calls.append(("remove", kwargs["scope"], kwargs["scope_id"], kwargs["key"]))
|
||||
|
||||
context = SimpleNamespace(
|
||||
get_config=lambda **kwargs: {
|
||||
"provider_settings": {"deerflow_agent_runner_provider_id": "deerflow-runner"}
|
||||
},
|
||||
provider_manager=SimpleNamespace(
|
||||
get_provider_config_by_id=lambda provider_id, merged=False: {
|
||||
"id": provider_id,
|
||||
"deerflow_api_base": "http://127.0.0.1:2026",
|
||||
"deerflow_api_key": "",
|
||||
"deerflow_auth_header": "",
|
||||
"proxy": "",
|
||||
}
|
||||
if merged
|
||||
else {"id": provider_id},
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(conversation_module, "DeerFlowAPIClient", FakeClient)
|
||||
monkeypatch.setattr(conversation_module.sp, "get_async", fake_get_async)
|
||||
monkeypatch.setattr(conversation_module.sp, "remove_async", fake_remove_async)
|
||||
|
||||
await conversation_module._clear_third_party_agent_runner_state(
|
||||
context,
|
||||
"umo-3",
|
||||
conversation_module.DEERFLOW_PROVIDER_TYPE,
|
||||
)
|
||||
|
||||
assert (
|
||||
"remove",
|
||||
"umo",
|
||||
"umo-3",
|
||||
conversation_module.DEERFLOW_THREAD_ID_KEY,
|
||||
) in calls
|
||||
36
tests/test_deerflow_agent_runner.py
Normal file
36
tests/test_deerflow_agent_runner.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from astrbot.core.agent.runners.deerflow.deerflow_agent_runner import (
|
||||
DeerFlowAgentRunner,
|
||||
)
|
||||
|
||||
|
||||
def test_build_payload_includes_configurable_runtime_overrides_and_legacy_context():
|
||||
runner = DeerFlowAgentRunner()
|
||||
runner.assistant_id = "lead_agent"
|
||||
runner.thinking_enabled = True
|
||||
runner.plan_mode = True
|
||||
runner.subagent_enabled = True
|
||||
runner.max_concurrent_subagents = 5
|
||||
runner.model_name = "gpt-4.1"
|
||||
runner.recursion_limit = 321
|
||||
|
||||
payload = runner._build_payload(
|
||||
thread_id="thread-123",
|
||||
prompt="hello deerflow",
|
||||
image_urls=[],
|
||||
system_prompt=None,
|
||||
)
|
||||
|
||||
expected_runtime = {
|
||||
"thread_id": "thread-123",
|
||||
"thinking_enabled": True,
|
||||
"is_plan_mode": True,
|
||||
"subagent_enabled": True,
|
||||
"max_concurrent_subagents": 5,
|
||||
"model_name": "gpt-4.1",
|
||||
}
|
||||
|
||||
assert payload["assistant_id"] == "lead_agent"
|
||||
assert payload["stream_mode"] == ["values", "messages-tuple", "custom"]
|
||||
assert payload["config"]["recursion_limit"] == 321
|
||||
assert payload["config"]["configurable"] == expected_runtime
|
||||
assert payload["context"] == expected_runtime
|
||||
50
tests/test_deerflow_api_client.py
Normal file
50
tests/test_deerflow_api_client.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import pytest
|
||||
|
||||
from astrbot.core.agent.runners.deerflow.deerflow_api_client import (
|
||||
DeerFlowAPIClient,
|
||||
DeerFlowAPIError,
|
||||
)
|
||||
|
||||
|
||||
class _FakeDeleteResponse:
|
||||
def __init__(self, status: int, body: str):
|
||||
self.status = status
|
||||
self._body = body
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
_ = exc_type, exc, tb
|
||||
|
||||
async def text(self) -> str:
|
||||
return self._body
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, response: _FakeDeleteResponse):
|
||||
self.closed = False
|
||||
self._response = response
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
_ = args, kwargs
|
||||
return self._response
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_thread_raises_api_error_with_thread_context():
|
||||
client = DeerFlowAPIClient(api_base="http://127.0.0.1:2026")
|
||||
client._session = _FakeSession(
|
||||
_FakeDeleteResponse(status=500, body="thread cleanup failed"),
|
||||
)
|
||||
|
||||
try:
|
||||
with pytest.raises(DeerFlowAPIError) as exc_info:
|
||||
await client.delete_thread("thread-123")
|
||||
finally:
|
||||
client._closed = True
|
||||
|
||||
assert exc_info.value.status == 500
|
||||
assert exc_info.value.thread_id == "thread-123"
|
||||
assert "/api/threads/thread-123" in str(exc_info.value)
|
||||
assert "thread cleanup failed" in str(exc_info.value)
|
||||
Reference in New Issue
Block a user