mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-02 10:40:15 +08:00
Compare commits
5 Commits
v4.23.0
...
fix/remove
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d00309d70 | ||
|
|
533a0bde6a | ||
|
|
35ce281cbe | ||
|
|
80c7ebae8a | ||
|
|
5f0178bc73 |
@@ -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.")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 – 248 icons */
|
||||
/* Auto-generated MDI subset – 247 icons */
|
||||
/* Do not edit manually. Run: pnpm run subset-icons */
|
||||
|
||||
@font-face {
|
||||
@@ -236,10 +236,6 @@
|
||||
content: "\F0167";
|
||||
}
|
||||
|
||||
.mdi-code-braces::before {
|
||||
content: "\F0169";
|
||||
}
|
||||
|
||||
.mdi-code-json::before {
|
||||
content: "\F0626";
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -678,7 +678,7 @@ import {
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useDisplay } from "vuetify";
|
||||
import axios from "axios";
|
||||
import { MarkdownCodeBlockNode, setCustomComponents } from "markstream-vue";
|
||||
import { setCustomComponents } from "markstream-vue";
|
||||
import "markstream-vue/index.css";
|
||||
import StyledMenu from "@/components/shared/StyledMenu.vue";
|
||||
import ProviderConfigDialog from "@/components/chat/ProviderConfigDialog.vue";
|
||||
@@ -696,6 +696,7 @@ import RefsSidebar from "@/components/chat/message_list_comps/RefsSidebar.vue";
|
||||
import RefNode from "@/components/chat/message_list_comps/RefNode.vue";
|
||||
import ActionRef from "@/components/chat/message_list_comps/ActionRef.vue";
|
||||
import MarkdownMessagePart from "@/components/chat/message_list_comps/MarkdownMessagePart.vue";
|
||||
import ThemeAwareMarkdownCodeBlock from "@/components/shared/ThemeAwareMarkdownCodeBlock.vue";
|
||||
import { useSessions, type Session } from "@/composables/useSessions";
|
||||
import {
|
||||
useMessages,
|
||||
@@ -720,7 +721,7 @@ const props = withDefaults(defineProps<{ chatboxMode?: boolean }>(), {
|
||||
|
||||
setCustomComponents("chat-message", {
|
||||
ref: RefNode,
|
||||
code_block: MarkdownCodeBlockNode,
|
||||
code_block: ThemeAwareMarkdownCodeBlock,
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
@@ -237,7 +237,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, ref } from "vue";
|
||||
import axios from "axios";
|
||||
import { MarkdownCodeBlockNode, setCustomComponents } from "markstream-vue";
|
||||
import { setCustomComponents } from "markstream-vue";
|
||||
import "markstream-vue/index.css";
|
||||
import IPythonToolBlock from "@/components/chat/message_list_comps/IPythonToolBlock.vue";
|
||||
import MarkdownMessagePart from "@/components/chat/message_list_comps/MarkdownMessagePart.vue";
|
||||
@@ -247,6 +247,7 @@ import RefsSidebar from "@/components/chat/message_list_comps/RefsSidebar.vue";
|
||||
import ToolCallCard from "@/components/chat/message_list_comps/ToolCallCard.vue";
|
||||
import ToolCallItem from "@/components/chat/message_list_comps/ToolCallItem.vue";
|
||||
import ActionRef from "@/components/chat/message_list_comps/ActionRef.vue";
|
||||
import ThemeAwareMarkdownCodeBlock from "@/components/shared/ThemeAwareMarkdownCodeBlock.vue";
|
||||
import type {
|
||||
ChatContent,
|
||||
ChatRecord,
|
||||
@@ -270,7 +271,7 @@ const props = withDefaults(
|
||||
|
||||
setCustomComponents("chat-message", {
|
||||
ref: RefNode,
|
||||
code_block: MarkdownCodeBlockNode,
|
||||
code_block: ThemeAwareMarkdownCodeBlock,
|
||||
});
|
||||
|
||||
const { tm } = useModuleI18n("features/chat");
|
||||
|
||||
@@ -183,7 +183,6 @@ import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||
import { enableKatex, enableMermaid, MarkdownCodeBlockNode, setCustomComponents } from 'markstream-vue'
|
||||
import 'markstream-vue/index.css'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import 'highlight.js/styles/github.css';
|
||||
import axios from 'axios';
|
||||
import { useToast } from '@/utils/toast'
|
||||
import ReasoningBlock from './message_list_comps/ReasoningBlock.vue';
|
||||
|
||||
@@ -174,7 +174,7 @@ import {
|
||||
ref,
|
||||
} from "vue";
|
||||
import axios from "axios";
|
||||
import { MarkdownCodeBlockNode, setCustomComponents } from "markstream-vue";
|
||||
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";
|
||||
@@ -183,6 +183,7 @@ import ReasoningBlock from "@/components/chat/message_list_comps/ReasoningBlock.
|
||||
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,
|
||||
@@ -201,7 +202,7 @@ const props = withDefaults(defineProps<{ configId?: string | null }>(), {
|
||||
|
||||
setCustomComponents("chat-message", {
|
||||
ref: RefNode,
|
||||
code_block: MarkdownCodeBlockNode,
|
||||
code_block: ThemeAwareMarkdownCodeBlock,
|
||||
});
|
||||
|
||||
const { tm } = useModuleI18n("features/chat");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
|
||||
|
||||
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 本地会话标识
|
||||
|
||||
@@ -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