更新压缩机制,提高前缀缓存命中率,优化UI渲染机制,提高帧数
This commit is contained in:
1 parent
6131c2563e
commit
0ce72f74f1
175 files changed
+7621
-2611
No files matched your search
@@ -13,7 +13,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from agent.core.config_manager import load_agent_configs, load_models, AgentConfig
|
||||
from agent.core.config_manager import load_agent_configs, load_models, AgentConfig, MAIN_DEFAULT_TOOLS
|
||||
from agent.core.graph import build_agent_graph
|
||||
from agent.core.runtime import execute_agent as execute_runtime_agent
|
||||
from agent.core.runtime import AgentRuntime
|
||||
@@ -79,10 +79,12 @@ def _build_agent_runtime(cfg: AgentConfig) -> AgentRuntime:
|
||||
else:
|
||||
agent_tools = all_tools
|
||||
|
||||
# 2.1 强制为主 Agent 加载 doc_tool 与 todo_list(不可通过配置移除)
|
||||
# doc_tool: 文档读写必需;todo_list: Plan 模式第一步规划必需
|
||||
# 2.1 强制为主 Agent 加载默认挂载工具(MAIN_DEFAULT_TOOLS,不可通过配置移除):
|
||||
# call_subagent(子 Agent 委派)/ doc_tool(文档读写)/
|
||||
# human_interaction(人机交互审查)/ todo_list(任务规划)/
|
||||
# terminal_execute(终端命令)
|
||||
if cfg.role == "main":
|
||||
for force_name in ("doc_tool", "todo_list"):
|
||||
for force_name in MAIN_DEFAULT_TOOLS:
|
||||
if not any(t.name == force_name for t in agent_tools):
|
||||
for t in all_tools:
|
||||
if t.name == force_name:
|
||||
|
||||
@@ -1,6 +1,92 @@
|
||||
{
|
||||
"agents": [],
|
||||
"tools": [],
|
||||
"agents": [
|
||||
{
|
||||
"name": "main_agent",
|
||||
"description": "",
|
||||
"max_iterations": 200,
|
||||
"llm_model_id": "affiaa7r",
|
||||
"tools": [
|
||||
"gui_tool",
|
||||
"browser_control",
|
||||
"web_page"
|
||||
],
|
||||
"enabled": true,
|
||||
"role": "main",
|
||||
"trajectory_rounds": 3
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"name": "browser_control",
|
||||
"display_name": "BrowserControl",
|
||||
"description": "控制浏览器(Playwright 独立进程,跨会话存活、自动重连):打开 URL、刷新、关闭浏览器/标签页、列出与切换标签页。页面元素级操作请用 web_page 工具。",
|
||||
"permission": "confirm",
|
||||
"auto_execute_rules": [
|
||||
{
|
||||
"parameter": "action",
|
||||
"operator": "equals",
|
||||
"value": "open"
|
||||
},
|
||||
{
|
||||
"parameter": "action",
|
||||
"operator": "equals",
|
||||
"value": "list_tabs"
|
||||
},
|
||||
{
|
||||
"parameter": "action",
|
||||
"operator": "equals",
|
||||
"value": "switch_tab"
|
||||
}
|
||||
],
|
||||
"enabled": true,
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
"name": "web_page",
|
||||
"display_name": "WebPage",
|
||||
"description": "对浏览器当前页面执行 DOM 级精准批量操作(Playwright,毫秒级,优于视觉定位):snapshot 获取可交互元素清单,按 ref 序号或选择器 click/fill/hover/select/scroll/press/extract/eval_js 等。典型流程:browser_control 打开 URL → web_page snapshot → 批量操作。",
|
||||
"permission": "direct",
|
||||
"auto_execute_rules": [],
|
||||
"enabled": true,
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
"name": "human_interaction",
|
||||
"display_name": "HumanInteraction",
|
||||
"description": "当任务缺少关键信息、需要人类选择路径、或需要人类审批后才能继续时调用。三种类型分别是:information=请求人类补充缺失信息;selection=让人类在多个选项中选择且可编辑;approval=请求人类审批高影响决策或继续执行。工具返回必须被视为用户原始意图的纯字符串反馈。其余情况严禁调用。",
|
||||
"permission": "confirm",
|
||||
"auto_execute_rules": [],
|
||||
"enabled": true,
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
"name": "todo_list",
|
||||
"display_name": "TodoList",
|
||||
"description": "任务管理工具。用于将复杂任务拆分为多个步骤并跟踪执行进度。",
|
||||
"permission": "direct",
|
||||
"auto_execute_rules": [],
|
||||
"enabled": true,
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
"name": "gui_tool",
|
||||
"display_name": "GUITool",
|
||||
"description": "GUI 自动化工具。通过截图视觉定位桌面元素,将坐标映射到真实屏幕,并执行 pyautogui 动作。接收一个有序的动作列表,按顺序依次执行。每个动作项支持的操作:click(点击)、type(输入文本,可通过 element_description 指定目标输入框自动聚焦)、drag_and_drop(拖拽)、scroll(滚动)、hotkey(快捷键)、hold_and_press(按住组合键",
|
||||
"permission": "direct",
|
||||
"auto_execute_rules": [],
|
||||
"enabled": true,
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
"name": "doc_tool",
|
||||
"display_name": "DocTool",
|
||||
"description": "文档操作工具。支持纯文本(plain_text)、表格(tabular: CSV/XLSX)、Word(docx)、PowerPoint(pptx)、PDF 文档的增删改查,以及图片(image)、音频(audio)的读取。",
|
||||
"permission": "direct",
|
||||
"auto_execute_rules": [],
|
||||
"enabled": true,
|
||||
"timeout": 300
|
||||
}
|
||||
],
|
||||
"env": {
|
||||
"USER_NICKNAME": "",
|
||||
"USER_PYTHON_PATH": "",
|
||||
@@ -24,7 +110,8 @@
|
||||
"MYSQL_DATABASE": "agent_history",
|
||||
"EMAIL_ADDRESS": "",
|
||||
"EMAIL_AUTH_CODE": "",
|
||||
"models": []
|
||||
"models": [],
|
||||
"BROWSER_PROFILE_DIR": ""
|
||||
},
|
||||
"gui": {
|
||||
"gui_monitor_name": "",
|
||||
|
||||
@@ -71,6 +71,19 @@ class ToolConfig:
|
||||
timeout: float = 300.0 # 工具执行超时(秒),超时后返回"工具执行超时"
|
||||
|
||||
|
||||
# main Agent 默认挂载工具(构建运行时强制注入,不可通过配置移除;
|
||||
# 前端对 role="main" 的 Agent 显示为锁定项——不能删除、无需手动添加)
|
||||
MAIN_DEFAULT_TOOLS: tuple[str, ...] = (
|
||||
"call_subagent", # agent_call.py:子 Agent 委派路由
|
||||
"doc_tool", # doc.py:文档读写
|
||||
"human_interaction", # human_interaction.py:人机交互/审查
|
||||
"todo_list", # todo_list.py:任务规划/进度标记
|
||||
"terminal_execute", # terminal_execute.py:终端命令执行(含挂起 background)
|
||||
"terminal_info", # terminal_info.py:查看后台终端任务状态/最新输出
|
||||
"wait", # wait.py:等待后台任务结束(普通通用工具)
|
||||
)
|
||||
|
||||
|
||||
# ---------- 通用 JSON 读写 ----------
|
||||
def _read_json(path: Path) -> dict | list | None:
|
||||
"""读取 JSON 文件,不存在或格式错误返回 None。"""
|
||||
@@ -442,6 +455,8 @@ def _default_env_config() -> dict:
|
||||
"GROUNDING_HEIGHT": "1000",
|
||||
"LOOP_DETECT_REPEATED_TOOL_WARN": "3",
|
||||
"SEND_FILE_SIZE_LIMIT": "30",
|
||||
# 浏览器控制工具持久 Profile 目录(空 = 每次启动一次性临时 Profile)
|
||||
"BROWSER_PROFILE_DIR": "",
|
||||
"STORAGE_BACKEND": "json",
|
||||
"MYSQL_HOST": "localhost",
|
||||
"MYSQL_PORT": "3306",
|
||||
|
||||
@@ -29,6 +29,67 @@ from agent.utils.image_utils import image_path_to_openai_image_url_part, image_b
|
||||
from agent.utils.tool_call_utils import split_inline_thinking
|
||||
from agent.memory.system_prompt import VISION_SYSTEM_PROMPT
|
||||
|
||||
|
||||
# ---------- LLM 错误域:超时判定 / 重试耗尽异常 / 用户可读文案 ----------
|
||||
# 系统定位:
|
||||
# 节点级重试(core.nodes._invoke_llm_with_retry)与轮次级错误分类
|
||||
# (core.runtime._classify_error)均复用本处的 LLM 域知识,
|
||||
# 保证"重试日志、异常类型、用户文案"三者在同一处维护、无重复定义。
|
||||
LLM_TIMEOUT_RETRY_MSG_TEMPLATE = (
|
||||
"LLM 服务调用超时,已重试 {n} 次仍失败,本轮执行已中止。"
|
||||
"本轮已执行的工具调用与消息已保留,请检查模型服务是否正常运行后重试。"
|
||||
)
|
||||
LLM_COMPRESS_TIMEOUT_MSG = (
|
||||
"上下文压缩节点调用超时,本轮执行已中止。"
|
||||
"本轮消息列表与工具调用已完整保留,请检查模型服务后重试。"
|
||||
)
|
||||
LLM_TIMEOUT_GENERIC_MSG = "LLM 服务调用超时,请检查模型服务是否正常运行后重试。"
|
||||
|
||||
|
||||
def is_timeout_error(e: BaseException) -> bool:
|
||||
"""判断异常是否为 LLM 服务超时/连接类错误(重试候选)。
|
||||
|
||||
识别范围:
|
||||
- httpx.TimeoutException 及 openai.APITimeoutError(底层网络/UDP 超时)
|
||||
- 异常消息含 "timed out" / "timeout" / "read timeout" /
|
||||
"connect" / "connection error" 等网络症状(兼容非标准封装)
|
||||
|
||||
输出:
|
||||
True 表示应触发重试。
|
||||
"""
|
||||
try:
|
||||
import httpx
|
||||
if isinstance(e, httpx.TimeoutException):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
import openai
|
||||
if isinstance(e, openai.APITimeoutError):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
msg = str(e).lower()
|
||||
return any(k in msg for k in ("timed out", "timeout", "read timeout", "connect error", "connection error", "connection aborted", "remote end closed connection"))
|
||||
|
||||
|
||||
class LLMRetryExhausted(Exception):
|
||||
"""节点级重试耗尽后抛出的终端异常。
|
||||
|
||||
属性:
|
||||
retries: 已执行的重试次数(不含首次尝试)。
|
||||
original: 最后一次失败的原始异常(作为 cause 保留)。
|
||||
|
||||
__str__ 返回用户可读文案(与 runtime._classify_error 输出一致),
|
||||
因此经 call_subagent 等工具边界捕获后展示给模型/用户的也是友好文案。
|
||||
"""
|
||||
|
||||
def __init__(self, retries: int = 0, original: BaseException | None = None) -> None:
|
||||
super().__init__(LLM_TIMEOUT_RETRY_MSG_TEMPLATE.format(n=retries))
|
||||
self.retries = retries
|
||||
self.original = original
|
||||
|
||||
|
||||
class Multimodel_LLM(ChatOpenAI):
|
||||
"""基于 OpenAI 兼容 API 的多模态模型封装,扩展深度思考归一化能力。
|
||||
|
||||
@@ -242,6 +303,26 @@ class Multimodel_LLM(ChatOpenAI):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _get_request_payload(self, input_, *, stop=None, **kwargs) -> dict:
|
||||
"""在 LangChain 消息转换后补回 ``reasoning_content``。
|
||||
|
||||
langchain-openai 的 ``_convert_message_to_dict`` 只透传 content/tool_calls
|
||||
等固定字段,``additional_kwargs["reasoning_content"]``(DeepSeek/Qwen 深度
|
||||
思考约定)会被丢弃;这里按索引回写为 provider 消息的 ``reasoning_content``
|
||||
字段,供历史上下文中的 AIMessage 携带深度思考。
|
||||
"""
|
||||
payload = super()._get_request_payload(input_, stop=stop, **kwargs)
|
||||
src = input_ if isinstance(input_, list) else (getattr(input_, "messages", None) or [])
|
||||
messages = payload.get("messages") or []
|
||||
if len(messages) != len(src):
|
||||
return payload
|
||||
for pdict, msg in zip(messages, src):
|
||||
if isinstance(msg, AIMessage) and pdict.get("role") == "assistant":
|
||||
rc = (msg.additional_kwargs or {}).get("reasoning_content")
|
||||
if isinstance(rc, str) and rc.strip() and not pdict.get("reasoning_content"):
|
||||
pdict["reasoning_content"] = rc
|
||||
return payload
|
||||
|
||||
def _create_chat_result(self, response, generation_info=None) -> ChatResult:
|
||||
"""解析 API 响应:深度思考归一化、XML/JSON tool_call 提取。
|
||||
|
||||
|
||||
+269
-73
@@ -15,6 +15,7 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Callable
|
||||
|
||||
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||
@@ -22,6 +23,7 @@ from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||
from agent.core.loop_detector import build_loop_reflection_prompt
|
||||
from agent.core.state import AgentState
|
||||
from agent.memory.system_prompt import SUB_AGENT_OOM_PROMPT
|
||||
from agent.core.llm import LLMRetryExhausted, is_timeout_error
|
||||
from agent.utils.image_utils import image_bytes_to_openai_image_url_part
|
||||
from agent.utils.tool_call_utils import normalize_tool_call, extract_thought, extract_reflection
|
||||
from langchain_core.messages import SystemMessage
|
||||
@@ -106,7 +108,7 @@ def _filter_short_term_memory(messages: list, trajectory_rounds: int) -> list:
|
||||
# 剥离历史模型思考文本:上一/几轮的思考不进入本轮上下文。
|
||||
# 带 tool_calls 的 AIMessage,其 content 仅是思维链,保留 tool_calls 即可
|
||||
# (思考/反思已由 extract_thought/extract_reflection 单独记录到 think 面板,
|
||||
# 并由 build_tool_history_messages 以 [历史思考]/[历史反思] 形式注入)。
|
||||
# 并由 build_native_history_messages 以原生 AIMessage 形式注入)。
|
||||
cleaned: list = []
|
||||
for m in result:
|
||||
if isinstance(m, AIMessage) and getattr(m, "tool_calls", None) and getattr(m, "content", None):
|
||||
@@ -248,19 +250,97 @@ def _estimate_token_breakdown(messages: list[Any], usage: dict) -> dict[str, flo
|
||||
}
|
||||
|
||||
|
||||
def _compute_next_cursor(session_id: str) -> int:
|
||||
"""计算下一次压缩游标:当前轮次起点在全局工具调用序列中的索引。
|
||||
# ---------- 链式压缩块:单元渲染 / 合并 / token 估算 ----------
|
||||
# 重建历史消息 id 前缀(agent_utils.build_native_history_messages),
|
||||
# 压缩节点按此前缀圈定压缩范围(块 SystemMessage 用 compressed_block_ 前缀,不冲突)。
|
||||
HISTORY_MSG_PREFIX = "hist_"
|
||||
COMPRESSED_BLOCK_ID_PREFIX = "compressed_block_"
|
||||
|
||||
触发压缩时当前 turn 尚未持久化(save_turn 在图执行结束后才调用),
|
||||
因此游标 = 当前 turn 之前所有已完成轮次的工具调用总数。
|
||||
游标之前的工具调用历史已包含在压缩摘要中,注入上下文时跳过。
|
||||
|
||||
def _msg_id(m: Any) -> str:
|
||||
"""取消息稳定 id;无 id 返回空串(当前轮消息无 id,天然被排除在压缩范围外)。"""
|
||||
mid = getattr(m, "id", None)
|
||||
return mid if isinstance(mid, str) else ""
|
||||
|
||||
|
||||
def _estimate_text_tokens(text: str) -> int:
|
||||
"""字符/token 估算口径:CJK 约 1 字符/token,ASCII 约 4 字符/token。"""
|
||||
if not text:
|
||||
return 0
|
||||
cjk = sum(0x4E00 <= ord(c) <= 0x9FFF or 0x3000 <= ord(c) <= 0x303F for c in text)
|
||||
return cjk + (len(text) - cjk + 3) // 4
|
||||
|
||||
|
||||
def _unit_to_text(m: Any) -> str:
|
||||
"""将单个重建单元渲染为压缩输入文本(多模态内容转文本占位,思考/工具调用附注)。"""
|
||||
lines: list[str] = []
|
||||
content = getattr(m, "content", None)
|
||||
if isinstance(content, str):
|
||||
if content.strip():
|
||||
lines.append(content.strip())
|
||||
elif isinstance(content, list):
|
||||
for part in content:
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
if part.get("type") == "text":
|
||||
t = str(part.get("text") or "").strip()
|
||||
if t:
|
||||
lines.append(t)
|
||||
elif part.get("type") == "image_url":
|
||||
lines.append(f"[图片: {part.get('name') or '截图'}]")
|
||||
elif part.get("type") == "file":
|
||||
lines.append(f"[文件: {part.get('name') or '附件'}]")
|
||||
if isinstance(m, AIMessage):
|
||||
rc = (getattr(m, "additional_kwargs", None) or {}).get("reasoning_content")
|
||||
if isinstance(rc, str) and rc.strip():
|
||||
lines.append(f"[思考] {rc.strip()}")
|
||||
for tc in getattr(m, "tool_calls") or []:
|
||||
tc = normalize_tool_call(tc)
|
||||
args = tc.get("args") or {}
|
||||
arg_text = json.dumps(args, ensure_ascii=False)
|
||||
if len(arg_text) > 200:
|
||||
arg_text = arg_text[:200] + "…"
|
||||
lines.append(f"[工具调用] {tc.get('name', 'unknown')}: {arg_text}")
|
||||
if isinstance(m, ToolMessage):
|
||||
name = getattr(m, "name", "") or ""
|
||||
if name and lines:
|
||||
lines[0] = f"({name}) {lines[0]}"
|
||||
elif name:
|
||||
lines.append(f"({name})")
|
||||
return "\n".join(x for x in lines if x).strip()
|
||||
|
||||
|
||||
def _merge_units(units: list[Any]) -> list[str]:
|
||||
"""合并相邻同角色消息(Human|AI,工具结果不合并)为压缩输入单元。
|
||||
|
||||
注意:
|
||||
langchain-core 1.x 无原生 role,按类型判定;连续多条 HumanMessage 或
|
||||
连续多条 AIMessage 合并为一条大单元(相邻异角色之间保持原顺序)。
|
||||
"""
|
||||
try:
|
||||
from agent.history.tool_call_recorder import list_turns
|
||||
turns = list_turns(session_id, limit=100000)
|
||||
return sum(len(t.get("tool_calls") or []) for t in turns)
|
||||
except Exception:
|
||||
return -1
|
||||
merged: list[str] = []
|
||||
prev_parts: list[str] | None = None # 当前合并单元的累积文本
|
||||
prev_role: str = "" # "human" | "ai" | "tool"
|
||||
for m in units:
|
||||
text = _unit_to_text(m)
|
||||
if not text:
|
||||
continue
|
||||
if isinstance(m, ToolMessage):
|
||||
role = "tool"
|
||||
elif isinstance(m, AIMessage):
|
||||
role = "ai"
|
||||
else:
|
||||
role = "human"
|
||||
# 仅相邻同角色 Human/AI 合并成一条大单元;工具结果不合并
|
||||
if prev_parts is not None and role == prev_role and role in ("human", "ai"):
|
||||
prev_parts.append(text)
|
||||
continue
|
||||
if prev_parts is not None:
|
||||
merged.append("\n".join(prev_parts))
|
||||
prev_parts = [text]
|
||||
prev_role = role
|
||||
if prev_parts is not None:
|
||||
merged.append("\n".join(prev_parts))
|
||||
return merged
|
||||
|
||||
|
||||
def _maybe_trigger_compress(response: Any, messages: list[Any] | None = None, model_name: str = "") -> tuple[str, int, int]:
|
||||
@@ -307,22 +387,35 @@ def _maybe_trigger_compress(response: Any, messages: list[Any] | None = None, mo
|
||||
return ("compress", total, threshold)
|
||||
|
||||
|
||||
COMPRESSED_SUMMARY_PREFIX = "【历史工具调用摘要】"
|
||||
COMPRESSED_SUMMARY_MSG_ID = "compressed_summary"
|
||||
|
||||
|
||||
def maybe_compress_context(state: AgentState) -> dict:
|
||||
"""上下文压缩节点:位于 process_tool_artifact 之后、agent 之前。
|
||||
|
||||
当 agent 节点检测到 token 超阈值(state["_need_compress"]=True)时,
|
||||
将上下文中注入的"历史工具调用与返回值"压缩为摘要(系统提示词、对话历史
|
||||
均不压缩),更新压缩游标,并通过 RemoveMessage 移除已被摘要覆盖的历史工具
|
||||
调用消息,替换为累积摘要 SystemMessage。
|
||||
将「上次压缩游标之后 → 当前轮用户消息之前」的原生历史(思考/反思/对话/
|
||||
工具调用与返回值,id 以 hist_ 开头)压缩为摘要块,**追加**为链式压缩块
|
||||
(新块不覆盖旧块),推进 ``cursor_msg``,并通过 RemoveMessage 移除已被
|
||||
摘要覆盖的原生历史消息。压缩块属于固定前缀的一部分,压缩时不重压、
|
||||
压缩后按原字节进入下一轮上下文 → 前缀缓存命中。
|
||||
|
||||
统计口径(每条消息 = 1 个重建单元,同现有组装视图):
|
||||
msg_from/msg_to —— 全局单元区间(1 起,游标为已压缩单元数);
|
||||
turns —— 覆盖的轮次数(按 hist_* 消息 id 中的时间戳轮次去重);
|
||||
merged_units —— 相邻同角色消息合并后送给摘要模型的单元数;
|
||||
tokens_before/tokens_after —— 压缩前后文本 token 估算。
|
||||
|
||||
输出:
|
||||
{"messages": [RemoveMessage..., 摘要 SystemMessage], "_need_compress": False}
|
||||
{"messages": [RemoveMessage..., 新块 SystemMessage], "_need_compress": False}
|
||||
"""
|
||||
if not state.get("_need_compress"):
|
||||
# 图外注入排水点(压缩未触发时也取走注入消息,追加进本轮消息)
|
||||
try:
|
||||
from agent.core.turn_injector import drain as _drain_injected
|
||||
from agent.history.tool_call_recorder import get_current_session
|
||||
injected = _drain_injected(get_current_session() or "")
|
||||
if injected:
|
||||
return {"messages": injected}
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
# 子 Agent 不做压缩(超限时已在 agent 节点直接报错)
|
||||
@@ -336,67 +429,96 @@ def maybe_compress_context(state: AgentState) -> dict:
|
||||
return {"_need_compress": False}
|
||||
|
||||
messages = list(state["messages"])
|
||||
ctx_msgs = [
|
||||
m for m in messages
|
||||
if isinstance(m, HumanMessage) and (m.additional_kwargs or {}).get("tool_ctx")
|
||||
]
|
||||
if not ctx_msgs:
|
||||
# 压缩范围:链式块之后、当前轮用户消息之前的原生历史(稳定 id 前缀 hist_)。
|
||||
# 当前轮消息(无稳定 id 的 user/AI/Tool)天然排除在外,不被压缩。
|
||||
units = [m for m in messages if _msg_id(m).startswith(HISTORY_MSG_PREFIX)]
|
||||
if not units:
|
||||
return {"_need_compress": False}
|
||||
|
||||
compress_text = "\n".join(
|
||||
str(getattr(m, "content", "") or "").strip() for m in ctx_msgs
|
||||
).strip()
|
||||
merged_units = _merge_units(units)
|
||||
compress_text = "\n\n".join(merged_units).strip()
|
||||
if not compress_text:
|
||||
return {"_need_compress": False}
|
||||
|
||||
before_len = len(messages)
|
||||
# 统计:覆盖轮次(turn_id 形如 YYYYMMDD_HHMMSS,含一个下划线)
|
||||
import re as _re
|
||||
import time as _time
|
||||
turn_ids: set[str] = set()
|
||||
for m in units:
|
||||
mt = _re.search(r"\d{8}_\d{6}", _msg_id(m))
|
||||
if mt:
|
||||
turn_ids.add(mt.group(0))
|
||||
|
||||
# SSE 通知前端:压缩开始
|
||||
# 读取当前压缩上下文(游标 + 块链),并计算新块区间
|
||||
from agent.utils.agent_utils import load_compressed_context, save_compressed_context, summarize_chat_text
|
||||
ctx = load_compressed_context(sid) if sid else {"version": 2, "cursor_msg": 0, "blocks": []}
|
||||
cursor = int(ctx.get("cursor_msg") or 0)
|
||||
msg_from = cursor + 1
|
||||
msg_to = cursor + len(units)
|
||||
seq = max((int(b.get("seq") or 0) for b in (ctx.get("blocks") or [])), default=0) + 1
|
||||
tokens_before = _estimate_text_tokens(compress_text)
|
||||
|
||||
# SSE 通知前端:压缩开始(附统计)
|
||||
try:
|
||||
from agent.history.tool_call_recorder import record_tool_call_live
|
||||
if sid:
|
||||
record_tool_call_live(sid, "summarizer", {
|
||||
"action": "上下文压缩中",
|
||||
"before_messages": before_len,
|
||||
"msg_from": msg_from,
|
||||
"msg_to": msg_to,
|
||||
"turns": len(turn_ids),
|
||||
"units": len(units),
|
||||
"merged_units": sum(1 for x in merged_units if x.strip()),
|
||||
"tokens_before": tokens_before,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from agent.core.llm import get_default_llm
|
||||
from agent.utils.agent_utils import get_session_meta, summarize_chat_text, update_session_meta
|
||||
|
||||
summary_text = summarize_chat_text(get_default_llm(), compress_text)
|
||||
|
||||
# 累积压缩摘要并更新压缩游标(游标前历史已被摘要覆盖)
|
||||
tokens_after = _estimate_text_tokens(summary_text)
|
||||
block = {
|
||||
"seq": seq,
|
||||
"content": summary_text,
|
||||
"msg_from": msg_from,
|
||||
"msg_to": msg_to,
|
||||
"units": len(units),
|
||||
"merged_units": sum(1 for x in merged_units if x.strip()),
|
||||
"turns": len(turn_ids),
|
||||
"tokens_before": tokens_before,
|
||||
"tokens_after": tokens_after,
|
||||
"compressed_at": int(_time.time()),
|
||||
}
|
||||
# 链式存储:新块追加到块链末尾,不覆盖旧块;游标推进到本轮压缩的末尾单元
|
||||
ctx["cursor_msg"] = msg_to
|
||||
(ctx.setdefault("blocks", [])).append(block)
|
||||
if sid:
|
||||
meta = get_session_meta(sid, "main")
|
||||
old_content = meta.get("compressed_content") or ""
|
||||
new_content = (old_content + "\n\n" + summary_text).strip() if old_content else summary_text
|
||||
cursor = _compute_next_cursor(sid)
|
||||
update_session_meta(sid, "main", cursor, new_content)
|
||||
else:
|
||||
new_content = summary_text
|
||||
save_compressed_context(sid, ctx)
|
||||
|
||||
# 通过 RemoveMessage 移除已被摘要覆盖的历史工具调用消息,
|
||||
# 并以固定 id 插入最新累积摘要(同 id 消息会被替换而非追加)
|
||||
# RemoveMessage 移除已被摘要覆盖的原生历史消息;新块以固定 id 追加
|
||||
# (LangGraph 仅支持尾部追加,下一轮装配时由 _inject_summary_context
|
||||
# 将全部块放回系统提示词之后的前缀位置)。
|
||||
from langgraph.graph.message import RemoveMessage
|
||||
updates: list = [
|
||||
RemoveMessage(id=m.id)
|
||||
for m in ctx_msgs
|
||||
for m in units
|
||||
if getattr(m, "id", None)
|
||||
]
|
||||
updates.append(SystemMessage(
|
||||
content=f"{COMPRESSED_SUMMARY_PREFIX}\n{new_content}",
|
||||
id=COMPRESSED_SUMMARY_MSG_ID,
|
||||
content=f"【历史对话摘要 #{seq}】\n{summary_text}",
|
||||
id=f"{COMPRESSED_BLOCK_ID_PREFIX}{seq}",
|
||||
))
|
||||
|
||||
# SSE 通知前端:压缩完成
|
||||
# SSE 通知前端:压缩完成(附统计)
|
||||
try:
|
||||
from agent.history.tool_call_recorder import record_tool_result_live
|
||||
if sid:
|
||||
record_tool_result_live(sid, "summarizer",
|
||||
f"压缩完成: 压缩 {len(ctx_msgs)} 条历史工具调用消息")
|
||||
f"压缩完成: 第 {msg_from}-{msg_to} 条消息({len(turn_ids)} 轮, "
|
||||
f"{len(units)} 条合并为 {block['merged_units']} 单元)"
|
||||
f"{tokens_before} → {tokens_after} token")
|
||||
except Exception:
|
||||
pass
|
||||
return {"messages": updates, "_need_compress": False}
|
||||
@@ -406,9 +528,14 @@ def maybe_compress_context(state: AgentState) -> dict:
|
||||
from agent.history.tool_call_recorder import record_tool_result_live
|
||||
if sid:
|
||||
record_tool_result_live(sid, "summarizer",
|
||||
f"压缩失败: {str(e)}")
|
||||
"压缩失败: LLM 调用超时" if is_timeout_error(e) else f"压缩失败: {str(e)}")
|
||||
except Exception:
|
||||
pass
|
||||
if is_timeout_error(e):
|
||||
# 压缩节点超时:中止本轮并交由 execute_agent 回复"压缩节点超时"。
|
||||
# 消息列表不删任何消息(RemoveMessage 仅成功时执行),
|
||||
# 完整保留并由 execute_agent 走 save_failed_turn 落盘。
|
||||
raise RuntimeError("COMPRESS_TIMEOUT") from e
|
||||
return {"_need_compress": False}
|
||||
|
||||
|
||||
@@ -434,29 +561,88 @@ def _inject_turn_thinking(messages: list[Any]) -> list[Any]:
|
||||
reflect_seq = 0
|
||||
for m in messages:
|
||||
if isinstance(m, AIMessage) and getattr(m, "tool_calls", None):
|
||||
try:
|
||||
thought = extract_thought(m)
|
||||
reflection = extract_reflection(m)
|
||||
except Exception:
|
||||
thought = reflection = ""
|
||||
if thought:
|
||||
out.append(HumanMessage(
|
||||
content=f"[思考] {thought}",
|
||||
additional_kwargs={"synthetic": True},
|
||||
id=f"turn_think_{think_seq}",
|
||||
))
|
||||
think_seq += 1
|
||||
if reflection:
|
||||
out.append(HumanMessage(
|
||||
content=f"[反思] {reflection}",
|
||||
additional_kwargs={"synthetic": True},
|
||||
id=f"turn_reflect_{reflect_seq}",
|
||||
))
|
||||
reflect_seq += 1
|
||||
# 原生重建历史(hist_*)已是 AIMessage 格式:思考在
|
||||
# additional_kwargs.reasoning_content、反思在 content,
|
||||
# 由 llm._get_request_payload 以原生字段透传 → 不在此合成文本。
|
||||
# 图中当前轮响应同理:reasoning_content 随之透传后
|
||||
# [思考] 合成为重复内容,跳过;[反思] 保持原行为兜底。
|
||||
if not _msg_id(m).startswith(HISTORY_MSG_PREFIX):
|
||||
try:
|
||||
thought = extract_thought(m)
|
||||
reflection = extract_reflection(m)
|
||||
except Exception:
|
||||
thought = reflection = ""
|
||||
has_reasoning = bool((getattr(m, "additional_kwargs", None) or {}).get("reasoning_content"))
|
||||
if thought and not has_reasoning:
|
||||
out.append(HumanMessage(
|
||||
content=f"[思考] {thought}",
|
||||
additional_kwargs={"synthetic": True},
|
||||
id=f"turn_think_{think_seq}",
|
||||
))
|
||||
think_seq += 1
|
||||
if reflection:
|
||||
out.append(HumanMessage(
|
||||
content=f"[反思] {reflection}",
|
||||
additional_kwargs={"synthetic": True},
|
||||
id=f"turn_reflect_{reflect_seq}",
|
||||
))
|
||||
reflect_seq += 1
|
||||
out.append(m)
|
||||
return out
|
||||
|
||||
|
||||
def _invoke_llm_with_retry(model: Any, messages: list[Any], session_id: str = "") -> Any:
|
||||
"""调用已绑定工具的模型,LLM 服务超时时自动重试(默认 3 次,LLM_MAX_RETRIES 配置)。
|
||||
|
||||
输入:
|
||||
model: 已绑定工具的 ChatOpenAI 实例。
|
||||
messages: 待发送消息列表。
|
||||
session_id: 会话 ID,用于前端实时事件通知与 abort 检查。
|
||||
|
||||
输出:
|
||||
模型响应;重试耗尽时抛出 LLMRetryExhausted(最终异常为 cause)。
|
||||
|
||||
系统定位:
|
||||
仅包装 ReAct 循环的 agent 节点调用——工具内部 LLM 调用(describe_image、
|
||||
文档摘要在工具内自行处理)与压缩节点不经过此处:前者失败由工具返回
|
||||
错误给主 Agent 不影响主图,后者在 maybe_compress_context 走独立超时路径。
|
||||
重试期间前端消息等待状态不变(轮次仍在后台线程执行),仅通过
|
||||
record_live_event 推送"超时正在重试"提示浮窗。
|
||||
"""
|
||||
import time as _time
|
||||
from agent.utils.env_utils import LLM_MAX_RETRIES
|
||||
max_retries = max(0, int(LLM_MAX_RETRIES or 0))
|
||||
attempt = 0
|
||||
while True:
|
||||
try:
|
||||
return model.invoke(messages)
|
||||
except Exception as e: # noqa: BLE001 — 重试候选异常需全量捕获再按类型判定
|
||||
if not is_timeout_error(e):
|
||||
raise
|
||||
if attempt >= max_retries:
|
||||
# 已重试过但最终仍失败:抛带次数的终端异常(保留原始异常作为 cause)
|
||||
if attempt:
|
||||
raise LLMRetryExhausted(retries=attempt, original=e) from e
|
||||
raise
|
||||
attempt += 1
|
||||
if session_id:
|
||||
try:
|
||||
from agent.history.tool_call_recorder import record_live_event
|
||||
record_live_event(session_id, f"LLM 服务调用超时,正在重试(第 {attempt}/{max_retries} 次)")
|
||||
except Exception:
|
||||
pass
|
||||
# 重试等待期间检查用户是否手动暂停
|
||||
try:
|
||||
from agent.core.runtime import check_abort
|
||||
if check_abort(session_id):
|
||||
raise RuntimeError("ABORTED_BY_USER")
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
_time.sleep(attempt) # 递增退避:1s / 2s / 3s
|
||||
|
||||
|
||||
def make_call_model_node(
|
||||
model_with_tools: Any,
|
||||
trajectory_rounds: int = 3,
|
||||
@@ -515,7 +701,8 @@ def make_call_model_node(
|
||||
additional_kwargs={"synthetic": True, "reflection": True},
|
||||
))
|
||||
|
||||
response = model_with_tools.invoke(messages_for_llm)
|
||||
# 超时自动重试:仅 ReAct 调用的模型调用(工具内部 LLM 与压缩节点不受影响)
|
||||
response = _invoke_llm_with_retry(model_with_tools, messages_for_llm, _sid)
|
||||
|
||||
# 模型名(用于全局用量统计按模型分账)
|
||||
model_name = ""
|
||||
@@ -534,7 +721,7 @@ def make_call_model_node(
|
||||
content=SUB_AGENT_OOM_PROMPT.format(total_tokens=_total, threshold=_threshold),
|
||||
additional_kwargs={"synthetic": True, "reflection": True},
|
||||
)
|
||||
response = model_with_tools.invoke(list(messages_for_llm) + [oom_msg])
|
||||
response = _invoke_llm_with_retry(model_with_tools, list(messages_for_llm) + [oom_msg], _sid)
|
||||
# 剥离工具调用:确保子 Agent 以文字整理结果结束,不再进入工具循环
|
||||
if getattr(response, "tool_calls", None):
|
||||
response = AIMessage(content=getattr(response, "content", "") or "(子Agent上下文超限,已完成任务整理)")
|
||||
@@ -581,6 +768,15 @@ def make_call_model_node(
|
||||
def process_tool_artifact(state: AgentState) -> dict:
|
||||
last_msg = state["messages"][-1]
|
||||
|
||||
# ---- 图外注入排水点:后台任务完成结果等外部消息追加进本轮消息 ----
|
||||
injected = []
|
||||
try:
|
||||
from agent.core.turn_injector import drain as _drain_injected
|
||||
from agent.history.tool_call_recorder import get_current_session
|
||||
injected = _drain_injected(get_current_session() or "")
|
||||
except Exception:
|
||||
injected = []
|
||||
|
||||
# ---- 实时记录工具结果到前端 ----
|
||||
# 处理尾部所有连续的 ToolMessage(并行工具调用时会有多条),
|
||||
# record_tool_result_live 对已 done 的记录是幂等的,不会重复标记
|
||||
@@ -606,7 +802,7 @@ def process_tool_artifact(state: AgentState) -> dict:
|
||||
pass
|
||||
|
||||
if not isinstance(last_msg, ToolMessage) or not last_msg.artifact:
|
||||
return {}
|
||||
return {"messages": injected} if injected else {}
|
||||
|
||||
artifact = last_msg.artifact
|
||||
img_bytes_list: list[bytes] = []
|
||||
@@ -632,7 +828,7 @@ def process_tool_artifact(state: AgentState) -> dict:
|
||||
img_bytes_list.append(artifact)
|
||||
|
||||
if not img_bytes_list:
|
||||
return {}
|
||||
return {"messages": injected} if injected else {}
|
||||
|
||||
content_parts = []
|
||||
failed_count = 0
|
||||
@@ -655,4 +851,4 @@ def process_tool_artifact(state: AgentState) -> dict:
|
||||
content_parts[-1]["text"] += f"(其中 {failed_count} 张图片处理失败,请忽略)"
|
||||
|
||||
human_msg = HumanMessage(content=content_parts, additional_kwargs={"synthetic": True})
|
||||
return {"messages": [human_msg]}
|
||||
return {"messages": [human_msg] + injected}
|
||||
+56
-26
@@ -23,9 +23,10 @@ from typing import Any, Literal
|
||||
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
||||
from langgraph.errors import GraphRecursionError
|
||||
|
||||
from agent.history.tool_call_recorder import save_turn, start_live_turn, end_live_turn, save_aborted_turn, set_current_turn, set_current_session
|
||||
from agent.history.tool_call_recorder import save_turn, start_live_turn, end_live_turn, save_aborted_turn, save_failed_turn, set_current_turn, set_current_session
|
||||
from agent.memory.system_prompt import get_plan_system_prompt, get_cron_system_prompt
|
||||
from agent.utils.agent_utils import assistant_text, get_history
|
||||
from agent.core.llm import LLMRetryExhausted, LLM_TIMEOUT_RETRY_MSG_TEMPLATE, LLM_TIMEOUT_GENERIC_MSG, LLM_COMPRESS_TIMEOUT_MSG
|
||||
from agent.utils.agent_utils import assistant_text
|
||||
from web.ui_session import rewrite_user_message_files_to_session_dir, sync_turn_from_chat_history
|
||||
|
||||
AgentMode = Literal["agent", "plan", "cron"]
|
||||
@@ -66,35 +67,38 @@ _OOM_MESSAGE = "OOM: 已达到最大迭代次数限制,任务未能完成。
|
||||
|
||||
|
||||
def _inject_summary_context(session_id: str, messages: list) -> None:
|
||||
"""注入压缩摘要(游标之前的工具调用历史已压缩进摘要,仅主 Agent)。"""
|
||||
"""注入链式压缩块(固定提示词之后的稳定前缀,仅主 Agent)。
|
||||
|
||||
每块独立 SystemMessage(固定 id compressed_block_{seq}),块间按 seq 追加。
|
||||
压缩只追加新块、不覆盖旧块 → 前缀(固定提示词 + 块链)字节不变,
|
||||
压缩时与压缩后前缀缓存均命中。
|
||||
"""
|
||||
if not session_id:
|
||||
return
|
||||
try:
|
||||
from agent.utils.agent_utils import get_session_meta
|
||||
meta = get_session_meta(session_id, "main")
|
||||
summary = meta.get("compressed_content") or ""
|
||||
if summary:
|
||||
# 固定 id:图内 compress 节点更新摘要时按同 id 替换,避免重复累积
|
||||
from agent.utils.agent_utils import load_compressed_context
|
||||
ctx = load_compressed_context(session_id)
|
||||
for block in ctx.get("blocks") or []:
|
||||
seq = block.get("seq", 0)
|
||||
content = block.get("content") or ""
|
||||
if not content:
|
||||
continue
|
||||
messages.append(SystemMessage(
|
||||
content=f"【历史工具调用摘要】\n{summary}",
|
||||
id="compressed_summary",
|
||||
content=f"【历史对话摘要 #{seq}】\n{content}",
|
||||
id=f"compressed_block_{seq}",
|
||||
))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _inject_tool_history_context(session_id: str, messages: list) -> None:
|
||||
"""注入压缩游标之后的全部轮次历史工具调用上下文(替代原"最近5轮"注入)。
|
||||
|
||||
系统定位:
|
||||
上下文组成(用户方案):
|
||||
系统提示词 + 压缩摘要 + 对话历史 + 压缩游标后的工具调用历史 + 本轮反思。
|
||||
"""
|
||||
"""注入压缩游标之后的全部轮次原生历史(user → AI/工具 → 最终回复)。"""
|
||||
if not session_id:
|
||||
return
|
||||
try:
|
||||
from agent.utils.agent_utils import build_tool_history_messages
|
||||
messages.extend(build_tool_history_messages(session_id, "main"))
|
||||
from agent.utils.agent_utils import build_native_history_messages, load_compressed_context
|
||||
msg_cursor = (load_compressed_context(session_id).get("cursor_msg") or 0)
|
||||
messages.extend(build_native_history_messages(session_id, msg_cursor))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -139,9 +143,15 @@ def _invoke_with_recursion_limit(
|
||||
input_data: dict,
|
||||
config: dict | None = None,
|
||||
) -> dict:
|
||||
"""包装 graph.invoke,注入 recursion_limit 并捕获超限错误返回 OOM 消息。"""
|
||||
"""包装 graph.invoke,注入 recursion_limit 并捕获超限错误返回 OOM 消息。
|
||||
|
||||
LangGraph 的 recursion_limit 按"节点执行次数"计数,而本图每轮 agent 循环
|
||||
消耗 4 个节点(agent → tools → process_tool_artifact → compress → agent),
|
||||
max_iterations 配置的语义是"最大 agent 轮数",故换算为节点数并留余量。
|
||||
"""
|
||||
cfg = dict(config or {})
|
||||
cfg["recursion_limit"] = runtime.max_iterations
|
||||
# 初始 agent 节点 1 个 + 每轮 4 节点 + 收尾余量
|
||||
cfg["recursion_limit"] = runtime.max_iterations * 4 + 16
|
||||
try:
|
||||
return graph.invoke(input_data, config=cfg)
|
||||
except GraphRecursionError:
|
||||
@@ -180,14 +190,23 @@ def _finalize_turn(
|
||||
def _classify_error(e: Exception) -> str:
|
||||
"""将异常归类为用户可读的错误提示。"""
|
||||
msg = str(e)
|
||||
# 节点级重试耗尽(LLM 超时已重试 N 次):文案含重试次数,与 llm 常量一致
|
||||
if isinstance(e, LLMRetryExhausted):
|
||||
return LLM_TIMEOUT_RETRY_MSG_TEMPLATE.format(n=e.retries)
|
||||
# 压缩节点超时(maybe_compress_context 抛出 COMPRESS_TIMEOUT)
|
||||
if msg == "COMPRESS_TIMEOUT":
|
||||
return LLM_COMPRESS_TIMEOUT_MSG
|
||||
# LLM API 认证/鉴权错误
|
||||
if any(k in msg.lower() for k in ("401", "403", "unauthorized", "forbidden", "invalid api key", "authentication", "incorrect api key")):
|
||||
return "API Key 无效或无权访问,请检查模型配置中的 API Key 和 Base URL。"
|
||||
# 速率限制
|
||||
if any(k in msg.lower() for k in ("429", "rate limit", "too many requests", "quota")):
|
||||
return "API 调用频率过高或额度不足,请稍后重试或检查 API 配额。"
|
||||
# LLM 调用超时(未走重试路径,如工具内部、子图边界)
|
||||
if any(k in msg.lower() for k in ("timed out", "timeout")):
|
||||
return LLM_TIMEOUT_GENERIC_MSG
|
||||
# 网络/连接错误
|
||||
if any(k in msg.lower() for k in ("connection", "timeout", "connect", "network", "refused", "unreachable", "econnrefused", "etimedout", "socket", "dns")):
|
||||
if any(k in msg.lower() for k in ("connection", "connect", "network", "refused", "unreachable", "econnrefused", "etimedout", "socket", "dns")):
|
||||
return f"无法连接到模型服务,请检查网络连接和 Base URL 是否正确。"
|
||||
# 上下文超长
|
||||
if any(k in msg.lower() for k in ("context length", "too long", "maximum context", "token", "reduce", "truncat")):
|
||||
@@ -253,9 +272,8 @@ def execute_agent(
|
||||
messages = [SystemMessage(content=system_prompt)]
|
||||
if session_id:
|
||||
_inject_summary_context(session_id, messages)
|
||||
history = get_history(session_id)
|
||||
messages.extend(history.messages)
|
||||
# 注入压缩游标之后的所有轮次工具调用上下文
|
||||
# 对话/工具历史统一由 build_native_history_messages 按原生格式重建
|
||||
# (游标前已由压缩块承载),不再单独注入 get_history 以避免重复
|
||||
_inject_tool_history_context(session_id, messages)
|
||||
messages.append(HumanMessage(content=user_content))
|
||||
|
||||
@@ -310,19 +328,31 @@ def execute_agent(
|
||||
sync_turn_from_chat_history(session_id, chat_history, "")
|
||||
return chat_history
|
||||
import traceback
|
||||
err_msg = _classify_error(e)
|
||||
# 必须在清上下文/end_live_turn 之前落盘:失败轮的已执行工具调用不得丢失
|
||||
if session_id and turn_id:
|
||||
try:
|
||||
save_failed_turn(session_id, turn_id, err_msg)
|
||||
except Exception:
|
||||
pass
|
||||
set_current_turn("")
|
||||
set_current_session("")
|
||||
end_live_turn(session_id)
|
||||
err_msg = _classify_error(e)
|
||||
print(f"[ERROR] 调用模型时出错: {err_msg}")
|
||||
traceback.print_exc()
|
||||
chat_history.append({"role": "assistant", "content": err_msg})
|
||||
except Exception as e:
|
||||
import traceback
|
||||
err_msg = _classify_error(e)
|
||||
# 与上方 RuntimeError 分支相同:失败轮工具调用先落盘再清理上下文
|
||||
if session_id and turn_id:
|
||||
try:
|
||||
save_failed_turn(session_id, turn_id, err_msg)
|
||||
except Exception:
|
||||
pass
|
||||
set_current_turn("")
|
||||
set_current_session("")
|
||||
end_live_turn(session_id)
|
||||
err_msg = _classify_error(e)
|
||||
print(f"[ERROR] 调用模型时出错: {err_msg}")
|
||||
traceback.print_exc()
|
||||
chat_history.append({"role": "assistant", "content": err_msg})
|
||||
|
||||
@@ -369,7 +369,7 @@ class JsonRecordRepository:
|
||||
record.update(meta) # meta 全量透传,消费方新增字段无需改动存储层
|
||||
if reflections:
|
||||
# 尾部思考记录(未被任何工具调用消费的反思),随 turn 一并持久化,
|
||||
# 供后续轮次 build_tool_history_messages 注入上下文
|
||||
# 供后续轮次 build_native_history_messages 重建原生消息注入上下文
|
||||
record["reflections"] = reflections
|
||||
with open(self._tool_path(session_id, turn_id), "w", encoding="utf-8") as f:
|
||||
json.dump(record, f, ensure_ascii=False, indent=2)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""图外注入机制:允许图执行线程之外(后台任务完成、未来人类手动插消息)向会话注入消息。
|
||||
|
||||
系统定位:
|
||||
后台任务 watcher 线程在任务结束时,若该会话轮次仍活跃,把结果作为
|
||||
ToolMessage 注入当前轮次的工具返回值(等同一次返回两个工具结果);
|
||||
注入消息经 ``drain`` 在 process_tool_artifact / compress 节点入口取走并
|
||||
追加进 state.messages(add_messages 合并),agent 下一轮可见,不触发新一轮。
|
||||
|
||||
通用接口 ``inject_message`` 接受任意 BaseMessage,为后续"人类手动插入
|
||||
消息"(UI 侧编辑/外部消息)预留同一排水通道。
|
||||
|
||||
轮次结束后不注入(后台任务数据由终端浮窗与 wait/terminal_info 承载,
|
||||
agent 下次需要时自行查询)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from collections import deque
|
||||
|
||||
from langchain_core.messages import BaseMessage, ToolMessage
|
||||
|
||||
_queues: dict[str, deque[BaseMessage]] = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def inject_message(session_id: str, message: BaseMessage) -> None:
|
||||
"""向会话注入任意消息(图执行线程之外调用是安全的)。"""
|
||||
if not session_id or message is None:
|
||||
return
|
||||
with _lock:
|
||||
if session_id not in _queues:
|
||||
_queues[session_id] = deque()
|
||||
_queues[session_id].append(message)
|
||||
|
||||
|
||||
def inject_tool_message(session_id: str, tool_call_id: str, name: str, content: str) -> None:
|
||||
"""注入工具结果消息(后台任务完成通知)。"""
|
||||
inject_message(session_id, ToolMessage(
|
||||
content=str(content),
|
||||
tool_call_id=tool_call_id or "bg_task",
|
||||
name=name or "background_task",
|
||||
))
|
||||
|
||||
|
||||
def drain(session_id: str) -> list[BaseMessage]:
|
||||
"""取出并清空该会话的待注入消息(图节点入口调用,消息追加进 state)。"""
|
||||
if not session_id:
|
||||
return []
|
||||
with _lock:
|
||||
q = _queues.pop(session_id, None)
|
||||
if not q:
|
||||
return []
|
||||
# 非空队列先取走;可能存在 drain 期间又被注入的竞态——保留队列待下一轮
|
||||
if q:
|
||||
result = list(q)
|
||||
_queues[session_id] = deque() # 清空已取走的
|
||||
return result
|
||||
return []
|
||||
|
||||
|
||||
def clear_session(session_id: str) -> None:
|
||||
"""会话清理(删除会话/重启时调用)。"""
|
||||
with _lock:
|
||||
_queues.pop(session_id, None)
|
||||
@@ -53,6 +53,14 @@ def start_turn(session_id: str, chat_history: list[dict[str, Any]], agent_mode:
|
||||
traceback.print_exc()
|
||||
handle["result"] = {"error": str(e)}
|
||||
finally:
|
||||
# 轮次结束清理:杀掉该会话残留的*前台*进程树(框架级工具超时后
|
||||
# daemon 线程仍在后台跑的进程无处兜底,在此按会话清理);
|
||||
# 后台挂起任务不受影响(跨轮存活,由终端浮窗管理)。
|
||||
try:
|
||||
from agent.tools.terminal_tasks import cleanup_session
|
||||
cleanup_session(session_id)
|
||||
except Exception:
|
||||
pass
|
||||
handle["done"].set()
|
||||
|
||||
thread = threading.Thread(target=_worker, name=f"turn-{session_id}", daemon=True)
|
||||
|
||||
@@ -15,11 +15,12 @@
|
||||
无人在线审批,退化为拦截避免静默失败。
|
||||
|
||||
注意: shell 命令无法完全沙箱(任意代码可执行),terminal_execute 的命令扫描为
|
||||
保守启发式,仅拦截明显的越界文件操作;强保证来自 doc_tool 路径校验与
|
||||
working_dir 校验。
|
||||
保守启发式,仅拦截明显的越界文件操作与危险指令(rm -rf、磁盘格式化、关机
|
||||
重启、注册表删除等);强保证来自 doc_tool 路径校验与 working_dir 校验。
|
||||
|
||||
可扩展性:
|
||||
- 新增需审查的工具时,扩展 ``check_violation`` 分支与 ``_POLICY_TOOLS``。
|
||||
- 扩充危险指令清单时向 ``_DANGEROUS_PATTERNS`` 追加 (正则, 描述) 元组。
|
||||
- 可将访问模式改为按工作空间独立存储(当前为全局单值)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -304,7 +305,7 @@ _POLICY_TOOLS = ("doc_tool", "terminal_execute")
|
||||
|
||||
|
||||
def check_violation(tool_name: str | None, args: dict[str, Any]) -> str | None:
|
||||
"""返回工具调用的越界原因;无越界或无需审查返回 None。
|
||||
"""返回工具调用的违反复审原因(越界 & 危险指令);无违规或无需审查返回 None。
|
||||
|
||||
输入:
|
||||
tool_name: 工具名。
|
||||
@@ -313,15 +314,20 @@ def check_violation(tool_name: str | None, args: dict[str, Any]) -> str | None:
|
||||
if tool_name == "doc_tool":
|
||||
return check_doc_tool(args)
|
||||
if tool_name == "terminal_execute":
|
||||
return check_terminal_execute(args)
|
||||
reason = check_terminal_execute(args)
|
||||
command = args.get("command")
|
||||
danger = detect_dangerous_command(command) if isinstance(command, str) else None
|
||||
if reason and danger:
|
||||
return f"{reason}\n{danger}"
|
||||
return danger or reason
|
||||
return None
|
||||
|
||||
|
||||
def decision(tool_name: str | None, args: dict[str, Any]) -> Literal["allow", "block", "approve"]:
|
||||
"""根据访问模式与越界情况决定处理方式。
|
||||
"""根据访问模式与违反复审(越界 / 危险指令)决定处理方式。
|
||||
|
||||
输出:
|
||||
- "allow": 放行(full 模式或未越界)。
|
||||
- "allow": 放行(full 模式或未违规)。
|
||||
- "block": 拦截(restricted 模式,或 approval 模式且无人值守)。
|
||||
- "approve": 暂停待人工审批(approval 模式、交互会话)。
|
||||
"""
|
||||
@@ -336,6 +342,54 @@ def decision(tool_name: str | None, args: dict[str, Any]) -> Literal["allow", "b
|
||||
return "approve"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 危险指令启发式扫描(静态字符串匹配,保守)
|
||||
# ============================================================
|
||||
|
||||
# (正则, 描述);参数匹配规则:-r/-f 开头参数(-r -f -rf -Recurse -Force 等)。
|
||||
# 注意: 仅静态扫描命令字符串,.bat/.ps1 文件内嵌命令无法检出;
|
||||
# 为保证不误伤 -WhatIf(dry-run)等,参数以 r/f 开头的才命中。
|
||||
_DANGEROUS_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||
# ---- 递归/强制删除 ----
|
||||
(re.compile(r"\brm\s+(?:-[rf][\w-]*\s+)+", re.IGNORECASE), "rm 递归/强制删除"),
|
||||
(re.compile(r"\bremove-item\b[^\r\n;|&]*\s+-[rf][\w-]*", re.IGNORECASE), "Remove-Item 递归/强制删除"),
|
||||
(re.compile(r"\b(?:del|erase)\s+/[sq][a-zA-Z]*(?:\s|$)", re.IGNORECASE), "del 静默删除(/s 或 /q)"),
|
||||
(re.compile(r"\b(?:rd|rmdir)\s+/[sq][a-zA-Z]*(?:\s|$)", re.IGNORECASE), "rd 递归/静默删除(/s 或 /q)"),
|
||||
(re.compile(r"\bdeltree\b", re.IGNORECASE), "deltree 递归删除"),
|
||||
# ---- 磁盘格式化/分区 ----
|
||||
(re.compile(r"\bformat\s+[a-zA-Z]:", re.IGNORECASE), "format 磁盘格式化"),
|
||||
(re.compile(r"\bformat-volume\b", re.IGNORECASE), "Format-Volume 磁盘格式化"),
|
||||
(re.compile(r"\bclear-disk\b", re.IGNORECASE), "Clear-Disk 磁盘清理"),
|
||||
(re.compile(r"\bdiskpart\b", re.IGNORECASE), "diskpart 磁盘分区"),
|
||||
# ---- 关机/重启(仅匹配命令位置,避免误伤 Get-Process shutdown 等参数) ----
|
||||
(re.compile(r"(?:^|[;&|]\s*)\b(?:shutdown|stop-computer|restart-computer)\b", re.IGNORECASE), "关机/重启系统"),
|
||||
# ---- 注册表破坏性删除 ----
|
||||
(re.compile(r"\breg\s+delete\b", re.IGNORECASE), "reg delete 注册表删除"),
|
||||
(re.compile(r"\bremove-itemproperty\b", re.IGNORECASE), "Remove-ItemProperty 注册表属性删除"),
|
||||
)
|
||||
|
||||
|
||||
def detect_dangerous_command(command: str) -> str | None:
|
||||
"""扫描命令字符串中的危险指令(rm -rf / 磁盘格式化 / 关机重启 / 注册表删除等)。
|
||||
|
||||
输入:
|
||||
command: terminal_execute 的命令字符串。
|
||||
|
||||
输出:
|
||||
首次命中的危险指令描述;无命中返回 None。
|
||||
|
||||
系统定位:
|
||||
供 ``check_violation`` 在越界检测之外追加"危险指令审查"——restricted 拦截、
|
||||
approval 审批(审批卡提示原因)、full 放行,与越界语义同构。
|
||||
"""
|
||||
if not command or not isinstance(command, str):
|
||||
return None
|
||||
for pattern, desc in _DANGEROUS_PATTERNS:
|
||||
if pattern.search(command):
|
||||
return f"危险指令: {desc}"
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 拦截文案
|
||||
# ============================================================
|
||||
@@ -349,7 +403,7 @@ _MODE_NAMES = {
|
||||
|
||||
def build_block_message(tool_name: str | None, args: dict[str, Any], reason: str | None = None) -> str:
|
||||
"""构建拦截时返回给 Agent 的错误文案(供其自行纠正)。"""
|
||||
reason = reason or check_violation(tool_name, args) or "检测到超出工作空间范围的操作"
|
||||
reason = reason or check_violation(tool_name, args) or "未通过安全策略"
|
||||
mode = get_access_mode()
|
||||
mode_name = _MODE_NAMES.get(mode, mode)
|
||||
root = get_workspace_root()
|
||||
@@ -359,8 +413,8 @@ def build_block_message(tool_name: str | None, args: dict[str, Any], reason: str
|
||||
elif tool_name == "terminal_execute" and args.get("command"):
|
||||
detail = f"\n命令: {(args.get('command') or '')[:200]}"
|
||||
return (
|
||||
f"[ERROR] 该操作已按访问模式「{mode_name}」拦截:超出工作空间范围。\n"
|
||||
f"[ERROR] 该操作已按访问模式「{mode_name}」拦截:未通过安全策略。\n"
|
||||
f"原因: {reason}\n"
|
||||
f"工作空间: {root}{detail}\n"
|
||||
f"请将目标路径调整到工作空间目录下后重试。"
|
||||
f"请调整命令或目标路径后重试。"
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"pids": []
|
||||
}
|
||||
@@ -7,11 +7,11 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
import queue as _queue_module
|
||||
import urllib.parse
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from datetime import datetime
|
||||
@@ -105,6 +105,13 @@ def sub_agent_context(tool_call_id: str | None = None):
|
||||
_live_lock = threading.Lock()
|
||||
_live_records: dict[str, list[dict[str, Any]]] = {}
|
||||
|
||||
# ---------- 增量推送状态(每条快照只推变化,避免长运行 O(N) 全量 JSON) ----------
|
||||
# 记录为追加 + 原地更新:dirty 集合记录本轮 notify 之间发生变更/新增的记录索引;
|
||||
# pushed_count / pushed_refs 记录增量协议已覆盖的记录数/反思数(新增部分按切片取)。
|
||||
_live_dirty: dict[str, set[int]] = {}
|
||||
_live_pushed_count: dict[str, int] = {}
|
||||
_live_pushed_refs: dict[str, int] = {}
|
||||
|
||||
# 子 Agent 实时工具调用记录((session_id, tool_call_id) → list),独立存储,
|
||||
# 在 get_live_tool_calls 中按 tool_call_id 合并到对应 call_subagent 条目的
|
||||
# sub_tool_calls 中供前端嵌套展开。多个子 Agent 并行时按 (session, tc_id) 隔离。
|
||||
@@ -124,6 +131,13 @@ _turn_start_times: dict[str, float] = {} # key = session_id
|
||||
# 活跃 session 集合,用于区分 turn 已结束 vs 服务重启
|
||||
_active_sessions: set[str] = set()
|
||||
|
||||
# 一次性实时事件(前端 toast,如"LLM 超时正在重试"),随全量快照/增量推送。
|
||||
# 事件为追加式(每条 {id, text, ts}),按 pushed 计数切片避免重复推送。
|
||||
_live_events: dict[str, list[dict[str, Any]]] = {} # session_id → 事件列表
|
||||
_live_events_pushed: dict[str, int] = {}
|
||||
_events_lock = threading.Lock()
|
||||
_events_seq = 0
|
||||
|
||||
# ---------- SSE 订阅机制(pub/sub) ----------
|
||||
# 每个 session 可有多个订阅者,工具调用状态变更时推送快照
|
||||
_live_subscribers: dict[str, list[_queue_module.Queue]] = {}
|
||||
@@ -261,10 +275,15 @@ def start_live_turn(session_id: str) -> str:
|
||||
def init_live_turn_with_id(session_id: str, turn_id: str) -> str:
|
||||
"""使用已有 turn_id 初始化 live 记录(不清除旧 turn 持久化数据)。"""
|
||||
set_current_turn(turn_id)
|
||||
# 清理上一轮可能残留的落盘防抖定时器(abort 等未走 end_live_turn 的路径)
|
||||
_cancel_live_persist(session_id)
|
||||
with _live_lock:
|
||||
_live_records[session_id] = []
|
||||
_turn_start_times[session_id] = time.time() # 记录轮次开始时间
|
||||
_active_sessions.add(session_id)
|
||||
with _events_lock:
|
||||
_live_events[session_id] = []
|
||||
_live_events_pushed[session_id] = 0
|
||||
with _pending_thinking_lock:
|
||||
_pending_thinking.pop(session_id, None)
|
||||
with _pending_reflection_lock:
|
||||
@@ -309,6 +328,8 @@ def record_tool_call_live(session_id: str, name: str, args: dict[str, Any], tool
|
||||
if reflection:
|
||||
entry["reflection"] = reflection
|
||||
_sub_live_records[key].append(entry)
|
||||
# 子记录变更 → 父级 call_subagent 条目(sub_tool_calls)变脏
|
||||
_mark_parent_dirty(session_id, _sub_key(session_id)[1])
|
||||
_notify_live(session_id)
|
||||
return
|
||||
with _pending_thinking_lock:
|
||||
@@ -331,6 +352,7 @@ def record_tool_call_live(session_id: str, name: str, args: dict[str, Any], tool
|
||||
if reflection:
|
||||
entry["reflection"] = reflection
|
||||
_live_records[session_id].append(entry)
|
||||
_live_dirty.setdefault(session_id, set()).add(len(_live_records[session_id]) - 1)
|
||||
_notify_live(session_id)
|
||||
|
||||
|
||||
@@ -349,10 +371,10 @@ def record_tool_result_live(
|
||||
for rec in reversed(records):
|
||||
if rec["name"] == name and rec["status"] == "running":
|
||||
rec["status"] = "done"
|
||||
rec["result_text"] = (result_text or "")[:500]
|
||||
images = _artifact_image_data_urls(artifact)
|
||||
if images:
|
||||
rec["result_images"] = images
|
||||
rec["result_text"] = (result_text or "")[:4000]
|
||||
# 子 Agent 轨迹不保存图片字节:前端不渲染子级图片,
|
||||
# 且轨迹会注入 call_subagent 条目持久化 → 避免 base64 膨胀
|
||||
rec.pop("result_images", None)
|
||||
audio_info = _artifact_audio_info(artifact)
|
||||
if audio_info:
|
||||
rec["result_audio"] = audio_info
|
||||
@@ -360,6 +382,8 @@ def record_tool_result_live(
|
||||
if file_infos:
|
||||
rec["result_file_info"] = file_infos
|
||||
break
|
||||
# 子记录变更 → 父级 call_subagent 条目(sub_tool_calls)变脏
|
||||
_mark_parent_dirty(session_id, key[1])
|
||||
_notify_live(session_id)
|
||||
return
|
||||
with _live_lock:
|
||||
@@ -367,22 +391,129 @@ def record_tool_result_live(
|
||||
for rec in reversed(records):
|
||||
if rec["name"] == name and rec["status"] == "running":
|
||||
rec["status"] = "done"
|
||||
rec["result_text"] = (result_text or "")[:500]
|
||||
images = _artifact_image_data_urls(artifact)
|
||||
if images:
|
||||
rec["result_images"] = images
|
||||
rec["result_text"] = (result_text or "")[:4000]
|
||||
# 图片/文件字节立即落盘为会话目录下的文件,记录只存文件名,
|
||||
# 不内嵌 base64(实时推送由 get_live_tool_calls 转成
|
||||
# /api/tool-image URL;aborted 轮次经 save_aborted_turn 落盘时
|
||||
# 文件已存在,历史回放按 result_files 即可还原)
|
||||
_persist_artifact_files(rec, artifact, records, session_id)
|
||||
audio_info = _artifact_audio_info(artifact)
|
||||
if audio_info:
|
||||
rec["result_audio"] = audio_info
|
||||
file_infos = _artifact_file_infos(artifact)
|
||||
if file_infos:
|
||||
rec["result_file_info"] = file_infos
|
||||
# 增量持久化到磁盘,防止进程崩溃时记录丢失
|
||||
_safe_persist_live_records(session_id, list(records))
|
||||
# 标记变更(增量推送用),并增量持久化到磁盘防止崩溃丢失
|
||||
_live_dirty.setdefault(session_id, set()).add(records.index(rec))
|
||||
_schedule_live_persist(session_id)
|
||||
break
|
||||
_notify_live(session_id)
|
||||
|
||||
|
||||
def record_tool_result_by_call_id(session_id: str, tool_call_id: str, result_text: str) -> bool:
|
||||
"""按 tool_call_id 更新指定工具调用状态为 done(幂等)。
|
||||
|
||||
后台任务结束时由 terminal_tasks watcher 调用:把挂起的 terminal_execute
|
||||
调用从 "running" 更新为 "done" 并写入最终输出(截断 4000 字符)。
|
||||
仅更新仍处于 running 的条目;子 Agent 嵌套记录不在此处理(后台任务按会话归属)。
|
||||
"""
|
||||
if not tool_call_id:
|
||||
return False
|
||||
with _live_lock:
|
||||
records = _live_records.get(session_id, [])
|
||||
for rec in records:
|
||||
if rec.get("id") == tool_call_id and rec.get("status") == "running":
|
||||
rec["status"] = "done"
|
||||
rec["result_text"] = (result_text or "")[:4000]
|
||||
_live_dirty.setdefault(session_id, set()).add(records.index(rec))
|
||||
_schedule_live_persist(session_id)
|
||||
break
|
||||
else:
|
||||
return False
|
||||
_notify_live(session_id)
|
||||
return True
|
||||
|
||||
|
||||
def is_live_turn_active(session_id: str) -> bool:
|
||||
"""该会话当前是否有活跃轮次(start_live_turn 之后、end_live_turn 之前)。"""
|
||||
with _live_lock:
|
||||
return session_id in _active_sessions
|
||||
|
||||
|
||||
def _mark_parent_dirty(session_id: str, parent_id: str) -> None:
|
||||
"""子 Agent 记录变更时标记父级 call_subagent 条目为脏(其 sub_tool_calls 变化)。"""
|
||||
if not parent_id:
|
||||
return
|
||||
with _live_lock:
|
||||
records = _live_records.get(session_id)
|
||||
if not records:
|
||||
return
|
||||
for i, rec in enumerate(records):
|
||||
if rec.get("id") == parent_id:
|
||||
_live_dirty.setdefault(session_id, set()).add(i)
|
||||
return
|
||||
|
||||
|
||||
def record_live_event(session_id: str, text: str) -> None:
|
||||
"""记录一次性实时事件(前端 toast),随下一个快照/增量推送。
|
||||
|
||||
系统定位:
|
||||
LLM 超时重试等"非工具调用"的运行状态提示。事件为追加式,
|
||||
每条自带唯一 id,前端按 id 去重;turn 开始/结束时随 live 记录一起清空。
|
||||
|
||||
输入:
|
||||
session_id: 会话 ID。
|
||||
text: 前端展示文案(如 "LLM 服务调用超时,正在重试(第 1/3 次)")。
|
||||
|
||||
输出:
|
||||
无。
|
||||
"""
|
||||
global _events_seq
|
||||
if not session_id:
|
||||
return
|
||||
with _events_lock:
|
||||
_events_seq += 1
|
||||
_live_events.setdefault(session_id, []).append({
|
||||
"id": f"evt_{_events_seq}_{time.time():.0f}",
|
||||
"text": text,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_notify_live(session_id)
|
||||
|
||||
|
||||
def _persist_artifact_files(
|
||||
rec: dict[str, Any],
|
||||
artifact: Any | None,
|
||||
records: list[dict[str, Any]],
|
||||
session_id: str,
|
||||
) -> None:
|
||||
"""把工具返回的图片/文件字节落盘到会话目录,记录中仅保存文件名。
|
||||
|
||||
命名与 extract_tool_calls_from_messages 对齐(``_artifact_{name}_{idx}.png`` /
|
||||
``_file_{name}_{idx}{ext}``),save_turn 再次写盘时幂等覆盖,不会产生重复文件;
|
||||
result_images 只含 _artifact_* 图片文件名,实时推送时由
|
||||
get_live_tool_calls 转为 /api/tool-image URL。
|
||||
"""
|
||||
if not artifact:
|
||||
return
|
||||
try:
|
||||
match_index = records.index(rec)
|
||||
except ValueError:
|
||||
return
|
||||
name = rec.get("name", "tool")
|
||||
idx = sum(1 for r in records[:match_index] if r.get("name") == name)
|
||||
base_name = f"{name}_{idx}"
|
||||
session_dir = session_tool_dir(session_id)
|
||||
_ensure_dir(session_dir)
|
||||
result_files, bytes_map = _save_artifact_files(artifact, base_name)
|
||||
if not bytes_map:
|
||||
return
|
||||
for fname, data_bytes in bytes_map.items():
|
||||
(session_dir / fname).write_bytes(data_bytes)
|
||||
rec["result_files"] = result_files
|
||||
rec["result_images"] = [f for f in result_files if f.startswith("_artifact_")]
|
||||
|
||||
|
||||
def _safe_persist_live_records(session_id: str, records: list[dict[str, Any]]) -> None:
|
||||
"""将实时记录增量写入存储,防止进程崩溃丢失数据。
|
||||
|
||||
@@ -402,53 +533,44 @@ def _safe_persist_live_records(session_id: str, records: list[dict[str, Any]]) -
|
||||
pass # 持久化失败不应影响主流程
|
||||
|
||||
|
||||
def _artifact_image_data_urls(artifact: Any | None) -> list[str]:
|
||||
if not artifact:
|
||||
return []
|
||||
# ---------- live 记录落盘防抖(每次结果全量写盘 O(N²) → 批量 ~0.8s 一次) ----------
|
||||
_persist_timers: dict[str, threading.Timer] = {}
|
||||
_persist_timers_lock = threading.Lock()
|
||||
_PERSIST_DEBOUNCE_S = 0.8
|
||||
|
||||
images: list[str] = []
|
||||
image_bytes_list: list[bytes] = []
|
||||
|
||||
def collect(item: Any) -> None:
|
||||
if isinstance(item, bytes):
|
||||
image_bytes_list.append(item)
|
||||
def _schedule_live_persist(session_id: str) -> None:
|
||||
"""防抖调度 live 记录落盘:连续工具结果合并为一次全量写盘。
|
||||
|
||||
record_tool_result_live 高频触发时,逐次全量写盘是 O(N²) 磁盘 IO,
|
||||
与前端进程同机运行时会争抢 CPU/IO;改为 0.8s 防抖后运行期写入次数
|
||||
与记录数同阶,轮次结束(end_live_turn)时再强制冲刷一次兜底。
|
||||
"""
|
||||
with _persist_timers_lock:
|
||||
if session_id in _persist_timers:
|
||||
return
|
||||
if not isinstance(item, dict):
|
||||
return
|
||||
if isinstance(item.get("image_bytes"), bytes):
|
||||
image_bytes_list.append(item["image_bytes"])
|
||||
# 支持 doc_tool 的 "images" 键(多图片列表)
|
||||
if isinstance(item.get("images"), list):
|
||||
for img in item["images"]:
|
||||
if isinstance(img, bytes):
|
||||
image_bytes_list.append(img)
|
||||
elif isinstance(img, dict) and isinstance(img.get("image_bytes"), bytes):
|
||||
image_bytes_list.append(img["image_bytes"])
|
||||
for key in ("image_url", "url", "data_url"):
|
||||
value = item.get(key)
|
||||
if isinstance(value, str) and (value.startswith("data:image/") or value.startswith("http")):
|
||||
images.append(value)
|
||||
image_url = item.get("image_url")
|
||||
if isinstance(image_url, dict):
|
||||
value = image_url.get("url")
|
||||
if isinstance(value, str) and (value.startswith("data:image/") or value.startswith("http")):
|
||||
images.append(value)
|
||||
timer = threading.Timer(_PERSIST_DEBOUNCE_S, _flush_live_persist, args=(session_id,))
|
||||
timer.daemon = True
|
||||
_persist_timers[session_id] = timer
|
||||
timer.start()
|
||||
|
||||
# 处理 batch 类型 artifact
|
||||
if isinstance(artifact, dict) and artifact.get("file_type") == "batch":
|
||||
for item in artifact.get("items", []):
|
||||
collect(item)
|
||||
elif isinstance(artifact, list):
|
||||
for item in artifact:
|
||||
collect(item)
|
||||
else:
|
||||
collect(artifact)
|
||||
|
||||
images.extend(
|
||||
"data:image/png;base64," + base64.standard_b64encode(img).decode("ascii")
|
||||
for img in image_bytes_list
|
||||
)
|
||||
return images
|
||||
def _flush_live_persist(session_id: str) -> None:
|
||||
"""定时器回调:取当前 live 记录做一次全量落盘(线程安全)。"""
|
||||
with _persist_timers_lock:
|
||||
_persist_timers.pop(session_id, None)
|
||||
with _live_lock:
|
||||
records = list(_live_records.get(session_id, []))
|
||||
if records:
|
||||
_safe_persist_live_records(session_id, records)
|
||||
|
||||
|
||||
def _cancel_live_persist(session_id: str) -> None:
|
||||
"""取消待执行的落盘定时器(轮次结束时调用)。"""
|
||||
with _persist_timers_lock:
|
||||
timer = _persist_timers.pop(session_id, None)
|
||||
if timer is not None:
|
||||
timer.cancel()
|
||||
|
||||
|
||||
def _artifact_audio_info(artifact: Any | None) -> dict[str, Any] | None:
|
||||
@@ -536,6 +658,15 @@ def end_live_turn(session_id: str) -> list[dict[str, Any]]:
|
||||
供随后 save_turn 持久化为 turn 记录的 reflections 字段(尾部思考注入用)。
|
||||
"""
|
||||
_stash_turn_reflections(session_id)
|
||||
# 轮次结束:取消防抖定时器并立即冲刷一次(此时 turn_id 仍有效),
|
||||
# 确保防抖窗口内最后一批记录不因进程退出/崩溃丢失
|
||||
_cancel_live_persist(session_id)
|
||||
# 同时取消 pending 的推送防抖:live 数据即将清空,延迟推送只会构建空增量
|
||||
_cancel_live_notify(session_id)
|
||||
with _live_lock:
|
||||
final_records = list(_live_records.get(session_id, []))
|
||||
if final_records:
|
||||
_safe_persist_live_records(session_id, final_records)
|
||||
set_current_turn("")
|
||||
clear_live_reflections(session_id)
|
||||
with _pending_thinking_lock:
|
||||
@@ -552,6 +683,9 @@ def end_live_turn(session_id: str) -> list[dict[str, Any]]:
|
||||
with _sub_live_lock:
|
||||
for k in [k for k in _sub_live_records if k[0] == session_id]:
|
||||
_sub_live_records.pop(k, None)
|
||||
with _events_lock:
|
||||
_live_events.pop(session_id, None)
|
||||
_live_events_pushed.pop(session_id, None)
|
||||
with _live_lock:
|
||||
_active_sessions.discard(session_id)
|
||||
return _live_records.pop(session_id, [])
|
||||
@@ -559,8 +693,18 @@ def end_live_turn(session_id: str) -> list[dict[str, Any]]:
|
||||
|
||||
# ---------- SSE 订阅 API ----------
|
||||
def subscribe_live(session_id: str) -> _queue_module.Queue:
|
||||
"""订阅指定 session 的实时工具调用更新,返回一个 Queue。"""
|
||||
"""订阅指定 session 的实时工具调用更新,返回一个 Queue。
|
||||
|
||||
订阅时把增量推送计数对齐到当前状态:连接建立后服务端先发一次全量快照,
|
||||
后续增量以此为基准(否则首条增量会与全量快照重复)。
|
||||
"""
|
||||
q: _queue_module.Queue = _queue_module.Queue(maxsize=64)
|
||||
with _live_lock:
|
||||
_live_pushed_count[session_id] = len(_live_records.get(session_id, []))
|
||||
with _think_lock:
|
||||
_live_pushed_refs[session_id] = len(_think_store.get(session_id, []))
|
||||
with _events_lock:
|
||||
_live_events_pushed[session_id] = len(_live_events.get(session_id, []))
|
||||
with _subscribers_lock:
|
||||
if session_id not in _live_subscribers:
|
||||
_live_subscribers[session_id] = []
|
||||
@@ -579,15 +723,17 @@ def unsubscribe_live(session_id: str, q: _queue_module.Queue) -> None:
|
||||
|
||||
|
||||
def _build_live_snapshot(session_id: str) -> dict[str, Any]:
|
||||
"""构建当前实时状态的快照(不加锁,调用者需确保数据一致性或在锁外读取)。
|
||||
"""构建当前实时状态的完整快照(连接建立时推送一次,后续推送增量 delta)。
|
||||
|
||||
除工具调用/思考/轮次开始时间外,还附带当前等待中的人工请求
|
||||
(human_requests),前端据此渲染确认浮窗。
|
||||
"""
|
||||
snapshot = {
|
||||
"type": "full",
|
||||
"tool_calls": get_live_tool_calls(session_id),
|
||||
"reflections": get_live_reflections(session_id),
|
||||
"started_at": get_turn_started_at(session_id),
|
||||
"events": _get_live_events(session_id),
|
||||
}
|
||||
try:
|
||||
# 惰性导入避免与 human_request(其模块级导入本模块) 循环依赖
|
||||
@@ -598,27 +744,138 @@ def _build_live_snapshot(session_id: str) -> dict[str, Any]:
|
||||
return snapshot
|
||||
|
||||
|
||||
# ---------- live 推送防抖(高频率工具事件 → 合并为 ~0.12s 一批 SSE 帧) ----------
|
||||
# record_*_live 在状态每次变更时即时调用 _notify_live;工具结果/反思高频变更时
|
||||
# 逐条推送会让前端 EventSource 在同一帧内收到大量小增量(每条含 4KB 结果文本),
|
||||
# JSON 解析 + DOM diff 反复占用主线程。改为按 session 防抖合并:
|
||||
# 脏标记(_live_dirty)持续累积,防抖窗口到期后一次性构建增量并推送,
|
||||
# 语义与逐条推送等价(delta 按索引切片,合并无信息损失)。
|
||||
_notify_timers: dict[str, threading.Timer] = {}
|
||||
_notify_timers_lock = threading.Lock()
|
||||
_NOTIFY_DEBOUNCE_S = 0.12
|
||||
|
||||
|
||||
def _notify_live(session_id: str) -> None:
|
||||
"""通知所有订阅者:推送当前快照。在工具调用状态变更后调用。"""
|
||||
"""调度合并推送:防抖窗口内多次变更合并为一次(无订阅者时直接跳过)。"""
|
||||
with _subscribers_lock:
|
||||
subs = _live_subscribers.get(session_id)
|
||||
has_subs = bool(subs)
|
||||
if not has_subs:
|
||||
return
|
||||
with _notify_timers_lock:
|
||||
if session_id in _notify_timers:
|
||||
return
|
||||
timer = threading.Timer(_NOTIFY_DEBOUNCE_S, _flush_live_notify, args=(session_id,))
|
||||
timer.daemon = True
|
||||
_notify_timers[session_id] = timer
|
||||
timer.start()
|
||||
|
||||
|
||||
def _flush_live_notify(session_id: str) -> None:
|
||||
"""定时器回调:通知所有订阅者,推送增量快照(只含变更部分)。
|
||||
|
||||
无订阅者时直接返回、不构建增量:订阅建立时 subscribe_live 会把推送
|
||||
计数对齐到当前状态,脏标记累积到下一次构建时一并处理(updated 取
|
||||
prev_count 之前的全部脏索引),增量语义不变。
|
||||
"""
|
||||
with _notify_timers_lock:
|
||||
_notify_timers.pop(session_id, None)
|
||||
with _subscribers_lock:
|
||||
subs = list(_live_subscribers.get(session_id, []))
|
||||
if not subs:
|
||||
return
|
||||
snapshot = _build_live_snapshot(session_id)
|
||||
delta = _build_live_delta(session_id)
|
||||
if delta is None:
|
||||
return
|
||||
for q in subs:
|
||||
try:
|
||||
q.put_nowait(snapshot)
|
||||
q.put_nowait(delta)
|
||||
except _queue_module.Full:
|
||||
# 队列满时丢弃最旧的消息,放入最新的
|
||||
try:
|
||||
q.get_nowait()
|
||||
q.put_nowait(snapshot)
|
||||
q.put_nowait(delta)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def save_aborted_turn(session_id: str, turn_id: str) -> None:
|
||||
"""Abort 时将当前内存中的 live 记录落盘为 turn JSON,避免工具调用历史丢失。"""
|
||||
def _cancel_live_notify(session_id: str) -> None:
|
||||
"""取消待执行的推送定时器(轮次结束时调用)。"""
|
||||
with _notify_timers_lock:
|
||||
timer = _notify_timers.pop(session_id, None)
|
||||
if timer is not None:
|
||||
timer.cancel()
|
||||
|
||||
|
||||
def _build_live_delta(session_id: str) -> dict[str, Any] | None:
|
||||
"""构建相对上一次推送的增量快照(新增记录 + 变更记录 + 新增反思)。
|
||||
|
||||
记录为追加 + 原地更新:新增部分按 ``pushed_count`` 切片,变更部分取
|
||||
``_live_dirty`` 中的索引;反思同样按 ``pushed_refs`` 切片。
|
||||
``total_calls / total_reflections`` 供前端校验合并是否对齐(丢包时触发全量重同步)。
|
||||
"""
|
||||
with _live_lock:
|
||||
records = list(_live_records.get(session_id, []))
|
||||
dirty = _live_dirty.pop(session_id, set())
|
||||
prev_count = _live_pushed_count.get(session_id, 0)
|
||||
_live_pushed_count[session_id] = len(records)
|
||||
with _think_lock:
|
||||
reflections = list(_think_store.get(session_id, []))
|
||||
prev_refs = _live_pushed_refs.get(session_id, 0)
|
||||
_live_pushed_refs[session_id] = len(reflections)
|
||||
with _events_lock:
|
||||
events = list(_live_events.get(session_id, []))
|
||||
prev_events = _live_events_pushed.get(session_id, 0)
|
||||
_live_events_pushed[session_id] = len(events)
|
||||
if prev_events > len(events):
|
||||
new_events = list(events)
|
||||
else:
|
||||
new_events = events[prev_events:]
|
||||
# 轮次重置(记录数回退)时按全量处理
|
||||
if prev_count > len(records):
|
||||
added, updated = list(records), []
|
||||
else:
|
||||
added = records[prev_count:]
|
||||
# 变更索引中"已推送过"的部分走 updated(>= prev_count 的新记录已含在 added 中)
|
||||
updated = [{"index": i, "call": records[i]} for i in sorted(dirty) if i < prev_count]
|
||||
if prev_refs > len(reflections):
|
||||
new_reflections = list(reflections)
|
||||
else:
|
||||
new_reflections = reflections[prev_refs:]
|
||||
# 与全量快照同构:子 Agent 轨迹注入 + 图片文件名转 /api/tool-image URL
|
||||
# (增量与全量两个通道必须一致,否则前端合并后子轨迹/图片丢失)
|
||||
_inject_sub_traces_into_records(session_id, added)
|
||||
_inject_sub_traces_into_records(session_id, [u["call"] for u in updated])
|
||||
added = _records_with_image_urls(session_id, added)
|
||||
updated = [{"index": u["index"], "call": c}
|
||||
for u, c in zip(updated, _records_with_image_urls(session_id, [u["call"] for u in updated]))]
|
||||
try:
|
||||
from agent.core.human_request import get_human_requests_for_session
|
||||
human_requests = get_human_requests_for_session(session_id)
|
||||
except Exception:
|
||||
human_requests = []
|
||||
# 空增量(无记录/反思/变更,仅人工请求变化时)也照常推送:人工请求浮窗依赖此通道
|
||||
return {
|
||||
"type": "delta",
|
||||
"added": added,
|
||||
"updated": updated,
|
||||
"reflections": new_reflections,
|
||||
"human_requests": human_requests,
|
||||
"events": new_events,
|
||||
"total_calls": len(records),
|
||||
"total_reflections": len(reflections),
|
||||
"started_at": _turn_start_times.get(session_id),
|
||||
}
|
||||
|
||||
|
||||
def _persist_live_records_as_turn(session_id: str, turn_id: str, meta: dict[str, Any]) -> None:
|
||||
"""将当前内存 live 记录落盘为 turn JSON(abort/失败共用,须在 end_live_turn 前调用)。
|
||||
|
||||
输入:
|
||||
session_id: 会话 ID。
|
||||
turn_id: 轮次 ID。
|
||||
meta: 附加元信息,如 {"aborted": True} 或 {"error": "..."}。
|
||||
"""
|
||||
with _live_lock:
|
||||
records = list(_live_records.get(session_id, []))
|
||||
if not records:
|
||||
@@ -636,6 +893,7 @@ def save_aborted_turn(session_id: str, turn_id: str) -> None:
|
||||
"thinking": r.get("thinking", ""),
|
||||
"reflection": r.get("reflection", ""),
|
||||
"result_images": r.get("result_images", []),
|
||||
"result_files": r.get("result_files", []),
|
||||
"result_audio": r.get("result_audio"),
|
||||
"result_file_info": r.get("result_file_info"),
|
||||
}
|
||||
@@ -651,11 +909,25 @@ def save_aborted_turn(session_id: str, turn_id: str) -> None:
|
||||
"started_at": started_at,
|
||||
"ended_at": ended_at,
|
||||
"duration": max(0, ended_at - started_at),
|
||||
"aborted": True,
|
||||
**meta,
|
||||
}
|
||||
session_storage.save_tool_calls(session_id, turn_id, normalized, meta=_meta)
|
||||
|
||||
|
||||
def save_aborted_turn(session_id: str, turn_id: str) -> None:
|
||||
"""Abort 时将当前内存中的 live 记录落盘为 turn JSON,避免工具调用历史丢失。"""
|
||||
_persist_live_records_as_turn(session_id, turn_id, {"aborted": True})
|
||||
|
||||
|
||||
def save_failed_turn(session_id: str, turn_id: str, reason: str) -> None:
|
||||
"""轮次失败(LLM 超时重试耗尽/压缩超时等)时将 live 记录落盘,便于恢复对话。
|
||||
|
||||
与 save_aborted_turn 的区别仅是 meta 标记(error 信息 vs aborted),
|
||||
前端据此区分"失败中止"与"用户手动暂停"。
|
||||
"""
|
||||
_persist_live_records_as_turn(session_id, turn_id, {"error": reason})
|
||||
|
||||
|
||||
def get_live_tool_calls(session_id: str) -> list[dict[str, Any]]:
|
||||
"""轮询接口:当前执行中 turn 的实时工具调用列表。
|
||||
|
||||
@@ -687,32 +959,75 @@ def get_live_tool_calls(session_id: str) -> list[dict[str, Any]]:
|
||||
return []
|
||||
|
||||
# ---- 注入子 Agent 实时轨迹到 call_subagent 条目(按 tool_call_id 精确匹配) ----
|
||||
# 并行子 Agent(含同名)按 (session, tool_call_id) 分槽,注入到各自对应条目;
|
||||
# 无 id 的旧数据回退到"最后一条 running"(旧行为兼容)。
|
||||
_inject_sub_traces_into_records(session_id, records)
|
||||
|
||||
return _records_with_image_urls(session_id, records)
|
||||
|
||||
|
||||
def _inject_sub_traces_into_records(session_id: str, records: list[dict[str, Any]]) -> None:
|
||||
"""把子 Agent 实时轨迹注入到 call_subagent / agent_call 条目(就地修改)。
|
||||
|
||||
并行子 Agent(含同名)按 (session, tool_call_id) 分槽,注入到各自对应条目;
|
||||
无 id 的旧数据回退到"最后一条 running"(旧行为兼容)。
|
||||
全量快照与增量 delta 推送共用本函数,保证子轨迹在两个通道中一致。
|
||||
"""
|
||||
with _sub_live_lock:
|
||||
sub_map = {k: list(v) for k, v in _sub_live_records.items() if k[0] == session_id}
|
||||
if sub_map:
|
||||
matched_ids: set[str] = set()
|
||||
fallback_rec: dict[str, Any] | None = None
|
||||
for rec in records:
|
||||
if rec.get("name") not in ("call_subagent", "agent_call"):
|
||||
continue
|
||||
tc_id = rec.get("id") or ""
|
||||
sub_calls = sub_map.get((session_id, tc_id))
|
||||
if sub_calls:
|
||||
rec["sub_tool_calls"] = sub_calls
|
||||
matched_ids.add(tc_id)
|
||||
elif rec.get("status") == "running" and fallback_rec is None:
|
||||
fallback_rec = rec
|
||||
if fallback_rec is not None and not fallback_rec.get("sub_tool_calls"):
|
||||
leftover: list[dict[str, Any]] = []
|
||||
for k, v in sub_map.items():
|
||||
if k[1] not in matched_ids:
|
||||
leftover.extend(v)
|
||||
if leftover:
|
||||
fallback_rec["sub_tool_calls"] = leftover
|
||||
if not sub_map:
|
||||
return
|
||||
matched_ids: set[str] = set()
|
||||
fallback_rec: dict[str, Any] | None = None
|
||||
for rec in records:
|
||||
if rec.get("name") not in ("call_subagent", "agent_call"):
|
||||
continue
|
||||
tc_id = rec.get("id") or ""
|
||||
sub_calls = sub_map.get((session_id, tc_id))
|
||||
if sub_calls:
|
||||
rec["sub_tool_calls"] = sub_calls
|
||||
matched_ids.add(tc_id)
|
||||
elif rec.get("status") == "running" and fallback_rec is None:
|
||||
fallback_rec = rec
|
||||
if fallback_rec is not None and not fallback_rec.get("sub_tool_calls"):
|
||||
leftover: list[dict[str, Any]] = []
|
||||
for k, v in sub_map.items():
|
||||
if k[1] not in matched_ids:
|
||||
leftover.extend(v)
|
||||
if leftover:
|
||||
fallback_rec["sub_tool_calls"] = leftover
|
||||
|
||||
return records
|
||||
|
||||
def _records_with_image_urls(session_id: str, records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""把 live 记录中的图片文件名转为 /api/tool-image URL(前端可直接 <img> 展示)。
|
||||
|
||||
record_tool_result_live 落盘时 result_images 只存相对文件名
|
||||
(_artifact_*.png),此处转为可访问的 URL;旧记录中的 base64 data URL
|
||||
原样保留(历史兼容)。返回浅拷贝,不修改共享内存中的 live 记录,
|
||||
避免后续 persist_live_records 把 URL 写回磁盘。
|
||||
"""
|
||||
out: list[dict[str, Any]] = []
|
||||
for rec in records:
|
||||
images = rec.get("result_images")
|
||||
needs_convert = isinstance(images, list) and any(
|
||||
isinstance(i, str) and i
|
||||
and not (i.startswith("data:") or i.startswith("/") or i.startswith("http"))
|
||||
for i in images
|
||||
)
|
||||
if not needs_convert:
|
||||
out.append(rec)
|
||||
continue
|
||||
copy = dict(rec)
|
||||
copy["result_images"] = [
|
||||
i
|
||||
if not isinstance(i, str) or not i
|
||||
or i.startswith("data:") or i.startswith("/") or i.startswith("http")
|
||||
else "/api/tool-image?path=" + urllib.parse.quote(str(session_tool_dir(session_id) / i))
|
||||
for i in images
|
||||
]
|
||||
subs = rec.get("sub_tool_calls")
|
||||
if isinstance(subs, list):
|
||||
copy["sub_tool_calls"] = _records_with_image_urls(session_id, subs)
|
||||
out.append(copy)
|
||||
return out
|
||||
|
||||
|
||||
def get_turn_started_at(session_id: str) -> float | None:
|
||||
@@ -819,6 +1134,12 @@ def get_live_reflections(session_id: str) -> list[dict[str, Any]]:
|
||||
return list(_think_store.get(session_id, []))
|
||||
|
||||
|
||||
def _get_live_events(session_id: str) -> list[dict[str, Any]]:
|
||||
"""获取当前会话的实时事件列表(随全量快照推送,前端按 id 去重)。"""
|
||||
with _events_lock:
|
||||
return list(_live_events.get(session_id, []))
|
||||
|
||||
|
||||
def clear_live_reflections(session_id: str) -> None:
|
||||
"""清空指定会话的反思记录。"""
|
||||
with _think_lock:
|
||||
@@ -840,6 +1161,9 @@ def extract_tool_calls_from_messages(messages: list[BaseMessage]) -> list[dict[s
|
||||
pending: list[dict[str, Any]] = []
|
||||
# 同名工具调用计数器,避免多张截图文件名冲突
|
||||
name_counter: dict[str, int] = {}
|
||||
# 分组计数器:同一 AIMessage(同一模型响应)的并行 tool_calls 共享 group_index,
|
||||
# 供历史原生重建时合并为一条 AIMessage(思考/反思/tool_calls 同 group)
|
||||
group_idx = -1
|
||||
for msg in messages:
|
||||
if isinstance(msg, AIMessage) and msg.tool_calls:
|
||||
# 提取该 AIMessage 的思考与反思,仅附加到第一个 tool_call:
|
||||
@@ -849,6 +1173,7 @@ def extract_tool_calls_from_messages(messages: list[BaseMessage]) -> list[dict[s
|
||||
thinking = extract_thought(msg)
|
||||
reflection = extract_reflection(msg)
|
||||
first = True
|
||||
group_idx += 1
|
||||
for tc in msg.tool_calls:
|
||||
d = normalize_tool_call(tc)
|
||||
entry = {
|
||||
@@ -860,6 +1185,7 @@ def extract_tool_calls_from_messages(messages: list[BaseMessage]) -> list[dict[s
|
||||
"result_text": "",
|
||||
"result_files": [],
|
||||
"result_images": [],
|
||||
"group_index": group_idx,
|
||||
}
|
||||
if first:
|
||||
if thinking:
|
||||
@@ -884,7 +1210,8 @@ def extract_tool_calls_from_messages(messages: list[BaseMessage]) -> list[dict[s
|
||||
# 图片不内嵌 base64(避免 tool json 膨胀数 MB):
|
||||
# PNG 统一落盘为 _artifact_*.png,回放时由
|
||||
# _tool_calls_from_turn_record 按 result_files 生成 /api/tool-image URL
|
||||
# 实时展示仍走 live 记录中的 data URL(record_tool_result_live)
|
||||
# 实时展示由 get_live_tool_calls 将 result_images 文件名转为
|
||||
# /api/tool-image URL(live 落盘与 turn 落盘同构)
|
||||
# 提取音频信息(不含字节,仅路径元数据)
|
||||
audio_info = _artifact_audio_info(artifact)
|
||||
if audio_info:
|
||||
@@ -1021,7 +1348,7 @@ def save_turn(session_id: str, turn_id: str, user_message: dict[str, Any],
|
||||
"duration": (ended_at - started_at) if started_at else None,
|
||||
}
|
||||
# 尾部思考(未被任何工具调用消费的反思):end_live_turn 暂存 → 此处持久化,
|
||||
# 供后续轮次 build_tool_history_messages 注入上下文(历史思考)。
|
||||
# 供后续轮次 build_native_history_messages 重建原生消息注入上下文。
|
||||
_reflections = _take_stashed_reflections(session_id)
|
||||
session_storage.save_tool_calls(session_id, turn_id, tool_calls, meta=_meta, reflections=_reflections)
|
||||
return None
|
||||
@@ -1191,9 +1518,9 @@ def _tool_calls_from_turn_record(record: dict[str, Any], session_id: str) -> lis
|
||||
import os as _os
|
||||
fname = _os.path.basename(fp)
|
||||
if fname.startswith("_artifact_"):
|
||||
image_files.append(f"/api/tool-image?path={fp}&session={turn_id}")
|
||||
image_files.append(f"/api/tool-image?path={urllib.parse.quote(str(fp))}&session={urllib.parse.quote(str(turn_id or ''))}")
|
||||
else:
|
||||
other_files.append(f"/api/tool-file?path={fp}&session={turn_id}")
|
||||
other_files.append(f"/api/tool-file?path={urllib.parse.quote(str(fp))}&session={urllib.parse.quote(str(turn_id or ''))}")
|
||||
# result_images:落盘记录不再内嵌 base64(体积原因),
|
||||
# 统一按 result_files 生成 /api/tool-image URL;
|
||||
# 若历史数据仍带 data URL(旧版记录)则保留以兼容
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"hours": {"2026-08-12T22": {"total": 41613, "by_model": {"Qwen3.5-122B-A10B": 41613}}, "2026-08-14T09": {"total": 535476, "by_model": {"Qwen3.5-122B-A10B": 535476}}, "2026-08-14T10": {"total": 91339, "by_model": {"Qwen3.5-122B-A10B": 91339}}, "2026-08-14T11": {"total": 67389, "by_model": {"Qwen3.5-122B-A10B": 67389}}, "2026-08-16T19": {"total": 558037, "by_model": {"Qwen3.5-122B-A10B": 558037}}, "2026-08-16T20": {"total": 212587, "by_model": {"Qwen3.5-122B-A10B": 212587}}, "2026-08-16T22": {"total": 5894, "by_model": {"Qwen3.5-122B-A10B": 5894}}, "2026-08-17T07": {"total": 100546, "by_model": {"Qwen3.5-122B-A10B": 100546}}, "2026-08-17T08": {"total": 135785, "by_model": {"Qwen3.5-122B-A10B": 135785}}, "2026-08-17T17": {"total": 5753, "by_model": {"Qwen3.5-122B-A10B": 5753}}, "2026-08-18T14": {"total": 27472, "by_model": {"Qwen3.5-122B-A10B": 27472}}, "2026-08-18T15": {"total": 45565, "by_model": {"Qwen3.5-122B-A10B": 45565}}, "2026-08-19T08": {"total": 6141, "by_model": {"Qwen3.5-122B-A10B": 6141}}, "2026-08-19T11": {"total": 13137, "by_model": {"Qwen3.5-122B-A10B": 13137}}, "2026-08-19T12": {"total": 53109, "by_model": {"Qwen3.5-122B-A10B": 53109}}}}
|
||||
{"hours": {"2026-08-12T22": {"total": 41613, "by_model": {"Qwen3.5-122B-A10B": 41613}}, "2026-08-14T09": {"total": 535476, "by_model": {"Qwen3.5-122B-A10B": 535476}}, "2026-08-14T10": {"total": 91339, "by_model": {"Qwen3.5-122B-A10B": 91339}}, "2026-08-14T11": {"total": 67389, "by_model": {"Qwen3.5-122B-A10B": 67389}}, "2026-08-16T19": {"total": 558037, "by_model": {"Qwen3.5-122B-A10B": 558037}}, "2026-08-16T20": {"total": 212587, "by_model": {"Qwen3.5-122B-A10B": 212587}}, "2026-08-16T22": {"total": 5894, "by_model": {"Qwen3.5-122B-A10B": 5894}}, "2026-08-17T07": {"total": 100546, "by_model": {"Qwen3.5-122B-A10B": 100546}}, "2026-08-17T08": {"total": 135785, "by_model": {"Qwen3.5-122B-A10B": 135785}}, "2026-08-17T17": {"total": 5753, "by_model": {"Qwen3.5-122B-A10B": 5753}}, "2026-08-18T14": {"total": 27472, "by_model": {"Qwen3.5-122B-A10B": 27472}}, "2026-08-18T15": {"total": 45565, "by_model": {"Qwen3.5-122B-A10B": 45565}}, "2026-08-19T08": {"total": 6141, "by_model": {"Qwen3.5-122B-A10B": 6141}}, "2026-08-19T11": {"total": 13137, "by_model": {"Qwen3.5-122B-A10B": 13137}}, "2026-08-19T12": {"total": 53109, "by_model": {"Qwen3.5-122B-A10B": 53109}}, "2026-08-19T16": {"total": 630061, "by_model": {"Qwen3.5-122B-A10B": 630061}}, "2026-08-19T17": {"total": 567154, "by_model": {"Qwen3.5-122B-A10B": 567154}}, "2026-08-19T18": {"total": 154588, "by_model": {"Qwen3.5-122B-A10B": 154588}}, "2026-08-19T19": {"total": 661541, "by_model": {"Qwen3.5-122B-A10B": 661541}}, "2026-08-20T08": {"total": 320021, "by_model": {"Qwen3.5-122B-A10B": 320021}}, "2026-08-20T10": {"total": 747256, "by_model": {"Qwen3.5-122B-A10B": 747256}}, "2026-08-20T11": {"total": 2172925, "by_model": {"Qwen3.5-122B-A10B": 2172925}}, "2026-08-20T17": {"total": 1628101, "by_model": {"Qwen3.5-122B-A10B": 1628101}}, "2026-08-20T18": {"total": 1290913, "by_model": {"Qwen3.5-122B-A10B": 1290913}}}}
|
||||
@@ -5,6 +5,7 @@ _BASE_PROMPT = """你是一个具备多模态理解能力的桌面操作助手
|
||||
- 只有在信息确实无法从当前对话或上传内容中获取,且必须通过外部工具才能获得时,才调用工具。
|
||||
- 当你调用工具收到截图或返回结果后,必须仔细观察返回内容,基于实际结果继续推进任务,严禁脱离实际结果凭空编造。
|
||||
- 对于同一个页面下的GUI操作,必须将连续操作合并为一次 gui_tool 调用(actions 列表传入多个动作),严禁逐个单步调用。仅当页面发生跳转/加载后需要重新观察时,才允许分步调用。
|
||||
- 【浏览器/网页操作 - web_page 与 gui_tool 合理配合,无固定优先级】涉及浏览器/网页上的操作(点击、输入、选择、滚动、截图、提取内容等),在 web_page(DOM 级:毫秒级、定位可靠)与 gui_tool(视觉级:截图+坐标,可操作 DOM 覆盖不到的区域)之间按场景合理选择、灵活配合:先用 web_page snapshot 获取页面元素清单(已覆盖弹窗/浮层[弹窗]/[浮层]标注、iframe(帧k)、shadow DOM、下拉选项、图标按钮、readonly 日期控件,字段名与[禁用]/[已选中]已标注),能 DOM 定位就优先用 web_page 的 ref/selector/type/date/select 等动作(状态变更操作后自动附最新快照与截图,无需再手动 snapshot);点选下拉选项/菜单项按文字找元素时用 selector `text="完整文字"`(text= 默认精确匹配);日期控件一律用 `date` 动作(value 传 YYYY-MM-DD,自动操作日历面板),不要 fill/eval_js 直接赋值;canvas、原生弹窗、系统对话框、坐标级操作等 DOM 无法覆盖的场景用 gui_tool 视觉定位;复杂控件(自定义下拉/日期/树/搜索框)两者可交叉配合确认。无论用哪个工具,同页连续操作都合并为一次调用。
|
||||
- 【任务规划】收到任务后,先判断任务复杂度:仅当任务为复杂任务(如多步骤、多阶段、流程较长或产出物较多)时,才调用 todo_list 工具将任务拆解为清晰的执行步骤列表,然后按步骤逐步执行;每完成一步,用 todo_list 标记进度(done_step),任务完成后将所有步骤标记为已完成。简单任务可直接执行,无需调用 todo_list。
|
||||
- 当任务完成时,向用户总结执行过程和结果。
|
||||
""".strip()
|
||||
@@ -180,6 +181,14 @@ CRON_MODE_PROMPT = """
|
||||
- 对于同一个页面下的 GUI 操作,必须将连续操作合并为一次 gui_tool 调用(actions 列表传入多个动作),严禁逐个单步调用。仅当页面发生跳转/加载后需要重新观察时,才允许分步调用。
|
||||
- 你可以使用 cron_manager 工具查看、新增、修改、删除定时任务(包括查看任务状态),这样无需用户手动操作即可管理定时任务。
|
||||
|
||||
【浏览器/网页操作 - web_page 与 gui_tool 合理配合,无固定优先级】
|
||||
涉及浏览器/网页上的操作(点击、输入、选择、滚动、截图、提取内容等),在 web_page(DOM 级,毫秒级定位可靠)与 gui_tool(视觉级,截图+坐标)之间按场景合理选择并灵活配合,不设固定优先级:
|
||||
1. web_page 的 snapshot 获取页面元素清单(覆盖弹窗/浮层(标注[弹窗]/[浮层]并排在最前)、iframe(标注(帧k),可用 frame=N >> 前缀)、shadow DOM、下拉选项、图标按钮、readonly 日期控件;字段名与[禁用]/[已选中]已标注;原生 select 附选项列表);**状态变更操作(click/fill/type/select/date/scroll/press/goto 等)执行后工具会自动附最新快照与截图**,直接继续下一步即可,无需再手动调 snapshot;仅当需要查看超过 120 个元素(max_elements 传更大值)或页面跳转后确认状态时才显式 snapshot。
|
||||
2. 定位习惯:点选下拉选项、菜单项、弹窗按钮等"按文字找元素"的场景,优先用 selector `text="完整文字"`(text= 已默认精确匹配,避免误中同名前缀元素);输入框/文本域按 placeholder 或字段名用 ref 或 `placeholder="..."`;按钮也常用 ref(snapshot 清单序号)。
|
||||
3. 日期控件(readonly 输入框、日历面板)一律用 `date` 动作(ref/selector + value 传 YYYY-MM-DD,自动完成年/月/日选择),严禁 fill 直接输入或 eval_js 赋值(Angular 绑定不生效);日期选择失败时先 snapshot/screenshot 核对面板结构再重试。
|
||||
4. canvas、浏览器原生弹窗、系统对话框、页面外桌面软件、坐标级操作等 DOM 无法覆盖的场景,用 gui_tool 视觉定位操作;复杂控件(自定义下拉/日期/树/搜索框、定位失败的字段)可 web_page 与 gui_tool 交叉配合确认(如 gui_tool 截图核对、web_page 精确执行)。
|
||||
5. 无论使用哪个工具,同一页面的连续操作必须合并为一次调用(actions 列表批量传入),严禁逐条单步调用。
|
||||
|
||||
【工作空间配置】
|
||||
- 工作空间 (WORKSPACE_DIR): {workspace_dir}
|
||||
- 你产生的代码、脚本、图片、文档等文件,默认保存到工作空间目录下。
|
||||
@@ -203,6 +212,9 @@ REFLECTION_PROMPT = """
|
||||
|
||||
【gui_tool 批量调用规则 - 务必遵守】
|
||||
如果你计划在同一页面/界面执行多个连续的 GUI 操作(例如:依次点击多个按钮、填写多个表单字段、连续输入多项内容),必须将所有这些操作合并为一次 gui_tool 调用,在 actions 列表中一次性传入所有动作。严禁将同一页面的连续操作拆分多次 gui_tool 调用——这样会导致严重的延迟。仅当需要等页面跳转/加载后才能确定后续操作时,才分步调用。
|
||||
|
||||
【浏览器/网页操作 - web_page 与 gui_tool 合理配合,无固定优先级】
|
||||
涉及浏览器/网页上的操作,在 web_page(DOM 级)与 gui_tool(视觉级)之间按场景合理选择并灵活配合:先 web_page snapshot 获取元素清单(已覆盖弹窗/浮层、iframe、shadow DOM、下拉选项、图标按钮与 readonly 日期控件),能 DOM 定位就用 web_page 的 ref/selector/type/date/select 批量操作(状态变更后自动附最新快照与截图,无需再手动 snapshot);点选下拉选项/菜单项按文字找元素时用 selector `text="完整文字"`(text= 默认精确匹配);日期控件一律用 `date` 动作(value 传 YYYY-MM-DD,自动操作日历面板);canvas、浏览器原生弹窗、系统对话框、页面外软件、坐标级操作等 DOM 无法覆盖的场景用 gui_tool 视觉定位;复杂控件可交叉配合确认。无论用哪个工具,同页连续操作都要合并为一次调用。
|
||||
""".strip()
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ from .date_query import DateQuery
|
||||
from .browser_control import BrowserControl
|
||||
from .web_page import WebPage
|
||||
from .human_interaction import HumanInteraction
|
||||
from .directory_listing import DirectoryListing
|
||||
from .todo_list import TodoList
|
||||
from .software_control import SoftwareControl
|
||||
from .gui import GUITool
|
||||
@@ -13,6 +12,8 @@ from .doc import DocTool
|
||||
from .skill_router import SkillRouter
|
||||
from .send_file import SendFileTool
|
||||
from .terminal_execute import TerminalExecute
|
||||
from .terminal_info import TerminalInfo
|
||||
from .wait import WaitTool
|
||||
from .email import MailTool
|
||||
from .image_gen import ImageGen
|
||||
from .cron_manager import CronManagerTool
|
||||
@@ -28,7 +29,6 @@ _all_tools = [
|
||||
HumanInteraction(),
|
||||
SoftwareControl(),
|
||||
RAGTool(),
|
||||
DirectoryListing(),
|
||||
TodoList(),
|
||||
GUITool(),
|
||||
CallSubAgentTool(),
|
||||
@@ -36,6 +36,8 @@ _all_tools = [
|
||||
SkillRouter(),
|
||||
SendFileTool(),
|
||||
TerminalExecute(),
|
||||
TerminalInfo(),
|
||||
WaitTool(),
|
||||
MailTool(),
|
||||
ImageGen(),
|
||||
CronManagerTool(),
|
||||
|
||||
@@ -148,7 +148,8 @@ class CallSubAgentTool(BaseTool):
|
||||
HumanMessage(content=task_description),
|
||||
]
|
||||
|
||||
config = {"recursion_limit": runtime.max_iterations}
|
||||
# recursion_limit 按节点计:本图每轮循环 4 节点,max_iterations 语义为轮数
|
||||
config = {"recursion_limit": runtime.max_iterations * 4 + 16}
|
||||
|
||||
# 设置子 Agent 上下文:agent 名称供前端浮窗按 agent 分组
|
||||
from agent.core.human_request import set_current_agent_name
|
||||
|
||||
@@ -6,12 +6,14 @@
|
||||
|
||||
会话架构(分离进程 + CDP 连接):
|
||||
- 浏览器以独立进程启动(--remote-debugging-port),Agent 退出后窗口保留
|
||||
- Agent 重启后通过 CDP 自动重连同一浏览器(登录态、标签页不丢)
|
||||
- Agent 重启后通过 CDP 自动重连同一浏览器(标签页不丢)
|
||||
- 默认每次启动使用一次性临时 profile(登录态不持久,不留盘);
|
||||
显式设置 BROWSER_PROFILE_DIR 环境变量时使用持久 profile(登录态保留)
|
||||
- 专用后台线程运行 asyncio loop,所有 Playwright 异步操作在该线程串行执行,
|
||||
规避 sync API 的线程亲和问题
|
||||
|
||||
可扩展性:
|
||||
- 端口与 profile 目录取环境变量 BROWSER_DEBUG_PORT / BROWSER_PROFILE_DIR 覆盖
|
||||
- 端口与持久 profile 目录取环境变量 BROWSER_DEBUG_PORT / BROWSER_PROFILE_DIR 覆盖
|
||||
- 新的页面级操作经 BrowserSession.call_async 提交协程即可,无需改动线程模型
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -22,8 +24,10 @@ import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
@@ -40,7 +44,10 @@ from pydantic import BaseModel, Field, create_model
|
||||
# ---------- 配置常量 ----------
|
||||
_AGENT_DIR = Path(__file__).resolve().parents[1]
|
||||
BROWSER_DEBUG_PORT = int(os.getenv("BROWSER_DEBUG_PORT", "9222"))
|
||||
# 显式设置 BROWSER_PROFILE_DIR 时使用持久 profile 目录(登录态保留);
|
||||
# 未设置时每次启动用一次性临时 profile(不落盘 data/,登录态不持久)。
|
||||
BROWSER_PROFILE_DIR = str(Path(os.getenv("BROWSER_PROFILE_DIR", str(_AGENT_DIR / "data" / "browser_profile"))))
|
||||
_TEMP_PROFILE_PREFIX = "agent_browser_profile_"
|
||||
_CDP_URL = f"http://127.0.0.1:{BROWSER_DEBUG_PORT}"
|
||||
_LAUNCH_TIMEOUT = 15.0 # 分离启动后等待 CDP 就绪的秒数
|
||||
_DEFAULT_OP_TIMEOUT = 45.0 # 单次操作的默认外层超时
|
||||
@@ -105,6 +112,27 @@ def _resolve_executable(browser: Optional[str]) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _resolve_profile_dir() -> str:
|
||||
"""解析浏览器 profile 目录。
|
||||
|
||||
- 显式设置 BROWSER_PROFILE_DIR 环境变量 → 使用持久目录(登录态跨重启保留);
|
||||
- 未设置 → 每次启动新建一次性临时目录(不落盘 data/,登录态不持久),
|
||||
并顺带清理上一次会话遗留的临时 profile。
|
||||
浏览器为分离进程(Agent 退出后可能仍存活),无法在退出时精确清理,
|
||||
故改为下次启动时统一清理。
|
||||
"""
|
||||
if os.getenv("BROWSER_PROFILE_DIR"):
|
||||
# 实时读环境变量(BROWSER_PROFILE_DIR 常量仅为导入期默认值快照)
|
||||
return str(Path(os.getenv("BROWSER_PROFILE_DIR")))
|
||||
base = tempfile.gettempdir()
|
||||
try:
|
||||
for old in glob.glob(os.path.join(base, _TEMP_PROFILE_PREFIX + "*")):
|
||||
shutil.rmtree(old, ignore_errors=True)
|
||||
except Exception:
|
||||
pass
|
||||
return tempfile.mkdtemp(prefix=_TEMP_PROFILE_PREFIX)
|
||||
|
||||
|
||||
def _cdp_alive() -> Optional[dict]:
|
||||
"""探测 CDP 调试端口是否存活,存活返回 /json/version 内容。"""
|
||||
try:
|
||||
@@ -129,8 +157,8 @@ class BrowserSession:
|
||||
self._page = None # 当前活动标签页
|
||||
self._proc: Optional[subprocess.Popen] = None
|
||||
self._watched: set[int] = set() # 已注册 close 监听的 page id
|
||||
# ref 注册表(snapshot 序号 -> CSS 选择器)
|
||||
self._refs: dict[int, str] = {}
|
||||
# ref 注册表(snapshot 序号 -> {"frame": frame 索引, "sel": CSS 选择器})
|
||||
self._refs: dict[int, dict] = {}
|
||||
self._refs_page = None
|
||||
self._launch_browser_pref: Optional[str] = None
|
||||
atexit.register(self._atexit_shutdown)
|
||||
@@ -190,7 +218,7 @@ class BrowserSession:
|
||||
contexts = self._browser.contexts
|
||||
self._context = contexts[0] if contexts else await self._browser.new_context()
|
||||
try:
|
||||
self._context.set_default_timeout(10_000) # 动作超时 10s
|
||||
self._context.set_default_timeout(3_000) # 动作超时 3s(_loc_op 统一重试一次,最坏 ≈6.6s)
|
||||
self._context.set_default_navigation_timeout(30_000) # 导航超时 30s
|
||||
except Exception:
|
||||
pass
|
||||
@@ -233,11 +261,12 @@ class BrowserSession:
|
||||
async def _launch_detached(self) -> None:
|
||||
"""以分离进程启动浏览器并等待 CDP 就绪。"""
|
||||
exe = _resolve_executable(self._launch_browser_pref)
|
||||
Path(BROWSER_PROFILE_DIR).mkdir(parents=True, exist_ok=True)
|
||||
profile_dir = _resolve_profile_dir()
|
||||
Path(profile_dir).mkdir(parents=True, exist_ok=True)
|
||||
cmd = [
|
||||
exe,
|
||||
f"--remote-debugging-port={BROWSER_DEBUG_PORT}",
|
||||
f"--user-data-dir={BROWSER_PROFILE_DIR}",
|
||||
f"--user-data-dir={profile_dir}",
|
||||
"--start-maximized",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
@@ -404,24 +433,36 @@ class BrowserSession:
|
||||
})
|
||||
return tabs
|
||||
|
||||
async def _settle_current(self) -> None:
|
||||
"""等待当前页稳定(截图前调用),避免截到加载动画而非动作结果。
|
||||
async def _settle_current(self, force: bool = False) -> None:
|
||||
"""等待当前页稳定(截图/观察前调用),避免截到加载动画而非动作结果。
|
||||
|
||||
三段式:主文档加载完成(4s 上限)→ 网络空闲(3s 上限,覆盖 AJAX;
|
||||
长轮询页面会超时直接放行)→ 0.3s 渲染缓冲。
|
||||
三段式:主文档加载完成 → 网络空闲(覆盖 AJAX;长轮询页面会超时直接放行)
|
||||
→ 短渲染缓冲(覆盖弹窗/浮层入场动画)。预算压缩后最长约 3.9s,
|
||||
页面已就绪时毫秒级返回。
|
||||
|
||||
性能:2s 内重复调用直接跳过(连续 snapshot/extract 只等待一次),
|
||||
状态变更动作后由 web_page 调 _mark_dirty() 重置,保证变更后必等。
|
||||
"""
|
||||
now = time.monotonic()
|
||||
if not force and (now - getattr(self, "_last_settle_ts", 0.0)) < 2.0:
|
||||
return
|
||||
page = self._page
|
||||
if page is None:
|
||||
return
|
||||
try:
|
||||
await page.wait_for_load_state("domcontentloaded", timeout=4000)
|
||||
await page.wait_for_load_state("domcontentloaded", timeout=2000)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await page.wait_for_load_state("networkidle", timeout=3000)
|
||||
await page.wait_for_load_state("networkidle", timeout=1500)
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(0.3)
|
||||
await asyncio.sleep(0.3) # 覆盖弹窗/浮层入场动画后再观察
|
||||
self._last_settle_ts = time.monotonic()
|
||||
|
||||
def _mark_dirty(self) -> None:
|
||||
"""标记页面状态已变更(点击/输入等动作后调用),强制下次 _settle_current 完整等待。"""
|
||||
self._last_settle_ts = 0.0
|
||||
|
||||
async def screenshot(self, full_page: bool = False) -> bytes:
|
||||
"""当前标签页截图(先等待页面稳定),返回 PNG 字节。"""
|
||||
@@ -430,13 +471,35 @@ class BrowserSession:
|
||||
return await self._page.screenshot(full_page=full_page)
|
||||
|
||||
# ---------- ref 注册表(供 web_page 在会话线程内使用) ----------
|
||||
def set_refs(self, refs: dict[int, str], page) -> None:
|
||||
def set_refs(self, refs: dict[int, dict], page) -> None:
|
||||
"""注册 snapshot 序号 -> {"frame": frame 索引, "sel": CSS 选择器}。
|
||||
|
||||
frame 索引对应 snapshot 时 page.frames 的顺序(0 为主 frame),
|
||||
使 ref 可以跨 iframe 定位元素。同时记录快照时的 URL——
|
||||
页面 reload/SPA 跳转(同 Page 对象)后旧 ref 会指向错误元素,
|
||||
需重新 snapshot。
|
||||
"""
|
||||
self._refs = refs
|
||||
self._refs_page = page
|
||||
try:
|
||||
self._refs_url = page.url
|
||||
except Exception:
|
||||
self._refs_url = ""
|
||||
|
||||
def get_ref_selector(self, ref: int) -> Optional[str]:
|
||||
def get_ref(self, ref: int) -> Optional[dict]:
|
||||
return self._refs.get(ref)
|
||||
|
||||
def refs_stale(self, page) -> bool:
|
||||
"""refs 是否已失效:页面被替换(换标签/关闭)或 URL 变化(reload/跳转)。"""
|
||||
if self._refs_page is not page or not self._refs:
|
||||
return True
|
||||
try:
|
||||
if page.url != self._refs_url:
|
||||
return True
|
||||
except Exception:
|
||||
return True
|
||||
return False
|
||||
|
||||
# ---------- 同步门面(工具线程/主线程调用,内部转投会话线程) ----------
|
||||
def open(self, url: Optional[str] = None, browser: Optional[str] = None) -> dict[str, Any]:
|
||||
return self.call_async(lambda s: s.ensure(browser) if url is None else s.open_tab(url, browser),
|
||||
@@ -527,7 +590,8 @@ class BrowserControl(BaseTool):
|
||||
"- action='close', url='任意非空字符(例如current)': 关闭当前标签页\n"
|
||||
"- action='list_tabs': 列出全部标签页(索引、标题、URL)\n"
|
||||
"- action='switch_tab', tab_index=N: 切换到第 N 个标签页\n"
|
||||
"操作完成后返回当前页面截图。打开页面后的元素级操作请使用 web_page 工具。\n"
|
||||
"操作完成后返回操作描述。浏览器为独立可见窗口(detached CDP),"
|
||||
"不随结果返回截图;打开页面后的元素级操作与页面状态请使用 web_page 工具。\n"
|
||||
"仅用于进行浏览器操作时调用,其余任何情况严禁调用。"
|
||||
)
|
||||
response_format: Literal["content", "content_and_artifact"] = "content_and_artifact"
|
||||
@@ -573,14 +637,10 @@ class BrowserControl(BaseTool):
|
||||
except Exception as e:
|
||||
return f"执行浏览器操作时出错: {e}", {}
|
||||
|
||||
# 关闭整个浏览器后无页面可截,其余情况返回当前页截图
|
||||
if action == "close" and url is None:
|
||||
return msg, {}
|
||||
try:
|
||||
png = session.screenshot_sync()
|
||||
return msg, {"image_bytes": png}
|
||||
except Exception as e:
|
||||
return f"{msg}\n(截屏失败: {e})", {}
|
||||
# 浏览器为独立可见窗口(detached CDP):截图模型不可见、且每次操作
|
||||
# 数百 KB,纯属浪费(打开浏览器不需要图片返回)。页面状态由 web_page
|
||||
# 工具按需获取(snapshot/screenshot 动作),此处仅返回文本描述。
|
||||
return msg, {}
|
||||
|
||||
# ---------- 异步入口 ----------
|
||||
async def _arun(self, action: str = "open", url: Optional[str] = None,
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from langchain.tools import BaseTool
|
||||
from pydantic import BaseModel, Field, create_model
|
||||
|
||||
|
||||
class DirectoryListing(BaseTool):
|
||||
"""列出指定目录下的文件和文件夹,帮助 Agent 了解本地文件结构。"""
|
||||
|
||||
args_schema: type[BaseModel] = create_model(
|
||||
"DirectoryListingInput",
|
||||
path=(
|
||||
Optional[str],
|
||||
Field(
|
||||
default=None,
|
||||
description="要查看的目录路径,默认为当前工作目录。"
|
||||
),
|
||||
),
|
||||
show_hidden=(
|
||||
bool,
|
||||
Field(
|
||||
default=False,
|
||||
description="是否显示隐藏文件(以点开头的文件/文件夹),默认不显示。"
|
||||
),
|
||||
),
|
||||
)
|
||||
name: str = "directory_listing"
|
||||
description: str = (
|
||||
"列出指定目录下的所有文件和子目录。可用于探索文件系统、查找文件位置。"
|
||||
"仅当需要获取目录内容或确认文件结构时调用,其余情况严禁调用。"
|
||||
)
|
||||
|
||||
def _run(self, path: Optional[str] = None, show_hidden: bool = False) -> str:
|
||||
try:
|
||||
target = Path(path).resolve() if path else Path.cwd()
|
||||
if not target.exists():
|
||||
return f"路径不存在: {target}"
|
||||
if not target.is_dir():
|
||||
return f"路径不是目录: {target}"
|
||||
|
||||
items = []
|
||||
with os.scandir(target) as entries:
|
||||
for entry in entries:
|
||||
if not show_hidden and entry.name.startswith('.'):
|
||||
continue
|
||||
item_type = "DIR" if entry.is_dir() else "FILE"
|
||||
items.append(f"[{item_type}] {entry.name}")
|
||||
|
||||
if not items:
|
||||
return f"目录 {target} 为空"
|
||||
return f"目录 {target} 的内容:\n" + "\n".join(sorted(items))
|
||||
except Exception as e:
|
||||
return f"列出目录时出错: {str(e)}"
|
||||
|
||||
async def _arun(self, path: Optional[str] = None, show_hidden: bool = False) -> str:
|
||||
return await asyncio.to_thread(self._run, path, show_hidden)
|
||||
@@ -10,20 +10,37 @@ from pydantic import BaseModel, Field
|
||||
|
||||
class HumanInteractionInput(BaseModel):
|
||||
title: str = Field(default="需要人工处理", description="展示给人类的交互标题。")
|
||||
prompt: str = Field(default="请处理后继续。", description="需要人类理解并响应的具体问题。")
|
||||
options: Optional[list[str] | str] = Field(
|
||||
prompt: str | list[str] = Field(
|
||||
default="请处理后继续。",
|
||||
description="需要人类理解并响应的具体问题。传字符串为单个问题;"
|
||||
"传字符串数组为一次交互的多个问题(人类依次作答,统一返回结果)。",
|
||||
)
|
||||
options: Optional[list | str] = Field(
|
||||
default=None,
|
||||
description="可选项。优先传字符串数组;JSON 字符串或逗号分隔会被自动解析。"
|
||||
"可空——人类也可直接在补充输入框中输入任意信息。",
|
||||
description="可选项,每个问题最多 3 项。单问题时优先传字符串数组;"
|
||||
"多问题时传与 prompt 一一对应的二维数组(每个子问题子数组最多 3 项)。"
|
||||
"支持 JSON 字符串或逗号分隔;可空——人类也可直接在补充输入框中输入任意信息。",
|
||||
)
|
||||
|
||||
|
||||
def normalize_human_interaction_args(args: dict) -> dict:
|
||||
"""规范化 human_interaction 工具参数为固定 schema(title / prompt / options)。"""
|
||||
"""规范化 human_interaction 工具参数为固定 schema(title / questions / prompt / options)。
|
||||
|
||||
questions: [{prompt, options}],多问题时由 prompt 列表 + options 二维数组拼出;
|
||||
每个问题 options 最多 3 项(超出截断)。
|
||||
"""
|
||||
normalized = dict(args or {})
|
||||
normalized["title"] = str(normalized.get("title") or "需要人工处理")
|
||||
normalized["prompt"] = str(normalized.get("prompt") or "请处理后继续。")
|
||||
normalized["options"] = HumanInteraction.normalize_options(normalized.get("options"))
|
||||
prompts = normalized.get("prompt")
|
||||
if isinstance(prompts, list):
|
||||
prompt_list = [str(p or "请处理后继续。") for p in prompts]
|
||||
else:
|
||||
prompt_list = [str(prompts or "请处理后继续。")]
|
||||
questions = HumanInteraction.build_questions(prompt_list, normalized.get("options"))
|
||||
normalized["questions"] = questions
|
||||
# 兼容旧字段:prompt / options 保留第一个问题(历史消费者与摘要渲染使用)
|
||||
normalized["prompt"] = questions[0]["prompt"]
|
||||
normalized["options"] = questions[0]["options"]
|
||||
return normalized
|
||||
|
||||
|
||||
@@ -34,9 +51,12 @@ class HumanInteraction(BaseTool):
|
||||
name: str = "human_interaction"
|
||||
description: str = (
|
||||
"当任务缺少关键信息或需要人类选择路径时调用。"
|
||||
"通过 options 提供可选项(可空),人类可以从选项中选择,"
|
||||
"也可在补充输入框中输入任意信息——输入内容将直接作为本工具的返回值。"
|
||||
"工具返回必须被视为用户原始意图的纯字符串反馈。其余情况严禁调用。"
|
||||
"prompt 可为单个问题或问题数组(一次交互多个问题,人类依次作答);"
|
||||
"每个问题的 options 最多 3 项,是/否类问题传 [\"是\", \"否\"];可空。"
|
||||
"多问题作答结果以编号列表统一返回,如:"
|
||||
"“1、用户选择B选项;2、用户选择忽略此问题;3、用户选择A选项;”。"
|
||||
"人类也可在补充输入框中输入任意信息——输入内容将作为对应问题的答案。"
|
||||
"本工具返回必须被视为用户原始意图的纯字符串反馈。其余情况严禁调用。"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -57,6 +77,36 @@ class HumanInteraction(BaseTool):
|
||||
return [str(parsed)]
|
||||
return [str(item) for item in options]
|
||||
|
||||
@staticmethod
|
||||
def build_questions(prompts: list[str], options) -> list[dict]:
|
||||
"""prompts 与 options 对齐构建问题列表;每个问题最多 3 个选项。
|
||||
|
||||
options 支持:二维数组(与问题一一对应,子元素可以是数组/JSON串/逗号串/None)、
|
||||
扁平数组(仅用于第一个问题)、JSON 字符串 / 逗号分隔串(同扁平数组)。
|
||||
"""
|
||||
if isinstance(options, str):
|
||||
raw = options.strip()
|
||||
try:
|
||||
parsed = json.loads(raw) if raw else []
|
||||
except json.JSONDecodeError:
|
||||
parsed = [item.strip() for item in raw.replace(",", ",").split(",") if item.strip()]
|
||||
options = parsed
|
||||
if isinstance(options, list) and any(isinstance(o, list) for o in options):
|
||||
# 二维矩阵:每个子项独立规范化(数组 / JSON/逗号串 / None 均可)
|
||||
matrix = options
|
||||
elif isinstance(options, list):
|
||||
matrix = [options] + [[] for _ in range(len(prompts) - 1)]
|
||||
else:
|
||||
matrix = [[] for _ in prompts]
|
||||
questions = []
|
||||
for i, prompt in enumerate(prompts):
|
||||
raw_opts = matrix[i] if i < len(matrix) else None
|
||||
questions.append({
|
||||
"prompt": prompt,
|
||||
"options": HumanInteraction.normalize_options(raw_opts)[:3],
|
||||
})
|
||||
return questions
|
||||
|
||||
@staticmethod
|
||||
def format_result(response: str) -> str:
|
||||
"""将用户输入(选中的选项或补充输入框内容)直接作为工具返回值。"""
|
||||
@@ -74,16 +124,25 @@ class HumanInteraction(BaseTool):
|
||||
agent_name = "主Agent" if _ag in ("", "__main__") else _ag
|
||||
except Exception:
|
||||
pass
|
||||
questions = args.get("questions") or [
|
||||
{"prompt": args.get("prompt") or "请处理后继续。", "options": args.get("options") or []}
|
||||
]
|
||||
return {
|
||||
"type": "human_interaction",
|
||||
"title": args["title"] or "需要人工处理",
|
||||
"prompt": args["prompt"] or "请处理后继续。",
|
||||
"questions": questions,
|
||||
# 兼容旧前端:按第一个问题给出 prompt / options 摘要
|
||||
"prompt": questions[0]["prompt"],
|
||||
"tool_name": "human_interaction",
|
||||
"args": {
|
||||
"说明": args["prompt"] or "请处理后继续。",
|
||||
"可选项": args["options"] or [],
|
||||
"问题数": len(questions),
|
||||
"说明": questions[0]["prompt"] or "请处理后继续。",
|
||||
"可选项": questions[0]["options"] or [],
|
||||
} if len(questions) > 1 else {
|
||||
"说明": questions[0]["prompt"] or "请处理后继续。",
|
||||
"可选项": questions[0]["options"] or [],
|
||||
},
|
||||
"options": args["options"] or [],
|
||||
"options": questions[0]["options"] or [],
|
||||
"agent_name": agent_name,
|
||||
"is_sub_agent": agent_name != "主Agent",
|
||||
"resolved": False,
|
||||
|
||||
@@ -30,6 +30,12 @@ class TerminalExecuteInput(BaseModel):
|
||||
True,
|
||||
description="是否在虚拟环境中执行命令。默认 True,会自动激活配置的虚拟环境。设为 False 则使用系统级环境。",
|
||||
)
|
||||
background: bool = Field(
|
||||
False,
|
||||
description="是否挂起后台执行(长驻/长时任务专用)。默认 False。设为 True 时立即返回任务ID(PID),"
|
||||
"进程在后台继续运行,可用 terminal_info 查看状态、wait 等待结束;"
|
||||
"本工具【不支持】交互式命令(密码/采集输入等),此类命令应放弃执行并建议用户手动操作。",
|
||||
)
|
||||
|
||||
|
||||
class TerminalExecute(BaseTool):
|
||||
@@ -46,7 +52,18 @@ class TerminalExecute(BaseTool):
|
||||
"【参数说明】\n"
|
||||
"- command: 必填,要执行的命令字符串\n"
|
||||
"- working_dir: 可选,命令执行的工作目录\n"
|
||||
"- timeout: 超时时间(秒),默认 120 秒\n\n"
|
||||
"- timeout: 超时时间(秒),默认 120 秒\n"
|
||||
"- background: 是否挂起后台执行(长驻服务、长时任务专用),默认 False\n\n"
|
||||
"【挂起执行(background=True)】\n"
|
||||
"- 立即返回任务ID(PID);进程在后台继续运行,在会话终端浮窗实时可见\n"
|
||||
"- 用 terminal_info(任务ID) 查看当前状态与最新输出;用 wait(任务ID, 超时秒数) 原地等待结束并获取最终输出\n"
|
||||
"- 终止任务:用本工具(background=False)执行 `taskkill /F /T /PID <任务ID>`,或让用户在终端浮窗点击终止按钮\n\n"
|
||||
"【重要限制:不支持交互式命令】\n"
|
||||
"- 本工具(含挂起模式)无法向终端输入任何内容。命令运行中途需要用户输入\n"
|
||||
" (SSH/凭据密码、`input()` 算法题采集、sudo 等)时,应【放弃执行】该命令,\n"
|
||||
" 改用 human_interaction 提示用户:请用户在桌面终端(底部终端栏)手动执行;\n"
|
||||
" 严禁发起此类无法推进的交互式命令。\n"
|
||||
"- 已挂起的交互式命令若卡住:用 background=False 执行 taskkill /F /T /PID <任务ID> 终止。\n\n"
|
||||
"【数据作图标准流程】(本工具替代 data_plot,用于基于已入库数据库数据作图)\n"
|
||||
"当用户要求对已入库的数据画图/出图表时,按以下流程用本工具执行一个 Python 脚本:\n"
|
||||
"1. 查库:脚本内连接数据库(MySQL 或 SQLite)。连接参数从 env_config.json 的\n"
|
||||
@@ -74,6 +91,7 @@ class TerminalExecute(BaseTool):
|
||||
working_dir: Optional[str] = None,
|
||||
timeout: int = 120,
|
||||
use_venv: bool = True,
|
||||
background: bool = False,
|
||||
) -> str:
|
||||
try:
|
||||
# 如果未指定 working_dir,优先使用运行时选中的工作空间,回退到 env_config 默认值
|
||||
@@ -93,6 +111,32 @@ class TerminalExecute(BaseTool):
|
||||
if not os.path.isdir(cwd):
|
||||
return f"[ERROR] 工作目录不存在: {cwd}"
|
||||
|
||||
# 挂起模式:交给统一进程注册表管理(不阻塞、不接管 stdin)
|
||||
if background:
|
||||
try:
|
||||
from agent.tools.terminal_tasks import start_background_task
|
||||
from agent.history.tool_call_recorder import get_current_session, get_current_tool_call_id
|
||||
pid = start_background_task(
|
||||
get_current_session() or "", command, cwd, use_venv=use_venv,
|
||||
tool_call_id=get_current_tool_call_id(),
|
||||
)
|
||||
return (
|
||||
f"=== 终端命令已挂起 ===\n"
|
||||
f"命令: {command}\n"
|
||||
f"工作目录: {cwd}\n"
|
||||
f"任务ID(PID): {pid}\n"
|
||||
f"状态: 后台运行中(已注册管理,可在终端浮窗实时查看)\n\n"
|
||||
f"后续操作:\n"
|
||||
f"- 查看状态: terminal_info(task_id={pid})\n"
|
||||
f"- 等待结束: wait(task_id={pid}, timeout=秒数)\n"
|
||||
f"- 手动终止: taskkill /F /T /PID {pid}(或让用户在终端浮窗点击终止)\n"
|
||||
f"注意: 本命令无法接收输入;若需要交互输入请放弃并让用户手动执行。"
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return f"[ERROR] 找不到 PowerShell,请确认系统环境正常"
|
||||
except Exception as e:
|
||||
return f"[ERROR] 挂起后台任务失败: {str(e)}"
|
||||
|
||||
# 构建完整命令:如果启用 venv,将虚拟环境 Scripts 目录注入 PATH 最前
|
||||
full_command = command
|
||||
if use_venv:
|
||||
@@ -112,19 +156,27 @@ class TerminalExecute(BaseTool):
|
||||
errors="replace",
|
||||
)
|
||||
|
||||
# 登记到统一进程注册表:超时/轮次结束时按会话杀整个进程树(资源边界)
|
||||
from agent.tools.terminal_tasks import register_foreground, unregister_foreground, kill_tree
|
||||
from agent.history.tool_call_recorder import get_current_session, get_current_tool_call_id
|
||||
register_foreground(proc, get_current_session() or "", command, cwd,
|
||||
tool_call_id=get_current_tool_call_id())
|
||||
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
kill_tree(proc.pid) # 杀整个进程树(含子进程),杜绝孤儿
|
||||
stdout, stderr = proc.communicate()
|
||||
return (
|
||||
f"[ERROR] 命令执行超时({timeout}秒),已强制终止\n"
|
||||
f"[ERROR] 命令执行超时({timeout}秒),已强制终止(含子进程树)\n"
|
||||
f"命令: {command}\n"
|
||||
f"工作目录: {cwd}\n"
|
||||
f"--- 超时前已获取的部分输出 ---\n"
|
||||
f"stdout: {(stdout or '').strip()[-500:]}\n"
|
||||
f"stderr: {(stderr or '').strip()[-500:]}"
|
||||
)
|
||||
finally:
|
||||
unregister_foreground(proc.pid)
|
||||
|
||||
returncode = proc.returncode
|
||||
stdout = stdout.strip()
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""terminal_info 工具:查看后台终端任务信息(终端工具族,图标同 terminal_execute)。
|
||||
|
||||
返回任务当前状态(运行中/已完成/异常退出/已终止)、退出码、异常标志、
|
||||
运行时长、输出字符数,以及**截断返回的最新 1000 字符**输出——用于 agent
|
||||
判断任务是否健康推进(长时间无输出且持续运行可能是交互式命令卡死,
|
||||
此时应 taskkill 终止并建议用户手动执行)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from langchain.tools import BaseTool
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TerminalInfoInput(BaseModel):
|
||||
task_id: int = Field(
|
||||
...,
|
||||
description="后台终端任务ID(即 terminal_execute(background=True) 返回的任务ID,也就是进程 PID)。",
|
||||
)
|
||||
|
||||
|
||||
class TerminalInfo(BaseTool):
|
||||
args_schema: type[BaseModel] = TerminalInfoInput
|
||||
name: str = "terminal_info"
|
||||
description: str = (
|
||||
"查看后台终端任务信息。查询由 terminal_execute(background=True) 挂起的任务当前状态,"
|
||||
"并返回最新输出(截断至最近 1000 字符)。\n\n"
|
||||
"返回内容:\n"
|
||||
"- 任务ID(PID) / 命令 / 工作目录\n"
|
||||
"- 状态: 运行中 | 已完成(退出码)| 异常退出(退出码)| 已终止\n"
|
||||
"- 运行时长 / 输出总计(超出缓冲上限会截断并提示)\n"
|
||||
"- 最新 1000 字符输出(stdout/stderr 分开)\n\n"
|
||||
"判断技巧:\n"
|
||||
"- 任务长时间“运行中”且输出不再增长 → 可能卡在交互输入(密码/采集输入),"
|
||||
"本工具不支持输入,应执行 taskkill /F /T /PID <任务ID> 终止,并提示用户手动完成该操作。\n"
|
||||
"- 任务结束 → 用 wait(task_id=任务ID) 获取完整最终输出。"
|
||||
)
|
||||
|
||||
def _run(self, task_id: int) -> str:
|
||||
from agent.tools.terminal_tasks import task_info_text
|
||||
return task_info_text(int(task_id), max_chars=1000)
|
||||
|
||||
async def _arun(self, **kwargs) -> str:
|
||||
import asyncio
|
||||
return await asyncio.to_thread(self._run, **kwargs)
|
||||
@@ -0,0 +1,621 @@
|
||||
"""统一进程注册表:管理由 terminal_execute 启动的进程(前台 + 后台挂起)。
|
||||
|
||||
职责:
|
||||
- 前台命令:登记/注销,超时或轮次结束时按会话清理并杀整个进程树
|
||||
(资源边界:杜绝"单次命令超时后其子进程/孤儿进程继续存活"——单点
|
||||
``proc.kill()`` 只杀 PowerShell 父进程,其子进程会变孤儿;注册表统一
|
||||
用 ``taskkill /F /T /PID`` 按树清理,覆盖工具内超时、框架级超时线程
|
||||
残留与服务退出/崩溃残留三个通道)。
|
||||
- 后台挂起任务:持续管理(stdout/stderr 环形缓冲、状态、退出码、异常标志),
|
||||
供 ``wait``/``terminal_info`` 工具与前端终端浮窗实时查看;**不接管 stdin**
|
||||
——交互式命令(密码、算法题采集输入等)由工具描述明确禁止 agent 执行。
|
||||
- 各任务按 session_id 隔离(子 Agent 经 contextvar 传播归属同一会话),
|
||||
父级会话浮窗可见全部任务,互不串扰。
|
||||
- 进程树终止统一使用 Windows 现成 ``taskkill /F /T /PID``(零新依赖;
|
||||
同 electron/main.js forceKill 与 cron runner 的先例)。
|
||||
|
||||
系统定位:
|
||||
被 ``tools/terminal_execute``(前台登记/后台挂起)、``tools/wait``、
|
||||
``tools/terminal_info``、``web/server.py``(SSE/终止/生命周期钩子)、
|
||||
``core/turn_runner``(轮次结束清理前台残留)使用。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue as _queue_module
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# ---------- 常量 ----------
|
||||
MAX_BUFFER_CHARS = 256 * 1024 # 每任务输出环形缓冲上限(字符),超出丢最旧
|
||||
MAX_TASKS_PER_SESSION = 50 # 每会话最多保留的任务条目(超出淘汰最旧已完成)
|
||||
READ_CHUNK = 4096 # reader 每次读取字符数
|
||||
KILL_TREE_TIMEOUT = 5 # taskkill 等待秒数
|
||||
INITIAL_OUTPUT_LIMIT = 64 * 1024 # SSE 连接/轮询时随任务下发的最多输出字符
|
||||
|
||||
# 崩溃恢复用的 pid 文件(服务退出时遗留进程,下次启动据此清理)
|
||||
try:
|
||||
from agent.utils.agent_utils import HISTORY_ROOT
|
||||
_PID_FILE = Path(os.path.join(HISTORY_ROOT, "terminal_tasks_pids.json"))
|
||||
except Exception:
|
||||
_PID_FILE = Path(os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "..", "..", "..",
|
||||
"history", "terminal_tasks_pids.json",
|
||||
))
|
||||
|
||||
|
||||
# ---------- 任务信息 ----------
|
||||
@dataclass
|
||||
class TaskInfo:
|
||||
pid: int
|
||||
session_id: str
|
||||
command: str # 展示用原始命令(未注入 venv PATH)
|
||||
cwd: str
|
||||
scope: str # "foreground" | "background"
|
||||
tool_call_id: str = ""
|
||||
status: str = "running" # running | done | killed
|
||||
exit_code: int | None = None
|
||||
abnormal: bool = False # 异常退出(exit_code 非 0 且非手动终止)
|
||||
started_at: float = field(default_factory=time.time)
|
||||
finished_at: float | None = None
|
||||
proc: Any | None = None
|
||||
# 输出环形缓冲:按 (seq, source, text) 追加;seq 单调递增(丢弃只丢最旧)
|
||||
chunks: deque[tuple[int, str, str]] = field(default_factory=deque)
|
||||
total_chars: int = 0
|
||||
truncated: bool = False # 缓冲是否因超限丢弃过最旧输出
|
||||
_chunk_seq: int = 0
|
||||
_readers: list[threading.Thread] = field(default_factory=list)
|
||||
lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
||||
|
||||
|
||||
# ---------- 全局注册表(session_id → {pid: TaskInfo}) ----------
|
||||
_tasks: dict[str, dict[int, TaskInfo]] = {}
|
||||
_tasks_lock = threading.Lock()
|
||||
|
||||
# pid 文件内存镜像(含已登记但未结束的前台命令)
|
||||
_pid_entries: set[int] = set()
|
||||
_pid_lock = threading.Lock()
|
||||
|
||||
# ---------- SSE 订阅(同 recorder 的 pub/sub 模式) ----------
|
||||
_subscribers: dict[str, list[_queue_module.Queue]] = {}
|
||||
_subscribers_lock = threading.Lock()
|
||||
|
||||
|
||||
def _now() -> float:
|
||||
return time.time()
|
||||
|
||||
|
||||
def _all_tasks() -> list[TaskInfo]:
|
||||
with _tasks_lock:
|
||||
return [t for d in _tasks.values() for t in d.values()]
|
||||
|
||||
|
||||
def get_task(pid: int) -> TaskInfo | None:
|
||||
"""按 PID 全局查找任务(PID 唯一)。"""
|
||||
with _tasks_lock:
|
||||
for d in _tasks.values():
|
||||
t = d.get(pid)
|
||||
if t:
|
||||
return t
|
||||
return None
|
||||
|
||||
|
||||
def list_tasks(session_id: str) -> list[TaskInfo]:
|
||||
"""列出某会话的全部任务(新→旧)。"""
|
||||
with _tasks_lock:
|
||||
d = _tasks.get(session_id, {})
|
||||
return sorted(d.values(), key=lambda t: t.started_at, reverse=True)
|
||||
|
||||
|
||||
# ---------- 启动/登记 ----------
|
||||
def _build_powershell_argv(command: str, cwd: str, use_venv: bool) -> list[str]:
|
||||
"""构建 PowerShell argv(venv PATH 注入逻辑与 terminal_execute 保持一致)。
|
||||
|
||||
末尾追加 ``exit $LASTEXITCODE``:powershell -Command 默认不传递原生命令的
|
||||
退出码(失败统一返回 1),后台任务按退出码判定"异常退出"需要真实值。
|
||||
"""
|
||||
full_command = command
|
||||
if use_venv:
|
||||
try:
|
||||
from agent.tools.terminal_execute import TerminalExecute
|
||||
scripts_dir = TerminalExecute._get_venv_scripts_dir()
|
||||
if scripts_dir:
|
||||
full_command = f'$env:PATH = "{scripts_dir};" + $env:PATH; {command}'
|
||||
except Exception:
|
||||
pass
|
||||
return ["powershell", "-Command", f'{full_command}; exit $LASTEXITCODE']
|
||||
|
||||
|
||||
def _spawn(command: str, cwd: str, use_venv: bool):
|
||||
"""Popen PowerShell,stdout/stderr 二进制管道(os.read 分块读取,解码 gbk),
|
||||
不接管 stdin(交互式命令由工具描述禁止 agent 执行)。
|
||||
|
||||
说明: 文本模式 TextIOWrapper 无 read1 方法,逐块接收会阻塞或异常关闭
|
||||
管道读端(导致子进程 BrokenPipe 退出);用二进制 + os.read 可即时、
|
||||
无损地流式读取。
|
||||
"""
|
||||
return subprocess.Popen(
|
||||
_build_powershell_argv(command, cwd, use_venv),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
cwd=cwd,
|
||||
)
|
||||
|
||||
|
||||
def _read_stream(task: TaskInfo, source: str, stream) -> None:
|
||||
"""os.read 分块读取管道(阻塞至至少 1 字节或 EOF),按 gbk 解码追加缓冲。"""
|
||||
fd = stream.fileno()
|
||||
while True:
|
||||
try:
|
||||
chunk = os.read(fd, READ_CHUNK)
|
||||
except Exception:
|
||||
return # 读取失败不再关闭管道(关闭读端会向子进程发破碎管道信号)
|
||||
if not chunk:
|
||||
break
|
||||
_append_output(task, source, chunk.decode("gbk", errors="replace"))
|
||||
try:
|
||||
stream.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def register_foreground(proc, session_id: str, command: str, cwd: str, tool_call_id: str = "") -> int:
|
||||
"""前台命令登记(启动后、执行前调用;正常返回前由 unregister_foreground 注销)。"""
|
||||
return _register(proc, session_id, command, cwd, "foreground", tool_call_id)
|
||||
|
||||
|
||||
def unregister_foreground(pid: int) -> None:
|
||||
"""前台命令注销(正常返回时调用,进程树已随命令结束)。"""
|
||||
task = get_task(pid)
|
||||
if not task or task.scope != "foreground":
|
||||
return
|
||||
with _tasks_lock:
|
||||
bucket = _tasks.get(task.session_id)
|
||||
if bucket:
|
||||
bucket.pop(pid, None)
|
||||
if not bucket:
|
||||
_tasks.pop(task.session_id, None)
|
||||
_pid_remove(pid)
|
||||
|
||||
|
||||
def start_background_task(
|
||||
session_id: str,
|
||||
command: str,
|
||||
cwd: str,
|
||||
use_venv: bool = True,
|
||||
tool_call_id: str = "",
|
||||
) -> int:
|
||||
"""启动后台挂起任务,立即返回(task_id = pid)。进程结束后自动:
|
||||
更新该工具调用的 live 记录为 done,并在会话轮次仍活跃时经图外注入
|
||||
把结果作为 ToolMessage 加入本轮工具返回值。不接管 stdin。"""
|
||||
proc = _spawn(command, cwd, use_venv)
|
||||
task = _register(proc, session_id, command, cwd, "background", tool_call_id)
|
||||
|
||||
def _watcher():
|
||||
try:
|
||||
proc.wait()
|
||||
except Exception:
|
||||
pass
|
||||
# 进程退出后短暂等待 reader 把管道尾部读完,再定稿状态
|
||||
for r in task._readers:
|
||||
r.join(timeout=2)
|
||||
_finalize_task(task)
|
||||
|
||||
for src, stream in (("out", proc.stdout), ("err", proc.stderr)):
|
||||
t = threading.Thread(target=_read_stream, args=(task, src, stream), daemon=True, name=f"term-reader-{proc.pid}-{src}")
|
||||
task._readers.append(t)
|
||||
t.start()
|
||||
watcher = threading.Thread(target=_watcher, daemon=True, name=f"term-watch-{proc.pid}")
|
||||
watcher.start()
|
||||
return proc.pid
|
||||
|
||||
|
||||
def _register(proc, session_id: str, command: str, cwd: str, scope: str, tool_call_id: str) -> TaskInfo:
|
||||
task = TaskInfo(
|
||||
pid=proc.pid,
|
||||
session_id=session_id or "",
|
||||
command=command,
|
||||
cwd=cwd,
|
||||
scope=scope,
|
||||
tool_call_id=tool_call_id or "",
|
||||
proc=proc,
|
||||
)
|
||||
with _tasks_lock:
|
||||
bucket = _tasks.setdefault(session_id or "", {})
|
||||
bucket[proc.pid] = task
|
||||
# 每会话最多保留 N 条:淘汰最旧的已完成任务
|
||||
running = [t for t in bucket.values() if t.status == "running"]
|
||||
done = sorted((t for t in bucket.values() if t.status != "running"), key=lambda t: t.started_at)
|
||||
surplus = len(running) + len(done) - MAX_TASKS_PER_SESSION
|
||||
for old in done[: max(0, surplus)]:
|
||||
bucket.pop(old.pid, None)
|
||||
_pid_add(proc.pid)
|
||||
_notify(task.session_id, {"type": "task_update", "task": task_to_dict(task)})
|
||||
return task
|
||||
|
||||
|
||||
# ---------- 输出缓冲 ----------
|
||||
def _append_output(task: TaskInfo, source: str, text: str) -> None:
|
||||
if not text:
|
||||
return
|
||||
with task.lock:
|
||||
task._chunk_seq += 1
|
||||
task.chunks.append((task._chunk_seq, source, text))
|
||||
task.total_chars += len(text)
|
||||
# 环形裁剪:超过上限丢最旧 chunk
|
||||
while task.total_chars > MAX_BUFFER_CHARS and task.chunks:
|
||||
_, _, old = task.chunks.popleft()
|
||||
task.total_chars -= len(old)
|
||||
task.truncated = True
|
||||
_notify(task.session_id, {
|
||||
"type": "output",
|
||||
"task_id": task.pid,
|
||||
"chunks": [{"seq": task._chunk_seq, "source": source, "text": text}],
|
||||
})
|
||||
|
||||
|
||||
def get_output_chunks(task: TaskInfo, max_chars: int = INITIAL_OUTPUT_LIMIT) -> tuple[list[dict], int]:
|
||||
"""返回最近 max_chars 的输出 chunk 列表(含 seq)及下一 seq。"""
|
||||
with task.lock:
|
||||
chunks = list(task.chunks)
|
||||
out: list[dict] = []
|
||||
total = 0
|
||||
for seq, source, text in reversed(chunks):
|
||||
if total + len(text) > max_chars and out:
|
||||
break
|
||||
out.append({"seq": seq, "source": source, "text": text})
|
||||
total += len(text)
|
||||
out.reverse()
|
||||
next_seq = (chunks[-1][0] + 1) if chunks else 0
|
||||
return out, next_seq
|
||||
|
||||
|
||||
def _tail(task: TaskInfo, max_chars: int) -> tuple[str, str]:
|
||||
"""返回 (stdout 尾部, stderr 尾部),各自不超过 max_chars 字符。"""
|
||||
with task.lock:
|
||||
chunks = list(task.chunks)
|
||||
out_parts: list[str] = []
|
||||
err_parts: list[str] = []
|
||||
out_len = err_len = 0
|
||||
for _, source, text in reversed(chunks):
|
||||
if source == "err":
|
||||
if err_len + len(text) > max_chars:
|
||||
text = text[-max(0, max_chars - err_len):]
|
||||
err_parts.append(text) if text else None
|
||||
err_len = max_chars
|
||||
else:
|
||||
err_parts.append(text)
|
||||
err_len += len(text)
|
||||
else:
|
||||
if out_len + len(text) > max_chars:
|
||||
text = text[-max(0, max_chars - out_len):]
|
||||
out_parts.append(text) if text else None
|
||||
out_len = max_chars
|
||||
else:
|
||||
out_parts.append(text)
|
||||
out_len += len(text)
|
||||
if out_len >= max_chars and err_len >= max_chars:
|
||||
break
|
||||
return "".join(reversed(out_parts)), "".join(reversed(err_parts))
|
||||
|
||||
|
||||
def _status_label(task: TaskInfo) -> str:
|
||||
if task.status == "running":
|
||||
return "运行中"
|
||||
if task.status == "killed":
|
||||
return "已终止"
|
||||
if task.abnormal:
|
||||
return f"异常退出(退出码: {task.exit_code})"
|
||||
return f"已完成(退出码: {task.exit_code})"
|
||||
|
||||
|
||||
def task_to_dict(task: TaskInfo) -> dict[str, Any]:
|
||||
"""SSE/API 用任务快照(不含输出)。"""
|
||||
with task.lock:
|
||||
return {
|
||||
"id": task.pid,
|
||||
"pid": task.pid,
|
||||
"command": task.command,
|
||||
"cwd": task.cwd,
|
||||
"status": task.status,
|
||||
"exit_code": task.exit_code,
|
||||
"abnormal": task.abnormal,
|
||||
"started_at": task.started_at,
|
||||
"finished_at": task.finished_at,
|
||||
"total_chars": task.total_chars,
|
||||
"truncated": task.truncated,
|
||||
"scope": task.scope,
|
||||
"tool_call_id": task.tool_call_id,
|
||||
}
|
||||
|
||||
|
||||
def _format_task_final(task: TaskInfo, max_chars: int = 4000) -> str:
|
||||
out_tail, err_tail = _tail(task, max_chars)
|
||||
elapsed = (task.finished_at or _now()) - task.started_at
|
||||
lines = [
|
||||
"=== 后台任务结束 ===",
|
||||
f"任务ID(PID): {task.pid}",
|
||||
f"命令: {task.command}",
|
||||
f"工作目录: {task.cwd}",
|
||||
f"状态: {_status_label(task)}",
|
||||
f"运行时长: {elapsed:.1f} 秒",
|
||||
f"输出总计: {task.total_chars} 字符" + ("(已截断,仅保留最新部分)" if task.truncated else ""),
|
||||
]
|
||||
if task.abnormal and task.total_chars == 0:
|
||||
lines.append("说明: 无任何输出且非零退出,疑似启动即失败(命令错误/端口占用/依赖缺失等)")
|
||||
if out_tail:
|
||||
lines.append(f"\n--- stdout(最新)---\n{out_tail}")
|
||||
if err_tail:
|
||||
lines.append(f"\n--- stderr(最新)---\n{err_tail}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_running(task: TaskInfo, max_chars: int = 1000) -> str:
|
||||
out_tail, err_tail = _tail(task, max_chars)
|
||||
elapsed = _now() - task.started_at
|
||||
lines = [
|
||||
"=== 后台任务仍在运行 ===",
|
||||
f"任务ID(PID): {task.pid}",
|
||||
f"命令: {task.command}",
|
||||
f"状态: 运行中(已运行 {elapsed:.0f} 秒)",
|
||||
f"输出总计: {task.total_chars} 字符" + ("(较早输出已截断)" if task.truncated else ""),
|
||||
f"等待剩余时间: 超时后返回摘要(可用 terminal_info 查看最新状态,任务结束后用 wait 获取最终输出)",
|
||||
]
|
||||
if out_tail:
|
||||
lines.append(f"\n--- stdout(最新)---\n{out_tail}")
|
||||
if err_tail:
|
||||
lines.append(f"\n--- stderr(最新)---\n{err_tail}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def task_info_text(pid: int, session_id: str = "", max_chars: int = 1000) -> str:
|
||||
"""terminal_info 工具:查看任务当前状态 + 最新输出(截断 max_chars)。"""
|
||||
task = get_task(pid)
|
||||
if not task:
|
||||
return f"[ERROR] 未找到后台任务(PID: {pid})。任务可能已从注册表清理,请检查命令是否正确挂起。"
|
||||
if task.status == "running":
|
||||
return _format_running(task, max_chars)
|
||||
return _format_task_final(task, max_chars)
|
||||
|
||||
|
||||
def wait_task(pid: int, timeout: float = 60, session_id: str = "") -> str:
|
||||
"""wait 工具:原地等待指定后台任务结束(线程阻塞轮询,响应 abort)。
|
||||
|
||||
任务已结束 → 立即返回最终输出;未结束 → 阻塞至结束或超时;
|
||||
超时 → 返回"仍在运行中"摘要(最新 1000 字符)。
|
||||
"""
|
||||
task = get_task(pid)
|
||||
if not task:
|
||||
return f"[ERROR] 未找到后台任务(PID: {pid})。任务可能已从注册表清理,请检查命令是否正确挂起。"
|
||||
|
||||
deadline = _now() + max(0.0, float(timeout))
|
||||
while True:
|
||||
with task.lock:
|
||||
status = task.status
|
||||
if status != "running":
|
||||
break
|
||||
if _now() >= deadline:
|
||||
break
|
||||
try:
|
||||
from agent.core.runtime import check_abort
|
||||
if task.session_id and check_abort(task.session_id):
|
||||
return f"[提示] 等待后台任务(PID: {task.pid})时收到用户中止,任务仍在后台运行,不会被自动终止。"
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.2)
|
||||
|
||||
if task.status == "running":
|
||||
return _format_running(task, 1000)
|
||||
return _format_task_final(task, 4000)
|
||||
|
||||
|
||||
# ---------- 终止/清理(进程树) ----------
|
||||
def kill_tree(pid: int) -> bool:
|
||||
"""杀整个进程树(taskkill /F /T);进程已死也返回 True。"""
|
||||
try:
|
||||
subprocess.run(
|
||||
["taskkill", "/F", "/T", "/PID", str(pid)],
|
||||
timeout=KILL_TREE_TIMEOUT,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def kill_task(pid: int, session_id: str = "") -> bool:
|
||||
"""终止任务(标记 killed + 杀进程树)。"""
|
||||
task = get_task(pid)
|
||||
if not task:
|
||||
return False
|
||||
if session_id and task.session_id != session_id:
|
||||
return False
|
||||
with task.lock:
|
||||
if task.status == "running":
|
||||
task.status = "killed"
|
||||
task.finished_at = _now()
|
||||
kill_tree(pid)
|
||||
# watcher 随后会 finalize(不再覆盖 killed 状态)
|
||||
_notify(task.session_id, {"type": "task_update", "task": task_to_dict(task)})
|
||||
return True
|
||||
|
||||
|
||||
def kill_all_session(session_id: str) -> int:
|
||||
"""终止某会话的全部运行中任务(含前台残留),返回终止数量。"""
|
||||
count = 0
|
||||
for task in list_tasks(session_id):
|
||||
with task.lock:
|
||||
if task.status == "running":
|
||||
task.status = "killed"
|
||||
task.finished_at = _now()
|
||||
count += 1
|
||||
else:
|
||||
continue
|
||||
kill_tree(task.pid)
|
||||
_notify(session_id, {"type": "task_update", "task": task_to_dict(task)})
|
||||
return count
|
||||
|
||||
|
||||
def cleanup_session(session_id: str) -> None:
|
||||
"""轮次结束清理:杀掉该会话残留的*前台*进程(框架超时后线程仍在跑的场景),
|
||||
并注销它们;后台挂起任务不受影响(跨轮存活,由浮窗管理)。"""
|
||||
for task in list_tasks(session_id):
|
||||
if task.scope != "foreground":
|
||||
continue
|
||||
with task.lock:
|
||||
if task.status != "running":
|
||||
continue
|
||||
task.status = "killed"
|
||||
task.finished_at = _now()
|
||||
kill_tree(task.pid)
|
||||
unregister_foreground(task.pid)
|
||||
_notify(session_id, {"type": "task_update", "task": task_to_dict(task)})
|
||||
|
||||
|
||||
def kill_all_running() -> int:
|
||||
"""杀全部运行中任务(服务退出/崩溃恢复),返回数量。"""
|
||||
count = 0
|
||||
for task in _all_tasks():
|
||||
with task.lock:
|
||||
if task.status == "running":
|
||||
task.status = "killed"
|
||||
task.finished_at = _now()
|
||||
count += 1
|
||||
else:
|
||||
continue
|
||||
kill_tree(task.pid)
|
||||
_pid_remove(task.pid)
|
||||
_notify(task.session_id, {"type": "task_update", "task": task_to_dict(task)})
|
||||
_save_pid_file()
|
||||
return count
|
||||
|
||||
|
||||
def get_running_count() -> int:
|
||||
"""全局运行中任务数(Electron 退出确认用)。"""
|
||||
with _tasks_lock:
|
||||
return sum(1 for d in _tasks.values() for t in d.values() if t.status == "running")
|
||||
|
||||
|
||||
# ---------- 进程收尾 ----------
|
||||
def _finalize_task(task: TaskInfo) -> None:
|
||||
with task.lock:
|
||||
if task.status == "running":
|
||||
task.status = "done"
|
||||
task.exit_code = task.proc.returncode if task.proc is not None else None
|
||||
task.abnormal = task.exit_code != 0
|
||||
task.finished_at = _now()
|
||||
task.proc = None
|
||||
_pid_remove(task.pid)
|
||||
_notify(task.session_id, {"type": "task_update", "task": task_to_dict(task)})
|
||||
|
||||
# 1) 更新挂起调用的 live 记录:running → done + 最终输出(轮次仍活跃时)
|
||||
# 2) 图外注入:把结果作为 ToolMessage 加入本轮工具返回值(仅轮次活跃时;
|
||||
# 轮次已结束不再注入,避免残留消息污染下一轮的上下文)
|
||||
try:
|
||||
from agent.history.tool_call_recorder import record_tool_result_by_call_id, is_live_turn_active
|
||||
if is_live_turn_active(task.session_id):
|
||||
final_text = _format_task_final(task)
|
||||
if task.tool_call_id:
|
||||
record_tool_result_by_call_id(task.session_id, task.tool_call_id, final_text)
|
||||
from agent.core.turn_injector import inject_tool_message
|
||||
inject_tool_message(task.session_id, f"bg_{task.pid}", "background_task", final_text)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------- pid 文件(崩溃恢复) ----------
|
||||
def _pid_add(pid: int) -> None:
|
||||
with _pid_lock:
|
||||
_pid_entries.add(pid)
|
||||
_save_pid_file()
|
||||
|
||||
|
||||
def _pid_remove(pid: int) -> None:
|
||||
with _pid_lock:
|
||||
_pid_entries.discard(pid)
|
||||
_save_pid_file()
|
||||
|
||||
|
||||
def _save_pid_file() -> None:
|
||||
try:
|
||||
with _pid_lock:
|
||||
pids = sorted(_pid_entries)
|
||||
_PID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(_PID_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump({"pids": pids}, f, ensure_ascii=False, indent=1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def startup_cleanup() -> None:
|
||||
"""服务启动清理:按 pid 文件杀掉上次崩溃/异常退出遗留的进程树。"""
|
||||
try:
|
||||
if _PID_FILE.is_file():
|
||||
with open(_PID_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
for pid in data.get("pids", []):
|
||||
kill_tree(int(pid))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
with _pid_lock:
|
||||
_pid_entries.clear()
|
||||
_save_pid_file()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------- SSE 订阅 ----------
|
||||
def subscribe_tasks(session_id: str) -> _queue_module.Queue:
|
||||
"""订阅某会话的终端任务更新(队列满丢最旧)。"""
|
||||
q: _queue_module.Queue = _queue_module.Queue(maxsize=256)
|
||||
with _subscribers_lock:
|
||||
if session_id not in _subscribers:
|
||||
_subscribers[session_id] = []
|
||||
_subscribers[session_id].append(q)
|
||||
return q
|
||||
|
||||
|
||||
def unsubscribe_tasks(session_id: str, q: _queue_module.Queue) -> None:
|
||||
with _subscribers_lock:
|
||||
subs = _subscribers.get(session_id, [])
|
||||
if q in subs:
|
||||
subs.remove(q)
|
||||
if not subs:
|
||||
_subscribers.pop(session_id, None)
|
||||
|
||||
|
||||
def _notify(session_id: str, payload: dict[str, Any]) -> None:
|
||||
with _subscribers_lock:
|
||||
subs = list(_subscribers.get(session_id, []))
|
||||
if not subs:
|
||||
return
|
||||
for q in subs:
|
||||
try:
|
||||
q.put_nowait(payload)
|
||||
except _queue_module.Full:
|
||||
try:
|
||||
q.get_nowait()
|
||||
q.put_nowait(payload)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def live_snapshot(session_id: str) -> dict[str, Any]:
|
||||
"""全量快照(SSE 连接建立 / 轮询兜底用):任务列表 + 各任务最近输出 chunk。"""
|
||||
tasks = list_tasks(session_id)
|
||||
return {
|
||||
"type": "full",
|
||||
"tasks": [task_to_dict(t) for t in tasks],
|
||||
"outputs": [
|
||||
{"id": t.pid, "chunks": get_output_chunks(t)[0], "truncated": t.truncated}
|
||||
for t in tasks
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"""wait 工具:通用"原地等待"工具(普通工具,非终端工具族)。
|
||||
|
||||
让 agent 暂停当前操作、阻塞等待指定后台任务结束(线程轮询实现,响应
|
||||
用户中止);任务已结束立即返回最终输出,超时返回"仍在运行中"摘要。
|
||||
查询实时状态请用 terminal_info;本工具的核心语义是"等待"。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from langchain.tools import BaseTool
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class WaitInput(BaseModel):
|
||||
task_id: int = Field(
|
||||
...,
|
||||
description="要等待的后台任务ID(即 terminal_execute(background=True) 返回的任务ID,也就是进程 PID)。",
|
||||
)
|
||||
timeout: int = Field(
|
||||
60,
|
||||
description="最长等待秒数(默认 60)。任务未结束且未超时时持续阻塞等待;"
|
||||
"超时后返回当前运行状态+最新输出摘要(可用 terminal_info 再查状态,稍后再 wait)。",
|
||||
)
|
||||
|
||||
|
||||
class WaitTool(BaseTool):
|
||||
args_schema: type[BaseModel] = WaitInput
|
||||
name: str = "wait"
|
||||
description: str = (
|
||||
"通用等待工具。阻塞等待指定的后台任务结束,期间不执行其他操作。\n\n"
|
||||
"- 任务已结束(含异常退出/已被终止)→ 立即返回最终输出、退出码与结果状态,不会继续等待\n"
|
||||
"- 任务未结束 → 原地等待,直到任务结束或到达 timeout\n"
|
||||
"- timeout 超时 → 返回\"仍在运行中\"摘要(运行时长/输出字符数/最新输出),agent 自行决定继续等还是先做别的\n\n"
|
||||
"典型用法:\n"
|
||||
"1. terminal_execute(background=True) 挂起长任务,返回任务ID\n"
|
||||
"2. wait(task_id=任务ID, timeout=120) 原地等待它结束并取回结果\n"
|
||||
"3. 若任务中途卡住(如误进了交互式命令),用 terminal_execute(background=False) 执行\n"
|
||||
" `taskkill /F /T /PID <任务ID>` 终止。"
|
||||
)
|
||||
|
||||
def _run(self, task_id: int, timeout: int = 60) -> str:
|
||||
from agent.tools.terminal_tasks import wait_task
|
||||
return wait_task(int(task_id), float(max(0, int(timeout))))
|
||||
|
||||
async def _arun(self, **kwargs) -> str:
|
||||
import asyncio
|
||||
return await asyncio.to_thread(self._run, **kwargs)
|
||||
+780
-112
File diff suppressed because it is too large.
Load diff
+199
-117
@@ -5,8 +5,8 @@
|
||||
- 所有历史数据基于 JSON 文件存储(multi_inputs 目录下)
|
||||
|
||||
主要子系统:
|
||||
1. 历史与元数据: get_history / get_session_meta / update_session_meta
|
||||
2. 上下文压缩: summarizer + 轻量/全量压缩
|
||||
1. 历史与元数据: get_history / load_compressed_context / save_compressed_context
|
||||
2. 上下文压缩: summarize_chat_text + 链式压缩块(compressed_context)+ 原生历史重建
|
||||
3. 文档处理: Documents_process 多格式解析
|
||||
4. 文本/音频: split_text、merge_wavs、assistant_text
|
||||
|
||||
@@ -173,24 +173,32 @@ def _save_session_meta(session_id: str, meta: dict) -> None:
|
||||
session_storage.save_session_extra(session_id, existing)
|
||||
|
||||
|
||||
def get_session_meta(session_id: str, agent_name: str) -> dict:
|
||||
"""读取会话在指定 Agent 下的压缩元数据(不含 turn_tool_context)。"""
|
||||
def load_compressed_context(session_id: str) -> dict:
|
||||
"""读取主 Agent 的压缩上下文(链式压缩块结构)。
|
||||
|
||||
结构: {"version": 2, "cursor_msg": int, "blocks": [
|
||||
{seq, content, msg_from, msg_to, turns, merged_units,
|
||||
tokens_before, tokens_after, compressed_at}]}
|
||||
|
||||
输入:
|
||||
session_id: 会话 ID。
|
||||
|
||||
输出:
|
||||
压缩上下文 dict;无记录 / 旧结构返回空结构(不做旧数据迁移)。
|
||||
"""
|
||||
meta = _load_session_meta(session_id)
|
||||
agent_meta = meta.get(agent_name, {})
|
||||
return {
|
||||
"cursor_index": agent_meta.get("cursor_index", -1),
|
||||
"compressed_content": agent_meta.get("compressed_content", ""),
|
||||
}
|
||||
ctx = (meta.get("main") or {}).get("compressed_context")
|
||||
if not isinstance(ctx, dict) or not isinstance(ctx.get("blocks"), list):
|
||||
return {"version": 2, "cursor_msg": 0, "blocks": []}
|
||||
return ctx
|
||||
|
||||
|
||||
def update_session_meta(session_id: str, agent_name: str,
|
||||
cursor_index: int, compressed_content: str) -> None:
|
||||
"""写入或更新 session_meta(仅 cursor 和压缩内容)。"""
|
||||
def save_compressed_context(session_id: str, ctx: dict) -> None:
|
||||
"""写入主 Agent 的压缩上下文(仅覆盖 compressed_context 键,不动其他字段)。"""
|
||||
meta = _load_session_meta(session_id)
|
||||
meta[agent_name] = {
|
||||
"cursor_index": cursor_index,
|
||||
"compressed_content": compressed_content,
|
||||
}
|
||||
entry = dict(meta.get("main") or {})
|
||||
entry["compressed_context"] = ctx
|
||||
meta["main"] = entry
|
||||
_save_session_meta(session_id, meta)
|
||||
|
||||
|
||||
@@ -230,7 +238,7 @@ def update_session_dynamic_tail(session_id: str, agent_key: str, text: str, max_
|
||||
max_entries: 历史条目上限(防止无界增长),超出丢弃最旧。
|
||||
|
||||
说明:
|
||||
与 compressed_content 合并保存(不覆盖);连续相同内容去重,
|
||||
独立于 compressed_context 保存(不覆盖);连续相同内容去重,
|
||||
使 todo 进度等状态变更形成可追溯的历史记录,供后续轮次注入查看
|
||||
(充分利用前缀缓存:变化部分始终位于消息末尾)。
|
||||
"""
|
||||
@@ -262,118 +270,192 @@ def get_session_dynamic_tail_history(session_id: str, agent_key: str) -> list[st
|
||||
return list(entry.get("dynamic_tail_history") or [])
|
||||
|
||||
|
||||
def build_tool_history_messages(session_id: str, agent_key: str = "main") -> list:
|
||||
"""构建压缩游标之后的"历史思考与工具调用"合成消息列表。
|
||||
def build_native_history_messages(session_id: str, msg_cursor: int = 0) -> list:
|
||||
"""重建压缩游标之后的历史为原生 LangChain 消息(链式压缩块的输入/输出视图)。
|
||||
|
||||
单轮拼接顺序(每条消息 = 1 个重建序号单位,压缩块按此区间计数):
|
||||
HumanMessage(用户原生 content 部分: text / image_url / file) hist_user_{turn_id}
|
||||
AIMessage(思考→additional_kwargs.reasoning_content,反思→content,
|
||||
tool_calls=[真实 id/name/args]) × 每个 assistant 组 hist_assist_{turn_id}_{g}
|
||||
—— 同一模型响应(AIMessage)的并行调用共享 group_index,合并为一条
|
||||
ToolMessage(result_text, tool_call_id/name 严格匹配) × 每个 call hist_tool_{turn_id}_{g}_{i}
|
||||
...未消费的尾部反思(与组内思考/反思去重后) hist_tail_{turn_id}_{i}
|
||||
AIMessage(最终无工具回复文本) hist_final_{turn_id}
|
||||
|
||||
所有消息带稳定 id 与 synthetic/tool_ctx 标记(兼容 _filter_short_term_memory
|
||||
与压缩节点的 RemoveMessage)。旧记录无 group_index 时每组回退为单 call。
|
||||
|
||||
输入:
|
||||
session_id: 会话 ID。
|
||||
agent_key: session_meta 中的 agent 键(主 Agent 为 "main")。
|
||||
msg_cursor: 已压缩的重建序号(此序号之前的历史已由压缩块承载,跳过不重建)。
|
||||
|
||||
输出:
|
||||
synthetic HumanMessage(带 ``tool_ctx`` 标记)列表;无历史或
|
||||
游标之后无内容时为空列表。
|
||||
|
||||
上下文拼接格式(前缀缓存友好,确定性构建):
|
||||
[历史思考] {thinking} ← 该工具调用前模型产出的深度思考(reasoning_content)
|
||||
[历史反思] {reflection} ← 该工具调用前模型产出的反思(content)
|
||||
[历史工具调用] {name}({args}) → {result}
|
||||
(思考、反思、工具调用按因果顺序交错;思考/反思独立注入——工具无
|
||||
结果文本(失败/无返回值)时仍保留;每轮末尾未被工具调用消费的
|
||||
尾部思考/反思去重后追加在该轮工具调用之后)
|
||||
|
||||
系统定位:
|
||||
runtime.execute_agent 构建上下文时调用,替代原"最近5轮"注入,
|
||||
改为注入压缩游标之后的所有轮次思考+工具调用历史(游标之前的
|
||||
部分已包含在压缩摘要中,由 ``compressed_content`` 承载)。
|
||||
所有消息从磁盘确定性构建、id 稳定(think_ctx_{idx}/
|
||||
reflect_ctx_{idx}/tool_ctx_{idx}),满足 LLM 前缀缓存条件;
|
||||
compress 节点按 id 精确移除。
|
||||
LangChain 消息列表;无历史 / 游标之后无内容时为空列表。
|
||||
"""
|
||||
if not session_id:
|
||||
return []
|
||||
meta = get_session_meta(session_id, agent_key)
|
||||
cursor = meta.get("cursor_index", -1)
|
||||
result: list = []
|
||||
out: list[BaseMessage] = []
|
||||
try:
|
||||
from agent.history import session_storage
|
||||
from agent.history.tool_call_recorder import list_turns
|
||||
turns = list(list_turns(session_id, limit=100000)) # 时间倒序
|
||||
turns.reverse() # 转为时间正序
|
||||
idx = 0
|
||||
for turn in turns:
|
||||
# 本轮已被工具调用消费的思考/反思内容(用于尾部去重;
|
||||
# 含游标之前的工具调用——被摘要覆盖的思考同样不得重复注入)
|
||||
consumed_thinkings: set[str] = set()
|
||||
consumed_reflections: set[str] = set()
|
||||
for tc in turn.get("tool_calls") or []:
|
||||
_think = str(tc.get("thinking", "") or "").strip()
|
||||
_reflect = str(tc.get("reflection", "") or "").strip()
|
||||
if _think:
|
||||
consumed_thinkings.add(_think)
|
||||
if _reflect:
|
||||
consumed_reflections.add(_reflect)
|
||||
if idx >= cursor:
|
||||
tn = tc.get("name", "unknown")
|
||||
ta = json.dumps(tc.get("args", {}), ensure_ascii=False)[:300]
|
||||
tr = str(tc.get("result_text", "") or "")[:500]
|
||||
# 思考/反思独立注入:即使工具无结果文本(失败/无返回值),
|
||||
# 模型的思考/反思仍有上下文价值
|
||||
if _think:
|
||||
result.append(HumanMessage(
|
||||
content=f"[历史思考] {_think[:500]}",
|
||||
additional_kwargs={"synthetic": True, "tool_ctx": True},
|
||||
id=f"think_ctx_{idx}",
|
||||
))
|
||||
if _reflect:
|
||||
result.append(HumanMessage(
|
||||
content=f"[历史反思] {_reflect[:500]}",
|
||||
additional_kwargs={"synthetic": True, "tool_ctx": True},
|
||||
id=f"reflect_ctx_{idx}",
|
||||
))
|
||||
if tn and tr:
|
||||
# 分配稳定 id(基于全局工具调用序号),供压缩节点 RemoveMessage 精确移除
|
||||
result.append(HumanMessage(
|
||||
content=f"[历史工具调用] {tn}({ta}) → {tr}",
|
||||
additional_kwargs={"synthetic": True, "tool_ctx": True},
|
||||
id=f"tool_ctx_{idx}",
|
||||
))
|
||||
idx += 1
|
||||
# 尾部思考/反思:该轮末尾未被任何工具调用消费的(分别去重后追加)
|
||||
if idx > cursor:
|
||||
think_tail_idx = 0
|
||||
reflect_tail_idx = 0
|
||||
for ref in turn.get("reflections") or []:
|
||||
_thought = str(ref.get("thought", "") or "").strip()
|
||||
_reflection = str(ref.get("reflection", "") or "").strip()
|
||||
if _thought and _thought not in consumed_thinkings:
|
||||
result.append(HumanMessage(
|
||||
content=f"[历史思考] {_thought[:500]}",
|
||||
additional_kwargs={"synthetic": True, "tool_ctx": True},
|
||||
id=f"think_tail_{turn.get('turn_id', idx)}_{think_tail_idx}",
|
||||
))
|
||||
think_tail_idx += 1
|
||||
if _reflection and _reflection not in consumed_reflections:
|
||||
result.append(HumanMessage(
|
||||
content=f"[历史反思] {_reflection[:500]}",
|
||||
additional_kwargs={"synthetic": True, "tool_ctx": True},
|
||||
id=f"reflect_tail_{turn.get('turn_id', idx)}_{reflect_tail_idx}",
|
||||
))
|
||||
reflect_tail_idx += 1
|
||||
# 旧格式兼容(仅 content,无 thought/reflection 字段):
|
||||
# 视为思考注入(与旧行为一致)
|
||||
if not _thought and not _reflection:
|
||||
rc = str(ref.get("content", "") or "").strip()
|
||||
if rc and rc not in consumed_thinkings:
|
||||
result.append(HumanMessage(
|
||||
content=f"[历史思考] {rc[:500]}",
|
||||
additional_kwargs={"synthetic": True, "tool_ctx": True},
|
||||
id=f"think_tail_{turn.get('turn_id', idx)}_{think_tail_idx}",
|
||||
))
|
||||
think_tail_idx += 1
|
||||
except Exception:
|
||||
return out
|
||||
try:
|
||||
turn_ids = [str(x) for x in (session_storage.list_turn_ids(session_id) or [])]
|
||||
except Exception:
|
||||
turn_ids = []
|
||||
records_by_turn: dict[str, dict] = {}
|
||||
try:
|
||||
for rec in list_turns(session_id, limit=100000):
|
||||
records_by_turn[str(rec.get("turn_id", ""))] = rec
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
if not turn_ids:
|
||||
# 回退:以记录中的 turn_id 定序(某些后端 list_turn_ids 不可用)
|
||||
turn_ids = sorted(records_by_turn.keys())
|
||||
for turn_id in turn_ids:
|
||||
try:
|
||||
turn_msgs = session_storage.load_turn_messages(session_id, turn_id) or []
|
||||
except Exception:
|
||||
turn_msgs = []
|
||||
record = records_by_turn.get(turn_id) or {}
|
||||
calls = [c for c in (record.get("tool_calls") or []) if isinstance(c, dict)]
|
||||
reflections = record.get("reflections") or []
|
||||
for m in _rebuild_turn_messages(turn_id, turn_msgs, calls, reflections):
|
||||
if msg_cursor > 0:
|
||||
msg_cursor -= 1
|
||||
continue
|
||||
out.append(m)
|
||||
return out
|
||||
|
||||
|
||||
def _native_user_message(turn_id: str, content) -> HumanMessage | None:
|
||||
"""将 turn 文件中的用户 content 部分还原为原生多模态 HumanMessage。
|
||||
|
||||
图片部分用 relpath 解析回会话目录绝对路径(入模时由 llm._preprocess_messages
|
||||
自动转 data URL 并注入尺寸提示);文件部分保留 path 供其转为文本引用提示。
|
||||
"""
|
||||
parts_out: list[dict[str, Any]] = []
|
||||
if isinstance(content, str):
|
||||
if content.strip():
|
||||
parts_out.append({"type": "text", "text": content})
|
||||
elif isinstance(content, list):
|
||||
for p in content:
|
||||
if not isinstance(p, dict):
|
||||
continue
|
||||
if p.get("type") == "text":
|
||||
t = str(p.get("text") or "").strip()
|
||||
if t:
|
||||
parts_out.append({"type": "text", "text": t})
|
||||
continue
|
||||
rel = str(p.get("relpath") or "").replace("\\", "/")
|
||||
abs_path = ""
|
||||
root_norm = os.path.normpath(SESSIONS_ROOT)
|
||||
if rel and not os.path.isabs(rel):
|
||||
abs_path = os.path.normpath(os.path.join(root_norm, rel.replace("/", os.sep)))
|
||||
try:
|
||||
within = os.path.commonpath([abs_path, root_norm]) == root_norm
|
||||
except ValueError:
|
||||
within = False
|
||||
if not within:
|
||||
abs_path = ""
|
||||
if p.get("type") == "image_url":
|
||||
if abs_path and os.path.isfile(abs_path):
|
||||
parts_out.append({"type": "image_url", "image_url": {"url": abs_path}, "name": p.get("name") or os.path.basename(abs_path)})
|
||||
else:
|
||||
parts_out.append({"type": "text", "text": f"[图片已丢失: {p.get('name') or '未命名'}]"})
|
||||
elif p.get("type") == "file":
|
||||
if abs_path and os.path.isfile(abs_path):
|
||||
parts_out.append({"type": "file", "path": abs_path, "name": p.get("name") or os.path.basename(abs_path)})
|
||||
else:
|
||||
parts_out.append({"type": "text", "text": f"[附件已丢失: {p.get('name') or '未命名'}]"})
|
||||
if not parts_out:
|
||||
return None
|
||||
return HumanMessage(content=parts_out, additional_kwargs={"synthetic": True, "tool_ctx": True}, id=f"hist_user_{turn_id}")
|
||||
|
||||
|
||||
def _tool_result_content(entry: dict) -> str:
|
||||
"""工具结果文本:result_text + 产物文件路径提示(图片/文件由 Agent 经文件工具打开)。"""
|
||||
lines = [str(entry.get("result_text") or "").strip()]
|
||||
for fname in (entry.get("result_images") or []) + (entry.get("result_files") or []):
|
||||
if fname:
|
||||
lines.append(f"[工具产物: {fname}]")
|
||||
return "\n".join(x for x in lines if x)
|
||||
|
||||
|
||||
def _rebuild_turn_messages(turn_id: str, turn_msgs: list, calls: list[dict], reflections: list) -> list:
|
||||
"""按原生格式重建单轮消息序列(顺序:user → (组AI+ToolMessage)* → 尾部反思 → 最终回复)。"""
|
||||
from langchain_core.messages import AIMessage, ToolMessage # noqa: F401
|
||||
msgs: list = []
|
||||
# 1) 用户消息
|
||||
user_entry = next((m for m in turn_msgs if isinstance(m, dict) and m.get("role") == "user" and isinstance(m.get("user"), dict)), None)
|
||||
if user_entry:
|
||||
hu = _native_user_message(turn_id, (user_entry.get("user") or {}).get("content") or "")
|
||||
if hu:
|
||||
msgs.append(hu)
|
||||
# 2) 工具调用分组:group_index 相同 → 同一模型响应(并行调用合并为一条 AIMessage)
|
||||
group_order: list = []
|
||||
group_map: dict = {}
|
||||
fallback_idx = 0
|
||||
consumed_thinkings: set[str] = set()
|
||||
consumed_reflections: set[str] = set()
|
||||
for c in calls:
|
||||
g = c.get("group_index")
|
||||
if not isinstance(g, int):
|
||||
fallback_idx += 1
|
||||
g = f"fb_{fallback_idx}" # 旧记录无分组 → 每 call 独立成组
|
||||
if g not in group_map:
|
||||
group_map[g] = []
|
||||
group_order.append(g)
|
||||
group_map[g].append(c)
|
||||
for g in group_order:
|
||||
entries = group_map[g]
|
||||
first = entries[0]
|
||||
thinking = str(first.get("thinking") or "").strip()
|
||||
reflection = str(first.get("reflection") or "").strip()
|
||||
kwargs = {"additional_kwargs": {"synthetic": True, "tool_ctx": True}, "id": f"hist_assist_{turn_id}_{g}"}
|
||||
if thinking:
|
||||
kwargs["additional_kwargs"]["reasoning_content"] = thinking
|
||||
consumed_thinkings.add(thinking)
|
||||
if reflection:
|
||||
consumed_reflections.add(reflection)
|
||||
tool_calls = [{"id": str(c.get("id") or ""), "name": str(c.get("name") or "unknown"), "args": c.get("args") or {}} for c in entries]
|
||||
msgs.append(AIMessage(content=reflection, tool_calls=tool_calls, **kwargs))
|
||||
for i, c in enumerate(entries):
|
||||
content = _tool_result_content(c)
|
||||
msgs.append(ToolMessage(
|
||||
content=content if content else "[无返回内容]",
|
||||
tool_call_id=str(c.get("id") or ""),
|
||||
name=str(c.get("name") or "unknown"),
|
||||
id=f"hist_tool_{turn_id}_{g}_{i}",
|
||||
additional_kwargs={"synthetic": True, "tool_ctx": True},
|
||||
))
|
||||
# 3) 尾部反思(未被任何工具调用组消费的思考/反思,按文本去重)
|
||||
tail_idx = 0
|
||||
for r in reflections:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
thought = str(r.get("thought") or "").strip()
|
||||
reflection = str(r.get("reflection") or r.get("content") or "").strip()
|
||||
if not thought and not reflection:
|
||||
continue
|
||||
if thought and thought in consumed_thinkings:
|
||||
thought = ""
|
||||
if reflection and reflection in consumed_reflections:
|
||||
reflection = ""
|
||||
if not thought and not reflection:
|
||||
continue
|
||||
kwargs = {"additional_kwargs": {"synthetic": True, "tool_ctx": True}, "id": f"hist_tail_{turn_id}_{tail_idx}"}
|
||||
if thought:
|
||||
kwargs["additional_kwargs"]["reasoning_content"] = thought
|
||||
msgs.append(AIMessage(content=reflection, **kwargs))
|
||||
tail_idx += 1
|
||||
# 4) 最终无工具回复(turn 文件 assistant.text)
|
||||
asst_entry = next((m for m in turn_msgs if isinstance(m, dict) and m.get("role") == "assistant" and isinstance(m.get("assistant"), dict)), None)
|
||||
if asst_entry:
|
||||
text = str((asst_entry.get("assistant") or {}).get("text") or "").strip()
|
||||
if text:
|
||||
msgs.append(AIMessage(content=text, additional_kwargs={"synthetic": True, "tool_ctx": True}, id=f"hist_final_{turn_id}"))
|
||||
return msgs
|
||||
|
||||
# ---------- Turn 级消息存储 ----------
|
||||
import threading as _threading
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ def _refresh_globals():
|
||||
此处仅保留全局默认档位作为无会话上下文(cron、describe_image 等)的兜底。
|
||||
"""
|
||||
global WORKING_DIR, WORKSPACE_DIR, USER_PYTHON_PATH
|
||||
global LLM_BASE_URL, LLM_API_KEY, LLM_MODEL, LLM_TIMEOUT
|
||||
global LLM_BASE_URL, LLM_API_KEY, LLM_MODEL, LLM_TIMEOUT, LLM_MAX_RETRIES
|
||||
global LLM_CONTEXT_WINDOW, COMPRESS_RATE, THINKING_LEVEL
|
||||
global IMG_SIZE, RAG_CHUNK_SIZE, RAG_CHUNK_OVERLAP, GROUNDING_WIDTH, GROUNDING_HEIGHT
|
||||
global SEND_FILE_SIZE_LIMIT
|
||||
@@ -71,6 +71,7 @@ def _refresh_globals():
|
||||
LLM_API_KEY = _get_env("LLM_API_KEY", required=True)
|
||||
LLM_MODEL = _get_env("LLM_MODEL", required=True)
|
||||
LLM_TIMEOUT = _get_env("LLM_TIMEOUT", default="60", cast=float)
|
||||
LLM_MAX_RETRIES = max(0, _get_env("LLM_MAX_RETRIES", default="3", cast=int))
|
||||
LLM_CONTEXT_WINDOW = int(os.getenv("LLM_CONTEXT_WINDOW", 262144))
|
||||
COMPRESS_RATE = float(os.getenv("COMPRESS_RATE", 0.6))
|
||||
THINKING_LEVEL = _get_env("THINKING_LEVEL", default="low")
|
||||
|
||||
@@ -106,7 +106,7 @@ def invoke_tool_and_build_message(tool, tool_name: str, tool_args: dict, tool_ca
|
||||
_policy_note = ""
|
||||
if _policy_decision(tool_name, tool_args) == "approve":
|
||||
_need_ask = True
|
||||
_policy_note = _policy_check_violation(tool_name, tool_args) or "检测到超出工作空间范围的操作"
|
||||
_policy_note = _policy_check_violation(tool_name, tool_args) or "未通过安全策略"
|
||||
|
||||
if _need_ask:
|
||||
_agent = get_current_agent_name()
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
@import url("./css/audio.css");
|
||||
@import url("./css/composer.css");
|
||||
@import url("./css/settings.css");
|
||||
@import url("./css/pending.css");
|
||||
@import url("./css/toolcalls.css");
|
||||
@import url("./css/notify-strip.css");
|
||||
@import url("./css/skills.css");
|
||||
|
||||
+22
-5
@@ -18,6 +18,7 @@ import { setRenderAttachmentChips as audioSetRenderChips } from './js/audio.js';
|
||||
// ---- 聊天渲染 ----
|
||||
import {
|
||||
renderMessages, renderMessagesIncremental, renderMessage, renderAttachmentChips, scrollChatToBottom,
|
||||
appendNewMessages,
|
||||
appendThinkingIndicator, streamAssistantMessage,
|
||||
renderAudioBubble, addTextQuote, initChatQuoteMenu,
|
||||
} from './js/chat-render.js';
|
||||
@@ -27,8 +28,23 @@ import { setRenderPersistentToolCalls } from './js/chat-render.js';
|
||||
import { injectLiveToolCalls, setLiveUiDeps } from './js/toolcalls.js';
|
||||
import { setRenderAudioBubble as toolcallsSetAudioBubble } from './js/toolcalls.js';
|
||||
|
||||
// ---- 人工确认浮窗 ----
|
||||
import { updatePendingOverlay, clearPendingOverlay } from './js/pending-overlay.js';
|
||||
// ---- 人工确认浮窗 / Todo(通知条实现,原 pending-overlay.js / todo.js 迁移后由 notify-strip 接管) ----
|
||||
import { updatePendingStrip, clearPendingStrip, updateTodoStrip, clearTodoStrip, saveStripSession } from './js/notify-strip.js';
|
||||
|
||||
/** 清除 pending 通知条(先持久化会话快照) */
|
||||
function clearPendingOverlay() {
|
||||
saveStripSession();
|
||||
clearPendingStrip();
|
||||
}
|
||||
|
||||
/** 清除 todo 通知条(先持久化会话快照) */
|
||||
function clearTodoOverlay() {
|
||||
saveStripSession();
|
||||
clearTodoStrip();
|
||||
}
|
||||
|
||||
const updatePendingOverlay = updatePendingStrip;
|
||||
const updateTodoOverlayFromRecords = updateTodoStrip;
|
||||
|
||||
// ---- 会话管理 ----
|
||||
import { renderSessions, bootstrap } from './js/sessions.js';
|
||||
@@ -39,7 +55,7 @@ import { sendChat, handlePauseClick } from './js/send.js';
|
||||
import { setSendDeps } from './js/send.js';
|
||||
|
||||
// ---- Todo ----
|
||||
import { updateTodoOverlayFromRecords, clearTodoOverlay } from './js/todo.js';
|
||||
// (notify-strip 已在上方统一导入:updateTodoStrip / clearTodoStrip / saveStripSession)
|
||||
|
||||
// ---- Token 活跃度卡 / 分时段标语 ----
|
||||
import { initUsageCard, refreshUsageCard } from './js/usage-stats.js';
|
||||
@@ -99,7 +115,7 @@ import { renderPersistentToolCalls } from './js/toolcalls.js';
|
||||
setRenderPersistentToolCalls(renderPersistentToolCalls);
|
||||
toolcallsSetAudioBubble(renderAudioBubble);
|
||||
|
||||
// live snapshot 驱动人工确认浮窗与 Todo 浮窗(toolcalls 收到快照后回调)
|
||||
// live snapshot 驱动人工确认通知条与 Todo 通知条(toolcalls 收到快照后回调)
|
||||
setLiveUiDeps({
|
||||
updatePendingOverlay,
|
||||
updateTodoOverlayFromRecords,
|
||||
@@ -122,6 +138,7 @@ setSendDeps({
|
||||
injectLiveToolCalls,
|
||||
clearTodoOverlay,
|
||||
streamAssistantMessage,
|
||||
appendNewMessages,
|
||||
onSendStart: () => setComposerCentered(false),
|
||||
});
|
||||
|
||||
@@ -686,7 +703,7 @@ if (sendBtn) {
|
||||
if (state.sending) {
|
||||
handlePauseClick().catch((e) => showToast(e.message || String(e)));
|
||||
} else if (state._pendingHumanAction) {
|
||||
import('./js/pending-overlay.js').then((m) => m.clearPendingOverlay());
|
||||
import('./js/notify-strip.js').then((m) => { m.saveStripSession(); m.clearPendingStrip(); }).catch(() => {});
|
||||
state._pendingHumanAction = false;
|
||||
if (sendBtn) {
|
||||
sendBtn.classList.remove("btn-pause");
|
||||
|
||||
@@ -71,7 +71,8 @@ body {
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
filter: blur(var(--bg-dof));
|
||||
will-change: filter, background-size, background-image;
|
||||
/* 不声明 will-change:全屏固定层常驻合成器大图层消耗显存并在
|
||||
backdrop-filter 表面上反复重采样;模糊层变化频率低,无需预提升 */
|
||||
transition: background-image 0.5s ease;
|
||||
}
|
||||
|
||||
|
||||
+163
-119
@@ -91,7 +91,7 @@
|
||||
}
|
||||
|
||||
.confirm-dialog {
|
||||
background: #fff;
|
||||
background: var(--surface, rgba(255, 255, 255, 0.94));
|
||||
border-radius: 14px;
|
||||
padding: 1.6rem;
|
||||
max-width: 420px;
|
||||
@@ -251,7 +251,9 @@
|
||||
gap: 0.4rem;
|
||||
align-items: flex-end;
|
||||
width: 100%;
|
||||
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
/* transition: all 在状态切换(欢迎/聊天)时动画化布局属性,配合输入区
|
||||
backdrop-filter 产生约 350ms 的连续重绘风暴;改为仅动画宽度变化 */
|
||||
transition: max-width 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.mm-input {
|
||||
@@ -300,9 +302,9 @@
|
||||
padding: 0.6rem 8rem 2.2rem 0.8rem;
|
||||
font: inherit;
|
||||
font-size: 0.92rem;
|
||||
background: rgba(255, 255, 255, 0.32);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
background: color-mix(in srgb, #fff var(--composer-alpha, 32%), transparent);
|
||||
backdrop-filter: blur(var(--composer-blur, 12px));
|
||||
-webkit-backdrop-filter: blur(var(--composer-blur, 12px));
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -314,7 +316,7 @@
|
||||
.mm-input textarea:focus {
|
||||
outline: 2px solid color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
outline-offset: 1px;
|
||||
background: rgba(255, 255, 255, 0.42);
|
||||
background: color-mix(in srgb, #fff calc(var(--composer-alpha, 32%) + 10%), transparent);
|
||||
}
|
||||
|
||||
/* ===== Bottom-left: 输出模式 / Agent 模式 + 操作按钮 ===== */
|
||||
@@ -358,7 +360,7 @@
|
||||
.inline-drop-trigger:hover,
|
||||
.inline-drop-trigger.is-open {
|
||||
color: var(--accent);
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
background: color-mix(in srgb, var(--surface) 30%, transparent);
|
||||
}
|
||||
|
||||
.inline-drop-trigger--model {
|
||||
@@ -374,7 +376,7 @@
|
||||
.inline-drop-trigger--model:hover,
|
||||
.inline-drop-trigger--model.is-open {
|
||||
color: var(--accent);
|
||||
background-color: rgba(255, 255, 255, 0.25);
|
||||
background-color: color-mix(in srgb, var(--surface) 25%, transparent);
|
||||
}
|
||||
|
||||
/* 下拉面板通用样式 */
|
||||
@@ -383,7 +385,7 @@
|
||||
bottom: calc(100% + 6px);
|
||||
left: 0;
|
||||
min-width: 120px;
|
||||
background: #fff;
|
||||
background: var(--surface, rgba(255, 255, 255, 0.94));
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 6px 24px var(--shadow), 0 2px 6px rgba(0, 0, 0, 0.06);
|
||||
z-index: 100;
|
||||
@@ -448,7 +450,7 @@
|
||||
}
|
||||
|
||||
.ws-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
background: color-mix(in srgb, var(--surface) 40%, transparent);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@@ -470,7 +472,7 @@
|
||||
right: 0;
|
||||
min-width: 260px;
|
||||
max-width: 380px;
|
||||
background: #fff;
|
||||
background: var(--surface, rgba(255, 255, 255, 0.94));
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px var(--shadow), 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
z-index: 100;
|
||||
@@ -636,7 +638,7 @@
|
||||
}
|
||||
|
||||
.mic-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
background: color-mix(in srgb, var(--surface) 40%, transparent);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@@ -656,7 +658,7 @@
|
||||
}
|
||||
|
||||
.mic-btn.is-recording {
|
||||
background: rgba(254, 226, 226, 0.85);
|
||||
background: color-mix(in srgb, var(--danger) 18%, transparent);
|
||||
box-shadow: 0 0 0 3px var(--danger-bg);
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -682,7 +684,7 @@
|
||||
}
|
||||
|
||||
.attachment-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
background: color-mix(in srgb, var(--surface) 40%, transparent);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@@ -714,7 +716,7 @@
|
||||
|
||||
.inline-select--model:hover {
|
||||
color: var(--accent);
|
||||
background-color: rgba(255, 255, 255, 0.25);
|
||||
background-color: color-mix(in srgb, var(--surface) 25%, transparent);
|
||||
}
|
||||
|
||||
/* 下拉选项:浅色背景,无边框 */
|
||||
@@ -722,7 +724,7 @@
|
||||
.inline-select option {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: #fff;
|
||||
background: var(--surface, rgba(255, 255, 255, 0.94));
|
||||
color: var(--text);
|
||||
padding: 0.35rem 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
@@ -786,7 +788,7 @@
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
background: color-mix(in srgb, var(--surface) 40%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
color: var(--text);
|
||||
@@ -991,7 +993,7 @@
|
||||
|
||||
.action-icon-btn:hover {
|
||||
opacity: 0.75;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
background: color-mix(in srgb, var(--surface) 30%, transparent);
|
||||
}
|
||||
|
||||
.action-icon-btn.is-active {
|
||||
@@ -1031,7 +1033,7 @@
|
||||
}
|
||||
|
||||
.file-preview-dialog {
|
||||
background: #fff;
|
||||
background: var(--surface, rgba(255, 255, 255, 0.94));
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.18);
|
||||
width: min(90vw, 900px);
|
||||
@@ -1091,7 +1093,7 @@
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.file-preview-btn:hover { background: rgba(0,0,0,0.06); }
|
||||
.file-preview-btn:hover { background: var(--surface-hover); }
|
||||
.file-preview-close:hover { color: var(--danger); }
|
||||
|
||||
.file-preview-body {
|
||||
@@ -1261,82 +1263,43 @@
|
||||
.access-mode-item--full { color: var(--danger); }
|
||||
.access-mode-item--full.is-selected { background: color-mix(in srgb, var(--danger) 14%, transparent); }
|
||||
|
||||
/* ===== 思考模式面板:档位标签 + 横向渐变色滑动条 =====
|
||||
/* ===== 思考模式面板:纵向选项列表(agent / plan / agent思考 / plan思考 / goal)=====
|
||||
面板右对齐向左展开(按钮位于右侧工具栏,避免伸出窗口右缘被截断) */
|
||||
.think-mode-panel {
|
||||
width: 164px;
|
||||
width: 158px;
|
||||
right: 0;
|
||||
left: auto;
|
||||
padding: 0.6rem 0.65rem 0.55rem;
|
||||
padding: 0.35rem;
|
||||
}
|
||||
.think-mode-labels {
|
||||
.think-mode-items {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.4rem;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
.think-mode-label {
|
||||
.think-mode-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 0.62rem;
|
||||
font-size: 0.68rem;
|
||||
font-family: var(--font-family, inherit);
|
||||
text-align: left;
|
||||
padding: 0.34rem 0.5rem;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.1rem;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.think-mode-label:hover {
|
||||
.think-mode-item:hover {
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--accent) 7%, transparent);
|
||||
}
|
||||
.think-mode-label.is-active {
|
||||
.think-mode-item.is-selected {
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
}
|
||||
.think-mode-track {
|
||||
position: relative;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
/* 渐变色滑动条:冷色(不思考) → 暖色(深度思考) */
|
||||
background: linear-gradient(90deg,
|
||||
color-mix(in srgb, var(--text-tertiary) 55%, transparent),
|
||||
var(--accent) 35%,
|
||||
var(--warning) 65%,
|
||||
var(--danger) 100%);
|
||||
}
|
||||
.think-mode-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(90deg,
|
||||
color-mix(in srgb, var(--text-tertiary) 55%, transparent),
|
||||
var(--accent) 35%,
|
||||
var(--warning) 65%,
|
||||
var(--danger) 100%);
|
||||
opacity: 0.9;
|
||||
}
|
||||
.think-mode-handle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface);
|
||||
border: 2px solid var(--accent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 22%, transparent), var(--shadow);
|
||||
transform: translate(-50%, -50%);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
.think-mode-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* ===== Token 环形指示器(模型选择器左侧)===== */
|
||||
.token-ring-wrap {
|
||||
@@ -1475,7 +1438,12 @@
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: color-mix(in srgb, var(--surface, #fff) 90%, #000);
|
||||
/* 先暗化 10%(保持原观感),再按 --terminal-alpha 与背景混合(透明度可调) */
|
||||
background: color-mix(in srgb,
|
||||
color-mix(in srgb, var(--surface, #fff) 90%, #000) var(--terminal-alpha, 100%),
|
||||
transparent);
|
||||
backdrop-filter: blur(var(--terminal-blur, 0px));
|
||||
-webkit-backdrop-filter: blur(var(--terminal-blur, 0px));
|
||||
border-top: 1px solid var(--border, rgba(0, 0, 0, 0.12));
|
||||
animation: terminal-dock-in 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
@@ -1603,8 +1571,9 @@
|
||||
display: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow-y: auto;
|
||||
padding: 8px 10px;
|
||||
/* 滚动交给 xterm 自带 viewport,外层只裁切 */
|
||||
overflow: hidden;
|
||||
padding: 6px 8px;
|
||||
background: color-mix(in srgb, var(--surface, #fff) 90%, #000);
|
||||
}
|
||||
|
||||
@@ -1612,59 +1581,134 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.term-out {
|
||||
/* xterm 容器:fit addon 依据此容器的实际像素尺寸计算行列数 */
|
||||
.term-holder {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: "Cascadia Mono", Consolas, "Courier New", monospace;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
color: var(--text);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ANSI 颜色 → 主题色 */
|
||||
.t-fg-muted { color: var(--muted); }
|
||||
.t-fg-danger { color: var(--danger, #ef4444); }
|
||||
.t-fg-success { color: var(--success, #22c55e); }
|
||||
.t-fg-warning { color: var(--warning, #eab308); }
|
||||
.t-fg-accent { color: var(--accent, #0e7490); }
|
||||
.t-fg-purple { color: #c084fc; }
|
||||
.t-fg-cyan { color: #22d3ee; }
|
||||
.t-bg-dark { background: rgba(0, 0, 0, 0.25); }
|
||||
.t-bg-danger { background: var(--danger-bg, rgba(239, 68, 68, 0.12)); }
|
||||
.t-bg-success { background: var(--success-bg, rgba(34, 197, 94, 0.12)); }
|
||||
.t-bg-warning { background: var(--warning-bg, rgba(234, 179, 8, 0.12)); }
|
||||
.t-bg-accent { background: color-mix(in srgb, var(--accent) 18%, transparent); }
|
||||
.term-holder .xterm {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 命令行(提示符 + 输入)作为终端输出的最后一行,随内容滚动,观感等同真实终端 */
|
||||
.terminal-input-row {
|
||||
.term-holder .xterm-viewport {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-thumb, rgba(148, 163, 184, 0.2)) transparent;
|
||||
}
|
||||
|
||||
/* ===== 后台任务标签(agent terminal_execute 挂起任务,与交互式终端视觉区分) ===== */
|
||||
.term-tab--task {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.term-tab--task .term-tab-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.term-tab--task.term-tab--dead .term-tab-title {
|
||||
text-decoration: none;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.term-task-pane {
|
||||
display: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
flex-direction: column;
|
||||
padding: 6px 8px;
|
||||
background: color-mix(in srgb, var(--surface, #fff) 90%, #000);
|
||||
}
|
||||
|
||||
.term-task-pane.active {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.terminal-prompt {
|
||||
font-family: "Cascadia Mono", Consolas, "Courier New", monospace;
|
||||
font-size: 0.8rem;
|
||||
color: var(--success, #22c55e);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
.term-task-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
padding: 2px 4px 6px;
|
||||
margin-bottom: 4px;
|
||||
border-bottom: 1px solid var(--border, rgba(0, 0, 0, 0.12));
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.terminal-input {
|
||||
.term-task-cmd {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
color: var(--text);
|
||||
caret-color: var(--success, #22c55e);
|
||||
font-family: "Cascadia Mono", Consolas, "Courier New", monospace;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.terminal-input:disabled {
|
||||
opacity: 0.5;
|
||||
.term-task-status {
|
||||
flex-shrink: 0;
|
||||
padding: 0.06rem 0.5rem;
|
||||
border-radius: 0.55rem;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
color: var(--muted);
|
||||
background: var(--bg-tertiary, rgba(0, 0, 0, 0.06));
|
||||
}
|
||||
|
||||
.term-task-status--running {
|
||||
color: var(--success, #22c55e);
|
||||
background: color-mix(in srgb, var(--success, #22c55e) 14%, transparent);
|
||||
}
|
||||
|
||||
.term-task-status--err {
|
||||
color: var(--danger, #ef4444);
|
||||
background: color-mix(in srgb, var(--danger, #ef4444) 14%, transparent);
|
||||
}
|
||||
|
||||
.term-task-kill {
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
padding: 0.2rem 0.65rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
background: var(--danger-bg, rgba(239, 68, 68, 0.12));
|
||||
color: var(--danger, #ef4444);
|
||||
transition: background 0.12s ease, filter 0.12s ease;
|
||||
}
|
||||
|
||||
.term-task-kill:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.term-task-output {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-thumb, rgba(148, 163, 184, 0.2)) transparent;
|
||||
margin: 0;
|
||||
padding: 6px 4px;
|
||||
font-family: "Cascadia Mono", Consolas, "Courier New", monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.term-task-output .term-task-err {
|
||||
color: var(--warning, #f59e0b);
|
||||
}
|
||||
|
||||
.term-task-output .term-task-truncated {
|
||||
color: var(--warning, #f59e0b);
|
||||
font-size: 11px;
|
||||
}
|
||||
+39
-39
@@ -42,7 +42,7 @@
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
background: rgba(14, 116, 144, 0.12);
|
||||
background: color-mix(in srgb, var(--accent, #0e7490) 12%, transparent);
|
||||
color: var(--accent);
|
||||
font-size: 1.05rem;
|
||||
line-height: 1;
|
||||
@@ -54,7 +54,7 @@
|
||||
}
|
||||
|
||||
.cron-new-btn:hover {
|
||||
background: rgba(14, 116, 144, 0.22);
|
||||
background: color-mix(in srgb, var(--accent, #0e7490) 22%, transparent);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
@@ -129,12 +129,12 @@
|
||||
}
|
||||
|
||||
.cron-item:hover {
|
||||
background: rgba(255, 255, 255, 0.28);
|
||||
background: color-mix(in srgb, var(--surface, #fff) 30%, transparent);
|
||||
}
|
||||
|
||||
.cron-item.active {
|
||||
border-color: rgba(14, 116, 144, 0.65);
|
||||
background: rgba(236, 254, 255, 0.35);
|
||||
border-color: color-mix(in srgb, var(--accent, #0e7490) 65%, transparent);
|
||||
background: color-mix(in srgb, var(--surface, #fff) 35%, transparent);
|
||||
}
|
||||
|
||||
.cron-item-body {
|
||||
@@ -221,7 +221,7 @@
|
||||
}
|
||||
|
||||
.cron-item-action-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
background: var(--bg-tertiary, rgba(0, 0, 0, 0.04));
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@
|
||||
padding: 0.5rem 0.85rem;
|
||||
margin-bottom: 0.4rem;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.42);
|
||||
background: color-mix(in srgb, var(--surface, #fff) 42%, transparent);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
@@ -308,11 +308,11 @@
|
||||
}
|
||||
|
||||
.cron-status-badge--pending { background: rgba(148, 163, 184, 0.2); color: var(--text-tertiary); }
|
||||
.cron-status-badge--running { background: rgba(59, 130, 246, 0.16); color: #1d4ed8; }
|
||||
.cron-status-badge--running { background: rgba(59, 130, 246, 0.16); color: var(--accent); }
|
||||
.cron-status-badge--completed { background: var(--success-bg); color: var(--success); }
|
||||
.cron-status-badge--failed { background: var(--danger-bg); color: var(--danger-hover); }
|
||||
.cron-status-badge--expired { background: var(--warning-bg); color: var(--warning); }
|
||||
.cron-status-badge--disabled { background: rgba(203, 213, 225, 0.3); color: var(--text-tertiary); }
|
||||
.cron-status-badge--disabled { background: var(--bg-tertiary, rgba(203, 213, 225, 0.3)); color: var(--text-tertiary); }
|
||||
|
||||
.cron-banner-actions {
|
||||
display: flex;
|
||||
@@ -328,7 +328,7 @@
|
||||
padding: 0.28rem 0.6rem;
|
||||
border: 1px solid rgba(14, 116, 144, 0.3);
|
||||
border-radius: 7px;
|
||||
background: rgba(14, 116, 144, 0.08);
|
||||
background: color-mix(in srgb, var(--accent, #0e7490) 8%, transparent);
|
||||
color: var(--accent);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
@@ -338,8 +338,8 @@
|
||||
}
|
||||
|
||||
.cron-banner-btn:hover:not(:disabled) {
|
||||
background: rgba(14, 116, 144, 0.18);
|
||||
border-color: rgba(14, 116, 144, 0.5);
|
||||
background: color-mix(in srgb, var(--accent, #0e7490) 18%, transparent);
|
||||
border-color: color-mix(in srgb, var(--accent, #0e7490) 50%, transparent);
|
||||
}
|
||||
|
||||
.cron-banner-btn:disabled {
|
||||
@@ -380,11 +380,11 @@
|
||||
width: min(560px, 92vw);
|
||||
max-height: 88vh;
|
||||
max-height: 88dvh;
|
||||
background: rgba(248, 250, 252, 0.97);
|
||||
background: var(--surface, rgba(248, 250, 252, 0.97));
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 24px 64px var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -413,7 +413,7 @@
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
background: color-mix(in srgb, var(--surface, #fff) 50%, transparent);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
color: var(--text-tertiary);
|
||||
@@ -421,7 +421,7 @@
|
||||
}
|
||||
|
||||
.cron-edit-close:hover {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
background: color-mix(in srgb, var(--surface, #fff) 85%, transparent);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@@ -459,7 +459,7 @@
|
||||
padding: 0.5rem 0.65rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.45);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
background: color-mix(in srgb, var(--surface, #fff) 70%, transparent);
|
||||
color: var(--text);
|
||||
font-size: 0.82rem;
|
||||
font-family: var(--font-family);
|
||||
@@ -472,7 +472,7 @@
|
||||
.cron-field textarea:focus,
|
||||
.cron-field select:focus {
|
||||
border-color: var(--accent);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
background: color-mix(in srgb, var(--surface, #fff) 95%, transparent);
|
||||
}
|
||||
|
||||
.cron-field textarea {
|
||||
@@ -485,7 +485,7 @@
|
||||
.cron-trigger-types {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
background: color-mix(in srgb, var(--surface, #fff) 50%, transparent);
|
||||
padding: 0.2rem;
|
||||
border-radius: 9px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
@@ -509,7 +509,7 @@
|
||||
}
|
||||
|
||||
.cron-trigger-type-btn.active {
|
||||
background: rgba(14, 116, 144, 0.14);
|
||||
background: color-mix(in srgb, var(--accent, #0e7490) 14%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@@ -579,13 +579,13 @@
|
||||
}
|
||||
|
||||
.cron-edit-btn--cancel {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
background: color-mix(in srgb, var(--surface, #fff) 60%, transparent);
|
||||
color: var(--text-tertiary);
|
||||
border: 1px solid rgba(148, 163, 184, 0.4);
|
||||
}
|
||||
|
||||
.cron-edit-btn--cancel:hover {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
background: color-mix(in srgb, var(--surface, #fff) 90%, transparent);
|
||||
}
|
||||
|
||||
.cron-edit-btn--save {
|
||||
@@ -668,7 +668,7 @@
|
||||
height: 38px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.4);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
background: color-mix(in srgb, var(--surface, #fff) 60%, transparent);
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
@@ -682,7 +682,7 @@
|
||||
}
|
||||
|
||||
.cron-weekday-btn:hover {
|
||||
border-color: rgba(14, 116, 144, 0.5);
|
||||
border-color: color-mix(in srgb, var(--accent, #0e7490) 50%, transparent);
|
||||
color: var(--accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
@@ -722,8 +722,8 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
background: color-mix(in srgb, var(--surface, #fff) 55%, transparent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 0.25rem 0.4rem;
|
||||
}
|
||||
@@ -751,7 +751,7 @@
|
||||
}
|
||||
|
||||
.cron-time-arrow:hover {
|
||||
background: rgba(14, 116, 144, 0.12);
|
||||
background: color-mix(in srgb, var(--accent, #0e7490) 12%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@@ -766,7 +766,7 @@
|
||||
height: 30px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
background: color-mix(in srgb, var(--surface, #fff) 85%, transparent);
|
||||
color: var(--text);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
@@ -825,7 +825,7 @@
|
||||
padding: 0.5rem 0.65rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.45);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
background: color-mix(in srgb, var(--surface, #fff) 70%, transparent);
|
||||
color: var(--text);
|
||||
font-size: 0.82rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
@@ -836,7 +836,7 @@
|
||||
|
||||
.cron-advanced-raw input:focus {
|
||||
border-color: var(--accent);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
background: color-mix(in srgb, var(--surface, #fff) 95%, transparent);
|
||||
}
|
||||
|
||||
/* ===== 一次性延时主题化选择器(once 类型) ===== */
|
||||
@@ -871,7 +871,7 @@
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.45);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
background: color-mix(in srgb, var(--surface, #fff) 70%, transparent);
|
||||
color: var(--text);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
@@ -880,7 +880,7 @@
|
||||
}
|
||||
|
||||
.cron-date-trigger:hover {
|
||||
border-color: rgba(14, 116, 144, 0.5);
|
||||
border-color: color-mix(in srgb, var(--accent, #0e7490) 50%, transparent);
|
||||
}
|
||||
|
||||
.cron-date-trigger.is-placeholder {
|
||||
@@ -907,10 +907,10 @@
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
width: 260px;
|
||||
background: rgba(248, 250, 252, 0.98);
|
||||
background: var(--surface, rgba(248, 250, 252, 0.98));
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 32px var(--shadow);
|
||||
padding: 0.6rem;
|
||||
@@ -938,7 +938,7 @@
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
background: color-mix(in srgb, var(--surface, #fff) 60%, transparent);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
@@ -948,7 +948,7 @@
|
||||
}
|
||||
|
||||
.cron-calendar-nav:hover {
|
||||
background: rgba(14, 116, 144, 0.12);
|
||||
background: color-mix(in srgb, var(--accent, #0e7490) 12%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@@ -988,7 +988,7 @@
|
||||
}
|
||||
|
||||
.cron-calendar-day:hover:not(:disabled) {
|
||||
background: rgba(14, 116, 144, 0.12);
|
||||
background: color-mix(in srgb, var(--accent, #0e7490) 12%, transparent);
|
||||
}
|
||||
|
||||
.cron-calendar-day.is-other-month {
|
||||
@@ -1044,12 +1044,12 @@
|
||||
}
|
||||
|
||||
.cron-calendar-footer .cron-cal-today {
|
||||
background: rgba(14, 116, 144, 0.12);
|
||||
background: color-mix(in srgb, var(--accent, #0e7490) 12%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.cron-calendar-footer .cron-cal-today:hover {
|
||||
background: rgba(14, 116, 144, 0.22);
|
||||
background: color-mix(in srgb, var(--accent, #0e7490) 22%, transparent);
|
||||
}
|
||||
|
||||
/* once 时间行垂直对齐日期 trigger */
|
||||
|
||||
+37
-37
@@ -28,12 +28,12 @@
|
||||
}
|
||||
|
||||
.mode-switch-item:hover {
|
||||
color: #334155;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--surface) 30%, transparent);
|
||||
}
|
||||
|
||||
.mode-switch-item.active {
|
||||
color: #0e7490;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.mode-switch-sep {
|
||||
@@ -124,17 +124,17 @@
|
||||
padding: 0.22rem 0.45rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
background: color-mix(in srgb, var(--surface) 25%, transparent);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.68rem;
|
||||
color: #475569;
|
||||
color: var(--muted);
|
||||
transition: background 0.15s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.doc-tree-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
background: color-mix(in srgb, var(--surface) 50%, transparent);
|
||||
}
|
||||
|
||||
.doc-tree {
|
||||
@@ -177,22 +177,22 @@
|
||||
}
|
||||
|
||||
.doc-tree-file--active {
|
||||
background: rgba(14, 116, 144, 0.25) !important;
|
||||
background: color-mix(in srgb, var(--accent) 25%, transparent) !important;
|
||||
}
|
||||
|
||||
/* git 状态着色:新增绿 / 修改橙 / 删除橙+删除线 */
|
||||
.doc-tree-file--modified {
|
||||
color: #f59e0b !important;
|
||||
color: var(--warning) !important;
|
||||
}
|
||||
|
||||
.doc-tree-file--new {
|
||||
color: #22c55e !important;
|
||||
color: var(--success) !important;
|
||||
}
|
||||
|
||||
.doc-tree-file--deleted {
|
||||
color: #f59e0b !important;
|
||||
color: var(--warning) !important;
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: #f59e0b;
|
||||
text-decoration-color: var(--warning);
|
||||
}
|
||||
|
||||
.doc-tree-file--deleted:hover {
|
||||
@@ -204,14 +204,14 @@
|
||||
opacity: 0.4;
|
||||
}
|
||||
.doc-tree-file--drop-target {
|
||||
background: rgba(14, 116, 144, 0.35) !important;
|
||||
outline: 1px dashed rgba(14, 116, 144, 0.6);
|
||||
background: color-mix(in srgb, var(--accent) 35%, transparent) !important;
|
||||
outline: 1px dashed color-mix(in srgb, var(--accent) 60%, transparent);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.doc-tree-folder--drop-target {
|
||||
background: rgba(96, 165, 250, 0.15) !important;
|
||||
outline: 2px dashed rgba(96, 165, 250, 0.5);
|
||||
background: color-mix(in srgb, var(--accent) 15%, transparent) !important;
|
||||
outline: 2px dashed color-mix(in srgb, var(--accent) 50%, transparent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
@@ -220,9 +220,9 @@
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.05rem 0.2rem;
|
||||
border: 1px solid #0e7490;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 3px;
|
||||
background: #3c3c3c;
|
||||
background: var(--surface);
|
||||
color: #ffffff;
|
||||
font: inherit;
|
||||
font-size: 0.76rem;
|
||||
@@ -246,8 +246,8 @@
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
min-width: 160px;
|
||||
background: #252526;
|
||||
border: 1px solid #454545;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
padding: 0.3rem 0;
|
||||
@@ -257,22 +257,22 @@
|
||||
padding: 0.35rem 1rem;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
color: #cccccc;
|
||||
color: var(--text);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.doc-context-menu-item:hover {
|
||||
background: #094771;
|
||||
background: color-mix(in srgb, var(--accent) 25%, transparent);
|
||||
}
|
||||
|
||||
.doc-context-menu-item--danger {
|
||||
color: #ef4444;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.doc-context-menu-sep {
|
||||
height: 1px;
|
||||
margin: 0.2rem 0.5rem;
|
||||
background: #454545;
|
||||
background: var(--divider);
|
||||
}
|
||||
|
||||
/* ===== Edit 工作区 ===== */
|
||||
@@ -376,7 +376,7 @@
|
||||
.edit-tab--modified::after {
|
||||
content: "●";
|
||||
font-size: 0.5rem;
|
||||
color: #eab308;
|
||||
color: var(--warning);
|
||||
margin-left: 0.15rem;
|
||||
}
|
||||
|
||||
@@ -398,7 +398,7 @@
|
||||
}
|
||||
|
||||
.edit-tab-close:hover {
|
||||
background: rgba(128,128,128,0.2);
|
||||
background: var(--border);
|
||||
color: var(--edit-tab-active-text, #fff);
|
||||
}
|
||||
|
||||
@@ -450,12 +450,12 @@
|
||||
|
||||
/* --- 预览区 Agent 变更彩色边框 --- */
|
||||
.edit-file-preview--agent-new {
|
||||
border: 3px solid #22c55e !important;
|
||||
border: 3px solid var(--success) !important;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.edit-file-preview--agent-modified {
|
||||
border: 3px solid #eab308 !important;
|
||||
border: 3px solid var(--warning) !important;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -486,7 +486,7 @@
|
||||
height: 100%;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.edit-empty-hint {
|
||||
@@ -595,11 +595,11 @@
|
||||
}
|
||||
|
||||
.doc-summary-item-name--modified {
|
||||
color: #eab308;
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.doc-summary-item-name--deleted {
|
||||
color: #ef4444;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.doc-summary-actions {
|
||||
@@ -629,16 +629,16 @@
|
||||
}
|
||||
|
||||
.doc-summary-accept:hover {
|
||||
background: rgba(34, 197, 94, 0.35);
|
||||
background: color-mix(in srgb, var(--success) 35%, transparent);
|
||||
}
|
||||
|
||||
.doc-summary-reject {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
background: var(--danger-bg, rgba(239, 68, 68, 0.12));
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.doc-summary-reject:hover {
|
||||
background: rgba(239, 68, 68, 0.35);
|
||||
background: color-mix(in srgb, var(--danger) 35%, transparent);
|
||||
}
|
||||
|
||||
.doc-summary-line-stat {
|
||||
@@ -651,7 +651,7 @@
|
||||
|
||||
/* --- 行级引用高亮 --- */
|
||||
.edit-editor-line--referenced {
|
||||
background: rgba(14, 116, 144, 0.2);
|
||||
background: color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
border-left: 2px solid #0e7490;
|
||||
padding-left: 0.35rem;
|
||||
}
|
||||
@@ -660,13 +660,13 @@
|
||||
.doc-tree-icon--plain_text { color: #9cdcfe; }
|
||||
.doc-tree-icon--docx { color: #4a9eff; }
|
||||
.doc-tree-icon--pptx { color: #ff6b35; }
|
||||
.doc-tree-icon--pdf { color: #ef4444; }
|
||||
.doc-tree-icon--pdf { color: var(--danger); }
|
||||
.doc-tree-icon--tabular { color: #22c55e; }
|
||||
.doc-tree-icon--image { color: #c586c0; }
|
||||
|
||||
/* 拖拽覆盖区域提示 */
|
||||
.edit-editor-pane.drag-over {
|
||||
border-color: #0e7490;
|
||||
border-color: var(--accent);
|
||||
box-shadow: inset 0 0 0 2px rgba(14, 116, 144, 0.3);
|
||||
}
|
||||
|
||||
|
||||
+14
-7
@@ -65,9 +65,9 @@
|
||||
width: var(--sidebar-w);
|
||||
min-width: var(--sidebar-w);
|
||||
border-right: none;
|
||||
background: var(--surface, rgba(255, 255, 255, 0.94));
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
background: color-mix(in srgb, var(--surface, rgba(255, 255, 255, 0.94)) var(--sidebar-alpha, 100%), transparent);
|
||||
backdrop-filter: blur(var(--sidebar-blur, 12px));
|
||||
-webkit-backdrop-filter: blur(var(--sidebar-blur, 12px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.5rem 0 0.5rem 0.5rem;
|
||||
@@ -316,12 +316,15 @@
|
||||
max-width: 88vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface, rgba(255, 255, 255, 0.94));
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
background: color-mix(in srgb, var(--surface, rgba(255, 255, 255, 0.94)) var(--right-alpha, 100%), transparent);
|
||||
/* 仅在打开态启用 backdrop-filter:关闭动画期间面板滑出,逐帧重算模糊会卡顿。
|
||||
(背景模糊 + transform 组合无法稳定走合成器,导致关闭明显滞后于打开) */
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
border-left: 1px solid var(--border, rgba(0, 0, 0, 0.1));
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
will-change: transform;
|
||||
z-index: 60;
|
||||
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
@@ -333,6 +336,8 @@
|
||||
|
||||
.app-root.right-panel-open .right-panel {
|
||||
transform: translateX(0);
|
||||
backdrop-filter: blur(var(--right-blur, 12px));
|
||||
-webkit-backdrop-filter: blur(var(--right-blur, 12px));
|
||||
}
|
||||
|
||||
.right-panel-head {
|
||||
@@ -659,7 +664,9 @@
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.4rem;
|
||||
background: var(--surface, rgba(255, 255, 255, 0.94));
|
||||
background: color-mix(in srgb, var(--surface, rgba(255, 255, 255, 0.94)) var(--topbar-alpha, 100%), transparent);
|
||||
backdrop-filter: blur(var(--topbar-blur, 0px));
|
||||
-webkit-backdrop-filter: blur(var(--topbar-blur, 0px));
|
||||
border-bottom: 1px solid var(--border, rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
|
||||
+224
-29
@@ -118,6 +118,12 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 权限审查类:警示色字体(与普通人机交互缩略行区分) */
|
||||
.strip-item-text--review {
|
||||
color: var(--warning, #f59e0b);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 计数 chip */
|
||||
.strip-item-count {
|
||||
flex-shrink: 0;
|
||||
@@ -153,6 +159,16 @@
|
||||
mask: url(/image/人机交互.svg) center / contain no-repeat;
|
||||
}
|
||||
|
||||
/* Terminal(后台终端任务)图标 */
|
||||
.strip-terminal-icon {
|
||||
flex-shrink: 0;
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
background-color: var(--accent);
|
||||
-webkit-mask: url(/image/终端.svg) center / contain no-repeat;
|
||||
mask: url(/image/终端.svg) center / contain no-repeat;
|
||||
}
|
||||
|
||||
/* ===== 展开区(垂直位于行容器下方) ===== */
|
||||
.strip-expand-area {
|
||||
padding: 0.35rem 0.6rem 0.5rem 0.6rem;
|
||||
@@ -287,54 +303,145 @@
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* 选项胶囊 */
|
||||
.strip-card-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.strip-option-pill {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 0.22rem 0.7rem;
|
||||
font-size: 0.72rem;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/* ===== 人机交互多问题卡片:无边框/无分割线的自上而下列表 ===== */
|
||||
.strip-hq-prompt {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text);
|
||||
background: var(--bg-tertiary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.strip-hq-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.strip-hq-option {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
font-family: var(--font-family, inherit);
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
padding: 0.32rem 0.5rem;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
.strip-option-pill:hover {
|
||||
.strip-hq-option:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.strip-option-pill.is-selected {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
.strip-hq-option.is-selected {
|
||||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 补充输入框(无边框简约风) */
|
||||
.strip-card-input {
|
||||
.strip-hq-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
min-height: 2rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
min-height: 2.4rem;
|
||||
padding: 0.4rem 0.55rem;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
background: var(--surface-hover);
|
||||
color: var(--text);
|
||||
font-size: 0.76rem;
|
||||
font-family: var(--font-family);
|
||||
line-height: 1.45;
|
||||
font-size: 0.8rem;
|
||||
font-family: var(--font-family, inherit);
|
||||
line-height: 1.5;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.strip-card-input:focus {
|
||||
.strip-hq-input::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* 底部:左下角问题切换箭头(圆角矩形按钮)+ 右下角 忽略/确认 */
|
||||
.strip-hq-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.strip-hq-nav {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.strip-hq-nav-btn {
|
||||
border: none;
|
||||
min-width: 2.2rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-hover);
|
||||
color: var(--text);
|
||||
font-family: var(--font-family, inherit);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.strip-hq-nav-btn:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.strip-hq-nav-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.strip-hq-actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.strip-hq-act {
|
||||
border: none;
|
||||
padding: 0.32rem 0.95rem;
|
||||
border-radius: 8px;
|
||||
font-family: var(--font-family, inherit);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
.strip-hq-act--ignore {
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.strip-hq-act--ignore:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.strip-hq-act--confirm {
|
||||
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.strip-hq-act--confirm:hover {
|
||||
background: color-mix(in srgb, var(--accent) 26%, transparent);
|
||||
}
|
||||
|
||||
/* 卡片底部按钮行(右对齐) */
|
||||
@@ -359,13 +466,11 @@
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.strip-card-btn--continue,
|
||||
.strip-card-btn--approve {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.strip-card-btn--continue:hover,
|
||||
.strip-card-btn--approve:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
@@ -447,3 +552,93 @@
|
||||
opacity: 0;
|
||||
transform: scale(0.6);
|
||||
}
|
||||
|
||||
/* ===== 后台终端任务列表(通知条展开区) ===== */
|
||||
.strip-terminal-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.15rem 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.strip-task-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.3rem 0.45rem;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--bg-tertiary) 55%, transparent);
|
||||
font-size: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.strip-task-dot {
|
||||
flex-shrink: 0;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.strip-task-dot--running {
|
||||
background: var(--success);
|
||||
animation: strip-task-pulse 1.4s ease infinite;
|
||||
}
|
||||
|
||||
.strip-task-dot--err {
|
||||
background: var(--danger, #ef4444);
|
||||
}
|
||||
|
||||
@keyframes strip-task-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
.strip-task-cmd {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.strip-task-meta {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.68rem;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.strip-task-kill {
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
padding: 0.22rem 0.6rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
background: var(--danger-bg, rgba(239, 68, 68, 0.12));
|
||||
color: var(--danger, #ef4444);
|
||||
transition: background 0.12s ease, filter 0.12s ease;
|
||||
}
|
||||
|
||||
.strip-task-kill:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.strip-task-status {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.68rem;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.strip-task-status--err {
|
||||
color: var(--danger, #ef4444);
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -1,472 +0,0 @@
|
||||
/* ===== HumanInteraction / 待确认工具 浮窗(主题变量化)===== */
|
||||
.pending-overlay {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 901;
|
||||
min-width: 320px;
|
||||
max-width: 500px;
|
||||
width: max-content;
|
||||
background: var(--surface);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-radius: var(--radius, 12px);
|
||||
box-shadow: 0 8px 32px var(--overlay-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-top: 2px solid var(--accent);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.pending-overlay-header {
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 8%, transparent), transparent);
|
||||
}
|
||||
|
||||
.pending-overlay-header:hover {
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 12%, transparent), var(--surface-hover));
|
||||
}
|
||||
|
||||
.pending-overlay-body {
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
padding: 0.4rem 0;
|
||||
}
|
||||
|
||||
/* ----- 折叠态 ----- */
|
||||
.pending-overlay--collapsed .pending-overlay-body {
|
||||
display: none;
|
||||
}
|
||||
.pending-overlay--collapsed {
|
||||
min-width: 180px;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
/* ===== agent 分组(主 Agent / 不同子 Agent)===== */
|
||||
.pending-overlay-agent-group {
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
.pending-overlay-agent-group + .pending-overlay-agent-group {
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.pending-overlay-agent-header {
|
||||
padding: 0.35rem 0.8rem;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.pending-overlay-agent-header .pending-overlay-agent-tag {
|
||||
display: inline-block;
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.68rem;
|
||||
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ===== 单个 pending 卡片 ===== */
|
||||
.pending-overlay-card {
|
||||
margin: 0.3rem 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: var(--surface-hover);
|
||||
box-shadow: 0 1px 4px var(--shadow);
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.pending-overlay-card--resolved {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pending-overlay-card--resolved .pending-overlay-btn,
|
||||
.pending-overlay-card--resolved .pending-overlay-send-btn {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pending-overlay-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.45rem 0.7rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text);
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.pending-overlay-card-header:hover {
|
||||
background: color-mix(in srgb, var(--accent) 6%, transparent);
|
||||
}
|
||||
|
||||
.pending-overlay-card-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pending-overlay-card-title {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pending-overlay-card-toggle {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
/* ----- 卡片折叠态 ----- */
|
||||
.pending-overlay-card--collapsed .pending-overlay-card-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pending-overlay-card-body {
|
||||
padding: 0.5rem 0.7rem;
|
||||
}
|
||||
|
||||
.pending-overlay-prompt {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 0.5rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* 工作空间越界审批提示(访问策略 policy_note) */
|
||||
.pending-overlay-note {
|
||||
font-size: 0.78rem;
|
||||
color: var(--warning, #b45309);
|
||||
background: var(--warning-bg, color-mix(in srgb, #f59e0b 12%, transparent));
|
||||
border: 1px solid color-mix(in srgb, var(--warning, #f59e0b) 45%, transparent);
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 0.55rem;
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ===== tool_call 卡片:参数摘要只读展示 ===== */
|
||||
.pending-overlay-tool-name {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.pending-overlay-tool-args {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text);
|
||||
background: color-mix(in srgb, var(--accent) 5%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 0.55rem;
|
||||
margin-bottom: 0.5rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
font-family: var(--font-family, inherit);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* ===== 按钮行(同意 / 拒绝 / 跳过)===== */
|
||||
.pending-overlay-btn-row {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.pending-overlay-btn {
|
||||
flex: 1;
|
||||
min-width: 64px;
|
||||
padding: 0.42rem 0.8rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, box-shadow 0.2s, background 0.2s, opacity 0.2s;
|
||||
}
|
||||
.pending-overlay-btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
.pending-overlay-btn:focus-visible,
|
||||
.pending-overlay-send-btn:focus-visible,
|
||||
.pending-overlay-submit-btn:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.pending-overlay-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pending-overlay-btn--approve {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.pending-overlay-btn--approve:hover {
|
||||
background: var(--accent-hover);
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
}
|
||||
|
||||
.pending-overlay-btn--reject {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
.pending-overlay-btn--reject:hover {
|
||||
filter: brightness(1.08);
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--danger) 45%, transparent);
|
||||
}
|
||||
|
||||
.pending-overlay-btn--skip {
|
||||
background: var(--skip-btn-bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.pending-overlay-btn--skip:hover {
|
||||
background: color-mix(in srgb, var(--text) 12%, transparent);
|
||||
}
|
||||
|
||||
/* ===== 文本输入行 ===== */
|
||||
.pending-overlay-input-row {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.pending-overlay-textarea {
|
||||
flex: 1;
|
||||
resize: vertical;
|
||||
min-height: 58px;
|
||||
max-height: 160px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.45rem 0.6rem;
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.pending-overlay-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
|
||||
.pending-overlay-send-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 0.4rem 1rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.pending-overlay-send-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
box-shadow: 0 0 14px color-mix(in srgb, var(--accent) 50%, transparent);
|
||||
}
|
||||
|
||||
/* ===== Selection 问题块 ===== */
|
||||
.pending-overlay-question {
|
||||
margin-bottom: 0.5rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pending-overlay-question:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.pending-overlay-question-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
background: var(--surface-hover);
|
||||
user-select: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.pending-overlay-question-header:hover {
|
||||
background: color-mix(in srgb, var(--accent) 7%, transparent);
|
||||
}
|
||||
|
||||
.pending-overlay-q-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* ----- 问题折叠态 ----- */
|
||||
.pending-overlay-question--collapsed .pending-overlay-question-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pending-overlay-question-body {
|
||||
padding: 0.5rem 0.6rem;
|
||||
}
|
||||
|
||||
/* ===== 选项 pills ===== */
|
||||
.pending-overlay-option-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.pending-overlay-option-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 0.3rem 0.7rem;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
transition: all 0.2s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pending-overlay-option-pill:hover {
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.pending-overlay-option-pill--selected {
|
||||
background: var(--accent) !important;
|
||||
border-color: var(--accent) !important;
|
||||
color: #fff !important;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 8px color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
}
|
||||
|
||||
/* ===== "其他" 输入 ===== */
|
||||
.pending-overlay-other-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.pending-overlay-other-input {
|
||||
flex: 1;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 0.3rem 0.7rem;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.pending-overlay-other-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
}
|
||||
|
||||
/* ===== 提交行 ===== */
|
||||
.pending-overlay-submit-row {
|
||||
padding: 0.4rem 0.7rem 0.6rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.pending-overlay-submit-btn {
|
||||
padding: 0.45rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px color-mix(in srgb, var(--accent) 25%, transparent);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.pending-overlay-submit-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
box-shadow: 0 4px 16px color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ===== 双浮窗并排布局 ===== */
|
||||
.todo-list-overlay--dual {
|
||||
left: calc(50% - 260px);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.pending-overlay--dual {
|
||||
left: calc(50% + 20px);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* ===== 响应式 ===== */
|
||||
@media (max-width: 768px) {
|
||||
.todo-list-overlay--dual {
|
||||
left: 1rem;
|
||||
transform: none;
|
||||
}
|
||||
.pending-overlay--dual {
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
transform: none;
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
max-width: unset;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.pending-overlay {
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
transform: none;
|
||||
min-width: unset;
|
||||
max-width: unset;
|
||||
}
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
/* ===== 人工交互 / 工具审批卡片 ===== */
|
||||
.pending-action-card {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: none;
|
||||
border-left: 3px solid color-mix(in srgb, var(--success) 45%, transparent);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, var(--surface) 0%, var(--bg-tertiary) 100%);
|
||||
box-shadow: 0 4px 20px var(--shadow), inset 0 1px 0 rgba(255,255,255,0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.pending-action-card:hover {
|
||||
border-color: color-mix(in srgb, var(--success) 25%, transparent);
|
||||
box-shadow: 0 4px 24px color-mix(in srgb, var(--success) 8%, transparent), inset 0 1px 0 rgba(255,255,255,0.6);
|
||||
}
|
||||
|
||||
.pending-action-card--resolved {
|
||||
opacity: 0.68;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
|
||||
.pending-action-title {
|
||||
font-weight: 700;
|
||||
font-size: 0.92rem;
|
||||
margin-bottom: 0.45rem;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.pending-action-prompt {
|
||||
margin-bottom: 0.55rem;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* information: textarea + inline arrow */
|
||||
.pending-action-input-row {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pending-action-editor,
|
||||
.pending-action-editor-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pending-action-editor {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font: inherit;
|
||||
font-size: 0.84rem;
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.pending-action-editor:focus {
|
||||
outline: none;
|
||||
border-color: color-mix(in srgb, var(--success) 50%, transparent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--success) 10%, transparent);
|
||||
}
|
||||
|
||||
.pending-action-editor--with-arrow {
|
||||
padding-right: 2.8rem;
|
||||
}
|
||||
|
||||
.pending-action-editor-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pending-action-send-btn {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--success) 0%, var(--accent) 100%);
|
||||
color: #fff;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.2s, box-shadow 0.2s, background 0.2s;
|
||||
line-height: 1;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.pending-action-send-btn:hover:not(:disabled) {
|
||||
transform: translateY(-50%) scale(1.12);
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--success) 75%, white) 0%, var(--accent) 100%);
|
||||
box-shadow: 0 0 14px color-mix(in srgb, var(--success) 55%, transparent), 0 0 28px color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.pending-action-send-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.pending-action-send-btn--small {
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
font-size: 0.9rem;
|
||||
position: static;
|
||||
flex-shrink: 0;
|
||||
margin-left: 0.4rem;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.pending-action-send-btn--small:hover:not(:disabled) {
|
||||
transform: scale(1.12);
|
||||
}
|
||||
|
||||
/* selection: multi-question */
|
||||
.pending-action-question {
|
||||
margin-bottom: 0.7rem;
|
||||
padding: 0.6rem 0.7rem;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: var(--bg-tertiary);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.pending-action-question:hover {
|
||||
border-color: color-mix(in srgb, var(--success) 20%, transparent);
|
||||
}
|
||||
|
||||
.pending-action-question-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.86rem;
|
||||
margin-bottom: 0.45rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* option pills (non-editable, click to select) */
|
||||
.pending-action-option-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.75rem;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
transition: all 0.2s;
|
||||
margin: 0.15rem 0.3rem 0.15rem 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pending-action-option-pill:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--success) 8%, transparent);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--success) 12%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.pending-action-option-pill--selected {
|
||||
background: linear-gradient(135deg, var(--success), var(--accent)) !important;
|
||||
border-color: color-mix(in srgb, var(--accent) 70%, transparent) !important;
|
||||
color: #fff !important;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 8px color-mix(in srgb, var(--success) 25%, transparent);
|
||||
}
|
||||
|
||||
.pending-action-option-pill:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* "其他" input row */
|
||||
.pending-action-other-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.pending-action-other-input {
|
||||
flex: 1;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 0.38rem 0.8rem;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.pending-action-other-input:focus {
|
||||
outline: none;
|
||||
border-color: color-mix(in srgb, var(--success) 45%, transparent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--success) 10%, transparent);
|
||||
}
|
||||
|
||||
.pending-action-other-input:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* options container (legacy) */
|
||||
.pending-action-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
.pending-action-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.65rem;
|
||||
}
|
||||
|
||||
.pending-action-btn {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 0.45rem 1rem;
|
||||
font: inherit;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
transition: all 0.2s;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.pending-action-btn:hover:not(:disabled) {
|
||||
background: var(--surface);
|
||||
box-shadow: 0 2px 8px var(--shadow);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.pending-action-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pending-action-btn--approve {
|
||||
background: linear-gradient(135deg, var(--success), var(--accent));
|
||||
border-color: transparent;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 8px color-mix(in srgb, var(--success) 20%, transparent);
|
||||
}
|
||||
|
||||
.pending-action-btn--approve:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--success) 75%, white), var(--accent-hover));
|
||||
box-shadow: 0 4px 16px color-mix(in srgb, var(--success) 40%, transparent), 0 0 24px color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
transform: translateY(-1px);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.pending-action-btn--reject {
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger-hover);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pending-action-btn--reject:hover:not(:disabled) {
|
||||
background: var(--danger-bg);
|
||||
box-shadow: 0 2px 10px color-mix(in srgb, var(--danger) 12%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
@@ -92,11 +92,4 @@
|
||||
|
||||
/* ===== 响应式 ===== */
|
||||
@media (max-width: 640px) {
|
||||
.todo-list-overlay {
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
transform: none;
|
||||
min-width: unset;
|
||||
max-width: unset;
|
||||
}
|
||||
}
|
||||
@@ -966,7 +966,7 @@
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: #cbd5e1;
|
||||
background: color-mix(in srgb, var(--text-tertiary) 30%, transparent);
|
||||
border-radius: 22px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
}
|
||||
|
||||
.toast-item {
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
background: color-mix(in srgb, var(--surface, #fff) 92%, transparent);
|
||||
backdrop-filter: blur(14px);
|
||||
border-radius: 10px;
|
||||
padding: 10px 20px;
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
/* ===== Todo 浮窗(主题变量化)===== */
|
||||
.todo-list-overlay {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 900;
|
||||
min-width: 280px;
|
||||
max-width: 420px;
|
||||
width: max-content;
|
||||
background: var(--surface);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border-radius: var(--radius, 12px);
|
||||
box-shadow: 0 8px 32px var(--overlay-bg);
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.todo-list-header {
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.todo-list-header:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.todo-list-body {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.5rem 0;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.todo-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.todo-list-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.todo-label {
|
||||
flex: 1;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ----- 勾已完成 ----- */
|
||||
.todo-check {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--success);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ----- 加载旋转动画(pending) ----- */
|
||||
.todo-spinner {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2.5px solid color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
border-top-color: var(--accent);
|
||||
animation: todo-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes todo-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ----- 折叠态 ----- */
|
||||
.todo-list-overlay--collapsed .todo-list-body {
|
||||
display: none;
|
||||
}
|
||||
.todo-list-overlay--collapsed {
|
||||
min-width: 180px;
|
||||
max-width: 240px;
|
||||
}
|
||||
@@ -35,14 +35,15 @@
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.tc-list-toggle-body {
|
||||
display: flex;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
margin-top: 0.15rem;
|
||||
max-height: 0;
|
||||
max-height: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tool-call-list-wrap--expanded .tc-list-toggle-body {
|
||||
display: flex;
|
||||
overflow-y: auto;
|
||||
max-height: 75vh;
|
||||
}
|
||||
@@ -174,17 +175,20 @@
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 展开详情区(行下方) */
|
||||
/* 展开详情区(行下方)。折叠态用 display:none 而非 max-height:0:
|
||||
max-height:0 仍会对巨型 result_text 全量排版(大会话渲染/滚动的主要开销),
|
||||
display:none 完全跳过布局,仅在展开瞬间排版一次。 */
|
||||
.tc-detail {
|
||||
margin: 0.15rem 0 0.3rem 1.4rem;
|
||||
padding: 0 0.45rem;
|
||||
display: flex;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
max-height: 0;
|
||||
max-height: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tool-call-row--expanded + .tc-detail {
|
||||
display: flex;
|
||||
max-height: 45vh;
|
||||
overflow-y: auto;
|
||||
padding: 0.3rem 0.45rem;
|
||||
@@ -306,10 +310,11 @@
|
||||
color: var(--text, #475569);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 0;
|
||||
display: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sub-reply-row--expanded .sub-reply-row-detail {
|
||||
display: block;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -434,10 +439,11 @@
|
||||
color: var(--muted, #64748b);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 0;
|
||||
display: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
.think-row--expanded .think-row-detail {
|
||||
display: block;
|
||||
max-height: 300px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -36,4 +36,16 @@
|
||||
--shadow: rgba(0, 0, 0, 0.15);
|
||||
--scrollbar-thumb: rgba(0, 0, 0, 0.15);
|
||||
--scrollbar-track: transparent;
|
||||
/* ──── 面板玻璃效果(透明度 = 面板背景相对 --surface 的不透明度;模糊度 = 背景模糊 px)────
|
||||
均在「设置 → 外观 → 面板玻璃效果」中可调,留空/未设置时使用此处默认值 */
|
||||
--sidebar-alpha: 100%;
|
||||
--sidebar-blur: 12px;
|
||||
--right-alpha: 100%;
|
||||
--right-blur: 12px;
|
||||
--topbar-alpha: 100%;
|
||||
--topbar-blur: 0px;
|
||||
--terminal-alpha: 100%;
|
||||
--terminal-blur: 0px;
|
||||
--composer-alpha: 32%;
|
||||
--composer-blur: 12px;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.6 MiB |
@@ -1 +0,0 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1784769993878" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5566" data-darkreader-inline-fill="" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M459.26562501 512l105.46874998 0L564.734375 134.05273437A43.92773438 43.92773438 0 0 0 520.80664063 90.125l-17.61328126 0A43.92773438 43.92773438 0 0 0 459.265625 134.05273437L459.26562501 512z" p-id="5567"></path><path d="M289.671875 578.97265625l181.93359375 295.04882813a43.92773438 43.92773438 0 0 0 74.51367188 0.47460937l186.83789062-295.04882813A43.92773438 43.92773438 0 0 0 695.83203126 512L327.06054687 512a43.92773438 43.92773438 0 0 0-37.44140624 66.97265625z" p-id="5568"></path></svg>
|
||||
|
Before Width: | Height: | Size: 862 B |
@@ -1 +0,0 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1786930110833" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9885" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M386.986667 335.36c17.493333 0 32-14.506667 32-32s-14.506667-32-32-32h-217.6V136.96c0-17.493333-14.506667-32-32-32s-32 14.506667-32 32v499.2c0 63.573333 51.626667 115.2 115.2 115.2h166.4c17.493333 0 32-14.506667 32-32s-14.506667-32-32-32h-166.4c-28.16 0-51.2-23.04-51.2-51.2V335.36h217.6zM823.893333 125.866667H738.133333c-3.413333 0-5.973333-1.706667-8.106666-4.266667l-17.493334-26.026667a73.386667 73.386667 0 0 0-61.013333-32h-77.226667c-52.053333 0-94.293333 42.24-94.293333 94.293334v208.213333c0 52.053333 42.24 94.293333 94.293333 94.293333h249.6c52.053333 0 94.293333-42.24 94.293334-94.293333V220.586667c0-52.053333-42.24-94.293333-94.293334-94.293334z m30.72 239.786666c0 16.64-13.653333 30.293333-30.293333 30.293334h-249.6c-16.64 0-30.293333-13.653333-30.293333-30.293334V157.866667c0-16.64 13.653333-30.293333 30.293333-30.293334h77.226667c3.413333 0 5.973333 1.706667 8.106666 4.266667l17.493334 26.026667c13.653333 20.053333 36.266667 32 61.013333 32h85.76c16.64 0 30.293333 13.653333 30.293333 30.293333v145.493333zM823.893333 583.253333H738.133333c-3.413333 0-5.973333-1.706667-8.106666-4.266666l-17.493334-26.026667c-13.653333-20.053333-36.266667-32.426667-61.013333-32.426667h-77.226667c-52.053333 0-94.293333 42.24-94.293333 94.293334v208.213333c0 52.053333 42.24 94.293333 94.293333 94.293333h249.6c52.053333 0 94.293333-42.24 94.293334-94.293333v-145.493333c0-52.053333-42.24-94.293333-94.293334-94.293334z m30.72 240.213334c0 16.64-13.653333 30.293333-30.293333 30.293333h-249.6c-16.64 0-30.293333-13.653333-30.293333-30.293333v-208.213334c0-16.64 13.653333-30.293333 30.293333-30.293333h77.226667c2.986667 0 5.973333 1.706667 8.106666 4.266667l17.493334 26.026666c13.653333 20.053333 36.266667 32.426667 61.013333 32.426667h85.76c16.64 0 30.293333 13.653333 30.293333 30.293333v145.493334z" p-id="9886" fill="#707070"></path></svg>
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
+17
-7
@@ -17,6 +17,7 @@
|
||||
</script>
|
||||
<!-- 本地字体(离线可用,不再依赖 Google Fonts CDN) -->
|
||||
<link rel="stylesheet" href="/fonts/fonts.css" />
|
||||
<link rel="stylesheet" href="/packages/node_modules/@xterm/xterm/css/xterm.css" />
|
||||
<link rel="icon" href="/image/favicon.svg" sizes="any" />
|
||||
<link rel="stylesheet" href="/app.css?v=9" />
|
||||
<link rel="stylesheet" href="/css/splash.css?v=1" />
|
||||
@@ -238,16 +239,12 @@
|
||||
<div class="inline-drop-panel" id="modelPanel" hidden></div>
|
||||
</div>
|
||||
<div class="inline-drop-wrap" id="thinkModeWrap">
|
||||
<button type="button" class="inline-drop-trigger inline-drop-trigger--icon" id="thinkModeTrigger" aria-label="思考模式" title="思考模式:low / high / xhigh / max / ultra">
|
||||
<button type="button" class="inline-drop-trigger inline-drop-trigger--icon" id="thinkModeTrigger" aria-label="思考模式" title="思考模式:agent / plan / agent 思考 / plan 思考 / goal">
|
||||
<img src="/image/思考模式.svg" alt="思考" id="thinkModeIcon" />
|
||||
<span class="think-mode-badge" id="thinkModeBadge"></span>
|
||||
</button>
|
||||
<div class="inline-drop-panel think-mode-panel" id="thinkModePanel" hidden>
|
||||
<div class="think-mode-labels" id="thinkModeLabels"></div>
|
||||
<div class="think-mode-track" id="thinkModeTrack">
|
||||
<div class="think-mode-fill" id="thinkModeFill"></div>
|
||||
<div class="think-mode-handle" id="thinkModeHandle"></div>
|
||||
</div>
|
||||
<div class="think-mode-items" id="thinkModeItems"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="mic-btn" id="micBtn" title="使用麦克风录音" aria-pressed="false" aria-label="使用麦克风录音">
|
||||
@@ -473,7 +470,7 @@
|
||||
<h2>外观</h2>
|
||||
<div class="settings-card">
|
||||
<div class="settings-row settings-row--block">
|
||||
<div class="row-text"><h4>主题预设</h4><p>即时生效,无需保存。</p></div>
|
||||
<div class="row-text"><h4>主题预设</h4><p>点击即预览,修改后点击下方「保存主题设置」生效。</p></div>
|
||||
<div class="theme-grid" id="themeGrid"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -512,6 +509,7 @@
|
||||
<div class="row-text"><h4>背景暗角</h4><p id="bgVignetteVal">0%</p></div>
|
||||
<div class="row-control"><input type="range" id="bgVignetteSlider" min="0" max="1" step="0.05" value="0" class="compact-slider" /></div>
|
||||
</div>
|
||||
<div id="panelGlassZone"></div>
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>字体</h4><p>应用全局字体族。</p></div>
|
||||
<div class="row-control">
|
||||
@@ -541,6 +539,9 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-page-actions">
|
||||
<button type="button" class="btn btn-primary" id="saveThemeBtn">保存主题设置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 模型设置 -->
|
||||
@@ -820,6 +821,12 @@
|
||||
<div class="row-control"><input type="number" data-env="LOOP_DETECT_REPEATED_TOOL_WARN" min="1" step="1" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<div class="settings-row">
|
||||
<div class="row-text"><h4>浏览器 Profile 目录</h4><p>浏览器控制工具的持久用户数据目录。留空 = 每次启动使用一次性临时 Profile(登录态不持久、不留盘);填写路径 = 持久 Profile(登录态跨重启保留,目录不存在时自动创建)。</p></div>
|
||||
<div class="row-control"><input type="text" data-env="BROWSER_PROFILE_DIR" placeholder="留空 = 临时 Profile(默认)" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-page-actions">
|
||||
<button type="button" class="btn btn-primary settings-save-btn" data-save="env">保存运行时参数</button>
|
||||
</div>
|
||||
@@ -833,6 +840,9 @@
|
||||
<div class="toast-container" id="toastContainer" aria-live="polite"></div>
|
||||
<script src="/packages/vendor/marked.min.js"></script>
|
||||
<script src="/packages/vendor/purify.min.js"></script>
|
||||
<!-- xterm.js(内置真终端渲染):UMD 全局 window.Terminal / window.FitAddon -->
|
||||
<script src="/packages/node_modules/@xterm/xterm/lib/xterm.js"></script>
|
||||
<script src="/packages/node_modules/@xterm/addon-fit/lib/addon-fit.js"></script>
|
||||
<!-- Monaco Editor: CDN 优先(Web端),本地 npm 包兜底(Electron 离线) -->
|
||||
<script>
|
||||
(function() {
|
||||
|
||||
+25
-1
@@ -18,6 +18,30 @@ async function api(path, opts = {}) {
|
||||
return r.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 与 api() 相同,但返回 {data, status, headers}(用于读取 ETag 等响应头)。
|
||||
* 304 不视为错误:data 为 null,由调用方使用本地缓存。
|
||||
*/
|
||||
async function apiWithHeaders(path, opts = {}) {
|
||||
const r = await fetch(path, {
|
||||
...opts,
|
||||
headers: opts.body instanceof FormData ? {} : { "Content-Type": "application/json", ...(opts.headers || {}) },
|
||||
});
|
||||
if (r.status === 304) return { data: null, status: 304, headers: r.headers };
|
||||
if (!r.ok) {
|
||||
let detail = r.statusText;
|
||||
try {
|
||||
const j = await r.json();
|
||||
if (j.detail) detail = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(detail || `HTTP ${r.status}`);
|
||||
}
|
||||
const data = await r.json();
|
||||
return { data, status: r.status, headers: r.headers };
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE 流式 API 调用。
|
||||
* @param {string} path
|
||||
@@ -62,4 +86,4 @@ async function streamApi(path, opts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export { api, streamApi };
|
||||
export { api, apiWithHeaders, streamApi };
|
||||
+185
-15
@@ -52,6 +52,14 @@ function scrollChatToBottom(container) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
/** 轻量渲染快照:仅浅克隆每条消息对象(content/meta 等内部结构共享引用)。
|
||||
* 所有消费方只读快照用于渲染或再次克隆(send.js 会按需自行深拷贝),
|
||||
* 无需整表 JSON 深拷贝——历史含大量工具调用文本时,每次渲染都深拷贝
|
||||
* 是切换/新建会话「整体卡一下」的最大单点开销。 */
|
||||
function snapshotMessages(list) {
|
||||
return list.map((m) => (m && typeof m === "object" ? { ...m } : m));
|
||||
}
|
||||
|
||||
function renderAttachmentChips() {
|
||||
const imagesArea = $("inputInlineImages");
|
||||
const chipsArea = $("inputInlineChips");
|
||||
@@ -367,15 +375,67 @@ async function pseudoStreamAssistant(bubble, full) {
|
||||
const t = full == null ? "" : String(full);
|
||||
bubble.classList.add("msg-bubble--rich", "markdown-body");
|
||||
bubble.textContent = "";
|
||||
if (!t) return;
|
||||
|
||||
const n = t.length;
|
||||
const charsPerSec = 350;
|
||||
const totalMs = Math.min(4000, Math.max(300, (n / charsPerSec) * 1000));
|
||||
const t0 = performance.now();
|
||||
let lastSnapshot = "";
|
||||
let lastRebuildAt = -Infinity;
|
||||
// 流式期间固定滚动目标为 bubble 所属的消息容器(双容器安全)
|
||||
const scrollTarget = bubble.closest(".chat-messages") || chatMessages;
|
||||
|
||||
// 预拆分全文为固定块 + 各块在全文中的结束偏移:已完成块只渲染一次并保持 DOM
|
||||
// 不动,每帧仅重渲染最后一个未完成块(O(块长) 局部重建),替代原先每帧对
|
||||
// 整段累计文本 O(n²) 的全量重解析(长回复下会持续占满主线程)。
|
||||
const blocks = splitMarkdownStreamBlocks(t);
|
||||
const blockEnds = [];
|
||||
{
|
||||
let acc = 0;
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
acc += blocks[i].length;
|
||||
blockEnds.push(acc);
|
||||
}
|
||||
}
|
||||
let renderedCount = 0; // 已固化(DOM 不再变化)的完整块数
|
||||
let tailBlock = null; // 当前尚未完成块的 DOM 节点(每帧就地替换)
|
||||
|
||||
function rebuildBlocks(k) {
|
||||
// 已完整覆盖的块数:块结束偏移 <= k
|
||||
let done = renderedCount;
|
||||
while (done < blocks.length && blockEnds[done] <= k) done++;
|
||||
if (done > renderedCount) {
|
||||
for (let j = renderedCount; j < done; j++) {
|
||||
// 非末块必然闭合围栏;仅全文末块(无闭合围栏)保留流式态
|
||||
bubble.appendChild(renderMarkdownBlock(blocks[j], j === blocks.length - 1));
|
||||
}
|
||||
if (tailBlock) { tailBlock.remove(); tailBlock = null; }
|
||||
renderedCount = done;
|
||||
}
|
||||
// 尾部未完成块:就地替换(不触碰已完成块);恰好落在块边界时跳过空尾块
|
||||
if (k < n && renderedCount < blocks.length) {
|
||||
const start = renderedCount > 0 ? blockEnds[renderedCount - 1] : 0;
|
||||
const partial = t.slice(start, k);
|
||||
if (partial !== "") {
|
||||
const stillOpenFence = (partial.match(/```/g) || []).length % 2 === 1;
|
||||
const next = renderMarkdownBlock(partial, true);
|
||||
if (tailBlock) {
|
||||
tailBlock.replaceWith(next);
|
||||
} else {
|
||||
bubble.appendChild(next);
|
||||
}
|
||||
tailBlock = next;
|
||||
} else if (tailBlock) {
|
||||
tailBlock.remove();
|
||||
tailBlock = null;
|
||||
}
|
||||
} else if (tailBlock) {
|
||||
tailBlock.remove();
|
||||
tailBlock = null;
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
function frame(now) {
|
||||
const elapsed = now - t0;
|
||||
@@ -383,15 +443,13 @@ async function pseudoStreamAssistant(bubble, full) {
|
||||
const k = Math.min(n, Math.floor(p * n));
|
||||
const snapshot = t.slice(0, k);
|
||||
|
||||
if (snapshot !== lastSnapshot) {
|
||||
// Markdown 重建节流(~9Hz):间隔未到或进度未推进时跳过重建,结束帧
|
||||
// 仍做一次精确全量渲染(尾部块合并为整段干净输出)。
|
||||
const rebuildDue = snapshot !== lastSnapshot && (p >= 1 || now - lastRebuildAt >= 110);
|
||||
if (rebuildDue) {
|
||||
lastSnapshot = snapshot;
|
||||
const blocks = splitMarkdownStreamBlocks(snapshot);
|
||||
bubble.textContent = "";
|
||||
blocks.forEach((block, idx) => {
|
||||
const isLast = idx === blocks.length - 1;
|
||||
const stillOpenFence = (block.match(/```/g) || []).length % 2 === 1;
|
||||
bubble.appendChild(renderMarkdownBlock(block, isLast || stillOpenFence));
|
||||
});
|
||||
lastRebuildAt = now;
|
||||
rebuildBlocks(k);
|
||||
scrollChatToBottom(scrollTarget);
|
||||
}
|
||||
|
||||
@@ -1089,7 +1147,7 @@ function renderMessage(msg, allowAutoplay = false, opts = {}) {
|
||||
wrap.appendChild(contentEl);
|
||||
wrap.appendChild(renderMessageActions("assistant", msg, wrap, actionOpts));
|
||||
if (msg.meta && msg.meta.pending_action) {
|
||||
// pending_action 不再内嵌在气泡中,由 pending-overlay 浮窗展示
|
||||
// pending_action 不再内嵌在气泡中,由人机交互通知条展示
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1299,15 +1357,22 @@ async function handleBranch(wrap, msg) {
|
||||
/* ===== 点赞 / 点踩 本地持久化 ===== */
|
||||
const FEEDBACK_STORAGE_KEY = "minor_agent_msg_feedback";
|
||||
|
||||
// 内存缓存:全量渲染 N 条 assistant 消息时避免逐条 JSON.parse(localStorage)。
|
||||
// 渲染是读取密集路径;仅在写入(_saveFeedbackMap)时刷新缓存。
|
||||
let _feedbackMapCache = null;
|
||||
|
||||
function _loadFeedbackMap() {
|
||||
if (_feedbackMapCache) return _feedbackMapCache;
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(FEEDBACK_STORAGE_KEY) || "{}");
|
||||
_feedbackMapCache = JSON.parse(localStorage.getItem(FEEDBACK_STORAGE_KEY) || "{}");
|
||||
} catch {
|
||||
return {};
|
||||
_feedbackMapCache = {};
|
||||
}
|
||||
return _feedbackMapCache;
|
||||
}
|
||||
|
||||
function _saveFeedbackMap(map) {
|
||||
_feedbackMapCache = map;
|
||||
try {
|
||||
localStorage.setItem(FEEDBACK_STORAGE_KEY, JSON.stringify(map));
|
||||
} catch {
|
||||
@@ -1497,7 +1562,8 @@ function renderMessages(messages, opts = {}) {
|
||||
target.innerHTML = "";
|
||||
|
||||
// 容器级渲染快照(增量渲染与恢复用)+ 全局快照(回合导航 / 撤回用,跟随最近渲染视图)
|
||||
const snapshot = list.length ? JSON.parse(JSON.stringify(list)) : [];
|
||||
// 轻量浅快照替代整表 JSON 深拷贝(大历史下深拷贝是主要卡顿源)
|
||||
const snapshot = list.length ? snapshotMessages(list) : [];
|
||||
target._renderedMessages = snapshot;
|
||||
target._renderedCount = list.length;
|
||||
state.lastRenderedMessages.length = 0;
|
||||
@@ -1509,6 +1575,7 @@ function renderMessages(messages, opts = {}) {
|
||||
if (!has) {
|
||||
// 确保无消息时隐藏:内联 display:none 优先级高于 CSS 类设定,不依赖 [hidden] 规则
|
||||
target.style.display = "none";
|
||||
_syncMsgCache(target, 0);
|
||||
// 设置为居中状态(仅 chat 视图影响 composer 布局)
|
||||
if (!keepComposer) import('../app.js').then((m) => m.setComposerCentered(true));
|
||||
renderTurnNav();
|
||||
@@ -1518,6 +1585,7 @@ function renderMessages(messages, opts = {}) {
|
||||
target.style.display = "";
|
||||
list.forEach((m) => target.appendChild(renderMessage(m, allowAutoplay, { minimalActions })));
|
||||
scrollChatToBottom(target);
|
||||
_syncMsgCache(target, list.length);
|
||||
// 取消居中状态
|
||||
if (!keepComposer) import('../app.js').then((m) => m.setComposerCentered(false));
|
||||
renderTurnNav();
|
||||
@@ -1562,14 +1630,115 @@ function renderMessagesIncremental(messages, opts = {}) {
|
||||
|
||||
const fresh = list.slice(prevCount);
|
||||
fresh.forEach((m) => target.appendChild(renderMessage(m, allowAutoplay, { minimalActions })));
|
||||
target._renderedMessages = JSON.parse(JSON.stringify(list));
|
||||
target._renderedMessages = snapshotMessages(list);
|
||||
target._renderedCount = list.length;
|
||||
state.lastRenderedMessages.length = 0;
|
||||
state.lastRenderedMessages.push(...target._renderedMessages);
|
||||
_syncMsgCache(target, list.length);
|
||||
scrollChatToBottom(target);
|
||||
renderTurnNav();
|
||||
}
|
||||
|
||||
/** 消息摘要键(一致性校验 / 缓存对比用) */
|
||||
function _msgKey(m) {
|
||||
if (!m) return "";
|
||||
const body = typeof m.content === "string"
|
||||
? m.content.slice(0, 64)
|
||||
: JSON.stringify(m.content || "").slice(0, 64);
|
||||
return `${m.role}|${(m.meta && m.meta.turn_id) || ""}|${body}`;
|
||||
}
|
||||
|
||||
/** 同步按会话的消息缓存(会话切换秒开 / 后台增量刷新用)。
|
||||
* 仅缓存 chat 容器;写入时置空 ETag(内容可能已变化,需重新校验)。 */
|
||||
function _syncMsgCache(target, total) {
|
||||
if (!target || target !== chatMessages || !state.sessionId) return;
|
||||
state._msgCache.set(state.sessionId, {
|
||||
messages: (target._renderedMessages || []).slice(),
|
||||
total: total != null ? total : (target._renderedCount || 0),
|
||||
etag: null,
|
||||
});
|
||||
}
|
||||
|
||||
/** 计数不一致(增量丢帧 / 回退场景)时的全量重同步兜底。 */
|
||||
async function resyncMessages(target) {
|
||||
try {
|
||||
const { api } = await import('./api.js');
|
||||
const data = await api(`/api/sessions/${encodeURIComponent(state.sessionId || "")}/messages`);
|
||||
if (data && Array.isArray(data.messages)) renderMessages(data.messages);
|
||||
} catch { /* 静默失败;保留现有 DOM */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* 增量契约追加(chat/start、complete、SSE text 的 new_message/new_messages):
|
||||
* 校验 base_count ↔ 容器计数 → 替换乐观用户消息(按 turn_id 识别)→
|
||||
* 追加新消息 DOM(可跳过,DOM 已由调用方渲染)→ 更新渲染簿记与会话缓存。
|
||||
* 计数不一致时以全量 GET 重同步,返回 false。
|
||||
*/
|
||||
function appendNewMessages(container, newMessages, opts = {}) {
|
||||
const target = container || chatMessages;
|
||||
if (!target) return false;
|
||||
const { baseCount = null, total = null, domAlreadyAppended = false } = opts;
|
||||
let freshList = Array.isArray(newMessages) ? newMessages.filter(Boolean) : [];
|
||||
if (!freshList.length) return false;
|
||||
|
||||
const prevSnap = target._renderedMessages || [];
|
||||
const lastRendered = prevSnap[prevSnap.length - 1];
|
||||
const firstNew = freshList[0];
|
||||
|
||||
// 乐观用户消息替换:末尾为无 turn_id 的 user(乐观渲染),新首条为有 turn_id 的
|
||||
// user(服务端 canonical 消息)→ 就地替换 DOM 与快照(顺带解决 blob→URL 差异
|
||||
// 触发的全量重建回退)。替换时容器已含乐观消息,计数按 base_count+1 期望。
|
||||
const replaceOptimistic = !!(
|
||||
firstNew && firstNew.role === "user" && firstNew.meta && firstNew.meta.turn_id &&
|
||||
lastRendered && lastRendered.role === "user" && (!lastRendered.meta || !lastRendered.meta.turn_id)
|
||||
);
|
||||
const expectCount = baseCount != null ? baseCount + (replaceOptimistic ? 1 : 0) : null;
|
||||
if (expectCount != null && target._renderedCount != null && expectCount !== target._renderedCount) {
|
||||
resyncMessages(target);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (replaceOptimistic) {
|
||||
// 语音转文字占位(独立 DOM,不在消息簿记内):先行移除
|
||||
const ph = target.querySelector(".msg .voice-transcribing");
|
||||
if (ph) {
|
||||
const phEl = ph.closest(".msg");
|
||||
if (phEl) phEl.remove();
|
||||
}
|
||||
const msgNodes = Array.from(target.children).filter(
|
||||
(el) => el.classList && el.classList.contains("msg") && !el.classList.contains("msg-typing")
|
||||
);
|
||||
const lastMsgEl = msgNodes[msgNodes.length - 1];
|
||||
if (lastMsgEl) {
|
||||
lastMsgEl.replaceWith(renderMessage(firstNew, false, {}));
|
||||
} else {
|
||||
target.appendChild(renderMessage(firstNew, false, {}));
|
||||
}
|
||||
if (prevSnap.length) prevSnap[prevSnap.length - 1] = firstNew;
|
||||
freshList = freshList.slice(1);
|
||||
}
|
||||
|
||||
if (!domAlreadyAppended) {
|
||||
freshList.forEach((m) => target.appendChild(renderMessage(m, false, {})));
|
||||
}
|
||||
if (freshList.length) {
|
||||
(target._renderedMessages = target._renderedMessages || []).push(...freshList);
|
||||
target._renderedCount = (target._renderedCount || 0) + freshList.length;
|
||||
}
|
||||
state.lastRenderedMessages.length = 0;
|
||||
state.lastRenderedMessages.push(...(target._renderedMessages || []));
|
||||
_syncMsgCache(target, total != null ? total : target._renderedCount);
|
||||
// todo 增量扫描(幂等:仅读取 meta.tool_calls 中的 todo_list 调用)
|
||||
if (freshList.length) {
|
||||
import('./notify-strip.js').then((m) => {
|
||||
try { m.restoreTodoFromMessages(freshList); } catch { /* ignore */ }
|
||||
}).catch(() => {});
|
||||
}
|
||||
scrollChatToBottom(target);
|
||||
renderTurnNav();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 释放非活动容器的 DOM(非运行态切出时调用,降低内存占用)。 */
|
||||
function releaseMessageContainer(container) {
|
||||
if (!container) return;
|
||||
@@ -1610,7 +1779,7 @@ function appendThinkingIndicator(container) {
|
||||
|
||||
function showPendingActionInThinking(typingEl, msg) {
|
||||
if (!typingEl || !msg || !msg.meta || !msg.meta.pending_action) return false;
|
||||
// pending_action 由 pending-overlay 浮窗展示
|
||||
// pending_action 由人机交互通知条展示
|
||||
|
||||
const snapshot = JSON.parse(JSON.stringify(msg));
|
||||
state.lastRenderedMessages.push(snapshot);
|
||||
@@ -1686,7 +1855,7 @@ async function streamAssistantMessage(msg, container) {
|
||||
}
|
||||
|
||||
if (msg.meta && msg.meta.pending_action) {
|
||||
// pending_action 不再内嵌在气泡中,由 pending-overlay 浮窗展示
|
||||
// pending_action 不再内嵌在气泡中,由人机交互通知条展示
|
||||
}
|
||||
|
||||
// 操作按钮行(复制 / 重试)
|
||||
@@ -1732,6 +1901,7 @@ export {
|
||||
renderUserContent, renderAssistantContent,
|
||||
renderFinalAssets, extractFinalAssets,
|
||||
renderMessage, renderMessages, renderMessagesIncremental, releaseMessageContainer,
|
||||
appendNewMessages,
|
||||
appendThinkingIndicator, showPendingActionInThinking,
|
||||
streamAssistantMessage,
|
||||
renderVoiceTranscribingPlaceholder,
|
||||
|
||||
+49
-3
@@ -28,6 +28,7 @@ const sidebarHint = document.querySelector(".sidebar-hint");
|
||||
let _externalChanges = {};
|
||||
let _fsChangeTimer = null;
|
||||
let _fsApplying = false;
|
||||
let _docTreeDirty = false; // 右栏关闭期间文件树内容落后标记(打开时补刷新)
|
||||
|
||||
// 预览相关
|
||||
const IMG_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.ico']);
|
||||
@@ -144,12 +145,46 @@ export function refreshGitStatus() {
|
||||
const data = await api(`/api/workspace/git-status?path=${encodeURIComponent(rootPath)}`);
|
||||
const map = {};
|
||||
for (const s of data.status || []) map[s.path] = s.state;
|
||||
const prev = state.gitStatus;
|
||||
state.gitStatus = map;
|
||||
renderDocTree();
|
||||
// 着色无变化时不触碰 DOM;有变化时增量更新类名(不重建整棵树)
|
||||
// —— 每次展开右栏触发刷新时全量重建是"展开卡一下"的主要来源
|
||||
if (gitStatusEqual(prev, map)) return;
|
||||
applyGitStatusToTree();
|
||||
} catch { /* git 不可用/非仓库时静默,保持无着色 */ }
|
||||
}, 150);
|
||||
}
|
||||
|
||||
/** git 状态映射是否等价(无变化时跳过全树遍历与类名切换) */
|
||||
function gitStatusEqual(a, b) {
|
||||
const ka = Object.keys(a || {});
|
||||
const kb = Object.keys(b || {});
|
||||
if (ka.length !== kb.length) return false;
|
||||
for (const k of ka) if ((b || {})[k] !== a[k]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 增量着色:仅切换既有文件节点的 git 类与拖拽可用性,不重建树 */
|
||||
function applyGitStatusToTree() {
|
||||
if (!docTree) return;
|
||||
const nodes = docTree.querySelectorAll(".doc-tree-file");
|
||||
if (!nodes.length) {
|
||||
// 树尚未渲染(如从未打开文件夹)时回退一次全量渲染
|
||||
if (state.docFiles && state.docFiles.length) renderDocTree();
|
||||
return;
|
||||
}
|
||||
const map = state.gitStatus || {};
|
||||
nodes.forEach((div) => {
|
||||
const path = div.dataset.path;
|
||||
if (!path) return;
|
||||
const gState = map[path];
|
||||
div.classList.toggle("doc-tree-file--new", gState === "untracked");
|
||||
div.classList.toggle("doc-tree-file--modified", gState === "modified");
|
||||
div.classList.toggle("doc-tree-file--deleted", gState === "deleted");
|
||||
div.draggable = gState !== "deleted";
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 右栏(Edit 文件树)控制 =====
|
||||
export function setRightPanel(open) {
|
||||
const appRoot = document.getElementById("app");
|
||||
@@ -157,11 +192,15 @@ export function setRightPanel(open) {
|
||||
appRoot.classList.toggle("right-panel-open", !!open);
|
||||
const rp = document.getElementById("rightPanel");
|
||||
if (rp) rp.setAttribute("aria-hidden", open ? "false" : "true");
|
||||
// 打开时刷新 git 状态着色
|
||||
// 打开时刷新 git 状态着色;并补一次关闭期间积压的文件变更刷新
|
||||
if (open) {
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent("git-status-changed"));
|
||||
} catch { /* ignore */ }
|
||||
if (_docTreeDirty) {
|
||||
_docTreeDirty = false;
|
||||
applyFsChanges();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,8 +438,15 @@ function handleFsChange(events) {
|
||||
_fsChangeTimer = setTimeout(() => applyFsChanges(events), 300);
|
||||
}
|
||||
|
||||
async function applyFsChanges(events) {
|
||||
async function applyFsChanges(events = []) {
|
||||
if (_fsApplying || !state._docRootPath) return;
|
||||
// 右栏未打开时文件树不可见:跳过 listDir + 重建(Agent 运行期高频文件读写
|
||||
// 不再反复消耗列目录 IPC 与主线程 DOM 重建),仅标记待刷新
|
||||
const appRoot = document.getElementById("app");
|
||||
if (!appRoot || !appRoot.classList.contains("right-panel-open")) {
|
||||
_docTreeDirty = true;
|
||||
return;
|
||||
}
|
||||
_fsApplying = true;
|
||||
try {
|
||||
const rootPath = state._docRootPath;
|
||||
|
||||
+31
-21
@@ -188,27 +188,8 @@ function initSidebarResize() {
|
||||
let startX = 0;
|
||||
let startW = 0;
|
||||
|
||||
handle.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault();
|
||||
if (appRoot && appRoot.classList.contains("sidebar-collapsed")) return;
|
||||
dragging = true;
|
||||
startX = e.clientX;
|
||||
startW = sidebar.getBoundingClientRect().width;
|
||||
// 拖动时禁用 transition 以保证流畅
|
||||
sidebar.style.transition = "none";
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
});
|
||||
|
||||
window.addEventListener("mousemove", (e) => {
|
||||
if (!dragging) return;
|
||||
const dx = e.clientX - startX;
|
||||
let newW = startW + dx;
|
||||
newW = Math.max(SIDEBAR_MIN_W, Math.min(SIDEBAR_MAX_W, newW));
|
||||
document.documentElement.style.setProperty("--sidebar-w", newW + "px");
|
||||
});
|
||||
|
||||
window.addEventListener("mouseup", () => {
|
||||
/** 结束拖拽并复位全部副作用(鼠标在窗口外松开 / 失焦时也会被调用) */
|
||||
function endDrag() {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
// 恢复 transition
|
||||
@@ -218,7 +199,36 @@ function initSidebarResize() {
|
||||
// 保存宽度
|
||||
const finalW = sidebar.getBoundingClientRect().width;
|
||||
try { localStorage.setItem(STORAGE_SIDEBAR_W, String(Math.round(finalW))); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Pointer Events + setPointerCapture:拖拽期间事件由 handle 捕获,
|
||||
// 鼠标移到窗口外/焦点丢失时仍能收到 pointerup,不会残留拖拽状态
|
||||
handle.addEventListener("pointerdown", (e) => {
|
||||
if (e.button !== 0) return; // 仅主键
|
||||
if (appRoot && appRoot.classList.contains("sidebar-collapsed")) return;
|
||||
e.preventDefault();
|
||||
dragging = true;
|
||||
startX = e.clientX;
|
||||
startW = sidebar.getBoundingClientRect().width;
|
||||
try { handle.setPointerCapture(e.pointerId); } catch { /* ignore */ }
|
||||
// 拖动时禁用 transition 以保证流畅
|
||||
sidebar.style.transition = "none";
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
});
|
||||
|
||||
handle.addEventListener("pointermove", (e) => {
|
||||
if (!dragging) return;
|
||||
const dx = e.clientX - startX;
|
||||
let newW = startW + dx;
|
||||
newW = Math.max(SIDEBAR_MIN_W, Math.min(SIDEBAR_MAX_W, newW));
|
||||
document.documentElement.style.setProperty("--sidebar-w", newW + "px");
|
||||
});
|
||||
|
||||
handle.addEventListener("pointerup", endDrag);
|
||||
handle.addEventListener("pointercancel", endDrag);
|
||||
// 兜底:失焦(如拖拽中点开其他窗口)时复位,避免 cursor/user-select 残留
|
||||
window.addEventListener("blur", endDrag);
|
||||
}
|
||||
|
||||
initSidebarResize();
|
||||
|
||||
+470
-68
@@ -8,7 +8,7 @@
|
||||
// 5. 会话级存储:chat session 与 cron task 各自保留 todo/pending 状态
|
||||
//
|
||||
// 数据源:
|
||||
// - todo: live snapshot tool_calls 中 name === "todo_list" 的调用(与原 todo.js 同逻辑)
|
||||
// - todo: live snapshot tool_calls 中 name === "todo_list" 的调用(与历史实现同逻辑)
|
||||
// - pending: live snapshot human_requests 数组(human_interaction / tool_call)
|
||||
//
|
||||
// 提交: 复用 pending.js handleHumanAction(阻塞式,后端唤醒工具线程)
|
||||
@@ -18,13 +18,19 @@
|
||||
|
||||
import { $, escapeHtml, showToast } from './utils.js';
|
||||
import { state } from './state.js';
|
||||
import { api } from './api.js';
|
||||
import { showConfirm } from './dialog.js';
|
||||
import { handleHumanAction } from './pending.js';
|
||||
|
||||
// ===== 模块状态 =====
|
||||
|
||||
const stripState = {
|
||||
todo: { steps: [], doneSteps: [], dismissed: false, expanded: false },
|
||||
pending: { items: [], index: 0, dismissed: false, expanded: false },
|
||||
// doneCount:本轮已答完的人机交互请求数(快照内"人机交互工具"类的 x 待处理 - y 已完成)
|
||||
// answers:人机交互多问题作答状态 { itemId: { list: [{kind,value}], qIndex } }
|
||||
pending: { items: [], index: 0, dismissed: false, expanded: false, doneCount: 0, answers: {} },
|
||||
// terminal:后台终端任务(来自 /api/terminal/tasks/{session}/stream,跨轮存活)
|
||||
terminal: { tasks: [], dismissed: false, expanded: false },
|
||||
};
|
||||
|
||||
/** 会话级快照:sessionKey → 深拷贝的 stripState */
|
||||
@@ -65,16 +71,19 @@ export function switchStripSession(newKey, saveKey) {
|
||||
const saved = _sessionStore.get(target);
|
||||
if (saved) {
|
||||
stripState.todo = saved.todo || { steps: [], doneSteps: [], dismissed: false, expanded: false };
|
||||
stripState.pending = saved.pending || { items: [], index: 0, dismissed: false, expanded: false };
|
||||
stripState.pending = saved.pending || { items: [], index: 0, dismissed: false, expanded: false, doneCount: 0, answers: {} };
|
||||
stripState.terminal = saved.terminal || { tasks: [], dismissed: false, expanded: false };
|
||||
} else {
|
||||
stripState.todo = { steps: [], doneSteps: [], dismissed: false, expanded: false };
|
||||
stripState.pending = { items: [], index: 0, dismissed: false, expanded: false };
|
||||
stripState.pending = { items: [], index: 0, dismissed: false, expanded: false, doneCount: 0, answers: {} };
|
||||
stripState.terminal = { tasks: [], dismissed: false, expanded: false };
|
||||
}
|
||||
_clearComposerBtns();
|
||||
renderStrip();
|
||||
_restartTerminalStream();
|
||||
}
|
||||
|
||||
// ===== Todo 数据派生(与原 todo.js updateTodoOverlayFromRecords 同逻辑) =====
|
||||
// ===== Todo 数据派生(与历史实现同逻辑) =====
|
||||
|
||||
/** 从 live tool_calls 派生 todo 数据并更新通知条。 */
|
||||
export function updateTodoStrip(toolCalls) {
|
||||
@@ -147,6 +156,10 @@ export function updatePendingStrip(humanRequests) {
|
||||
const prevIds = stripState.pending.items.map((p) => p.id).join(",");
|
||||
const nextIds = items.map((p) => p.id).join(",");
|
||||
const changed = prevIds !== nextIds || items.length !== stripState.pending.items.length;
|
||||
const prevHaveHuman = stripState.pending.items.some((p) => p.type !== "tool_call");
|
||||
const nextHaveHuman = items.some((p) => p.type !== "tool_call");
|
||||
// 新一批人机交互开始(此前无待处理 → 现在有):已完成计数从 0 重新累计
|
||||
if (!prevHaveHuman && nextHaveHuman) stripState.pending.doneCount = 0;
|
||||
stripState.pending.items = items;
|
||||
if (stripState.pending.index >= items.length) {
|
||||
stripState.pending.index = Math.max(0, items.length - 1);
|
||||
@@ -156,6 +169,7 @@ export function updatePendingStrip(humanRequests) {
|
||||
if (!items.length) {
|
||||
stripState.pending.expanded = false;
|
||||
stripState.pending.index = 0;
|
||||
stripState.pending.doneCount = 0;
|
||||
}
|
||||
if (changed) renderStrip(); // 仅变化时渲染
|
||||
}
|
||||
@@ -178,14 +192,15 @@ function _hasContent() {
|
||||
}
|
||||
|
||||
/** 主渲染入口(补丁式):diff 条集合 → 复用/增删 → 就地更新内容。 */
|
||||
export function renderStrip() {
|
||||
function renderStrip() {
|
||||
const root = _stripRoot();
|
||||
const items = _itemsRoot();
|
||||
if (!root || !items) return;
|
||||
|
||||
// 期望的可见条集合(出现顺序自下而上:todo 在下,pending 在上)
|
||||
// 期望的可见条集合(出现顺序自下而上:todo 在下,terminal 居中,pending 在上)
|
||||
const desired = [];
|
||||
if (stripState.pending.items.length > 0 && !stripState.pending.dismissed) desired.push({ kind: "pending" });
|
||||
if (stripState.terminal.tasks.length > 0 && !stripState.terminal.dismissed) desired.push({ kind: "terminal" });
|
||||
if (stripState.todo.steps.length > 0 && !stripState.todo.dismissed) desired.push({ kind: "todo" });
|
||||
|
||||
if (!desired.length) {
|
||||
@@ -212,7 +227,7 @@ export function renderStrip() {
|
||||
const newKids = desired.map((d) => {
|
||||
const existing = oldKids.find((el) => el.dataset.stripKind === d.kind);
|
||||
if (existing) return existing; // 复用,避免重建
|
||||
return d.kind === "todo" ? _buildTodoItem() : _buildPendingItem();
|
||||
return d.kind === "todo" ? _buildTodoItem() : (d.kind === "terminal" ? _buildTerminalItem() : _buildPendingItem());
|
||||
});
|
||||
oldKids.forEach((el) => { if (!newKids.includes(el)) el.remove(); });
|
||||
newKids.forEach((el, i) => {
|
||||
@@ -222,6 +237,7 @@ export function renderStrip() {
|
||||
// 就地更新内容(只改变化的文本/chip/图标/展开区)
|
||||
newKids.forEach((el) => {
|
||||
if (el.dataset.stripKind === "todo") _updateTodoItemEl(el);
|
||||
else if (el.dataset.stripKind === "terminal") _updateTerminalItemEl(el);
|
||||
else _updatePendingItemEl(el);
|
||||
});
|
||||
}
|
||||
@@ -305,11 +321,10 @@ function _buildTodoExpandBody() {
|
||||
return body;
|
||||
}
|
||||
|
||||
/** 就地更新 Todo 条:图标/文本/chip 只改文本节点;展开区按内容键重建。 */
|
||||
/** 就地更新 Todo 条:折叠态显示"x 待处理 - y 已完成";展开区按内容键重建。 */
|
||||
function _updateTodoItemEl(item) {
|
||||
const { steps, doneSteps, expanded } = stripState.todo;
|
||||
const nextIdx = steps.findIndex((_, i) => !doneSteps.includes(i));
|
||||
const showIdx = nextIdx >= 0 ? nextIdx : steps.length - 1;
|
||||
const allDone = nextIdx < 0;
|
||||
|
||||
item.classList.toggle("strip-item--expanded", expanded);
|
||||
@@ -317,7 +332,7 @@ function _updateTodoItemEl(item) {
|
||||
const icon = item.querySelector(".strip-todo-icon");
|
||||
if (icon) icon.classList.toggle("strip-todo-icon--done", allDone);
|
||||
const text = item.querySelector(".strip-item-text");
|
||||
if (text) text.textContent = steps[showIdx] || "";
|
||||
if (text) text.textContent = `${steps.length - doneSteps.length} 待处理 - ${doneSteps.length} 已完成`;
|
||||
const chip = item.querySelector(".strip-item-count");
|
||||
if (chip) chip.textContent = `${doneSteps.length}/${steps.length}`;
|
||||
|
||||
@@ -336,6 +351,244 @@ function _updateTodoItemEl(item) {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Terminal(后台终端任务)通知条 =====
|
||||
|
||||
/** Terminal 通知条基础行(按钮 + 内容;展开区由 _updateTerminalItemEl 维护) */
|
||||
function _buildTerminalItem() {
|
||||
const item = document.createElement("div");
|
||||
item.className = "strip-item strip-item--terminal";
|
||||
item.dataset.stripKind = "terminal";
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "strip-item-row";
|
||||
|
||||
// 左侧按钮:点击隐藏本条 → 输入区生成按钮
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "strip-item-btn";
|
||||
btn.title = "隐藏后台终端条(收入输入区)";
|
||||
btn.innerHTML = '<img src="/image/终端.svg" alt="后台终端" />';
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
_dismissStripItem("terminal");
|
||||
});
|
||||
row.appendChild(btn);
|
||||
|
||||
// 内容区(点击展开/收起)
|
||||
const content = document.createElement("div");
|
||||
content.className = "strip-item-content";
|
||||
content.addEventListener("click", () => {
|
||||
stripState.terminal.expanded = !stripState.terminal.expanded;
|
||||
renderStrip();
|
||||
});
|
||||
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "strip-terminal-icon";
|
||||
content.appendChild(icon);
|
||||
|
||||
const text = document.createElement("span");
|
||||
text.className = "strip-item-text";
|
||||
content.appendChild(text);
|
||||
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "strip-item-count";
|
||||
content.appendChild(chip);
|
||||
|
||||
row.appendChild(content);
|
||||
item.appendChild(row);
|
||||
return item;
|
||||
}
|
||||
|
||||
/** 后台任务状态 → {label, cls} */
|
||||
function _taskStatusMeta(t) {
|
||||
if (!t) return { label: "未知", cls: "done" };
|
||||
if (t.status === "running") return { label: "运行中", cls: "running" };
|
||||
if (t.status === "killed") return { label: "已终止", cls: "done" };
|
||||
if (t.abnormal || (t.exit_code != null && t.exit_code !== 0)) return { label: `异常退出 · exit ${t.exit_code}`, cls: "err" };
|
||||
return { label: "已完成", cls: "done" };
|
||||
}
|
||||
|
||||
function _fmtDuration(sec) {
|
||||
const n = Math.max(0, Number(sec) || 0);
|
||||
if (n < 60) return `${Math.round(n)}s`;
|
||||
if (n < 3600) return `${Math.floor(n / 60)}m ${Math.round(n % 60)}s`;
|
||||
return `${Math.floor(n / 3600)}h ${Math.floor((n % 3600) / 60)}m`;
|
||||
}
|
||||
|
||||
/** Terminal 展开区:任务列表(命令/PID/状态/时长;运行中行带"终止"按钮) */
|
||||
function _buildTerminalExpandBody() {
|
||||
const { tasks } = stripState.terminal;
|
||||
const body = document.createElement("div");
|
||||
body.className = "strip-expand-area";
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "strip-expand-head";
|
||||
const label = document.createElement("span");
|
||||
label.className = "strip-expand-label";
|
||||
const running = tasks.filter((t) => t.status === "running").length;
|
||||
label.textContent = `后台终端任务 · ${running} 运行中 / ${tasks.length} 个`;
|
||||
head.appendChild(label);
|
||||
head.appendChild(_buildCollapseBtn("terminal"));
|
||||
body.appendChild(head);
|
||||
|
||||
const list = document.createElement("ul");
|
||||
list.className = "strip-terminal-list";
|
||||
for (const t of tasks) {
|
||||
const st = _taskStatusMeta(t);
|
||||
const li = document.createElement("li");
|
||||
li.className = "strip-task-row";
|
||||
li.dataset.pid = String(t.id);
|
||||
|
||||
const dot = document.createElement("span");
|
||||
dot.className = `strip-task-dot strip-task-dot--${st.cls}`;
|
||||
li.appendChild(dot);
|
||||
|
||||
const cmd = document.createElement("span");
|
||||
cmd.className = "strip-task-cmd";
|
||||
cmd.title = t.command || "";
|
||||
cmd.textContent = String(t.command || "").slice(0, 60) || "(无命令)";
|
||||
li.appendChild(cmd);
|
||||
|
||||
const meta = document.createElement("span");
|
||||
meta.className = "strip-task-meta";
|
||||
meta.textContent = `#${t.id} · ${_fmtDuration((t.finished_at || Date.now() / 1000) - t.started_at)} · ${_fmtChars(t.total_chars)}`;
|
||||
li.appendChild(meta);
|
||||
|
||||
if (t.status === "running") {
|
||||
const killBtn = document.createElement("button");
|
||||
killBtn.type = "button";
|
||||
killBtn.className = "strip-task-kill";
|
||||
killBtn.textContent = "终止";
|
||||
killBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
_killTerminalTask(t).catch(() => {});
|
||||
});
|
||||
li.appendChild(killBtn);
|
||||
} else {
|
||||
const tag = document.createElement("span");
|
||||
tag.className = `strip-task-status strip-task-status--${st.cls}`;
|
||||
tag.textContent = st.label;
|
||||
li.appendChild(tag);
|
||||
}
|
||||
list.appendChild(li);
|
||||
}
|
||||
body.appendChild(list);
|
||||
return body;
|
||||
}
|
||||
|
||||
function _fmtChars(n) {
|
||||
const v = Number(n) || 0;
|
||||
if (v < 1000) return `${v}字符`;
|
||||
return `${(v / 1000).toFixed(1)}k字符`;
|
||||
}
|
||||
|
||||
/** 终止后台任务(确认 → POST kill → 后端 SSE 同步状态) */
|
||||
async function _killTerminalTask(t) {
|
||||
const ok = await showConfirm(`确定终止后台终端任务吗?\n任务ID: #${t.id}\n命令: ${String(t.command || "").slice(0, 120)}`);
|
||||
if (!ok) return;
|
||||
const fd = new FormData();
|
||||
fd.append("session_id", state.mode === "cron" ? (state.activeCronTaskId || "") : (state.sessionId || ""));
|
||||
try {
|
||||
const res = await api(`/api/terminal/tasks/${encodeURIComponent(t.id)}/kill`, { method: "POST", body: fd });
|
||||
if (res && res.ok) return;
|
||||
showToast("终止失败: " + ((res && (res.error || res.detail)) || "未知错误"));
|
||||
} catch (e) {
|
||||
showToast("终止失败: " + (e.message || String(e)));
|
||||
}
|
||||
}
|
||||
|
||||
/** 就地更新 Terminal 条:折叠态"x 后台运行中 - y 已完成";展开区按内容键重建。 */
|
||||
function _updateTerminalItemEl(item) {
|
||||
const { tasks, expanded } = stripState.terminal;
|
||||
const running = tasks.filter((t) => t.status === "running").length;
|
||||
const done = tasks.length - running;
|
||||
|
||||
item.classList.toggle("strip-item--expanded", expanded);
|
||||
const text = item.querySelector(".strip-item-text");
|
||||
if (text) text.textContent = `${running} 后台运行中 - ${done} 已完成`;
|
||||
const chip = item.querySelector(".strip-item-count");
|
||||
if (chip) chip.textContent = `${running}/${tasks.length}`;
|
||||
|
||||
// 展开区维护:仅在内容键变化时重建,否则保留 DOM
|
||||
const existingBody = item.querySelector(".strip-expand-area");
|
||||
const key = tasks.map((t) => `${t.id}:${t.status}:${t.total_chars}`).join("|");
|
||||
if (expanded) {
|
||||
if (!existingBody || item._taskKey !== key) {
|
||||
if (existingBody) existingBody.remove();
|
||||
item.appendChild(_buildTerminalExpandBody());
|
||||
item._taskKey = key;
|
||||
}
|
||||
} else if (existingBody) {
|
||||
existingBody.remove();
|
||||
item._taskKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Terminal 任务 SSE 流(与工具调用 SSE 独立通道;仅 chat 会话) =====
|
||||
let _termStreamEv = null;
|
||||
let _termStreamSid = "";
|
||||
|
||||
function _stopTerminalStream() {
|
||||
if (_termStreamEv) { try { _termStreamEv.close(); } catch {} _termStreamEv = null; }
|
||||
_termStreamSid = "";
|
||||
}
|
||||
|
||||
function _restartTerminalStream() {
|
||||
_stopTerminalStream();
|
||||
// cron 任务在独立子进程中执行,其终端任务不在此服务注册表 → 仅 chat 模式订阅
|
||||
if (state.mode === "cron") return;
|
||||
const sid = state.sessionId || "";
|
||||
if (!sid) return;
|
||||
try {
|
||||
const es = new EventSource(`/api/terminal/tasks/${encodeURIComponent(sid)}/stream`);
|
||||
es.addEventListener("update", (ev) => {
|
||||
let payload = null;
|
||||
try { payload = JSON.parse(ev.data); } catch { return; }
|
||||
if (!payload) return;
|
||||
if (payload.type === "full") {
|
||||
stripState.terminal.tasks = Array.isArray(payload.tasks) ? payload.tasks : [];
|
||||
stripState.terminal.dismissed = false;
|
||||
renderStrip();
|
||||
} else if (payload.type === "task_update" && payload.task) {
|
||||
_mergeTerminalTask(payload.task);
|
||||
} else if (payload.type === "output") {
|
||||
// 输出增量:刷新该任务总字符数(展开区时长/字符展示)
|
||||
const t = stripState.terminal.tasks.find((x) => x.id === payload.task_id);
|
||||
if (t) {
|
||||
let chars = 0;
|
||||
for (const c of (payload.chunks || [])) chars += String(c.text || "").length;
|
||||
t.total_chars = (t.total_chars || 0) + chars;
|
||||
if (stripState.terminal.expanded) renderStrip();
|
||||
}
|
||||
}
|
||||
});
|
||||
es.onerror = () => { /* EventSource 自动重连;断线后由 full 快照重同步 */ };
|
||||
_termStreamEv = es;
|
||||
_termStreamSid = sid;
|
||||
} catch { /* EventSource 不可用时忽略(无终端浮窗能力) */ }
|
||||
}
|
||||
|
||||
function _mergeTerminalTask(task) {
|
||||
const idx = stripState.terminal.tasks.findIndex((x) => x.id === task.id);
|
||||
if (idx >= 0) stripState.terminal.tasks[idx] = task;
|
||||
else stripState.terminal.tasks.unshift(task);
|
||||
// 超限(后端同样限制 50 条):从末尾淘汰最旧的已完成任务(新任务在前)
|
||||
let overflow = stripState.terminal.tasks.length - 50;
|
||||
for (let i = stripState.terminal.tasks.length - 1; i >= 0 && overflow > 0; i--) {
|
||||
if (stripState.terminal.tasks[i].status === "running") continue;
|
||||
stripState.terminal.tasks.splice(i, 1);
|
||||
overflow--;
|
||||
}
|
||||
if (stripState.terminal.tasks.length > 0) stripState.terminal.dismissed = false;
|
||||
renderStrip();
|
||||
}
|
||||
|
||||
/** 清空终端通知条(新会话/清空会话时) */
|
||||
export function clearTerminalStrip() {
|
||||
stripState.terminal = { tasks: [], dismissed: false, expanded: false };
|
||||
renderStrip();
|
||||
}
|
||||
|
||||
/** Pending 通知条基础行(按钮 + 内容;展开区由 _updatePendingItemEl 维护) */
|
||||
function _buildPendingItem() {
|
||||
const item = document.createElement("div");
|
||||
@@ -382,20 +635,37 @@ function _buildPendingItem() {
|
||||
return item;
|
||||
}
|
||||
|
||||
/** 就地更新 Pending 条:标题/chip 只改文本;展开区按卡片键重建。 */
|
||||
/** 就地更新 Pending 条:折叠态按类型区分——
|
||||
* tool_call=权限审查("{工具} 工具 权限审查",警示色字体);
|
||||
* human_interaction=人机交互工具("x 待处理 - y 已完成");展开区按卡片键重建。 */
|
||||
function _updatePendingItemEl(item) {
|
||||
const { items, index, expanded } = stripState.pending;
|
||||
const current = items[index];
|
||||
|
||||
item.classList.toggle("strip-item--expanded", expanded);
|
||||
const text = item.querySelector(".strip-item-text");
|
||||
if (text) text.textContent = current ? (current.title || "需要确认") : "需要确认";
|
||||
if (text) {
|
||||
if (current && current.type === "tool_call") {
|
||||
// 权限审查(含工作区泄露审查=terminal 工具、以及其他工具权限审查)
|
||||
const tool = String(current.tool_name || "未知").trim();
|
||||
text.textContent = `${tool} 工具 权限审查`;
|
||||
text.classList.add("strip-item-text--review");
|
||||
} else {
|
||||
// 人机交互工具:x 待处理 - y 已完成
|
||||
const humanPending = items.filter((p) => p.type !== "tool_call").length;
|
||||
text.textContent = `${humanPending} 待处理 - ${stripState.pending.doneCount} 已完成`;
|
||||
text.classList.remove("strip-item-text--review");
|
||||
}
|
||||
}
|
||||
const chip = item.querySelector(".strip-item-count");
|
||||
if (chip) chip.textContent = items.length > 1 ? `×${items.length}` : "1";
|
||||
|
||||
const existingBody = item.querySelector(".strip-expand-area");
|
||||
if (expanded && current) {
|
||||
const key = (current.id || "") + "|" + index;
|
||||
// 含问题游标 + 作答版本:多问题切换或选项选中时强制重建卡片
|
||||
const hst = current.type === "tool_call" ? null : _humanState(current);
|
||||
const qIdx = hst ? hst.qIndex : 0;
|
||||
const key = (current.id || "") + "|" + index + "|" + qIdx + "|" + (hst ? (hst.ver || 0) : 0);
|
||||
if (!existingBody || item._cardKey !== key) {
|
||||
if (existingBody) existingBody.remove();
|
||||
const body = document.createElement("div");
|
||||
@@ -420,6 +690,7 @@ function _buildCollapseBtn(kind) {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (kind === "todo") stripState.todo.expanded = false;
|
||||
else if (kind === "terminal") stripState.terminal.expanded = false;
|
||||
else stripState.pending.expanded = false;
|
||||
renderStrip();
|
||||
});
|
||||
@@ -497,11 +768,11 @@ function _buildToolCallCard(card, pending) {
|
||||
head.appendChild(_buildCollapseBtn("pending"));
|
||||
card.appendChild(head);
|
||||
|
||||
// 越界审批提示
|
||||
// 安全审查提示(越界/危险指令)
|
||||
if (pending.policy_note) {
|
||||
const note = document.createElement("div");
|
||||
note.className = "strip-card-note";
|
||||
note.textContent = "⚠ 越界操作待审批:" + pending.policy_note;
|
||||
note.textContent = "⚠ 安全审查:" + pending.policy_note;
|
||||
card.appendChild(note);
|
||||
}
|
||||
|
||||
@@ -537,8 +808,84 @@ function _buildToolCallCard(card, pending) {
|
||||
card.appendChild(footer);
|
||||
}
|
||||
|
||||
/** 人机交互卡片:标题 + 问题 + 选项 + 补充输入框(输入内容直接作为工具返回值) */
|
||||
// ===== 人机交互卡片(多问题:列表式无边框 UI,箭头切换问题,最后确认汇总提交) =====
|
||||
|
||||
/** 取某个 pending 项的问题列表(多问题 questions,兼容旧的单问题 prompt/options) */
|
||||
function _humanQuestions(pending) {
|
||||
if (Array.isArray(pending.questions) && pending.questions.length) {
|
||||
return pending.questions.map((q) => ({
|
||||
prompt: String(q && q.prompt || "请处理后继续。"),
|
||||
options: Array.isArray(q && q.options) ? q.options.slice(0, 3) : [],
|
||||
}));
|
||||
}
|
||||
return [{
|
||||
prompt: String(pending.prompt || "请处理后继续。"),
|
||||
options: Array.isArray(pending.options) ? pending.options.slice(0, 3) : [],
|
||||
}];
|
||||
}
|
||||
|
||||
/** 人机交互作答状态(按 item id 保存:list=每问答案 {kind,value},qIndex=当前问题游标,ver=作答版本) */
|
||||
function _humanState(pending) {
|
||||
const key = pending && pending.id ? pending.id : "_";
|
||||
if (!stripState.pending.answers || !stripState.pending.answers[key]) {
|
||||
stripState.pending.answers = stripState.pending.answers || {};
|
||||
stripState.pending.answers[key] = { list: [], qIndex: 0, ver: 0 };
|
||||
}
|
||||
return stripState.pending.answers[key];
|
||||
}
|
||||
|
||||
/** 记录当前问题答案(kind: option|input|ignored)并递增版本(触发选中态重绘) */
|
||||
function _recordHumanQuestion(pending, kind, value) {
|
||||
const st = _humanState(pending);
|
||||
st.list[st.qIndex || 0] = { kind, value: String(value || "") };
|
||||
st.ver = (st.ver || 0) + 1;
|
||||
}
|
||||
|
||||
/** 上一问(箭头),不足 0 时禁用 */
|
||||
function _gotoHumanQuestion(pending, delta) {
|
||||
const questions = _humanQuestions(pending);
|
||||
const st = _humanState(pending);
|
||||
const next = Math.max(0, Math.min(questions.length - 1, st.qIndex + delta));
|
||||
if (next === st.qIndex) return;
|
||||
st.qIndex = next;
|
||||
renderStrip();
|
||||
}
|
||||
|
||||
/** 答完当前问(选项/回车/确认/忽略)→ 切换到下一问;最后一问且 finalize=true 时汇总提交 */
|
||||
function _advanceHumanQuestion(pending, finalize) {
|
||||
const questions = _humanQuestions(pending);
|
||||
const st = _humanState(pending);
|
||||
if (st.qIndex < questions.length - 1) {
|
||||
st.qIndex += 1;
|
||||
renderStrip();
|
||||
} else if (finalize) {
|
||||
_submitPending(pending, "edit", _aggregateHumanAnswers(pending));
|
||||
} else {
|
||||
renderStrip(); // 仅刷新选中态
|
||||
}
|
||||
}
|
||||
|
||||
/** 汇总本轮所有问答结果为编号列表(未作答/忽略 → "用户选择忽略此问题") */
|
||||
function _aggregateHumanAnswers(pending) {
|
||||
const questions = _humanQuestions(pending);
|
||||
const st = _humanState(pending);
|
||||
return questions.map((_, i) => {
|
||||
const a = st.list[i];
|
||||
const value = a && a.kind !== "ignored" && (a.value || "").trim() ? a.value.trim() : "";
|
||||
return `${i + 1}、用户选择${value || "忽略此问题"};`;
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
/** 人机交互卡片:问题 → 选项(最多3,无边框列表)→ 可编辑输入框;
|
||||
左下角箭头切换问题(圆角矩形按钮),右下角 忽略/确认 均自动切下一问,
|
||||
最后一问点确认 → 汇总编号结果作为工具调用结果返回。 */
|
||||
function _buildHumanCard(card, pending, isLast) {
|
||||
const questions = _humanQuestions(pending);
|
||||
const st = _humanState(pending);
|
||||
if (st.qIndex >= questions.length) st.qIndex = 0;
|
||||
const q = questions[st.qIndex];
|
||||
const existing = (st.list && st.list[st.qIndex]) || null;
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "strip-card-head";
|
||||
const title = document.createElement("div");
|
||||
@@ -548,62 +895,109 @@ function _buildHumanCard(card, pending, isLast) {
|
||||
head.appendChild(_buildCollapseBtn("pending"));
|
||||
card.appendChild(head);
|
||||
|
||||
if (pending.prompt) {
|
||||
const content = document.createElement("div");
|
||||
content.className = "strip-card-content";
|
||||
content.textContent = _unescapeNewlines(pending.prompt);
|
||||
card.appendChild(content);
|
||||
}
|
||||
// 从上到下的简约列表(无边框、无分割线):问题 → 选项 → 输入框
|
||||
const content = document.createElement("div");
|
||||
content.className = "strip-card-content";
|
||||
|
||||
// 选项胶囊(可空——无选项时仅问题 + 补充输入框)
|
||||
let selectedPill = null;
|
||||
const options = Array.isArray(pending.options) ? pending.options : [];
|
||||
if (options.length) {
|
||||
const pillsWrap = document.createElement("div");
|
||||
pillsWrap.className = "strip-card-options";
|
||||
options.forEach((opt) => {
|
||||
const pill = document.createElement("button");
|
||||
pill.type = "button";
|
||||
pill.className = "strip-option-pill";
|
||||
pill.textContent = String(opt);
|
||||
pill.addEventListener("click", (e) => {
|
||||
const promptEl = document.createElement("div");
|
||||
promptEl.className = "strip-hq-prompt";
|
||||
promptEl.textContent = _unescapeNewlines(q.prompt);
|
||||
content.appendChild(promptEl);
|
||||
|
||||
const opts = q.options.slice(0, 3);
|
||||
if (opts.length) {
|
||||
const optsWrap = document.createElement("div");
|
||||
optsWrap.className = "strip-hq-options";
|
||||
opts.forEach((opt) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "strip-hq-option"
|
||||
+ (existing && existing.kind === "option" && existing.value === String(opt) ? " is-selected" : "");
|
||||
btn.textContent = String(opt);
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
// 仅切换类名,不触发重渲染(消除卡顿)
|
||||
pillsWrap.querySelectorAll(".strip-option-pill").forEach((p) => p.classList.remove("is-selected"));
|
||||
pill.classList.add("is-selected");
|
||||
selectedPill = String(opt);
|
||||
// 点击选项:记录并自动切换到下一个问题
|
||||
_recordHumanQuestion(pending, "option", String(opt));
|
||||
_advanceHumanQuestion(pending, false);
|
||||
});
|
||||
pillsWrap.appendChild(pill);
|
||||
optsWrap.appendChild(btn);
|
||||
});
|
||||
card.appendChild(pillsWrap);
|
||||
content.appendChild(optsWrap);
|
||||
}
|
||||
|
||||
// 补充输入框:内容直接作为工具返回值
|
||||
const input = document.createElement("textarea");
|
||||
input.className = "strip-card-input";
|
||||
input.className = "strip-hq-input";
|
||||
input.rows = 2;
|
||||
input.placeholder = options.length ? "补充信息(或直接输入选项)…" : "请输入…";
|
||||
card.appendChild(input);
|
||||
|
||||
const footer = document.createElement("div");
|
||||
footer.className = "strip-card-footer";
|
||||
|
||||
const submitBtn = document.createElement("button");
|
||||
submitBtn.type = "button";
|
||||
submitBtn.className = "strip-card-btn strip-card-btn--continue";
|
||||
submitBtn.textContent = isLast ? "确认" : "继续";
|
||||
submitBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
// 优先补充输入框内容(直达工具返回值),否则取选中选项
|
||||
const val = input.value.trim() || selectedPill || "";
|
||||
if (!val) {
|
||||
showToast("请先输入内容或选择选项");
|
||||
return;
|
||||
input.placeholder = "补充信息(输入后回车或点「确认」)…";
|
||||
input.value = existing && existing.kind === "input" ? existing.value : "";
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
// 编辑结束按回车:作为当前问题答案并自动切换
|
||||
_recordHumanQuestion(pending, "input", input.value);
|
||||
_advanceHumanQuestion(pending, false);
|
||||
}
|
||||
_submitPending(pending, "edit", val, submitBtn);
|
||||
});
|
||||
content.appendChild(input);
|
||||
card.appendChild(content);
|
||||
|
||||
footer.appendChild(submitBtn);
|
||||
// 底部:左下角问题切换箭头(圆角矩形),右下角 忽略/确认
|
||||
const footer = document.createElement("div");
|
||||
footer.className = "strip-hq-footer";
|
||||
|
||||
const nav = document.createElement("div");
|
||||
nav.className = "strip-hq-nav";
|
||||
if (questions.length > 1) {
|
||||
const prevBtn = document.createElement("button");
|
||||
prevBtn.type = "button";
|
||||
prevBtn.className = "strip-hq-nav-btn";
|
||||
prevBtn.textContent = "‹";
|
||||
prevBtn.title = "上一个问题";
|
||||
prevBtn.disabled = st.qIndex <= 0;
|
||||
prevBtn.addEventListener("click", (e) => { e.stopPropagation(); _gotoHumanQuestion(pending, -1); });
|
||||
const nextBtn = document.createElement("button");
|
||||
nextBtn.type = "button";
|
||||
nextBtn.className = "strip-hq-nav-btn";
|
||||
nextBtn.textContent = "›";
|
||||
nextBtn.title = "下一个问题";
|
||||
nextBtn.disabled = st.qIndex >= questions.length - 1;
|
||||
nextBtn.addEventListener("click", (e) => { e.stopPropagation(); _gotoHumanQuestion(pending, 1); });
|
||||
nav.appendChild(prevBtn);
|
||||
nav.appendChild(nextBtn);
|
||||
}
|
||||
footer.appendChild(nav);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "strip-hq-actions";
|
||||
const ignoreBtn = document.createElement("button");
|
||||
ignoreBtn.type = "button";
|
||||
ignoreBtn.className = "strip-hq-act strip-hq-act--ignore";
|
||||
ignoreBtn.textContent = "忽略";
|
||||
ignoreBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (input.value.trim()) _recordHumanQuestion(pending, "input", input.value);
|
||||
_recordHumanQuestion(pending, "ignored", "");
|
||||
_advanceHumanQuestion(pending, true); // 最后一问 → 汇总提交(记为本问忽略)
|
||||
});
|
||||
const confirmBtn = document.createElement("button");
|
||||
confirmBtn.type = "button";
|
||||
confirmBtn.className = "strip-hq-act strip-hq-act--confirm";
|
||||
confirmBtn.textContent = "确认";
|
||||
confirmBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
// 以点击时刻的最新状态为准(避免覆盖刚点选的选项)
|
||||
const st = _humanState(pending);
|
||||
const cur = st.list[st.qIndex];
|
||||
if (input.value.trim()) {
|
||||
_recordHumanQuestion(pending, "input", input.value);
|
||||
} else if (!cur) {
|
||||
_recordHumanQuestion(pending, "option", "");
|
||||
}
|
||||
_advanceHumanQuestion(pending, true); // 最后一问 → 汇总提交
|
||||
});
|
||||
actions.appendChild(ignoreBtn);
|
||||
actions.appendChild(confirmBtn);
|
||||
footer.appendChild(actions);
|
||||
card.appendChild(footer);
|
||||
}
|
||||
|
||||
@@ -623,6 +1017,9 @@ async function _submitPending(pending, decision, instruction, btnEl) {
|
||||
return;
|
||||
}
|
||||
// 本地立即移除该项(后端快照随后也会同步);前进到下一张
|
||||
// 人机交互工具答完 → 累计"已完成"计数(权限审查不参与计数)
|
||||
if (pending.type !== "tool_call") stripState.pending.doneCount++;
|
||||
if (stripState.pending.answers) delete stripState.pending.answers[pending.id]; // 清理多问题作答状态
|
||||
const items = stripState.pending.items;
|
||||
const idx = items.findIndex((p) => p.id === pending.id);
|
||||
if (idx >= 0) items.splice(idx, 1);
|
||||
@@ -647,7 +1044,8 @@ function _activeSessionIdForPending() {
|
||||
/** 隐藏某类通知条,并在输入区生成对应按钮 */
|
||||
function _dismissStripItem(kind) {
|
||||
if (kind === "todo") stripState.todo.dismissed = true;
|
||||
else stripState.pending.dismissed = true;
|
||||
else if (kind === "pending") stripState.pending.dismissed = true;
|
||||
else stripState.terminal.dismissed = true;
|
||||
_addComposerBtn(kind);
|
||||
renderStrip();
|
||||
}
|
||||
@@ -661,15 +1059,18 @@ function _addComposerBtn(kind) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "action-icon-btn strip-composer-btn";
|
||||
btn.title = kind === "todo" ? "Todo List" : "人机交互";
|
||||
btn.title = kind === "todo" ? "Todo List" : (kind === "terminal" ? "后台终端任务" : "人机交互");
|
||||
btn.innerHTML = kind === "todo"
|
||||
? '<img src="/image/todolist.svg" alt="Todo" />'
|
||||
: '<img src="/image/人机交互.svg" alt="交互" />';
|
||||
: (kind === "terminal"
|
||||
? '<img src="/image/终端.svg" alt="后台终端" />'
|
||||
: '<img src="/image/人机交互.svg" alt="交互" />');
|
||||
btn.addEventListener("click", () => {
|
||||
// 点击输入区按钮 → 按钮消失、对应通知条重新出现
|
||||
_removeComposerBtn(kind);
|
||||
if (kind === "todo") stripState.todo.dismissed = false;
|
||||
else stripState.pending.dismissed = false;
|
||||
else if (kind === "pending") stripState.pending.dismissed = false;
|
||||
else stripState.terminal.dismissed = false;
|
||||
renderStrip();
|
||||
});
|
||||
|
||||
@@ -727,14 +1128,15 @@ export function clearTodoStrip() {
|
||||
|
||||
/** 仅清空 pending 通知条(不影响 todo) */
|
||||
export function clearPendingStrip() {
|
||||
stripState.pending = { items: [], index: 0, dismissed: false, expanded: false };
|
||||
stripState.pending = { items: [], index: 0, dismissed: false, expanded: false, doneCount: 0, answers: {} };
|
||||
renderStrip();
|
||||
}
|
||||
|
||||
/** 清除全部通知条状态(新会话/清空会话时) */
|
||||
export function clearAllStrips() {
|
||||
stripState.todo = { steps: [], doneSteps: [], dismissed: false, expanded: false };
|
||||
stripState.pending = { items: [], index: 0, dismissed: false, expanded: false };
|
||||
stripState.pending = { items: [], index: 0, dismissed: false, expanded: false, doneCount: 0, answers: {} };
|
||||
stripState.terminal = { tasks: [], dismissed: false, expanded: false };
|
||||
_clearComposerBtns();
|
||||
renderStrip();
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// pending-overlay.js -- 人机交互 / 工具审查(通知条版)
|
||||
//
|
||||
// 原浮窗实现已迁移至 notify-strip.js:
|
||||
// 等待中的 human_requests 以输入区上方通知条展示(标题 + 计数 chip),
|
||||
// 点击展开为卡片(人机交互:内容/选项/输入 + 继续/确认;审查:同意/拒绝),
|
||||
// 多项时左右箭头切换。本文件保留导出签名以兼容既有调用点。
|
||||
|
||||
import {
|
||||
updatePendingStrip,
|
||||
clearPendingStrip,
|
||||
saveStripSession,
|
||||
} from './notify-strip.js';
|
||||
|
||||
/** live snapshot 驱动入口:human_requests 数组出现即渲染,应答后随快照消失。 */
|
||||
function updatePendingOverlay(pendingActions) {
|
||||
updatePendingStrip(pendingActions);
|
||||
}
|
||||
|
||||
/** 清除当前会话的 pending 通知条(仅 pending,不影响 todo;先持久化到会话快照)。 */
|
||||
function clearPendingOverlay() {
|
||||
saveStripSession();
|
||||
clearPendingStrip();
|
||||
}
|
||||
|
||||
/** 兼容旧接口:通知条模式下无独立浮窗可切换。 */
|
||||
function togglePendingOverlay() {}
|
||||
|
||||
/** 兼容旧接口:通知条模式下无双浮窗布局需要调整。 */
|
||||
function updateOverlayLayout() {}
|
||||
|
||||
/** 兼容旧接口:通知条由 notify-strip 构建。 */
|
||||
function buildPendingOverlay() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export {
|
||||
buildPendingOverlay,
|
||||
updatePendingOverlay,
|
||||
clearPendingOverlay,
|
||||
togglePendingOverlay,
|
||||
updateOverlayLayout,
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
// pending.js -- 人工请求决策提交(卡片渲染在 pending-overlay.js,数据源为 live snapshot)
|
||||
// pending.js -- 人工请求决策提交(卡片渲染在 notify-strip.js,数据源为 live snapshot)
|
||||
|
||||
import { api } from './api.js';
|
||||
import { state } from './state.js';
|
||||
|
||||
@@ -6,7 +6,13 @@ import { $, showToast } from './utils.js';
|
||||
import { showConfirm } from './dialog.js';
|
||||
import { api } from './api.js';
|
||||
import { state } from './state.js';
|
||||
import { clearPendingOverlay } from './pending-overlay.js';
|
||||
import { clearPendingStrip, saveStripSession } from './notify-strip.js';
|
||||
|
||||
/** 清除 pending 通知条(先持久化会话快照;原 pending-overlay.js 迁移后由 notify-strip 接管) */
|
||||
function clearPendingOverlay() {
|
||||
saveStripSession();
|
||||
clearPendingStrip();
|
||||
}
|
||||
|
||||
// 依赖注入
|
||||
let renderMessagesFn = null;
|
||||
|
||||
+128
-54
@@ -5,7 +5,13 @@ import { showConfirm } from './dialog.js';
|
||||
import { api, streamApi } from './api.js';
|
||||
import { startPollLiveToolCalls } from './toolcalls.js';
|
||||
import { state } from './state.js';
|
||||
import { clearPendingOverlay } from './pending-overlay.js';
|
||||
import { clearPendingStrip, saveStripSession } from './notify-strip.js';
|
||||
|
||||
/** 清除 pending 通知条(先持久化会话快照;原 pending-overlay.js 迁移后由 notify-strip 接管) */
|
||||
function clearPendingOverlay() {
|
||||
saveStripSession();
|
||||
clearPendingStrip();
|
||||
}
|
||||
import { renderVoiceTranscribingPlaceholder } from './chat-render.js';
|
||||
|
||||
let renderSessionsFn = null;
|
||||
@@ -14,10 +20,20 @@ let renderMessagesIncrementalFn = null;
|
||||
let renderAttachmentChipsFn = null;
|
||||
let appendThinkingIndicatorFn = null;
|
||||
let injectLiveToolCallsFn = null;
|
||||
let clearTodoOverlayFn = null;
|
||||
let streamAssistantMessageFn = null;
|
||||
let appendNewMessagesFn = null;
|
||||
let onSendStartFn = null;
|
||||
let _audioAbort = null; // 音频流的独立 AbortController,供打断用
|
||||
let _toolPollStop = null; // 当前轮次的实时轮询停止函数(暂停时主动释放 SSE 连接)
|
||||
|
||||
/** 停止当前轮次的实时工具轮询(幂等;暂停/轮次结束/发送新消息时调用) */
|
||||
function _stopToolPoll() {
|
||||
if (_toolPollStop) {
|
||||
const f = _toolPollStop;
|
||||
_toolPollStop = null;
|
||||
f();
|
||||
}
|
||||
}
|
||||
|
||||
/** 清理思考气泡:停止内部计时器后移除 DOM */
|
||||
function _cleanupTypingEl(el) {
|
||||
@@ -83,8 +99,8 @@ export function setSendDeps(deps) {
|
||||
renderAttachmentChipsFn = deps.renderAttachmentChips;
|
||||
appendThinkingIndicatorFn = deps.appendThinkingIndicator;
|
||||
injectLiveToolCallsFn = deps.injectLiveToolCalls;
|
||||
clearTodoOverlayFn = deps.clearTodoOverlay;
|
||||
streamAssistantMessageFn = deps.streamAssistantMessage;
|
||||
appendNewMessagesFn = deps.appendNewMessages || null;
|
||||
onSendStartFn = deps.onSendStart;
|
||||
}
|
||||
|
||||
@@ -142,8 +158,12 @@ async function handlePauseClick() {
|
||||
state.sessionId = data.sessionId;
|
||||
state.sessions.length = 0;
|
||||
state.sessions.push(...(data.sessions || []));
|
||||
if (clearTodoOverlayFn) clearTodoOverlayFn();
|
||||
// 不清空 todo——todo 是会话级全局状态,跨轮次保持(与 sendChat 新轮次语义一致,
|
||||
// 暂停只结束本轮,后续重启/切会话都会从历史消息重建;此处清空会造成
|
||||
// 「暂停后消失、重启后复现」的不一致)。pending 请求随轮次结束失效,照常清空。
|
||||
clearPendingOverlay();
|
||||
// 暂停即定格:停止实时轮询(本轮工具记录已由后端落盘,剩余在途结果不再推送)
|
||||
_stopToolPoll();
|
||||
if (renderSessionsFn) renderSessionsFn();
|
||||
if (renderMessagesFn) renderMessagesFn(data.messages || []);
|
||||
} catch (e) {
|
||||
@@ -232,7 +252,9 @@ async function sendChat() {
|
||||
const voiceInputFile = savedFiles.find((f) => f.__voiceInput) || null;
|
||||
const regularFiles = savedFiles.filter((f) => !f.__voiceInput && !f.__isRef && !(f.type && f.type.startsWith("folder/")));
|
||||
const hasVoiceInput = !!voiceInputFile;
|
||||
const priorMessages = JSON.parse(JSON.stringify(state.lastRenderedMessages));
|
||||
// 浅拷贝快照数组即可:元素为渲染层生成的只读快照对象(无消费方会改写其内部字段),
|
||||
// 整表 JSON 深拷贝在大历史下是发送时的主要同步开销之一
|
||||
const priorMessages = state.lastRenderedMessages.slice();
|
||||
const blobUrls = [];
|
||||
const hadOptimisticMedia = regularFiles.length > 0 || hasVoiceInput;
|
||||
let sessionIdAtStart = state.sessionId;
|
||||
@@ -263,19 +285,17 @@ async function sendChat() {
|
||||
if (hasVoiceInput) {
|
||||
// 语音输入:先渲染已有消息(含普通文件 + 用户输入的文本),再追加"语音转文字中"占位
|
||||
const optimisticContent = buildOptimisticUserContent(effectiveText, fileParts);
|
||||
if (renderMessagesFn) {
|
||||
if (optimisticContent != null) {
|
||||
renderMessagesFn([...priorMessages, { role: "user", content: optimisticContent }]);
|
||||
} else {
|
||||
renderMessagesFn(priorMessages);
|
||||
}
|
||||
if (optimisticContent != null) {
|
||||
_renderRuntime([...priorMessages, { role: "user", content: optimisticContent }]);
|
||||
} else {
|
||||
_renderRuntime(priorMessages);
|
||||
}
|
||||
renderVoiceTranscribingPlaceholder();
|
||||
} else {
|
||||
// 仅普通文件:乐观渲染图片/文件
|
||||
const optimisticContent = buildOptimisticUserContent(effectiveText, fileParts);
|
||||
if (optimisticContent != null && renderMessagesFn) {
|
||||
renderMessagesFn([...priorMessages, { role: "user", content: optimisticContent }]);
|
||||
if (optimisticContent != null) {
|
||||
_renderRuntime([...priorMessages, { role: "user", content: optimisticContent }]);
|
||||
}
|
||||
}
|
||||
if (textInput) textInput.value = "";
|
||||
@@ -286,9 +306,8 @@ async function sendChat() {
|
||||
if (renderAttachmentChipsFn) renderAttachmentChipsFn();
|
||||
} else if (effectiveText) {
|
||||
// 纯文本/引用消息:立即乐观渲染用户消息,避免等待 API /start 响应
|
||||
if (renderMessagesFn) {
|
||||
renderMessagesFn([...priorMessages, { role: "user", content: effectiveText }]);
|
||||
}
|
||||
// 走增量路径(仅追加本条),避免全量重建历史导致点击后 hover 失效
|
||||
_renderRuntime([...priorMessages, { role: "user", content: effectiveText }]);
|
||||
if (textInput) textInput.value = "";
|
||||
clearQuoteRefs();
|
||||
clearFileRefs();
|
||||
@@ -319,20 +338,14 @@ async function sendChat() {
|
||||
if (renderAttachmentChipsFn) renderAttachmentChipsFn();
|
||||
}
|
||||
if (renderSessionsFn) renderSessionsFn();
|
||||
const _isVoice = state.outputMode === "voice";
|
||||
// 语音模式下只追加用户消息气泡(保留旧消息),完整列表等流式 text 事件再刷新
|
||||
// 但若已通过乐观渲染显示了用户消息(含语音输入占位),则重新渲染全部消息以替换占位
|
||||
if (_isVoice && renderMessagesFn && !hadOptimisticMedia) {
|
||||
const startMsgs = startData.messages || [];
|
||||
const userMsg = startMsgs[startMsgs.length - 1];
|
||||
if (userMsg && userMsg.role === "user") {
|
||||
const current = (state.lastRenderedMessages || []).slice();
|
||||
current.push(userMsg);
|
||||
_renderRuntime(current);
|
||||
}
|
||||
} else if (renderMessagesFn) {
|
||||
// 包括:文本模式、或语音输出模式但有乐观渲染/语音输入占位需要替换
|
||||
// 运行期增量渲染:乐观渲染已包含历史 + 用户消息时只 append 差异
|
||||
// 增量契约:start 返回 canonical 用户消息 + 计数,替换乐观渲染的占位消息
|
||||
if (appendNewMessagesFn && startData.new_message) {
|
||||
appendNewMessagesFn(null, [startData.new_message], {
|
||||
baseCount: startData.base_count,
|
||||
total: startData.total,
|
||||
});
|
||||
} else if (renderMessagesFn && startData.messages) {
|
||||
// 兼容回退:旧契约(全量 messages)
|
||||
_renderRuntime(startData.messages || []);
|
||||
}
|
||||
revokeBlobUrlStack(blobUrls);
|
||||
@@ -353,7 +366,7 @@ async function sendChat() {
|
||||
clearPendingOverlay();
|
||||
|
||||
// 启动实时工具调用与反思轮询(snapshot 同时驱动人工请求浮窗与 Todo 浮窗)
|
||||
const stopToolPoll = startPollLiveToolCalls(state.sessionId, typingEl, injectLiveToolCallsFn);
|
||||
_toolPollStop = startPollLiveToolCalls(state.sessionId, typingEl, injectLiveToolCallsFn);
|
||||
|
||||
const fd2 = new FormData();
|
||||
fd2.append("session_id", state.sessionId);
|
||||
@@ -366,6 +379,7 @@ async function sendChat() {
|
||||
}
|
||||
sessionIdAtStart = state.sessionId;
|
||||
|
||||
const _isVoice = state.outputMode === "voice";
|
||||
if (_isVoice) {
|
||||
// ====== 流式 TTS ======
|
||||
_stopAllTtsPlayback();
|
||||
@@ -384,7 +398,7 @@ async function sendChat() {
|
||||
method: "POST",
|
||||
body: fd2,
|
||||
signal: state.abortController.signal,
|
||||
onEvent: (event) => {
|
||||
onEvent: async (event) => {
|
||||
if (state.sessionId !== sessionIdAtStart) return;
|
||||
if (_audioGeneration !== gen) return; // 被新请求打断,忽略旧回调
|
||||
|
||||
@@ -392,20 +406,50 @@ async function sendChat() {
|
||||
textRendered = true;
|
||||
streamSessionId = event.sessionId || sessionIdAtStart;
|
||||
streamSessions = event.sessions || [];
|
||||
const msgs = event.messages || [];
|
||||
const last = msgs[msgs.length - 1];
|
||||
const newMsgs = event.new_messages || [];
|
||||
const last = newMsgs[newMsgs.length - 1];
|
||||
|
||||
if (last && last.role === "assistant") {
|
||||
const head = msgs.slice(0, -1);
|
||||
_renderRuntime(head);
|
||||
// 同步 lastRenderedMessages 为完整消息列表,确保后续打断时 assistant 消息不丢失
|
||||
state.lastRenderedMessages.length = 0;
|
||||
state.lastRenderedMessages.push(...JSON.parse(JSON.stringify(msgs)));
|
||||
if (last && last.role === "assistant" && appendNewMessagesFn) {
|
||||
// 增量契约:多消息轮次先 append head;assistant 末条由伪流式追加 DOM,
|
||||
// append 仅补簿记(含 lastRenderedMessages 同步)
|
||||
if (newMsgs.length > 1) {
|
||||
appendNewMessagesFn(null, newMsgs.slice(0, -1), {
|
||||
baseCount: event.base_count,
|
||||
total: event.total,
|
||||
});
|
||||
}
|
||||
revokeBlobUrlStack(blobUrls);
|
||||
if (streamAssistantMessageFn) streamAssistantMessageFn(last);
|
||||
if (streamAssistantMessageFn) {
|
||||
await streamAssistantMessageFn(last);
|
||||
appendNewMessagesFn(null, [last], {
|
||||
baseCount: event.total != null ? event.total - 1 : null,
|
||||
total: event.total,
|
||||
domAlreadyAppended: true,
|
||||
});
|
||||
} else {
|
||||
appendNewMessagesFn(null, [last], {
|
||||
baseCount: event.base_count,
|
||||
total: event.total,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
_renderRuntime(msgs);
|
||||
revokeBlobUrlStack(blobUrls);
|
||||
if (appendNewMessagesFn && newMsgs.length) {
|
||||
appendNewMessagesFn(null, newMsgs, { baseCount: event.base_count, total: event.total });
|
||||
} else if (event.messages) {
|
||||
// 兼容回退:旧契约(全量 messages)
|
||||
const msgs = event.messages || [];
|
||||
const fbLast = msgs[msgs.length - 1];
|
||||
if (fbLast && fbLast.role === "assistant") {
|
||||
const head = msgs.slice(0, -1);
|
||||
_renderRuntime(head);
|
||||
state.lastRenderedMessages.length = 0;
|
||||
state.lastRenderedMessages.push(...msgs);
|
||||
if (streamAssistantMessageFn) streamAssistantMessageFn(fbLast);
|
||||
} else {
|
||||
_renderRuntime(msgs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 文字渲染完毕,释放按钮
|
||||
@@ -419,7 +463,7 @@ async function sendChat() {
|
||||
state.sessions.push(...streamSessions);
|
||||
if (renderSessionsFn) renderSessionsFn();
|
||||
|
||||
stopToolPoll();
|
||||
_stopToolPoll();
|
||||
if (typingEl) _cleanupTypingEl(typingEl);
|
||||
typingEl = null;
|
||||
}
|
||||
@@ -460,30 +504,60 @@ async function sendChat() {
|
||||
const done = await api("/api/chat/complete", { method: "POST", body: fd2, signal: state.abortController.signal });
|
||||
|
||||
if (state.sessionId !== sessionIdAtStart) {
|
||||
stopToolPoll();
|
||||
_stopToolPoll();
|
||||
return;
|
||||
}
|
||||
|
||||
stopToolPoll();
|
||||
_stopToolPoll();
|
||||
|
||||
state.sessionId = done.sessionId;
|
||||
state.sessions.length = 0;
|
||||
state.sessions.push(...(done.sessions || []));
|
||||
if (renderSessionsFn) renderSessionsFn();
|
||||
|
||||
const messages = done.messages || [];
|
||||
const last = messages[messages.length - 1];
|
||||
const newMessages = done.new_messages || [];
|
||||
const last = newMessages[newMessages.length - 1];
|
||||
if (typingEl) _cleanupTypingEl(typingEl);
|
||||
typingEl = null;
|
||||
if (last && last.role === "assistant") {
|
||||
const head = messages.slice(0, -1);
|
||||
// 增量渲染:head 与已渲染内容一致时零重建,仅由 streamAssistantMessage 追加末条
|
||||
_renderRuntime(head);
|
||||
if (last && last.role === "assistant" && appendNewMessagesFn) {
|
||||
// 增量契约:多消息轮次 head 先 append;末条 assistant 由伪流式追加 DOM,
|
||||
// append 仅补簿记(修复此前 streamAssistantMessage 不计数的簿记缺口)
|
||||
if (newMessages.length > 1) {
|
||||
appendNewMessagesFn(null, newMessages.slice(0, -1), {
|
||||
baseCount: done.base_count,
|
||||
total: done.total,
|
||||
});
|
||||
}
|
||||
revokeBlobUrlStack(blobUrls);
|
||||
if (streamAssistantMessageFn) {
|
||||
await streamAssistantMessageFn(last);
|
||||
appendNewMessagesFn(null, [last], {
|
||||
baseCount: done.total != null ? done.total - 1 : null,
|
||||
total: done.total,
|
||||
domAlreadyAppended: true,
|
||||
});
|
||||
} else {
|
||||
appendNewMessagesFn(null, [last], {
|
||||
baseCount: done.base_count,
|
||||
total: done.total,
|
||||
});
|
||||
}
|
||||
} else if (appendNewMessagesFn && newMessages.length) {
|
||||
appendNewMessagesFn(null, newMessages, { baseCount: done.base_count, total: done.total });
|
||||
revokeBlobUrlStack(blobUrls);
|
||||
if (streamAssistantMessageFn) await streamAssistantMessageFn(last);
|
||||
} else {
|
||||
_renderRuntime(messages);
|
||||
revokeBlobUrlStack(blobUrls);
|
||||
// 兼容回退:旧契约(全量 messages)
|
||||
const messages = done.messages || [];
|
||||
const fbLast = messages[messages.length - 1];
|
||||
if (fbLast && fbLast.role === "assistant") {
|
||||
const head = messages.slice(0, -1);
|
||||
_renderRuntime(head);
|
||||
revokeBlobUrlStack(blobUrls);
|
||||
if (streamAssistantMessageFn) await streamAssistantMessageFn(fbLast);
|
||||
} else {
|
||||
_renderRuntime(messages);
|
||||
revokeBlobUrlStack(blobUrls);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
+84
-9
@@ -2,9 +2,15 @@
|
||||
|
||||
import { $, showToast } from './utils.js';
|
||||
import { showConfirm, showPrompt } from './dialog.js';
|
||||
import { api } from './api.js';
|
||||
import { api, apiWithHeaders } from './api.js';
|
||||
import { state } from './state.js';
|
||||
import { clearPendingOverlay } from './pending-overlay.js';
|
||||
import { clearPendingStrip, saveStripSession } from './notify-strip.js';
|
||||
|
||||
/** 清除 pending 通知条(先持久化会话快照;原 pending-overlay.js 迁移后由 notify-strip 接管) */
|
||||
function clearPendingOverlay() {
|
||||
saveStripSession();
|
||||
clearPendingStrip();
|
||||
}
|
||||
import { refreshTokens } from './token-ring.js';
|
||||
|
||||
let renderSessionsFnRef = null;
|
||||
@@ -28,13 +34,19 @@ const _collapsedWs = new Set();
|
||||
// ---------- 会话显示名称(localStorage) ----------
|
||||
const SESSION_NAMES_KEY = "minor_session_names";
|
||||
|
||||
// 内存缓存:renderSessions 逐条读取会话名时避免重复 JSON.parse(localStorage)
|
||||
let _sessionNamesCache = null;
|
||||
|
||||
function loadSessionNames() {
|
||||
if (_sessionNamesCache) return _sessionNamesCache;
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(SESSION_NAMES_KEY) || "{}");
|
||||
} catch { return {}; }
|
||||
_sessionNamesCache = JSON.parse(localStorage.getItem(SESSION_NAMES_KEY) || "{}");
|
||||
} catch { _sessionNamesCache = {}; }
|
||||
return _sessionNamesCache;
|
||||
}
|
||||
|
||||
function saveSessionNames(names) {
|
||||
_sessionNamesCache = names;
|
||||
localStorage.setItem(SESSION_NAMES_KEY, JSON.stringify(names));
|
||||
}
|
||||
|
||||
@@ -117,6 +129,8 @@ function renderSessionItem(id) {
|
||||
li.className = "session-item" + (isActive ? " active" : "");
|
||||
li.setAttribute("role", "option");
|
||||
li.setAttribute("aria-selected", isActive ? "true" : "false");
|
||||
// 供删除等场景按 id 精准定位/移除节点,避免全量重建
|
||||
li.dataset.sessionId = id;
|
||||
|
||||
// 会话图标(文档按钮样式,随主题着色)
|
||||
const icon = document.createElement("span");
|
||||
@@ -286,8 +300,11 @@ async function deleteSessionItem(id) {
|
||||
if (!await showConfirm(`确认删除会话「${displayName}」吗?该会话的所有记录和文件都将被永久删除。`)) return;
|
||||
|
||||
const deletingSessionId = id;
|
||||
const deletedActive = id === state.sessionId; // 切换前记录:删除的是否为当前会话
|
||||
const fd = new FormData();
|
||||
fd.append("session_id", id);
|
||||
// 告知服务端当前会话:删除非当前会话时无需返回全量历史,避免无谓序列化
|
||||
if (state.sessionId) fd.append("current_session_id", state.sessionId);
|
||||
const data = await api("/api/sessions/delete", { method: "POST", body: fd });
|
||||
|
||||
// 如果删除的是当前会话,切换到服务端返回的新会话
|
||||
@@ -307,7 +324,31 @@ async function deleteSessionItem(id) {
|
||||
if (data.session_workspaces) state.sessionWorkspaces = data.session_workspaces;
|
||||
syncWorkspaceChip();
|
||||
|
||||
renderSessions();
|
||||
// 原地移除会话节点(避免全量重建列表 → 鼠标下方节点被替换导致 hover 失效、
|
||||
// 列表闪烁);找不到对应节点(如列表未渲染)时回退全量渲染
|
||||
let removedInPlace = false;
|
||||
try {
|
||||
const itemEl = sessionList.querySelector(`.session-item[data-session-id="${CSS.escape(deletingSessionId)}"]`);
|
||||
if (itemEl) {
|
||||
const groupLi = itemEl.closest(".ws-group");
|
||||
itemEl.remove();
|
||||
// 空的工作区组连组移除
|
||||
if (groupLi) {
|
||||
const inner = groupLi.querySelector(".ws-group-sessions");
|
||||
if (inner && !inner.children.length) groupLi.remove();
|
||||
}
|
||||
removedInPlace = true;
|
||||
// 删除的是当前会话:把高亮移到服务端回退的新会话
|
||||
if (deletedActive && data.sessionId && data.sessionId !== deletingSessionId) {
|
||||
const nextActive = sessionList.querySelector(`.session-item[data-session-id="${CSS.escape(data.sessionId)}"]`);
|
||||
if (nextActive) {
|
||||
nextActive.classList.add("active");
|
||||
nextActive.setAttribute("aria-selected", "true");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
if (!removedInPlace) renderSessions();
|
||||
|
||||
// 清理 localStorage 中的名称记录
|
||||
const names = loadSessionNames();
|
||||
@@ -315,6 +356,8 @@ async function deleteSessionItem(id) {
|
||||
delete names[deletingSessionId];
|
||||
saveSessionNames(names);
|
||||
}
|
||||
// 失效被删会话的消息缓存
|
||||
state._msgCache.delete(deletingSessionId);
|
||||
|
||||
// close drawer on mobile
|
||||
const _ar4 = $("app");
|
||||
@@ -323,7 +366,7 @@ async function deleteSessionItem(id) {
|
||||
|
||||
// ---------- 会话级思考档位同步 ----------
|
||||
// 各会话独立保存 THINKING_LEVEL(session_meta.json),切换/新建/删除会话时
|
||||
// 用服务端返回的 thinking_level 同步滑条 UI。
|
||||
// 用服务端返回的 thinking_level 同步思考模式 UI。
|
||||
function applySessionThinkingLevel(data) {
|
||||
if (!data || !data.thinking_level) return;
|
||||
if (state.thinkingLevel === data.thinking_level) return;
|
||||
@@ -359,15 +402,44 @@ export async function bootstrap() {
|
||||
async function selectSession(id) {
|
||||
if (!id || id === state.sessionId) return;
|
||||
if (state.sending) return; // Agent 运行中禁止切换会话
|
||||
const data = await api(`/api/sessions/${encodeURIComponent(id)}/messages`);
|
||||
await applySessionData(data);
|
||||
const prevSessionId = state.sessionId;
|
||||
const cached = state._msgCache.get(id);
|
||||
const cachedEtag = cached ? cached.etag : null;
|
||||
|
||||
// 缓存秒开:先用缓存内容渲染(临时把 session id 指向目标会话,让缓存快照
|
||||
// 落到目标键下——内容一致,安全),再带 If-None-Match 后台校验
|
||||
if (cached && Array.isArray(cached.messages)) {
|
||||
state.sessionId = id;
|
||||
if (renderMessagesFn) renderMessagesFn(cached.messages);
|
||||
state.sessionId = prevSessionId;
|
||||
try {
|
||||
const { restoreTodoFromMessages } = await import('./notify-strip.js');
|
||||
restoreTodoFromMessages(cached.messages);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const headers = cachedEtag ? { "If-None-Match": cachedEtag } : undefined;
|
||||
const res = await apiWithHeaders(`/api/sessions/${encodeURIComponent(id)}/messages`, { headers });
|
||||
if (res.status === 304) {
|
||||
// 缓存与服务器一致:切换生效并补齐被跳过的副作用(chip / 通知条)
|
||||
const entry = state._msgCache.get(id);
|
||||
if (entry) entry.etag = cachedEtag;
|
||||
state.sessionId = id;
|
||||
syncWorkspaceChip();
|
||||
try {
|
||||
const { switchStripSession } = await import('./notify-strip.js');
|
||||
switchStripSession("chat:" + (id || ""), "chat:" + prevSessionId);
|
||||
} catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
await applySessionData(res.data, { etag: res.headers.get("ETag") });
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用一次会话数据(sessionId / sessions / messages / tokens)并刷新界面。
|
||||
* 供「分支」等创建新会话后直接跳转使用。
|
||||
*/
|
||||
export async function applySessionData(data) {
|
||||
export async function applySessionData(data, opts = {}) {
|
||||
if (!data) return;
|
||||
// 记录旧会话键(切换前保存其通知条状态,避免被存到新键下)
|
||||
const oldSessionId = state.sessionId;
|
||||
@@ -389,6 +461,9 @@ export async function applySessionData(data) {
|
||||
if (renderSessionsFnRef) renderSessionsFnRef();
|
||||
if (renderMessagesFn) renderMessagesFn(data.messages || []);
|
||||
refreshTokens();
|
||||
// 记录本次校验指纹(供下次切换的 If-None-Match 304 判定)
|
||||
const entry = state._msgCache.get(state.sessionId);
|
||||
if (entry && opts.etag) entry.etag = opts.etag;
|
||||
// 设置居中状态
|
||||
const msgs = data.messages || [];
|
||||
import('../app.js').then((m) => m.setComposerCentered(msgs.length === 0));
|
||||
|
||||
+132
-29
@@ -5,7 +5,7 @@ import { $, escapeHtml, makeId, showToast } from './utils.js';
|
||||
import { showConfirm } from './dialog.js';
|
||||
import { api } from './api.js';
|
||||
import { state } from './state.js';
|
||||
import { THEMES, getCurrentTheme, applyTheme, getBgImage, setBgImage, getBgBlur, setBgBlur, getBgVignette, setBgVignette, FONT_PRESETS, FONT_SIZE_PRESETS, getFontSetting, setFontSetting, getAvatar, setAvatar } from './themes.js';
|
||||
import { THEMES, getCurrentTheme, applyTheme, getBgImage, setBgImage, getBgBlur, setBgBlur, getBgVignette, setBgVignette, FONT_PRESETS, FONT_SIZE_PRESETS, getFontSetting, setFontSetting, getAvatar, setAvatar, PANEL_ZONES, getPanelGlass, setPanelGlass, saveThemeSettings } from './themes.js';
|
||||
import { LANGS, setLanguage } from './i18n.js';
|
||||
|
||||
let renderSkillsFn = null;
|
||||
@@ -88,6 +88,7 @@ async function loadAllConfigs() {
|
||||
});
|
||||
state.registeredToolNames.length = 0;
|
||||
state.registeredToolNames.push(...(toolRes.registered_tool_names || []));
|
||||
state.mainDefaultTools = Array.isArray(agentRes.main_default_tools) ? agentRes.main_default_tools : [];
|
||||
window._toolParameters = toolRes.tool_parameters || {};
|
||||
window._forcedPermissions = toolRes.forced_permissions || {};
|
||||
// 所有页面同时渲染(nav 切换仅控制显隐)
|
||||
@@ -206,11 +207,25 @@ function buildAgentCard(agent, idx) {
|
||||
const selectedToolsList = document.createElement("div");
|
||||
selectedToolsList.style.cssText = "display:flex;flex-wrap:wrap;gap:0.25rem;margin-bottom:0.35rem;min-height:1.5rem;";
|
||||
|
||||
// main Agent 默认挂载工具(锁定:不能删除、无需手动添加;仅 role=main 生效)
|
||||
const lockedTools = agent.role === "main" ? (state.mainDefaultTools || []) : [];
|
||||
const LOCKED_STYLE = "display:inline-flex;align-items:center;gap:0.2rem;padding:0.15rem 0.4rem;background:rgba(16,185,129,0.14);border-radius:6px;font-size:0.78rem;";
|
||||
const NORMAL_STYLE = "display:inline-flex;align-items:center;gap:0.2rem;padding:0.15rem 0.4rem;background:rgba(79,70,229,0.12);border-radius:6px;font-size:0.78rem;";
|
||||
|
||||
function renderSelectedTools() {
|
||||
selectedToolsList.innerHTML = "";
|
||||
// 锁定区:默认挂载(不在 agent.tools 中,代码注入)
|
||||
lockedTools.forEach((tname) => {
|
||||
const chip = document.createElement("span");
|
||||
chip.style.cssText = LOCKED_STYLE;
|
||||
chip.textContent = tname + " \u{1F512}"; // 🔒
|
||||
chip.title = "main Agent 默认挂载,不可移除、无需手动添加";
|
||||
selectedToolsList.appendChild(chip);
|
||||
});
|
||||
// 可选区:配置的工具(main 的可删,子 agent 保持原样)
|
||||
(agent.tools || []).forEach((tname) => {
|
||||
const chip = document.createElement("span");
|
||||
chip.style.cssText = "display:inline-flex;align-items:center;gap:0.2rem;padding:0.15rem 0.4rem;background:rgba(79,70,229,0.12);border-radius:6px;font-size:0.78rem;";
|
||||
chip.style.cssText = NORMAL_STYLE;
|
||||
chip.textContent = tname;
|
||||
const delBtn = document.createElement("span");
|
||||
delBtn.textContent = "\u00D7";
|
||||
@@ -236,7 +251,8 @@ function buildAgentCard(agent, idx) {
|
||||
emptyToolOpt.textContent = "\u9009\u62E9\u5DE5\u5177...";
|
||||
toolSelect.appendChild(emptyToolOpt);
|
||||
state.registeredToolNames.forEach((tname) => {
|
||||
if (!(agent.tools || []).includes(tname)) {
|
||||
// main 的默认挂载工具已锁定存在,无需手动添加
|
||||
if (!(agent.tools || []).includes(tname) && !lockedTools.includes(tname)) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = tname;
|
||||
opt.textContent = tname;
|
||||
@@ -998,6 +1014,20 @@ document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && settingsOverlay && !settingsOverlay.hidden) closeSettings();
|
||||
});
|
||||
|
||||
// 保存主题设置:外观页修改只更新内存态与预览,点击本按钮才写回服务端
|
||||
const saveThemeBtn = $("saveThemeBtn");
|
||||
if (saveThemeBtn) {
|
||||
saveThemeBtn.addEventListener("click", async () => {
|
||||
setStatus("正在保存主题设置...", "");
|
||||
try {
|
||||
await saveThemeSettings();
|
||||
setStatus("主题设置已保存 ✓", "success");
|
||||
} catch (e) {
|
||||
setStatus("主题设置保存失败: " + (e.message || String(e)), "error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 保存回调
|
||||
document.querySelectorAll(".settings-save-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
@@ -1064,6 +1094,85 @@ document.querySelectorAll(".settings-save-btn").forEach((btn) => {
|
||||
});
|
||||
|
||||
// ===== 主题/语言面板 =====
|
||||
// ===== 外观页图片工具 =====
|
||||
/** 过长 data URL 的展示占位:避免把数 MB 的 base64 塞进输入框导致卡顿 */
|
||||
const LONG_DATA_PLACEHOLDER = "[已保存图片 · 数据过长,不在此处完整显示]";
|
||||
|
||||
function displayImageSetting(value) {
|
||||
if (typeof value === "string" && value.startsWith("data:") && value.length > 2048) {
|
||||
return LONG_DATA_PLACEHOLDER;
|
||||
}
|
||||
return value || "";
|
||||
}
|
||||
|
||||
/** 用户图片统一先上传到服务端落盘,配置只存 URL(替代 base64 大字段) */
|
||||
async function uploadUserImage(file) {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const res = await api("/api/config/user-image", { method: "POST", body: fd });
|
||||
return (res && res.url) || "";
|
||||
} catch (e) {
|
||||
setStatus("图片上传失败: " + (e.message || String(e)), "error");
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 面板玻璃效果设置区(左侧栏/右侧栏/顶栏/终端栏/输入区:透明度 + 模糊度) =====
|
||||
function renderPanelGlassZone() {
|
||||
const zone = $("panelGlassZone");
|
||||
if (!zone) return;
|
||||
zone.innerHTML = "";
|
||||
|
||||
const headerRow = document.createElement("div");
|
||||
headerRow.className = "settings-row settings-row--block";
|
||||
headerRow.innerHTML = `
|
||||
<div class="row-text">
|
||||
<h4>面板玻璃效果</h4>
|
||||
<p>左侧栏、右侧栏、顶栏与输入区的透明度和背景模糊度,修改即预览,点击「保存主题设置」后生效。</p>
|
||||
</div>`;
|
||||
zone.appendChild(headerRow);
|
||||
|
||||
PANEL_ZONES.forEach((z) => {
|
||||
const cur = getPanelGlass(z.id);
|
||||
|
||||
// 透明度滑条
|
||||
const alphaRow = document.createElement("div");
|
||||
alphaRow.className = "settings-row";
|
||||
alphaRow.innerHTML = `
|
||||
<div class="row-text"><h4>${z.label} · 透明度</h4><p>${cur.alpha}%</p></div>
|
||||
<div class="row-control">
|
||||
<input type="range" min="0" max="100" step="5" value="${cur.alpha}" class="compact-slider" />
|
||||
</div>`;
|
||||
const alphaSlider = alphaRow.querySelector("input");
|
||||
const alphaVal = alphaRow.querySelector("p");
|
||||
zone.appendChild(alphaRow);
|
||||
|
||||
// 模糊度滑条
|
||||
const blurRow = document.createElement("div");
|
||||
blurRow.className = "settings-row";
|
||||
blurRow.innerHTML = `
|
||||
<div class="row-text"><h4>${z.label} · 模糊度</h4><p>${cur.blur}px</p></div>
|
||||
<div class="row-control">
|
||||
<input type="range" min="0" max="20" step="1" value="${cur.blur}" class="compact-slider" />
|
||||
</div>`;
|
||||
const blurSlider = blurRow.querySelector("input");
|
||||
const blurVal = blurRow.querySelector("p");
|
||||
zone.appendChild(blurRow);
|
||||
|
||||
const applyGlass = () => {
|
||||
alphaVal.textContent = alphaSlider.value + "%";
|
||||
blurVal.textContent = blurSlider.value + "px";
|
||||
setPanelGlass(z.id, Number(alphaSlider.value), Number(blurSlider.value));
|
||||
};
|
||||
alphaSlider.oninput = applyGlass;
|
||||
blurSlider.oninput = applyGlass;
|
||||
// 拖动结束时精确保存一次(防抖持久化)
|
||||
alphaSlider.onchange = applyGlass;
|
||||
blurSlider.onchange = applyGlass;
|
||||
});
|
||||
}
|
||||
|
||||
function renderThemePanel() {
|
||||
const grid = $("themeGrid");
|
||||
const langSelect = $("langSelect");
|
||||
@@ -1128,9 +1237,10 @@ function renderThemePanel() {
|
||||
const bgPickerBtn = $("bgImagePickerBtn");
|
||||
const bgFileInput = $("bgImageFileInput");
|
||||
if (bgInput) {
|
||||
bgInput.value = getBgImage();
|
||||
bgInput.value = displayImageSetting(getBgImage());
|
||||
const applyBg = () => {
|
||||
const url = bgInput.value.trim();
|
||||
if (url === LONG_DATA_PLACEHOLDER) return; // 展示占位时视为未修改
|
||||
setBgImage(url); // 存储原始路径(可读),applyBgImage 内部自动转换
|
||||
if (bgStatus) {
|
||||
bgStatus.textContent = url ? "背景已更新 ✓" : "已恢复默认背景";
|
||||
@@ -1152,25 +1262,17 @@ function renderThemePanel() {
|
||||
bgPickerBtn.onclick = () => {
|
||||
bgFileInput.click();
|
||||
};
|
||||
|
||||
// 文件选择事件
|
||||
bgFileInput.onchange = (e) => {
|
||||
|
||||
// 文件选择事件:统一先上传落盘再存 URL(避免 base64 大字段写入主题配置)
|
||||
bgFileInput.onchange = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// 使用文件路径(Electron 环境)或创建临时 URL(Web 环境)
|
||||
if (window.electronAPI && window.electronAPI.getFilePath) {
|
||||
// Electron 环境:获取文件路径
|
||||
const filePath = window.electronAPI.getFilePath(file);
|
||||
bgInput.value = filePath;
|
||||
const url = await uploadUserImage(file);
|
||||
if (url) {
|
||||
bgInput.value = url;
|
||||
applyBg();
|
||||
} else {
|
||||
// Web 环境:创建临时 URL
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
bgInput.value = event.target?.result || "";
|
||||
applyBg();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
setStatus("背景图上传失败,请更换图片后重试", "error");
|
||||
}
|
||||
}
|
||||
// 清空 input 以便重复选择同一文件
|
||||
@@ -1205,6 +1307,9 @@ function renderThemePanel() {
|
||||
};
|
||||
}
|
||||
|
||||
// 面板玻璃效果(透明度 / 模糊度滑条)
|
||||
renderPanelGlassZone();
|
||||
|
||||
// 头像设置
|
||||
const avatarInput = $("avatarInput");
|
||||
const avatarApplyBtn = $("avatarApplyBtn");
|
||||
@@ -1213,9 +1318,10 @@ function renderThemePanel() {
|
||||
const avatarPickerBtn = $("avatarPickerBtn");
|
||||
const avatarFileInput = $("avatarFileInput");
|
||||
if (avatarInput) {
|
||||
avatarInput.value = getAvatar();
|
||||
avatarInput.value = displayImageSetting(getAvatar());
|
||||
const applyAvatarSetting = () => {
|
||||
const url = avatarInput.value.trim();
|
||||
if (url === LONG_DATA_PLACEHOLDER) return; // 展示占位时视为未修改
|
||||
setAvatar(url);
|
||||
if (avatarStatus) {
|
||||
avatarStatus.textContent = url ? "头像已更新 ✓" : "已恢复默认头像";
|
||||
@@ -1233,19 +1339,16 @@ function renderThemePanel() {
|
||||
|
||||
if (avatarPickerBtn && avatarFileInput) {
|
||||
avatarPickerBtn.onclick = () => avatarFileInput.click();
|
||||
avatarFileInput.onchange = (e) => {
|
||||
// 统一先上传落盘再存 URL(避免 base64 大字段写入主题配置)
|
||||
avatarFileInput.onchange = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (window.electronAPI && window.electronAPI.getFilePath) {
|
||||
avatarInput.value = window.electronAPI.getFilePath(file) || "";
|
||||
const url = await uploadUserImage(file);
|
||||
if (url) {
|
||||
avatarInput.value = url;
|
||||
applyAvatarSetting();
|
||||
} else {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
avatarInput.value = event.target?.result || "";
|
||||
applyAvatarSetting();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
setStatus("头像上传失败,请更换图片后重试", "error");
|
||||
}
|
||||
}
|
||||
avatarFileInput.value = "";
|
||||
|
||||
+9
-1
@@ -43,6 +43,12 @@ export const state = {
|
||||
|
||||
/** 最近一次 renderMessages 的 JSON 可序列化快照 */
|
||||
lastRenderedMessages: [],
|
||||
/**
|
||||
* 按会话的消息缓存(会话切换秒开 + ETag 后台校验):
|
||||
* Map<sessionId, {messages: 浅拷贝快照, total, etag: string|null}>
|
||||
* etag 为 null 表示内容可能已变化,需重新全量校验。
|
||||
*/
|
||||
_msgCache: new Map(),
|
||||
activeAudioController: null,
|
||||
|
||||
// 工具调用 / think 展开状态
|
||||
@@ -55,6 +61,8 @@ export const state = {
|
||||
cachedEnvConfig: {},
|
||||
cachedModels: [],
|
||||
registeredToolNames: [],
|
||||
/** @type {string[]} main Agent 默认挂载工具(前端显示为锁定项,不可移除/无需添加) */
|
||||
mainDefaultTools: [],
|
||||
cachedGuiConfig: { monitors: [], selected_name: "", gui_model_id: "", models: [] },
|
||||
editingModelIdx: -1,
|
||||
|
||||
@@ -81,7 +89,7 @@ export const state = {
|
||||
// 输出模式(文字/语音,由 文本语音.svg 按钮开关控制)
|
||||
outputMode: "text",
|
||||
|
||||
// 思考模式档位:low | high | xhigh | max | ultra
|
||||
// 思考模式档位:low | high | xhigh | max | ultra(前端呈现为 agent/plan/agent思考/plan思考/goal)
|
||||
thinkingLevel: "low",
|
||||
|
||||
// 工作空间
|
||||
|
||||
+405
-221
@@ -1,81 +1,42 @@
|
||||
// terminal.js -- 内置 PowerShell 终端(原生管道,多标签)
|
||||
// 主进程 spawn powershell.exe,stdin/stdout 经 IPC 桥接;前端自绘终端 UI。
|
||||
// terminal.js -- 内置 PowerShell 终端(xterm.js + ConPTY 真终端)
|
||||
// 主进程经 node-pty(ConPTY) 启动真实 PowerShell 会话,stdin/stdout 经 IPC 桥接;
|
||||
// 前端用 xterm.js 渲染(完整 ANSI / 光标 / 屏幕状态机)。
|
||||
// PSReadLine、Tab 补全、命令历史、全屏应用由真实控制台提供,前端不再模拟。
|
||||
// 面板折叠不 kill(仅隐藏);标签 x 单个 kill;右上角 x 确认后 kill 全部。
|
||||
// 颜色全部映射主题 CSS 变量,随主题变化。
|
||||
|
||||
import { $, showToast } from './utils.js';
|
||||
import { showConfirm } from './dialog.js';
|
||||
import { state } from './state.js';
|
||||
import { api } from './api.js';
|
||||
|
||||
/** 终端实例:{ id(主进程), title, cwd, pane, outEl, alive } */
|
||||
/** 终端实例:{ id(主进程), title, tab, pane, holder, term, fit, ro, alive } */
|
||||
let terminals = [];
|
||||
let activeId = null;
|
||||
let _dockVisible = false;
|
||||
|
||||
// ---------- ANSI 解析(CSI SGR 颜色 → 主题色 class;其余控制序列忽略) ----------
|
||||
const ANSI_FG = {
|
||||
'0': '', '30': 't-fg-muted', '31': 't-fg-danger', '32': 't-fg-success',
|
||||
'33': 't-fg-warning', '34': 't-fg-accent', '35': 't-fg-purple', '36': 't-fg-cyan',
|
||||
'37': '', '90': 't-fg-muted', '91': 't-fg-danger', '92': 't-fg-success',
|
||||
'93': 't-fg-warning', '94': 't-fg-accent', '95': 't-fg-purple', '96': 't-fg-cyan', '97': '',
|
||||
};
|
||||
const ANSI_BG = {
|
||||
'40': 't-bg-dark', '41': 't-bg-danger', '42': 't-bg-success', '43': 't-bg-warning',
|
||||
'44': 't-bg-accent', '47': '',
|
||||
};
|
||||
// ===== 后台任务标签(agent terminal_execute 挂起任务的实时视图,与交互式终端区分) =====
|
||||
// 数据通道:/api/terminal/tasks/{session_id}/stream SSE(独立于 Electron IPC 的 xterm 终端);
|
||||
// 状态更新 → 头部徽标;输出增量 → 输出区 append(按 seq 去重);终止 → POST kill。
|
||||
const taskTabs = new Map(); // taskId -> { id, tab, pane, out, statusEl, killBtn, lastSeq, truncated }
|
||||
let _taskStreamEv = null;
|
||||
let _taskStreamSid = "";
|
||||
const MAX_TASK_TABS = 8;
|
||||
|
||||
/** 把含 ANSI 序列的文本转为安全的 HTML 片段(SGR 颜色映射,其他 CSI 忽略) */
|
||||
export function ansiToHtml(text) {
|
||||
let html = '';
|
||||
let i = 0;
|
||||
let curCls = '';
|
||||
const n = text.length;
|
||||
while (i < n) {
|
||||
const ch = text[i];
|
||||
if (ch === '\x1b' && text[i + 1] === '[') {
|
||||
// CSI: \x1b[ ... 最终字符(A-Z 或 @-~)
|
||||
let j = i + 2;
|
||||
while (j < n && !/[A-Za-z@-~]/.test(text[j])) j++;
|
||||
if (j < n) {
|
||||
const params = text.slice(i + 2, j);
|
||||
const final = text[j];
|
||||
if (final === 'm') {
|
||||
// SGR:最后一个参数决定样式
|
||||
const codes = params.split(';').filter(Boolean);
|
||||
const last = codes[codes.length - 1] || '0';
|
||||
if (last === '0') {
|
||||
curCls = '';
|
||||
} else if (ANSI_FG[last] !== undefined) {
|
||||
curCls = ANSI_FG[last];
|
||||
} else if (ANSI_BG[last] !== undefined) {
|
||||
curCls = ANSI_BG[last];
|
||||
} else {
|
||||
// 亮度/其他:忽略
|
||||
}
|
||||
}
|
||||
// 其余 CSI(光标移动等)忽略
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\r') {
|
||||
i += 1;
|
||||
continue; // \r\n 中的 \r 忽略,单独 \r 不处理(简化)
|
||||
}
|
||||
if (ch === '\n') {
|
||||
html += '</span>\n';
|
||||
curCls = ''; // 换行后颜色状态简化重置(避免跨行 span 混乱)
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const esc = ch === '&' ? '&' : ch === '<' ? '<' : ch === '>' ? '>' : ch;
|
||||
html += curCls ? `<span class="${curCls}">${esc}</span>` : esc;
|
||||
i += 1;
|
||||
}
|
||||
if (curCls) html += '</span>';
|
||||
return html;
|
||||
// ---------- 主题 ----------
|
||||
function cssVar(name, fallback) {
|
||||
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
return v || fallback;
|
||||
}
|
||||
|
||||
/** 主题 CSS 变量 → xterm theme(背景/前景/光标跟随全局主题) */
|
||||
function readTermTheme() {
|
||||
return {
|
||||
background: cssVar('--surface', '#1e1e23'),
|
||||
foreground: cssVar('--text', '#e5e7eb'),
|
||||
cursor: cssVar('--accent', '#007acc'),
|
||||
cursorAccent: cssVar('--surface', '#1e1e23'),
|
||||
selectionBackground: cssVar('--accent', 'rgba(0, 122, 204, 0.4)'),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- 终端实例管理 ----------
|
||||
@@ -99,55 +60,96 @@ function ensureElectron() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 新建一个 PowerShell 终端标签 */
|
||||
/** 新建一个 PowerShell 终端标签(xterm 挂载后按真实尺寸启动 ConPTY 会话) */
|
||||
export async function spawnTerminal() {
|
||||
if (!ensureElectron()) return null;
|
||||
const cwd = currentCwd();
|
||||
|
||||
// 1) 构建标签 + 面板并临时激活(fit 需要容器有真实尺寸)
|
||||
const tab = document.createElement('div');
|
||||
tab.className = 'term-tab';
|
||||
tab.innerHTML = `<span class="term-tab-title">PowerShell <span class="term-tab-ws">${escapeHtml(wsName())}</span></span>`;
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.className = 'term-tab-close';
|
||||
closeBtn.title = '关闭此终端(终止进程)';
|
||||
closeBtn.textContent = '\u2715';
|
||||
tab.appendChild(closeBtn);
|
||||
// 标签插到 + 号之前,保证 [标签…][+] 在左、[×] 在右的布局
|
||||
const tabsBar = $('terminalTabs');
|
||||
const addBtn = tabsBar.querySelector('.term-tab-add');
|
||||
if (addBtn) tabsBar.insertBefore(tab, addBtn);
|
||||
else tabsBar.appendChild(tab);
|
||||
|
||||
const pane = document.createElement('div');
|
||||
pane.className = 'term-pane active'; // 临时 active:fit 需要可见容器;spawn 成功后重新激活
|
||||
const holder = document.createElement('div');
|
||||
holder.className = 'term-holder';
|
||||
pane.appendChild(holder);
|
||||
$('terminalBody').appendChild(pane);
|
||||
|
||||
// 2) xterm 实例(UMD 全局:window.Terminal / window.FitAddon)
|
||||
let term, fit;
|
||||
try {
|
||||
const cwd = currentCwd();
|
||||
const { id, ok, error } = await window.electronAPI.spawnTerminal(cwd);
|
||||
if (!ok) {
|
||||
showToast('终端启动失败: ' + (error || '未知错误'));
|
||||
return null;
|
||||
}
|
||||
// 渲染标签 + 输出面板
|
||||
const tab = document.createElement('div');
|
||||
tab.className = 'term-tab';
|
||||
tab.dataset.tid = String(id);
|
||||
tab.innerHTML = `<span class="term-tab-title">PowerShell <span class="term-tab-ws">${escapeHtml(wsName())}</span></span>`;
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.className = 'term-tab-close';
|
||||
closeBtn.title = '关闭此终端(终止进程)';
|
||||
closeBtn.textContent = '\u2715';
|
||||
closeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
killTerminal(id);
|
||||
term = new window.Terminal({
|
||||
cursorBlink: true,
|
||||
fontFamily: 'Cascadia Mono, Consolas, "Courier New", monospace',
|
||||
fontSize: 13,
|
||||
scrollback: 5000,
|
||||
theme: readTermTheme(),
|
||||
});
|
||||
tab.appendChild(closeBtn);
|
||||
tab.addEventListener('click', () => activateTerminal(id));
|
||||
// 标签插到 + 号之前,保证 [标签…][+] 在左、[×] 在右的布局
|
||||
const tabsBar = $('terminalTabs');
|
||||
const addBtn = tabsBar.querySelector('.term-tab-add');
|
||||
if (addBtn) tabsBar.insertBefore(tab, addBtn);
|
||||
else tabsBar.appendChild(tab);
|
||||
|
||||
const pane = document.createElement('div');
|
||||
pane.className = 'term-pane';
|
||||
pane.dataset.tid = String(id);
|
||||
const outEl = document.createElement('div');
|
||||
outEl.className = 'term-out';
|
||||
pane.appendChild(outEl);
|
||||
$('terminalBody').appendChild(pane);
|
||||
|
||||
terminals.push({ id, tab, pane, outEl, alive: true });
|
||||
activateTerminal(id);
|
||||
// 首次 spawn 时输出欢迎提示
|
||||
appendOutput(id, `PowerShell 已启动${cwd ? `(工作区: ${cwd})` : ''}\r\n`);
|
||||
return id;
|
||||
fit = new window.FitAddon.FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open(holder);
|
||||
if (holder.clientWidth > 20 && holder.clientHeight > 20) fit.fit();
|
||||
} catch (e) {
|
||||
showToast('终端启动失败: ' + (e.message || e));
|
||||
tab.remove();
|
||||
pane.remove();
|
||||
showToast('终端初始化失败: ' + (e.message || e));
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3) 主进程 spawn(用 fit 后的真实行列数,避免提示符宽度不对)
|
||||
const { id, ok, error } = await window.electronAPI.spawnTerminal(cwd, term.cols, term.rows)
|
||||
.catch((e) => ({ ok: false, error: (e && e.message) || e }));
|
||||
if (!ok) {
|
||||
tab.remove();
|
||||
pane.remove();
|
||||
try { term.dispose(); } catch { /* ignore */ }
|
||||
showToast('终端启动失败: ' + (error || '未知错误'));
|
||||
return null;
|
||||
}
|
||||
|
||||
const t = { id, tab, pane, holder, term, fit, ro: null, alive: true };
|
||||
terminals.push(t);
|
||||
|
||||
// 4) 事件接线
|
||||
closeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
killTerminal(id);
|
||||
});
|
||||
tab.addEventListener('click', () => activateTerminal(id));
|
||||
// 键盘进 xterm 状态机 → 直接写 PTY(行编辑/补全交给 PSReadLine)
|
||||
term.onData((data) => {
|
||||
if (t.alive) window.electronAPI.sendTerminalInput(id, data);
|
||||
});
|
||||
// 尺寸变化(fit 触发)→ 同步 ConPTY 行列数
|
||||
term.onResize(({ cols, rows }) => {
|
||||
window.electronAPI.resizeTerminal(id, cols, rows);
|
||||
});
|
||||
// 容器尺寸变化(窗口拉伸/面板展开)→ 重新 fit
|
||||
try {
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (holder.clientWidth < 20 || holder.clientHeight < 20) return;
|
||||
try { t.fit.fit(); } catch { /* ignore */ }
|
||||
});
|
||||
ro.observe(holder);
|
||||
t.ro = ro;
|
||||
} catch { /* ResizeObserver 不可用时忽略 */ }
|
||||
|
||||
activateTerminal(id);
|
||||
term.write(`\r\nPowerShell 已启动${cwd ? `(工作区: ${cwd})` : ''}\r\n`);
|
||||
return id;
|
||||
}
|
||||
|
||||
function getTerm(id) {
|
||||
@@ -158,41 +160,26 @@ function activateTerminal(id) {
|
||||
activeId = id;
|
||||
for (const t of terminals) {
|
||||
const isActive = t.id === id;
|
||||
t.tab.classList.toggle('active', isActive);
|
||||
t.pane.classList.toggle('active', isActive);
|
||||
t.tab.classList.toggle("active", isActive);
|
||||
t.pane.classList.toggle("active", isActive);
|
||||
if (isActive) {
|
||||
// 激活时补一次 fit(面板尺寸可能已变化),onResize 自动同步主进程
|
||||
try {
|
||||
if (t.holder.clientWidth > 20 && t.holder.clientHeight > 20) t.fit.fit();
|
||||
} catch { /* ignore */ }
|
||||
t.term.focus();
|
||||
}
|
||||
}
|
||||
// 命令行(提示符+输入)挂载到活动面板输出末尾,观感等同真实终端的当前行
|
||||
const t = getTerm(id);
|
||||
if (!_cmdlineEl) buildCmdline(); // 兜底:initTerminal 尚未执行时按需构建
|
||||
const cl = _cmdlineEl;
|
||||
if (t && cl) {
|
||||
t.outEl.appendChild(cl);
|
||||
scrollPaneToBottom(t.pane);
|
||||
}
|
||||
const input = _cmdInput;
|
||||
if (input) {
|
||||
input.disabled = !t?.alive;
|
||||
input.focus();
|
||||
// 交互终端激活时取消任务标签的活动态
|
||||
for (const tb of taskTabs.values()) {
|
||||
tb.tab.classList.remove("active");
|
||||
tb.pane.classList.remove("active");
|
||||
}
|
||||
}
|
||||
|
||||
/** 滚动容器是 .term-pane(非 .term-out),输出追加后保持跟随底部 */
|
||||
function scrollPaneToBottom(pane) {
|
||||
if (pane) pane.scrollTop = pane.scrollHeight;
|
||||
}
|
||||
|
||||
function appendOutput(id, chunk) {
|
||||
const t = getTerm(id);
|
||||
if (!t) return;
|
||||
const html = ansiToHtml(chunk);
|
||||
const cl = _cmdlineEl;
|
||||
if (cl && cl.parentNode === t.outEl) {
|
||||
// 命令行保持在输出最后一行:新输出插在它之前
|
||||
cl.insertAdjacentHTML('beforebegin', html);
|
||||
} else {
|
||||
t.outEl.insertAdjacentHTML('beforeend', html);
|
||||
}
|
||||
scrollPaneToBottom(t.pane);
|
||||
function disposeTerm(t) {
|
||||
try { t.ro?.disconnect(); } catch { /* ignore */ }
|
||||
try { t.term.dispose(); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** 单个终端关闭(kill 进程并移除标签) */
|
||||
@@ -200,6 +187,7 @@ export function killTerminal(id) {
|
||||
const t = getTerm(id);
|
||||
if (!t) return;
|
||||
t.alive = false;
|
||||
disposeTerm(t);
|
||||
try { window.electronAPI?.killTerminal(id); } catch { /* ignore */ }
|
||||
t.tab.remove();
|
||||
t.pane.remove();
|
||||
@@ -208,7 +196,7 @@ export function killTerminal(id) {
|
||||
activeId = null;
|
||||
if (terminals.length > 0) activateTerminal(terminals[terminals.length - 1].id);
|
||||
}
|
||||
if (terminals.length === 0) {
|
||||
if (terminals.length === 0 && taskTabs.size === 0) {
|
||||
setDockVisible(false); // 全部关闭后隐藏面板
|
||||
}
|
||||
}
|
||||
@@ -217,12 +205,14 @@ export function killTerminal(id) {
|
||||
export function killAllTerminals() {
|
||||
try { window.electronAPI?.killAllTerminals(); } catch { /* ignore */ }
|
||||
for (const t of terminals) {
|
||||
t.alive = false;
|
||||
disposeTerm(t);
|
||||
t.tab.remove();
|
||||
t.pane.remove();
|
||||
}
|
||||
terminals = [];
|
||||
activeId = null;
|
||||
setDockVisible(false);
|
||||
if (taskTabs.size === 0) setDockVisible(false);
|
||||
}
|
||||
|
||||
/** 显示/折叠终端面板(不 kill,仅隐藏) */
|
||||
@@ -231,8 +221,12 @@ export function setDockVisible(visible) {
|
||||
const dock = $('terminalDock');
|
||||
if (!dock) return;
|
||||
dock.hidden = !_dockVisible;
|
||||
if (_dockVisible && terminals.length > 0 && activeId == null) {
|
||||
activateTerminal(terminals[terminals.length - 1].id);
|
||||
// 重新展开时激活最后一个标签;ResizeObserver/fit 会恢复正确行列数
|
||||
if (_dockVisible) {
|
||||
if (terminals.length > 0 && activeId == null) activateTerminal(terminals[terminals.length - 1].id);
|
||||
// 展开时同步后台任务标签(确保新任务在展开瞬间即可见)
|
||||
_ensureTaskStream();
|
||||
syncTaskTabs().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,83 +240,265 @@ export function toggleTerminal() {
|
||||
setDockVisible(true);
|
||||
if (terminals.length === 0) {
|
||||
spawnTerminal();
|
||||
} else if (activeId == null && terminals.length > 0) {
|
||||
} else if (activeId == null) {
|
||||
activateTerminal(terminals[terminals.length - 1].id);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 输入 ----------
|
||||
// 管道模式下 PowerShell 不回显提示符与命令,前端自行回显以还原真实终端观感
|
||||
const _cmdHistory = [];
|
||||
let _histIdx = -1; // -1 = 未在浏览历史
|
||||
// 命令行元素引用:buildCmdline 构建后先处于游离态(未挂到 document),
|
||||
// 不能用 getElementById 查找(查不到游离节点),必须用模块级引用持有
|
||||
let _cmdlineEl = null;
|
||||
let _cmdInput = null;
|
||||
// ===== 后台任务标签实现 =====
|
||||
|
||||
function echoCommand(text) {
|
||||
const t = getTerm(activeId);
|
||||
if (!t) return;
|
||||
const html = `<span class="t-fg-success">PS></span> ${escapeHtml(text)}\n`;
|
||||
const cl = _cmdlineEl;
|
||||
if (cl && cl.parentNode === t.outEl) cl.insertAdjacentHTML('beforebegin', html);
|
||||
else t.outEl.insertAdjacentHTML('beforeend', html);
|
||||
scrollPaneToBottom(t.pane);
|
||||
function _taskStatusMeta(t) {
|
||||
if (!t) return { label: "未知", cls: "done" };
|
||||
if (t.status === "running") return { label: "运行中", cls: "running" };
|
||||
if (t.status === "killed") return { label: "已终止", cls: "done" };
|
||||
if (t.abnormal || (t.exit_code != null && t.exit_code !== 0)) return { label: `异常退出 · exit ${t.exit_code}`, cls: "err" };
|
||||
return { label: "已完成", cls: "done" };
|
||||
}
|
||||
|
||||
function sendInput() {
|
||||
const input = _cmdInput;
|
||||
const t = getTerm(activeId);
|
||||
if (!t || !t.alive || !input) return;
|
||||
const text = input.value;
|
||||
input.value = '';
|
||||
_histIdx = -1;
|
||||
if (!text) return;
|
||||
if (_cmdHistory[_cmdHistory.length - 1] !== text) _cmdHistory.push(text);
|
||||
if (_cmdHistory.length > 200) _cmdHistory.shift();
|
||||
echoCommand(text);
|
||||
try { window.electronAPI.sendTerminalInput(activeId, text + '\r'); } catch { /* ignore */ }
|
||||
function _currentTaskSid() {
|
||||
// cron 子进程任务不在此服务注册表;仅 chat 会话支持任务标签
|
||||
if (state.mode === "cron") return "";
|
||||
return state.sessionId || "";
|
||||
}
|
||||
|
||||
/** ↑/↓ 浏览命令历史 */
|
||||
function historyNav(dir) {
|
||||
const input = _cmdInput;
|
||||
if (!input || _cmdHistory.length === 0) return;
|
||||
if (_histIdx === -1) _histIdx = _cmdHistory.length;
|
||||
_histIdx += dir;
|
||||
if (_histIdx < 0) _histIdx = 0;
|
||||
if (_histIdx >= _cmdHistory.length) { _histIdx = -1; input.value = ''; return; }
|
||||
input.value = _cmdHistory[_histIdx];
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
}
|
||||
/** 打开/刷新指定任务标签(已存在则激活) */
|
||||
export function openTaskTab(task, { activate = true } = {}) {
|
||||
const id = task?.id != null ? Number(task.id) : null;
|
||||
if (id == null) return null;
|
||||
const exist = taskTabs.get(id);
|
||||
if (exist) {
|
||||
_updateTaskTabMeta(exist, task);
|
||||
if (activate) activateTaskTab(id);
|
||||
return exist;
|
||||
}
|
||||
// 数量上限:超出淘汰最旧的已完成任务标签
|
||||
if (taskTabs.size >= MAX_TASK_TABS) {
|
||||
for (const [tid, tb] of taskTabs) {
|
||||
if (tb.task && tb.task.status !== "running") { closeTaskTab(tid); break; }
|
||||
}
|
||||
}
|
||||
|
||||
/** 构建命令行元素(提示符 + 输入框);由 activateTerminal 挂到活动面板输出末尾 */
|
||||
function buildCmdline() {
|
||||
if (_cmdlineEl) return;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'terminal-input-row';
|
||||
row.id = 'terminalCmdline';
|
||||
const prompt = document.createElement('span');
|
||||
prompt.className = 'terminal-prompt';
|
||||
prompt.id = 'terminalPrompt';
|
||||
prompt.textContent = 'PS>';
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'terminal-input';
|
||||
input.id = 'terminalInput';
|
||||
input.autocomplete = 'off';
|
||||
input.spellcheck = false;
|
||||
input.placeholder = '输入命令,Enter 执行';
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); sendInput(); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); historyNav(-1); }
|
||||
else if (e.key === 'ArrowDown') { e.preventDefault(); historyNav(1); }
|
||||
const tab = document.createElement("div");
|
||||
tab.className = "term-tab term-tab--task";
|
||||
tab.title = `后台任务 #${id}`;
|
||||
tab.innerHTML = `<img class="term-tab-icon" src="/image/终端.svg" alt="" /><span class="term-tab-title">任务#${id}</span>`;
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.type = "button";
|
||||
closeBtn.className = "term-tab-close";
|
||||
closeBtn.title = "关闭此标签(不终止任务)";
|
||||
closeBtn.textContent = "\u2715";
|
||||
closeBtn.addEventListener("click", (e) => { e.stopPropagation(); closeTaskTab(id); });
|
||||
tab.appendChild(closeBtn);
|
||||
|
||||
const tabsBar = $("terminalTabs");
|
||||
const addBtn = tabsBar.querySelector(".term-tab-add");
|
||||
if (addBtn) tabsBar.insertBefore(tab, addBtn);
|
||||
else tabsBar.appendChild(tab);
|
||||
|
||||
const pane = document.createElement("div");
|
||||
pane.className = "term-task-pane";
|
||||
pane.innerHTML = `
|
||||
<div class="term-task-head">
|
||||
<span class="term-task-cmd"></span>
|
||||
<span class="term-task-status"></span>
|
||||
<button type="button" class="term-task-kill">终止</button>
|
||||
</div>
|
||||
<pre class="term-task-output"></pre>
|
||||
`;
|
||||
$("terminalBody").appendChild(pane);
|
||||
|
||||
const tb = {
|
||||
id, task: task || null, tab, pane,
|
||||
cmdEl: pane.querySelector(".term-task-cmd"),
|
||||
statusEl: pane.querySelector(".term-task-status"),
|
||||
killBtn: pane.querySelector(".term-task-kill"),
|
||||
out: pane.querySelector(".term-task-output"),
|
||||
lastSeq: 0,
|
||||
truncated: false,
|
||||
nearBottom: true,
|
||||
};
|
||||
tb.killBtn.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
await _killTaskById(id);
|
||||
});
|
||||
row.appendChild(prompt);
|
||||
row.appendChild(input);
|
||||
// 游离态持有引用;activateTerminal 首次挂载后才进入 document
|
||||
_cmdlineEl = row;
|
||||
_cmdInput = input;
|
||||
taskTabs.set(id, tb);
|
||||
_updateTaskTabMeta(tb, task);
|
||||
if (activate) activateTaskTab(id);
|
||||
return tb;
|
||||
}
|
||||
|
||||
function _updateTaskTabMeta(tb, task) {
|
||||
if (!task) return;
|
||||
tb.task = task;
|
||||
const st = _taskStatusMeta(task);
|
||||
const wasRunning = tb.statusEl.dataset.cls === "running";
|
||||
tb.cmdEl.textContent = String(task.command || "").slice(0, 80) || "(无命令)";
|
||||
tb.statusEl.textContent = st.label;
|
||||
tb.statusEl.dataset.cls = st.cls;
|
||||
tb.statusEl.className = "term-task-status" + (st.cls === "running" ? " term-task-status--running" : (st.cls === "err" ? " term-task-status--err" : ""));
|
||||
tb.killBtn.hidden = task.status !== "running";
|
||||
tb.tab.classList.toggle("term-tab--dead", task.status !== "running");
|
||||
if (task.truncated && !tb.truncated) {
|
||||
tb.truncated = true;
|
||||
const warn = document.createElement("div");
|
||||
warn.className = "term-task-truncated";
|
||||
warn.textContent = `(较早输出已截断,仅保留最近 256KB)`;
|
||||
tb.out.appendChild(warn);
|
||||
}
|
||||
// 状态从运行→结束:补一行结束信息(仅在已有输出末尾提示,不重复)
|
||||
if (wasRunning && task.status !== "running") {
|
||||
_appendOutputText(tb, `\n${st.label} · 退出码 ${task.exit_code == null ? "?" : task.exit_code}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function _appendOutputText(tb, text, source) {
|
||||
const atBottom = tb.out.scrollHeight - tb.out.scrollTop - tb.out.clientHeight < 40;
|
||||
if (source === "err") {
|
||||
const span = document.createElement("span");
|
||||
span.className = "term-task-err";
|
||||
span.textContent = text;
|
||||
tb.out.appendChild(span);
|
||||
} else {
|
||||
tb.out.appendChild(document.createTextNode(text));
|
||||
}
|
||||
if (atBottom) tb.out.scrollTop = tb.out.scrollHeight;
|
||||
}
|
||||
|
||||
function _appendTaskChunks(id, chunks) {
|
||||
const tb = taskTabs.get(id);
|
||||
if (!tb || !Array.isArray(chunks)) return;
|
||||
for (const c of chunks) {
|
||||
if (!c || c.seq == null || c.seq <= tb.lastSeq) continue;
|
||||
tb.lastSeq = c.seq;
|
||||
_appendOutputText(tb, String(c.text || ""), c.source === "err" ? "err" : "out");
|
||||
}
|
||||
}
|
||||
|
||||
function activateTaskTab(id) {
|
||||
// 交互式 xterm 与任务标签共用 dock:激活任务标签时取消交互终端的活动态
|
||||
for (const t of terminals) {
|
||||
t.tab.classList.remove("active");
|
||||
t.pane.classList.remove("active");
|
||||
}
|
||||
activeId = null;
|
||||
for (const [tid, tb] of taskTabs) {
|
||||
const isActive = tid === Number(id);
|
||||
tb.tab.classList.toggle("active", isActive);
|
||||
tb.pane.classList.toggle("active", isActive);
|
||||
if (isActive && tb.task && tb.task.status === "running") {
|
||||
tb.out.scrollTop = tb.out.scrollHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function closeTaskTab(id, { offDock = true } = {}) {
|
||||
const tb = taskTabs.get(Number(id));
|
||||
if (!tb) return;
|
||||
tb.tab.remove();
|
||||
tb.pane.remove();
|
||||
taskTabs.delete(Number(id));
|
||||
if (taskTabs.size === 0 && terminals.length === 0 && offDock) {
|
||||
setDockVisible(false);
|
||||
}
|
||||
}
|
||||
|
||||
export function closeAllTaskTabs() {
|
||||
for (const id of [...taskTabs.keys()]) closeTaskTab(id, { offDock: false });
|
||||
if (terminals.length === 0) setDockVisible(false);
|
||||
}
|
||||
|
||||
async function _killTaskById(id) {
|
||||
const tb = taskTabs.get(Number(id));
|
||||
const cmd = tb?.task?.command || "";
|
||||
const ok = await showConfirm(`确定终止后台终端任务吗?\n任务ID: #${id}\n命令: ${String(cmd).slice(0, 120)}`);
|
||||
if (!ok) return;
|
||||
const fd = new FormData();
|
||||
fd.append("session_id", state.sessionId || "");
|
||||
try {
|
||||
const res = await api(`/api/terminal/tasks/${encodeURIComponent(id)}/kill`, { method: "POST", body: fd });
|
||||
if (res && res.ok) return;
|
||||
showToast("终止失败: " + ((res && (res.error || res.detail)) || "未知错误"));
|
||||
} catch (e) {
|
||||
showToast("终止失败: " + (e.message || String(e)));
|
||||
}
|
||||
}
|
||||
|
||||
/** 拉取全量任务列表并同步标签(dock 展开 / SSE full / 轮询兜底) */
|
||||
async function syncTaskTabs() {
|
||||
const sid = _currentTaskSid();
|
||||
if (!sid) return;
|
||||
try {
|
||||
const data = await api(`/api/terminal/tasks/${encodeURIComponent(sid)}/live`);
|
||||
const tasks = Array.isArray(data && data.tasks) ? data.tasks : [];
|
||||
for (const t of tasks) {
|
||||
openTaskTab(t, { activate: false });
|
||||
}
|
||||
// 已从后端消失的"运行中"标签:标记停止并补输出尾部
|
||||
for (const [id, tb] of taskTabs) {
|
||||
if (tb.task && tb.task.status === "running" && !tasks.find((t) => t.id === id)) {
|
||||
_updateTaskTabMeta(tb, { ...tb.task, status: "killed", exit_code: null, abnormal: false });
|
||||
}
|
||||
}
|
||||
// 输出回放(重连/初次同步)
|
||||
const outputs = Array.isArray(data && data.outputs) ? data.outputs : [];
|
||||
for (const o of outputs) {
|
||||
const tb = taskTabs.get(o.id);
|
||||
if (!tb) continue;
|
||||
const chunks = Array.isArray(o.chunks) ? o.chunks : [];
|
||||
const last = chunks.length ? chunks[chunks.length - 1].seq : 0;
|
||||
if (last < tb.lastSeq) continue; // 已有更新输出
|
||||
tb.out.innerHTML = "";
|
||||
tb.lastSeq = 0;
|
||||
tb.truncated = false;
|
||||
_appendTaskChunks(o.id, chunks);
|
||||
}
|
||||
} catch (e) {
|
||||
showToast("同步后台任务失败: " + (e.message || String(e)));
|
||||
}
|
||||
}
|
||||
|
||||
/** 任务 SSE 流:确保绑定当前会话;会话变化时重建并清空旧标签 */
|
||||
function _ensureTaskStream() {
|
||||
const sid = _currentTaskSid();
|
||||
if (!sid) return;
|
||||
if (_taskStreamSid === sid && _taskStreamEv) return;
|
||||
_stopTaskStream();
|
||||
openTaskTabByIdsReset(); // 会话已变:清空旧标签(仅隐藏,不终止任务)
|
||||
const es = new EventSource(`/api/terminal/tasks/${encodeURIComponent(sid)}/stream`);
|
||||
es.addEventListener("update", (ev) => {
|
||||
let payload = null;
|
||||
try { payload = JSON.parse(ev.data); } catch { return; }
|
||||
if (!payload) return;
|
||||
if (payload.type === "full") {
|
||||
_ensureTaskStreamCheckSid();
|
||||
syncTaskTabs().catch(() => {});
|
||||
} else if (payload.type === "task_update" && payload.task) {
|
||||
_ensureTaskStreamCheckSid();
|
||||
const task = payload.task;
|
||||
const tb = taskTabs.get(task.id);
|
||||
if (tb) _updateTaskTabMeta(tb, task);
|
||||
else if (_dockVisible) openTaskTab(task);
|
||||
} else if (payload.type === "output") {
|
||||
_ensureTaskStreamCheckSid();
|
||||
_appendTaskChunks(payload.task_id, payload.chunks);
|
||||
}
|
||||
});
|
||||
es.onerror = () => { /* EventSource 自动重连,重连后 full 快照重同步 */ };
|
||||
_taskStreamEv = es;
|
||||
_taskStreamSid = sid;
|
||||
}
|
||||
|
||||
function _ensureTaskStreamCheckSid() {
|
||||
if (_taskStreamSid !== _currentTaskSid()) _ensureTaskStream();
|
||||
}
|
||||
|
||||
function openTaskTabByIdsReset() {
|
||||
closeAllTaskTabs();
|
||||
}
|
||||
|
||||
function _stopTaskStream() {
|
||||
if (_taskStreamEv) { try { _taskStreamEv.close(); } catch {} _taskStreamEv = null; }
|
||||
_taskStreamSid = "";
|
||||
}
|
||||
|
||||
// ---------- 事件绑定 ----------
|
||||
@@ -337,15 +513,11 @@ export function initTerminal() {
|
||||
const termBtn = $('terminalBtn');
|
||||
if (termBtn) termBtn.classList.remove('is-placeholder');
|
||||
|
||||
// 命令行(提示符 + 输入)动态构建,挂到活动面板输出末尾,随内容滚动
|
||||
buildCmdline();
|
||||
|
||||
// 点击终端区域任意处直接键入(焦点落到命令行输入框)
|
||||
// 点击终端区域任意处直接键入(焦点落到活动 xterm)
|
||||
const termBody = $('terminalBody');
|
||||
if (termBody) {
|
||||
termBody.addEventListener('click', () => {
|
||||
const input = _cmdInput;
|
||||
if (input && !input.disabled) input.focus();
|
||||
getTerm(activeId)?.term.focus();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -365,23 +537,35 @@ export function initTerminal() {
|
||||
dockClose.title = '关闭全部终端(终止所有进程)';
|
||||
dockClose.textContent = '\u2715';
|
||||
dockClose.addEventListener('click', async () => {
|
||||
const ok = await showConfirm('确定关闭全部终端吗?\n将终止所有 PowerShell 进程。');
|
||||
if (ok) killAllTerminals();
|
||||
const hasTask = taskTabs.size > 0;
|
||||
const ok = await showConfirm(hasTask
|
||||
? '确定关闭全部终端吗?\n将终止所有 PowerShell 进程(后台任务仅隐藏标签,不会终止;可在通知条中终止)。'
|
||||
: '确定关闭全部终端吗?\n将终止所有 PowerShell 进程。');
|
||||
if (ok) { killAllTerminals(); closeAllTaskTabs(); }
|
||||
});
|
||||
$('terminalTabs').appendChild(dockClose);
|
||||
|
||||
// 主进程数据推送
|
||||
if (window.electronAPI?.onTerminalData) {
|
||||
window.electronAPI.onTerminalData(({ id, chunk }) => appendOutput(id, chunk || ''));
|
||||
window.electronAPI.onTerminalData(({ id, chunk }) => {
|
||||
const t = getTerm(id);
|
||||
if (t && chunk) t.term.write(chunk);
|
||||
});
|
||||
window.electronAPI.onTerminalExit(({ id, code }) => {
|
||||
const t = getTerm(id);
|
||||
if (!t) return;
|
||||
t.alive = false;
|
||||
t.tab.classList.add('term-tab--dead');
|
||||
appendOutput(id, `\r\n[进程已退出,code=${code == null ? '?' : code}](关闭标签可移除)\r\n`);
|
||||
if (activeId === id && _cmdInput) _cmdInput.disabled = true;
|
||||
try { t.ro?.disconnect(); } catch { /* ignore */ }
|
||||
t.term.write(`\r\n\x1b[90m[进程已退出,code=${code == null ? '?' : code}](关闭标签可移除)\x1b[0m\r\n`);
|
||||
});
|
||||
}
|
||||
|
||||
// 主题切换(themes.js dispatch theme-changed)→ 更新所有 xterm 配色
|
||||
document.addEventListener('theme-changed', () => {
|
||||
const theme = readTermTheme();
|
||||
for (const t of terminals) t.term.options.theme = theme;
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
|
||||
+96
-17
@@ -593,11 +593,28 @@ let _settings = {
|
||||
bg_blur: "",
|
||||
bg_vignette: "",
|
||||
avatar: "",
|
||||
// 面板玻璃效果:{ sidebar: { alpha, blur }, ... };alpha=透明度%,blur=模糊 px,空串/缺省=使用 CSS 默认
|
||||
panel_glass: {},
|
||||
};
|
||||
let _loadingTheme = false; // 从服务端加载期间不写回,避免覆盖已有配置
|
||||
let _persistTimer = null;
|
||||
|
||||
/** 防抖写回服务端 theme_config.json(仅保存用户主动修改,加载期间跳过) */
|
||||
/** theme_config.json 的写入载荷(头像/背景只存 URL,不再携带 base64 大字段) */
|
||||
function _themePayload() {
|
||||
return {
|
||||
theme: _settings.theme,
|
||||
bg_image: _settings.bg_image,
|
||||
font_family: _settings.font_family,
|
||||
font_size: _settings.font_size,
|
||||
bg_blur: _settings.bg_blur,
|
||||
bg_vignette: _settings.bg_vignette,
|
||||
avatar: _settings.avatar,
|
||||
panel_glass: _settings.panel_glass,
|
||||
};
|
||||
}
|
||||
|
||||
/** 防抖写回服务端 theme_config.json(仅保存用户主动修改,加载期间跳过)。
|
||||
仅由「保存主题设置」触发:外观页修改只更新内存态与 CSS 预览,不逐次回写。 */
|
||||
function _persistThemeConfig() {
|
||||
if (_loadingTheme) return;
|
||||
if (_persistTimer) clearTimeout(_persistTimer);
|
||||
@@ -606,20 +623,29 @@ function _persistThemeConfig() {
|
||||
try {
|
||||
api("/api/config/theme", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
theme: _settings.theme,
|
||||
bg_image: _settings.bg_image,
|
||||
font_family: _settings.font_family,
|
||||
font_size: _settings.font_size,
|
||||
bg_blur: _settings.bg_blur,
|
||||
bg_vignette: _settings.bg_vignette,
|
||||
avatar: _settings.avatar,
|
||||
}),
|
||||
body: JSON.stringify(_themePayload()),
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
}, 300);
|
||||
}
|
||||
|
||||
/** 立即保存主题设置(「设置 → 外观 → 保存主题设置」按钮触发,绕过防抖即时落盘) */
|
||||
export function saveThemeSettings() {
|
||||
if (_loadingTheme) return;
|
||||
if (_persistTimer) {
|
||||
clearTimeout(_persistTimer);
|
||||
_persistTimer = null;
|
||||
}
|
||||
try {
|
||||
return api("/api/config/theme", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(_themePayload()),
|
||||
});
|
||||
} catch (e) {
|
||||
return Promise.reject(e);
|
||||
}
|
||||
}
|
||||
|
||||
// 字体预设
|
||||
export const FONT_PRESETS = {
|
||||
"system": { name: "系统默认", family: 'system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif' },
|
||||
@@ -648,7 +674,6 @@ export function setFontSetting(key, val) {
|
||||
if (key === "family") _settings.font_family = val || "";
|
||||
else if (key === "size") _settings.font_size = val || "";
|
||||
applyFontSetting(key, val);
|
||||
_persistThemeConfig();
|
||||
}
|
||||
export function applyFontSetting(key, val) {
|
||||
const r = document.documentElement;
|
||||
@@ -673,7 +698,6 @@ export function getBgImage() {
|
||||
export function setBgImage(url) {
|
||||
_settings.bg_image = url || "";
|
||||
applyBgImage(_settings.bg_image);
|
||||
_persistThemeConfig();
|
||||
}
|
||||
export function applyBgImage(url) {
|
||||
const r = document.documentElement;
|
||||
@@ -704,7 +728,6 @@ export function getAvatar() {
|
||||
export function setAvatar(val) {
|
||||
_settings.avatar = val || "";
|
||||
applyAvatar(_settings.avatar);
|
||||
_persistThemeConfig();
|
||||
}
|
||||
/** 应用头像到左栏用户区:有头像显示图片(本地路径走 API 代理),无头像显示昵称首字 */
|
||||
export function applyAvatar(url) {
|
||||
@@ -739,9 +762,8 @@ export function applyTheme(themeId) {
|
||||
Object.entries(theme.edit).forEach(([k, v]) => r.style.setProperty(k, v));
|
||||
}
|
||||
|
||||
// 更新内存态并落盘(加载期间跳过,避免覆盖服务端已有配置)
|
||||
// 更新内存态(落盘在「保存主题设置」时执行)
|
||||
_settings.theme = THEMES[themeId] ? themeId : DEFAULT_THEME;
|
||||
_persistThemeConfig();
|
||||
|
||||
// 触发 body 类名切换(用于全局暗/亮模式判断)
|
||||
// 优先使用主题对象的 dark 字段;缺失时回退到 id 字符串推断
|
||||
@@ -777,7 +799,6 @@ export function getBgBlur() {
|
||||
export function setBgBlur(val) {
|
||||
_settings.bg_blur = val || "";
|
||||
applyBgBlur(_settings.bg_blur);
|
||||
_persistThemeConfig();
|
||||
}
|
||||
export function applyBgBlur(val) {
|
||||
// val 为空字符串时使用主题默认值(由 applyTheme 设置),否则覆盖
|
||||
@@ -796,7 +817,6 @@ export function getBgVignette() {
|
||||
export function setBgVignette(val) {
|
||||
_settings.bg_vignette = val || "";
|
||||
applyBgVignette(_settings.bg_vignette);
|
||||
_persistThemeConfig();
|
||||
}
|
||||
export function applyBgVignette(val) {
|
||||
const r = document.documentElement;
|
||||
@@ -807,6 +827,58 @@ export function applyBgVignette(val) {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 面板玻璃效果(左侧栏 / 右侧栏 / 顶栏 / 输入区) =====
|
||||
// alpha = 面板背景不透明度(%),blur = 背景模糊度(px,0 表示不模糊);
|
||||
// CSS 侧默认值在 variables.css :root 中定义,未显式设置时不覆盖。
|
||||
// 注:终端栏不提供该设置——terminal 输出面板(.term-pane)有独立不透明背景,
|
||||
// 与其背景模糊叠加后透明度不生效,故不在设置列表中列出。
|
||||
export const PANEL_ZONES = [
|
||||
{ id: "sidebar", label: "左侧栏", alphaVar: "--sidebar-alpha", blurVar: "--sidebar-blur", defaultAlpha: 100, defaultBlur: 12 },
|
||||
{ id: "right", label: "右侧栏", alphaVar: "--right-alpha", blurVar: "--right-blur", defaultAlpha: 100, defaultBlur: 12 },
|
||||
{ id: "topbar", label: "顶栏", alphaVar: "--topbar-alpha", blurVar: "--topbar-blur", defaultAlpha: 100, defaultBlur: 0 },
|
||||
{ id: "composer", label: "输入区", alphaVar: "--composer-alpha", blurVar: "--composer-blur", defaultAlpha: 32, defaultBlur: 12 },
|
||||
];
|
||||
|
||||
export function getPanelGlass(zoneId) {
|
||||
const cfg = PANEL_ZONES.find((z) => z.id === zoneId);
|
||||
if (!cfg) return { alpha: 100, blur: 0 };
|
||||
const saved = _settings.panel_glass?.[zoneId] || {};
|
||||
const num = (v, dflt) => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) && n >= 0 ? n : dflt;
|
||||
};
|
||||
return { alpha: num(saved.alpha, cfg.defaultAlpha), blur: num(saved.blur, cfg.defaultBlur) };
|
||||
}
|
||||
|
||||
/** 设置某个面板的透明度/模糊度并即时生效(alpha、blur 传数字或空串="恢复默认") */
|
||||
export function setPanelGlass(zoneId, alpha, blur) {
|
||||
const cfg = PANEL_ZONES.find((z) => z.id === zoneId);
|
||||
if (!cfg) return;
|
||||
_settings.panel_glass = _settings.panel_glass || {};
|
||||
_settings.panel_glass[zoneId] = {
|
||||
alpha: alpha === "" || alpha === null || alpha === undefined ? "" : alpha,
|
||||
blur: blur === "" || blur === null || blur === undefined ? "" : blur,
|
||||
};
|
||||
applyPanelGlass(zoneId, alpha, blur);
|
||||
}
|
||||
|
||||
/** 将面板玻璃参数写入 CSS 变量(空串=移除内联覆盖,回退 themes 默认) */
|
||||
export function applyPanelGlass(zoneId, alpha, blur) {
|
||||
const cfg = PANEL_ZONES.find((z) => z.id === zoneId);
|
||||
if (!cfg) return;
|
||||
const r = document.documentElement;
|
||||
if (alpha !== "" && alpha !== null && alpha !== undefined) {
|
||||
r.style.setProperty(cfg.alphaVar, `${Number(alpha)}%`);
|
||||
} else {
|
||||
r.style.removeProperty(cfg.alphaVar);
|
||||
}
|
||||
if (blur !== "" && blur !== null && blur !== undefined) {
|
||||
r.style.setProperty(cfg.blurVar, `${Number(blur)}px`);
|
||||
} else {
|
||||
r.style.removeProperty(cfg.blurVar);
|
||||
}
|
||||
}
|
||||
|
||||
/** 从服务端 theme_config.json 加载主题配置并应用(不使用 localStorage) */
|
||||
export async function loadThemeConfig() {
|
||||
try {
|
||||
@@ -841,6 +913,13 @@ export async function loadThemeConfig() {
|
||||
_settings.avatar = res.avatar || "";
|
||||
applyAvatar(_settings.avatar);
|
||||
}
|
||||
if (res.panel_glass !== undefined) {
|
||||
_settings.panel_glass = (res.panel_glass && typeof res.panel_glass === "object") ? res.panel_glass : {};
|
||||
PANEL_ZONES.forEach((z) => {
|
||||
const saved = _settings.panel_glass[z.id] || {};
|
||||
applyPanelGlass(z.id, saved.alpha, saved.blur);
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// 服务端不可达:保持默认设置
|
||||
} finally {
|
||||
|
||||
+40
-74
@@ -1,11 +1,13 @@
|
||||
// think-level.js -- 思考模式档位选择(low / high / xhigh / max / ultra)
|
||||
// think-level.js -- 思考模式选择(agent 模式 / plan 模式 / agent 思考 / plan 思考 / goal 模式)
|
||||
// 档位同时决定运行模式与深度思考:
|
||||
// low=agent+不思考 high=plan+不思考 xhigh=agent+思考 max=plan+思考
|
||||
// ultra 为未来 react→审批图预留占位(暂按 agent+思考 处理)
|
||||
// agent=agent+不思考 plan=plan+不思考 agent思考=agent+思考 plan思考=plan+思考
|
||||
// goal 为未来目标导向(react→审批图)预留占位,暂按 agent+思考 处理
|
||||
// 会话级变量:持久化在各会话 session_meta.json 的 thinking_level
|
||||
// (POST /api/sessions/{id}/thinking-level),由 llm.Multimodel_LLM 每轮
|
||||
// 按当前会话动态解析 extra_body,仅作用于该会话。
|
||||
// 交互通过 document 级事件委托实现(触发器开关 + 档位点击 + 滑条拖动)。
|
||||
// 交互通过 document 级事件委托实现(触发器开关 + 模式选项点击)。
|
||||
// 注:内部档位值沿用后端 wire 格式(low / high / xhigh / max / ultra),
|
||||
// 仅前端呈现为五种语义模式;goal 仍以 ultra 存储,旧会话档位不受影响。
|
||||
|
||||
import { $, showToast, closeInlinePanelsExcept } from './utils.js';
|
||||
import { api } from './api.js';
|
||||
@@ -13,15 +15,24 @@ import { state } from './state.js';
|
||||
|
||||
export const THINK_LEVELS = ["low", "high", "xhigh", "max", "ultra"];
|
||||
|
||||
const THINK_LEVEL_TIPS = {
|
||||
low: "low:agent 模式,不开启深度思考",
|
||||
high: "high:plan 模式,不开启深度思考",
|
||||
xhigh: "xhigh:agent 模式 + 深度思考",
|
||||
max: "max:plan 模式 + 深度思考",
|
||||
ultra: "ultra:预留占位(未来 react→审批图)",
|
||||
/** 模式展示名(触发器徽标 + 面板选项文本) */
|
||||
const THINK_MODE_LABELS = {
|
||||
low: "agent 模式",
|
||||
high: "plan 模式",
|
||||
xhigh: "agent 思考",
|
||||
max: "plan 思考",
|
||||
ultra: "goal 模式",
|
||||
};
|
||||
|
||||
/** 根据思考档位派生运行模式(low/xhigh/ultra→agent,high/max→plan) */
|
||||
const THINK_LEVEL_TIPS = {
|
||||
low: "agent 模式:标准执行,不开启深度思考",
|
||||
high: "plan 模式:先规划再执行,不开启深度思考",
|
||||
xhigh: "agent 思考模式:标准执行 + 深度思考",
|
||||
max: "plan 思考模式:先规划再执行 + 深度思考",
|
||||
ultra: "goal 模式:目标导向(预留占位,暂按 agent + 深度思考 处理)",
|
||||
};
|
||||
|
||||
/** 根据思考档位派生运行模式(agent/agent思考/goal→agent,plan/plan思考→plan) */
|
||||
export function getAgentModeFromLevel(level) {
|
||||
const lv = THINK_LEVELS.includes(level) ? level : "low";
|
||||
return lv === "high" || lv === "max" ? "plan" : "agent";
|
||||
@@ -34,60 +45,52 @@ export function getThinkingEnabled(level) {
|
||||
}
|
||||
|
||||
let _panelOpen = false;
|
||||
let _dragging = false;
|
||||
let _committing = false;
|
||||
let _lastCommitAt = 0;
|
||||
let _lastTriggerAt = 0;
|
||||
|
||||
/** 初始化思考档位(bootstrap 后调用):state.thinkingLevel 已由会话数据同步 */
|
||||
/** 初始化思考模式(bootstrap 后调用):state.thinkingLevel 已由会话数据同步 */
|
||||
export function initThinkLevel() {
|
||||
if (!THINK_LEVELS.includes(state.thinkingLevel)) {
|
||||
state.thinkingLevel = "low";
|
||||
}
|
||||
renderLabels();
|
||||
renderModeItems();
|
||||
syncThinkLevelUI();
|
||||
}
|
||||
|
||||
/** 渲染 5 档标签(横向布局:flex space-between,与滑条档位对齐) */
|
||||
function renderLabels() {
|
||||
const container = $("thinkModeLabels");
|
||||
/** 渲染 5 个模式选项(纵向列表:agent / plan / agent思考 / plan思考 / goal) */
|
||||
function renderModeItems() {
|
||||
const container = $("thinkModeItems");
|
||||
if (!container) return;
|
||||
container.innerHTML = "";
|
||||
THINK_LEVELS.forEach((lv) => {
|
||||
const item = document.createElement("button");
|
||||
item.type = "button";
|
||||
item.className = "think-mode-label";
|
||||
item.className = "think-mode-item";
|
||||
item.dataset.value = lv;
|
||||
item.textContent = lv;
|
||||
item.textContent = THINK_MODE_LABELS[lv] || lv;
|
||||
item.title = THINK_LEVEL_TIPS[lv] || lv;
|
||||
container.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
/** 将滑条手柄/填充/标签选中态同步到 state.thinkingLevel(横向:左=low 右=ultra) */
|
||||
/** 将纵向选项选中态与触发器徽标同步到 state.thinkingLevel */
|
||||
export function syncThinkLevelUI() {
|
||||
const handle = $("thinkModeHandle");
|
||||
const fill = $("thinkModeFill");
|
||||
const labels = document.querySelectorAll(".think-mode-label");
|
||||
const items = document.querySelectorAll(".think-mode-item");
|
||||
const trigger = $("thinkModeTrigger");
|
||||
if (!handle || !fill) return;
|
||||
const idx = Math.max(0, THINK_LEVELS.indexOf(state.thinkingLevel));
|
||||
const pct = (idx / (THINK_LEVELS.length - 1)) * 100;
|
||||
handle.style.left = `${pct}%`;
|
||||
fill.style.width = `${pct}%`;
|
||||
labels.forEach((el) => {
|
||||
el.classList.toggle("is-active", el.dataset.value === state.thinkingLevel);
|
||||
items.forEach((el) => {
|
||||
el.classList.toggle("is-selected", el.dataset.value === state.thinkingLevel);
|
||||
});
|
||||
if (trigger) {
|
||||
trigger.title = THINK_LEVEL_TIPS[state.thinkingLevel] || "思考模式";
|
||||
trigger.dataset.value = state.thinkingLevel;
|
||||
// 按钮右侧小字:当前思考等级
|
||||
// 按钮右侧小字:当前模式名称
|
||||
const badge = trigger.querySelector(".think-mode-badge");
|
||||
if (badge) badge.textContent = state.thinkingLevel;
|
||||
if (badge) badge.textContent = THINK_MODE_LABELS[state.thinkingLevel] || state.thinkingLevel;
|
||||
}
|
||||
}
|
||||
|
||||
/** 绑定事件:document 级委托(触发器开关 + 档位点击 + 滑条拖动) */
|
||||
/** 绑定事件:document 级委托(触发器开关 + 模式选项点击) */
|
||||
export function bindThinkModeEvents() {
|
||||
document.addEventListener("click", (e) => {
|
||||
const wrap = $("thinkModeWrap");
|
||||
@@ -110,9 +113,9 @@ export function bindThinkModeEvents() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 档位标签点击:立即选择并提交
|
||||
if (target.closest && target.closest(".think-mode-label")) {
|
||||
const lv = target.closest(".think-mode-label").dataset.value;
|
||||
// 模式选项点击:立即选择并提交
|
||||
if (target.closest && target.closest("#thinkModePanel .think-mode-item")) {
|
||||
const lv = target.closest("#thinkModePanel .think-mode-item").dataset.value;
|
||||
if (!THINK_LEVELS.includes(lv)) return;
|
||||
state.thinkingLevel = lv;
|
||||
syncThinkLevelUI();
|
||||
@@ -125,32 +128,6 @@ export function bindThinkModeEvents() {
|
||||
closePanel();
|
||||
}
|
||||
});
|
||||
|
||||
// 滑条:Pointer Events 拖动(含触摸),松手提交(横向坐标 clientX)
|
||||
document.addEventListener("pointerdown", (e) => {
|
||||
if (!e.target.closest || !e.target.closest("#thinkModeTrack")) return;
|
||||
const track = $("thinkModeTrack");
|
||||
if (!track) return;
|
||||
e.preventDefault();
|
||||
_dragging = true;
|
||||
track.setPointerCapture(e.pointerId);
|
||||
setLevelFromPointer(e.clientX);
|
||||
});
|
||||
document.addEventListener("pointermove", (e) => {
|
||||
if (!_dragging) return;
|
||||
setLevelFromPointer(e.clientX);
|
||||
});
|
||||
const endDrag = (e) => {
|
||||
if (!_dragging) return;
|
||||
_dragging = false;
|
||||
const track = $("thinkModeTrack");
|
||||
if (track) {
|
||||
try { track.releasePointerCapture(e.pointerId); } catch { /* ignore */ }
|
||||
}
|
||||
commitLevel(state.thinkingLevel);
|
||||
};
|
||||
document.addEventListener("pointerup", endDrag);
|
||||
document.addEventListener("pointercancel", endDrag);
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
@@ -159,17 +136,6 @@ function closePanel() {
|
||||
_panelOpen = false;
|
||||
}
|
||||
|
||||
function setLevelFromPointer(clientX) {
|
||||
const track = $("thinkModeTrack");
|
||||
if (!track) return;
|
||||
const rect = track.getBoundingClientRect();
|
||||
// 横向滑条:左端=low(0%),右端=ultra(100%)
|
||||
const pct = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
|
||||
const idx = Math.round(pct * (THINK_LEVELS.length - 1));
|
||||
state.thinkingLevel = THINK_LEVELS[idx];
|
||||
syncThinkLevelUI();
|
||||
}
|
||||
|
||||
/** 提交档位到当前会话(会话级变量,存于该会话 session_meta.json) */
|
||||
async function commitLevel(level) {
|
||||
if (_committing) return;
|
||||
@@ -187,7 +153,7 @@ async function commitLevel(level) {
|
||||
if (data && data.level) {
|
||||
state.thinkingLevel = data.level;
|
||||
syncThinkLevelUI();
|
||||
showToast(`思考模式已切换为「${data.level}」(仅当前会话)`);
|
||||
showToast(`思考模式已切换为「${THINK_MODE_LABELS[data.level] || data.level}」(仅当前会话)`);
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(e.message || String(e));
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
// todo.js -- Todo List(通知条版)
|
||||
//
|
||||
// 原浮窗实现已迁移至 notify-strip.js:
|
||||
// todo 数据从 live snapshot 的 tool_calls 派生(todo_list 工具调用),
|
||||
// 以输入区上方通知条形式展示(最新步骤 + 计数 chip,可展开完整列表)。
|
||||
// 本文件保留导出签名以兼容既有依赖注入(setLiveUiDeps / clearTodoOverlay)。
|
||||
|
||||
import {
|
||||
updateTodoStrip,
|
||||
clearTodoStrip,
|
||||
saveStripSession,
|
||||
} from './notify-strip.js';
|
||||
|
||||
/** 从 live 工具调用记录派生 Todo 通知条数据。
|
||||
* @param {Array} toolCalls - live snapshot 的 tool_calls 数组 */
|
||||
function updateTodoOverlayFromRecords(toolCalls) {
|
||||
updateTodoStrip(toolCalls);
|
||||
}
|
||||
|
||||
/** 清空 todo 通知条(仅 todo,不影响人机交互条)。 */
|
||||
function clearTodoOverlay() {
|
||||
saveStripSession();
|
||||
clearTodoStrip();
|
||||
}
|
||||
|
||||
/** 兼容旧接口:通知条模式下无需构建独立 overlay。 */
|
||||
function buildTodoListOverlay() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function updateTodoOverlay() {
|
||||
// 通知条由 updateTodoStrip 驱动,此接口保留为空实现以兼容旧调用点
|
||||
}
|
||||
|
||||
export { buildTodoListOverlay, updateTodoOverlay, updateTodoOverlayFromRecords, clearTodoOverlay };
|
||||
+426
-89
@@ -9,9 +9,11 @@ const TOOL_META = {
|
||||
doc_tool: { icon: null, dynamicIcon: true }, // 按 action 选 写/读/删除.svg
|
||||
todo_list: { icon: '/image/todolist.svg' },
|
||||
browser_control: { icon: '/image/控制.svg' },
|
||||
web_page: { icon: '/image/web.svg' },
|
||||
web_page: { icon: '/image/控制.svg' },
|
||||
software_control: { icon: '/image/控制.svg' },
|
||||
terminal_execute: { icon: '/image/终端.svg' },
|
||||
terminal_info: { icon: '/image/终端.svg' },
|
||||
wait: { icon: '/image/时钟.svg', noParam: true },
|
||||
email: { icon: '/image/邮件.svg' },
|
||||
image_gen: { icon: '/image/图片生成.svg' },
|
||||
skill_router: { icon: '/image/skill.svg' },
|
||||
@@ -43,6 +45,8 @@ const HUMAN_INTERACTION_LABEL = { information: '请求信息', selection: '选
|
||||
const TOOL_DISPLAY_NAMES = {
|
||||
web_search: '联网搜索',
|
||||
terminal_execute: '终端执行',
|
||||
terminal_info: '终端信息',
|
||||
wait: '等待',
|
||||
image_gen: '图片生成',
|
||||
sendfile: '发送文件',
|
||||
skill_router: '技能加载',
|
||||
@@ -506,6 +510,45 @@ function renderUserFileCard(fileUrl, fileName, options = {}) {
|
||||
}
|
||||
|
||||
/* ===== 横向滚动行辅助:滚轮转 scrollLeft + 溢出渐变遮罩 ===== */
|
||||
|
||||
// 溢出检测批处理:新建行时若立即读 scrollWidth/clientWidth 会在 DOM 脏态下
|
||||
// 强制整页同步布局(运行中每来一条工具记录就触发数次 → 持续卡顿)。
|
||||
// 统一收集到待检集合,每帧只做一次读(同一帧内的读取可复用同一次布局结果)。
|
||||
const _overflowPending = new Set();
|
||||
let _overflowRafScheduled = false;
|
||||
|
||||
function _flushOverflowChecks() {
|
||||
_overflowRafScheduled = false;
|
||||
for (const el of _overflowPending) {
|
||||
if (!el.isConnected) {
|
||||
// 尚未入 DOM(构建与挂载跨帧):顺延到下一帧,最多重试若干次后放弃
|
||||
// (仅影响装饰性渐变遮罩,滚动时 update 仍会补上)
|
||||
el._hcRetries = (el._hcRetries || 0) + 1;
|
||||
if (el._hcRetries > 30) _overflowPending.delete(el);
|
||||
continue;
|
||||
}
|
||||
_overflowPending.delete(el);
|
||||
const overflow = el.scrollWidth > el.clientWidth + 2;
|
||||
if (el._hcOverflow !== overflow) {
|
||||
el._hcOverflow = overflow;
|
||||
el.classList.toggle("user-media-row--overflow", overflow);
|
||||
}
|
||||
}
|
||||
if (_overflowPending.size > 0 && !_overflowRafScheduled) {
|
||||
_overflowRafScheduled = true;
|
||||
requestAnimationFrame(_flushOverflowChecks);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleOverflowCheck(el) {
|
||||
if (!el) return;
|
||||
_overflowPending.add(el);
|
||||
if (!_overflowRafScheduled) {
|
||||
_overflowRafScheduled = true;
|
||||
requestAnimationFrame(_flushOverflowChecks);
|
||||
}
|
||||
}
|
||||
|
||||
function attachHorizontalScroll(el) {
|
||||
if (!el || el._hcBound) return;
|
||||
el._hcBound = true;
|
||||
@@ -516,12 +559,15 @@ function attachHorizontalScroll(el) {
|
||||
}
|
||||
}, { passive: false });
|
||||
const update = () => {
|
||||
el.classList.toggle("user-media-row--overflow", el.scrollWidth > el.clientWidth + 2);
|
||||
const overflow = el.scrollWidth > el.clientWidth + 2;
|
||||
if (el._hcOverflow !== overflow) {
|
||||
el._hcOverflow = overflow;
|
||||
el.classList.toggle("user-media-row--overflow", overflow);
|
||||
}
|
||||
};
|
||||
el.addEventListener("scroll", update, { passive: true });
|
||||
// 延迟一次以等待内容布局
|
||||
requestAnimationFrame(update);
|
||||
setTimeout(update, 100);
|
||||
// 初始溢出判定延迟到下一帧统一批处理(避免构建期强制同步布局)
|
||||
scheduleOverflowCheck(el);
|
||||
}
|
||||
|
||||
/* ===== 单条工具调用行渲染 =====
|
||||
@@ -589,6 +635,8 @@ function renderToolCallRow(call, options = {}) {
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "tool-call-row" + (isRunning ? " tool-call-row--running" : " tool-call-row--done") + (defaultExpanded ? " tool-call-row--expanded" : "");
|
||||
// 工具调用 id(tool_call_id):live 补丁 diff 按 id 匹配复用旧 DOM
|
||||
if (call.id) row.dataset.tcId = String(call.id);
|
||||
|
||||
// 图标(运行中橙黄,结束绿色;点击展开/折叠)
|
||||
const iconPath = getToolIcon(call);
|
||||
@@ -643,8 +691,14 @@ function renderToolCallRow(call, options = {}) {
|
||||
icon.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const nowExpanded = row.classList.toggle("tool-call-row--expanded");
|
||||
// 展开后若详情在可视区外,滚动到可见位置(block:nearest 已可见时不滚动)
|
||||
if (nowExpanded) detailEl.scrollIntoView({ block: "nearest" });
|
||||
if (nowExpanded) {
|
||||
// 懒解码:展开后才加载 base64 大图(避免一次性同步解码卡顿)
|
||||
_loadLazyImages(detailEl);
|
||||
// 滚动延迟到下一帧执行,避免与图片解码同帧阻塞点击响应
|
||||
requestAnimationFrame(() => {
|
||||
if (detailEl.isConnected) detailEl.scrollIntoView({ block: "nearest" });
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -699,6 +753,8 @@ function renderToolCallRow(call, options = {}) {
|
||||
}
|
||||
|
||||
// 图片结果(image_gen / 截图等)
|
||||
// base64 大图(可达数百 KB~MB):构建时直接赋 src 会在展开瞬间同步解码
|
||||
// 全部图片导致主线程卡顿;改为懒解码——折叠时不加载,行展开时统一赋值。
|
||||
const imageUrls = Array.isArray(call.result_images) ? call.result_images.filter(Boolean) : [];
|
||||
if (imageUrls.length > 0 && !isSubAgentCall) {
|
||||
const imgWrap = document.createElement("div");
|
||||
@@ -706,14 +762,15 @@ function renderToolCallRow(call, options = {}) {
|
||||
imageUrls.forEach((url) => {
|
||||
const img = document.createElement("img");
|
||||
img.className = "tc-detail-img";
|
||||
img.src = url;
|
||||
img.alt = "工具返回图片";
|
||||
img.loading = "lazy";
|
||||
img.setAttribute("data-lazy-src", url);
|
||||
img.addEventListener("click", (e) => { e.stopPropagation(); showImagePreview(url); });
|
||||
imgWrap.appendChild(img);
|
||||
});
|
||||
attachHorizontalScroll(imgWrap);
|
||||
detailEl.appendChild(imgWrap);
|
||||
// 默认展开的行:构建后立即加载
|
||||
if (defaultExpanded) _loadLazyImages(imgWrap);
|
||||
}
|
||||
|
||||
// sendfile 返回的文件/文件夹 → 优先用 result_file_info(正确文件名)
|
||||
@@ -835,9 +892,14 @@ function renderPersistentToolCalls(toolCalls, options = {}) {
|
||||
const toggleArrow = document.createElement("span");
|
||||
toggleArrow.className = "tc-list-toggle-arrow";
|
||||
const toggleText = document.createElement("span");
|
||||
toggleText.className = "tc-list-toggle-text";
|
||||
const updateToggleText = (elapsedSec) => {
|
||||
const dur = isLive ? elapsedSec : totalDuration;
|
||||
toggleText.textContent = `调用 ${totalCount} 个工具 · 用时 ${formatDuration(dur)}`;
|
||||
// live 模式工具计数动态读取顶层行数(列表改为增量更新,不再整条重建)
|
||||
const cnt = isLive
|
||||
? wrap.querySelectorAll(":scope > .tc-list-toggle-body > .tool-call-row").length
|
||||
: totalCount;
|
||||
toggleText.textContent = `调用 ${cnt} 个工具 · 用时 ${formatDuration(dur)}`;
|
||||
};
|
||||
updateToggleText(isLive ? _calcLiveDuration(toolCalls, turnStartedAt) : totalDuration);
|
||||
toggle.appendChild(toggleArrow);
|
||||
@@ -914,6 +976,16 @@ function renderPersistentToolCalls(toolCalls, options = {}) {
|
||||
|
||||
/* ===== Live 工具调用增量更新(运行中) ===== */
|
||||
|
||||
/** 工具调用行签名缓存:按 call 对象身份复用(delta 合并时更新记录为新对象) */
|
||||
const _sigCache = new WeakMap();
|
||||
|
||||
/** djb2 字符串散列:单遍扫描、零中间分配,用于大文本的廉价变更检测 */
|
||||
function _djb2(s) {
|
||||
let h = 5381;
|
||||
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
|
||||
return h;
|
||||
}
|
||||
|
||||
/** 判断两行工具调用渲染是否完全相同(名称/运行状态/参数/详情内容)。 */
|
||||
function _sameToolCallRow(oldRow, newRow) {
|
||||
const status = (r) => (r.classList.contains("tool-call-row--running") ? "running" : "done");
|
||||
@@ -948,54 +1020,235 @@ function injectLiveToolCalls(typingEl, liveCalls, turnStartedAt, _reflections) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存展开状态:记录哪些 tool-call-row 和 think-row 是展开的
|
||||
let expandedRows = new Set();
|
||||
let collapsedThinkRows = new Set();
|
||||
oldWrap.querySelectorAll(".tool-call-row--expanded").forEach((r) => {
|
||||
const idx = Array.from(oldWrap.querySelectorAll(".tool-call-row")).indexOf(r);
|
||||
if (idx >= 0) expandedRows.add(idx);
|
||||
});
|
||||
oldWrap.querySelectorAll(".think-row").forEach((r, i) => {
|
||||
if (!r.classList.contains("think-row--expanded")) collapsedThinkRows.add(i);
|
||||
});
|
||||
if (oldWrap._stopTimer) oldWrap._stopTimer();
|
||||
// ===== 增量就地更新(不重建整个列表) =====
|
||||
// 快照高频到达时全量重建的成本随记录数线性增长:长列表(几十上百条工具
|
||||
// 调用)每帧重建会占满主线程(点击/滚动卡顿),且整块替换导致滚动跳变。
|
||||
// 改为按 id 就地复用/更新/追加/移除,仅变更行与新增行产生 DOM 操作。
|
||||
const body = oldWrap.querySelector(".tc-list-toggle-body");
|
||||
if (!body) return;
|
||||
|
||||
// 将 reflections 传给 renderPersistentToolCalls
|
||||
const newWrap = renderPersistentToolCalls(liveCalls, { live: true, reflections: _reflections, turnStartedAt: turnStartedAt });
|
||||
if (!newWrap) return;
|
||||
// 滚动保持:贴底跟随,否则保持原位置。
|
||||
// 位置读取延迟到首次实际变更前(captureScroll):快照无变化时直接跳过,
|
||||
// 避免每次注入都读 scrollHeight 在脏 DOM 上强制同步布局。
|
||||
let savedTop = 0;
|
||||
let wasAtBottom = false;
|
||||
let scrollCaptured = false;
|
||||
let mutated = false;
|
||||
const captureScroll = () => {
|
||||
if (scrollCaptured) return;
|
||||
scrollCaptured = true;
|
||||
savedTop = body.scrollTop;
|
||||
wasAtBottom = body.scrollHeight - body.scrollTop - body.clientHeight < 24;
|
||||
};
|
||||
|
||||
// 行级 diff:未变化的行复用旧 DOM(保持阅读位置与展开态),
|
||||
// 仅更新状态变化的行并追加新行,避免整个列表每次快照都重建
|
||||
const oldRows = Array.from(oldWrap.querySelectorAll(".tool-call-row"));
|
||||
const newRows = Array.from(newWrap.querySelectorAll(".tool-call-row"));
|
||||
newRows.forEach((newRow, i) => {
|
||||
const oldRow = oldRows[i];
|
||||
if (oldRow && _sameToolCallRow(oldRow, newRow)) {
|
||||
newRow.replaceWith(oldRow);
|
||||
// 现有顶层行按 id 索引(嵌套子 Agent 行不参与)
|
||||
const rowById = new Map();
|
||||
Array.from(body.children).forEach((el) => {
|
||||
if (el.classList.contains("tool-call-row") && el.dataset.tcId) {
|
||||
rowById.set(el.dataset.tcId, el);
|
||||
}
|
||||
});
|
||||
|
||||
oldWrap.replaceWith(newWrap);
|
||||
// 期望序列:思考/反思块穿插在工具调用之间(与 renderPersistentToolCalls 同逻辑)
|
||||
const reflectionMap = new Map();
|
||||
(_reflections || []).forEach((r) => {
|
||||
if (!r || !r.content) return;
|
||||
const idx = (r.between_calls != null) ? r.between_calls : -1;
|
||||
if (!reflectionMap.has(idx)) reflectionMap.set(idx, []);
|
||||
reflectionMap.get(idx).push({ content: r.content, kind: r.kind === "reflection" ? "reflection" : "thought" });
|
||||
});
|
||||
const seq = [];
|
||||
let thinkNo = 0;
|
||||
liveCalls.forEach((call, ci) => {
|
||||
(reflectionMap.get(ci) || []).forEach((item) => {
|
||||
thinkNo++;
|
||||
seq.push({ type: "think", content: item.content, kind: item.kind, index: thinkNo });
|
||||
});
|
||||
seq.push({ type: "row", call });
|
||||
});
|
||||
(reflectionMap.get(-1) || []).forEach((item) => {
|
||||
thinkNo++;
|
||||
seq.push({ type: "think", content: item.content, kind: item.kind, index: thinkNo });
|
||||
});
|
||||
|
||||
// 恢复展开状态
|
||||
if (expandedRows.size > 0 || collapsedThinkRows.size > 0) {
|
||||
const rows = newWrap.querySelectorAll(".tool-call-row");
|
||||
expandedRows.forEach((idx) => {
|
||||
if (idx < rows.length) {
|
||||
rows[idx].classList.add("tool-call-row--expanded");
|
||||
// 按序消费现有子元素:复用 → 原地更新 → 追加;未消费的(已消失的记录)移除。
|
||||
// detail 跟随其行:行被复用/插入时其后的 detail 一并标记为已消费。
|
||||
const children = Array.from(body.children);
|
||||
const consumed = new Set();
|
||||
let cursor = 0;
|
||||
|
||||
// 游标起第一个未消费元素的下标(跳过已消费的 detail/行),没有则指向末尾
|
||||
const anchorIdxFrom = (from) => {
|
||||
let i = from;
|
||||
while (i < children.length && consumed.has(children[i])) i++;
|
||||
return i;
|
||||
};
|
||||
|
||||
const markDetailConsumed = (row) => {
|
||||
const next = row.nextElementSibling;
|
||||
if (next && next.classList && next.classList.contains("tc-detail")) consumed.add(next);
|
||||
};
|
||||
|
||||
// 行内容签名:仅状态/结果/思考/反思/子调用变化时才重建该行(避免每帧全量构建)。
|
||||
// 签名按 call 对象缓存(WeakMap):delta 合并时更新的记录是全新对象,同一对象
|
||||
// 的签名不会变化——避免每次注入(~12Hz)都重新扫描巨量 result_text 拼接字符串
|
||||
// (长工具输出下单次注入即产生 MB 级字符串分配,运行全程持续占满主线程)。
|
||||
// 大文本用长度+djb2 散列参与签名,消除分配。
|
||||
const rowSig = (call) => {
|
||||
const cached = _sigCache.get(call);
|
||||
if (cached !== undefined) return cached;
|
||||
const rt = call.result_text || "";
|
||||
const subs = (call.sub_tool_calls || []).map((s) => s.id || "").join(",");
|
||||
const sig = `${call.status}|${rt.length}:${_djb2(rt)}|${_djb2(String(call.thinking || ""))}|${_djb2(String(call.reflection || ""))}|${subs}`;
|
||||
_sigCache.set(call, sig);
|
||||
return sig;
|
||||
};
|
||||
|
||||
seq.forEach((item) => {
|
||||
if (item.type === "think") {
|
||||
// 从游标起找内容匹配的未消费思考行
|
||||
let reuse = null;
|
||||
for (let i = cursor; i < children.length; i++) {
|
||||
const el = children[i];
|
||||
if (consumed.has(el) || !el.classList.contains("think-row")) continue;
|
||||
const d = el.querySelector(".think-row-detail");
|
||||
if (d && d.textContent === item.content) { reuse = el; cursor = i + 1; break; }
|
||||
}
|
||||
});
|
||||
// 恢复思考行折叠状态(live 重建时默认展开,需回设为折叠)
|
||||
const thinkRows = newWrap.querySelectorAll(".think-row");
|
||||
thinkRows.forEach((row, i) => {
|
||||
if (collapsedThinkRows.has(i)) {
|
||||
row.classList.remove("think-row--expanded");
|
||||
if (!reuse) {
|
||||
captureScroll();
|
||||
const ai = anchorIdxFrom(cursor);
|
||||
reuse = renderThinkRow(item.content, { live: true, index: item.index, type: item.kind });
|
||||
body.insertBefore(reuse, children[ai] || null);
|
||||
children.splice(ai, 0, reuse);
|
||||
cursor = ai + 1;
|
||||
mutated = true;
|
||||
} else {
|
||||
// 思考行编号就地修正(前方插入新思考时编号顺延)
|
||||
const label = reuse.querySelector(".think-row-label");
|
||||
if (label) {
|
||||
label.textContent = item.kind === "reflection" ? `Reflection #${item.index}` : `Thought #${item.index}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
consumed.add(reuse);
|
||||
} else {
|
||||
const call = item.call;
|
||||
const id = call.id ? String(call.id) : null;
|
||||
let row = (id && rowById.get(id) && !consumed.has(rowById.get(id))) ? rowById.get(id) : null;
|
||||
if (row) {
|
||||
consumed.add(row);
|
||||
const pos = children.indexOf(row);
|
||||
if (pos < cursor) {
|
||||
// 顺序变化(罕见):移动到游标处(跳过已消费元素)
|
||||
captureScroll();
|
||||
const ai = anchorIdxFrom(cursor);
|
||||
children.splice(pos, 1);
|
||||
children.splice(ai - 1, 0, row);
|
||||
body.insertBefore(row, children[ai] || null);
|
||||
cursor = ai;
|
||||
mutated = true;
|
||||
} else {
|
||||
cursor = pos + 1;
|
||||
}
|
||||
// 仅内容变化的行重建(状态/结果/思考/子调用),保留展开态
|
||||
if (row._sig !== rowSig(call)) {
|
||||
captureScroll();
|
||||
mutated = true;
|
||||
const pos2 = children.indexOf(row);
|
||||
const oldDetail = (row.nextElementSibling && row.nextElementSibling.classList.contains("tc-detail"))
|
||||
? row.nextElementSibling : null;
|
||||
const wasExpanded = row.classList.contains("tool-call-row--expanded");
|
||||
const built = renderToolCallRow(call, { defaultExpanded: false });
|
||||
built.row._sig = rowSig(call);
|
||||
consumed.add(built.row);
|
||||
if (built.detail) consumed.add(built.detail);
|
||||
row.replaceWith(built.row);
|
||||
if (oldDetail) oldDetail.remove();
|
||||
if (built.detail) body.insertBefore(built.detail, built.row.nextSibling);
|
||||
if (wasExpanded && built.detail) {
|
||||
built.row.classList.add("tool-call-row--expanded");
|
||||
_loadLazyImages(built.detail);
|
||||
}
|
||||
children.splice(pos2, 1);
|
||||
if (oldDetail) {
|
||||
const di = children.indexOf(oldDetail);
|
||||
if (di >= 0) children.splice(di, 1);
|
||||
}
|
||||
children.splice(pos2, 0, built.row);
|
||||
if (built.detail) children.splice(pos2 + 1, 0, built.detail);
|
||||
cursor = pos2 + 1 + (built.detail ? 1 : 0);
|
||||
if (id) rowById.set(id, built.row);
|
||||
} else {
|
||||
markDetailConsumed(row);
|
||||
}
|
||||
} else {
|
||||
// 新增行:插入锚点处(含其 detail)
|
||||
captureScroll();
|
||||
const ai = anchorIdxFrom(cursor);
|
||||
const built = renderToolCallRow(call, { defaultExpanded: false });
|
||||
built.row._sig = rowSig(call);
|
||||
body.insertBefore(built.row, children[ai] || null);
|
||||
children.splice(ai, 0, built.row);
|
||||
cursor = ai + 1;
|
||||
mutated = true;
|
||||
if (built.detail) {
|
||||
body.insertBefore(built.detail, built.row.nextSibling);
|
||||
children.splice(cursor, 0, built.detail);
|
||||
cursor++;
|
||||
consumed.add(built.detail);
|
||||
}
|
||||
consumed.add(built.row);
|
||||
if (id) rowById.set(id, built.row);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 移除未消费的(记录已消失的行/思考/孤立 detail)
|
||||
children.forEach((el) => {
|
||||
if (!consumed.has(el)) { captureScroll(); el.remove(); mutated = true; }
|
||||
});
|
||||
|
||||
// 滚动保持(仅列表实际变化时恢复,避免每帧 3 次布局读取 + 写入)
|
||||
if (mutated) {
|
||||
if (wasAtBottom) {
|
||||
body.scrollTop = body.scrollHeight;
|
||||
} else {
|
||||
body.scrollTop = savedTop;
|
||||
}
|
||||
}
|
||||
|
||||
// 执行中默认展开,用户可折叠;完成后由 renderAssistantContent 控制默认折叠
|
||||
newWrap.classList.add("tool-call-list-wrap--expanded");
|
||||
// 折叠条计数与总计时由 renderPersistentToolCalls 的 1s 计时器就地刷新
|
||||
// (列表不再整条重建,这里不再每次注入重写文本,避免对聊天 DOM 高频触发布局)
|
||||
}
|
||||
|
||||
/* ===== 上下文压缩 toast 去重(模块级,跨轮次保留) ===== */
|
||||
let _shownSummarizerToasts = null;
|
||||
|
||||
/* ===== 一次性实时事件 toast(如"LLM 超时正在重试"),按事件 id 去重 ===== */
|
||||
let _shownLiveEvents = null;
|
||||
|
||||
function showLiveEvents(events) {
|
||||
if (!Array.isArray(events)) return;
|
||||
if (!_shownLiveEvents) _shownLiveEvents = new Set();
|
||||
for (const ev of events) {
|
||||
if (!ev || !ev.text) continue;
|
||||
const key = ev.id || ev.text;
|
||||
if (_shownLiveEvents.has(key)) continue;
|
||||
_shownLiveEvents.add(key);
|
||||
if (_shownLiveEvents.size > 100) _shownLiveEvents.clear();
|
||||
showToast(ev.text);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 懒解码(展开时加载 base64 大图) ===== */
|
||||
function _loadLazyImages(root) {
|
||||
if (!root) return;
|
||||
root.querySelectorAll("img[data-lazy-src]").forEach((img) => {
|
||||
const src = img.getAttribute("data-lazy-src");
|
||||
if (!src || img.src) return;
|
||||
img.decoding = "async";
|
||||
img.src = src;
|
||||
img.removeAttribute("data-lazy-src");
|
||||
});
|
||||
}
|
||||
|
||||
/* ===== 思考记录伪流式输出(介于工具调用之间) ===== */
|
||||
@@ -1031,6 +1284,7 @@ function injectThinkStream(typingEl, thinkContent, options = {}) {
|
||||
const totalMs = Math.min(3000, Math.max(250, (n / charsPerSec) * 1000));
|
||||
const t0 = performance.now();
|
||||
let last = "";
|
||||
let lastPaintAt = -Infinity;
|
||||
|
||||
const cancel = () => {
|
||||
if (thinkBlock._raf) { cancelAnimationFrame(thinkBlock._raf); thinkBlock._raf = null; }
|
||||
@@ -1042,8 +1296,11 @@ function injectThinkStream(typingEl, thinkContent, options = {}) {
|
||||
const p = Math.min(1, elapsed / totalMs);
|
||||
const k = Math.min(n, Math.floor(p * n));
|
||||
const snapshot = full.slice(0, k);
|
||||
if (snapshot !== last) {
|
||||
// 限流 ~12fps:textContent 重写会触发布局,帧级更新在长思考文本下持续占用主线程;
|
||||
// 间隔不足时仅推进进度不碰 DOM,结束帧补一次全量
|
||||
if (snapshot !== last && (p >= 1 || now - lastPaintAt >= 80)) {
|
||||
last = snapshot;
|
||||
lastPaintAt = now;
|
||||
thinkBlock.textContent = snapshot;
|
||||
}
|
||||
if (p < 1) {
|
||||
@@ -1122,7 +1379,6 @@ export function setLiveUiDeps(deps) {
|
||||
|
||||
function startPollLiveToolCalls(sessionId, typingEl, injectFn) {
|
||||
let active = true;
|
||||
let lastReflectionCount = 0;
|
||||
let collectedReflections = [];
|
||||
let turnStartedAt = null;
|
||||
let eventSource = null;
|
||||
@@ -1131,21 +1387,82 @@ function startPollLiveToolCalls(sessionId, typingEl, injectFn) {
|
||||
// rAF 合并:同一帧内多个快照只注入最新一次。
|
||||
// 运行中快照高频到达(每次工具记录/结果/反思变更都推送),
|
||||
// 直接逐条重建工具列表会导致卡顿;合并后渲染频率上限 = 帧率。
|
||||
// 进一步限流:整表 diff + 滚动读取按 ~12.5Hz 上限执行,避免运行全程
|
||||
// 每帧 diff 持续占满主线程;内容未变的快照(如仅 human_requests 变化)
|
||||
// 直接跳过注入,不触发任何 DOM/布局操作。
|
||||
let _pendingInject = null;
|
||||
let _injectRafId = 0;
|
||||
let _injectRafId = 0; // 已调度标记:0=空闲,1=定时器顺延占位,其余=rAF id
|
||||
let _injectTimerId = 0; // 顺延 setTimeout id(限流等待期间)
|
||||
let _liveVersion = 0; // 记录/反思内容版本(handleSnapshot 变化时 +1)
|
||||
let _lastInjectedVersion = 0; // 已注入的版本
|
||||
let _lastInjectAt = 0; // 上次注入完成时刻
|
||||
const _MIN_INJECT_INTERVAL_MS = 120; // 与后端 ~0.12s 推送防抖对齐,降低运行期 DOM diff 频率
|
||||
|
||||
const scheduleInject = (liveCalls) => {
|
||||
_pendingInject = { typingEl, injectFn, liveCalls, turnStartedAt, reflections: collectedReflections };
|
||||
if (_injectRafId) return;
|
||||
_injectRafId = requestAnimationFrame(() => {
|
||||
_pendingInject = { typingEl, injectFn, liveCalls, turnStartedAt, reflections: collectedReflections, version: _liveVersion };
|
||||
if (_injectRafId) return; // 已有待执行注入,等待中的快照已被最新值覆盖
|
||||
const run = () => {
|
||||
_injectRafId = 0;
|
||||
const p = _pendingInject;
|
||||
_pendingInject = null;
|
||||
if (p && p.typingEl && p.injectFn && p.liveCalls && p.liveCalls.length) {
|
||||
p.injectFn(p.typingEl, p.liveCalls, p.turnStartedAt, p.reflections);
|
||||
if (!p || !p.typingEl || !p.injectFn || !p.liveCalls || !p.liveCalls.length) return;
|
||||
// 内容无变化(仅人工请求等非工具字段变化)→ 跳过整表 diff
|
||||
if (p.version === _lastInjectedVersion) return;
|
||||
const wait = _MIN_INJECT_INTERVAL_MS - (performance.now() - _lastInjectAt);
|
||||
if (wait > 0) {
|
||||
_injectRafId = 1; // 占位:顺延期间新快照只更新 _pendingInject,不再调度
|
||||
_injectTimerId = setTimeout(run, wait);
|
||||
return;
|
||||
}
|
||||
_lastInjectAt = performance.now();
|
||||
_lastInjectedVersion = p.version;
|
||||
p.injectFn(p.typingEl, p.liveCalls, p.turnStartedAt, p.reflections);
|
||||
};
|
||||
_injectRafId = requestAnimationFrame(run);
|
||||
};
|
||||
|
||||
// ===== 本地合并的完整实时状态(增量推送 → 按 id 合并;全量推送 → 整体替换) =====
|
||||
let liveCalls = []; // 完整工具调用列表(与后端索引对齐,供增量渲染)
|
||||
let liveReflectionCount = 0; // 服务端反思条目计数(增量对齐校验用)
|
||||
|
||||
// 服务端反思条目 → 前端思考/反思块(条目可同时携带 thought + reflection)
|
||||
const collectReflections = (serverReflections) => {
|
||||
if (!Array.isArray(serverReflections) || !serverReflections.length) return;
|
||||
const pos = liveCalls.length;
|
||||
serverReflections.forEach((r) => {
|
||||
if (!r) return;
|
||||
if (r.thought) collectedReflections.push({ content: r.thought, between_calls: pos, kind: "thought" });
|
||||
if (r.reflection) collectedReflections.push({ content: r.reflection, between_calls: pos, kind: "reflection" });
|
||||
// 旧格式兼容:仅有 content 时按思考处理
|
||||
if (!r.thought && !r.reflection && r.content) {
|
||||
collectedReflections.push({ content: r.content, between_calls: pos, kind: "thought" });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const summarizerToast = (calls) => {
|
||||
for (const tc of calls) {
|
||||
if (tc.name === "__summarizer__" || tc.name === "summarizer") {
|
||||
if (!_shownSummarizerToasts) _shownSummarizerToasts = new Set();
|
||||
const key = tc.name + (tc.args ? JSON.stringify(tc.args) : '');
|
||||
if (!_shownSummarizerToasts.has(key)) {
|
||||
_shownSummarizerToasts.add(key);
|
||||
const action = (tc.args && tc.args.action) || "compress";
|
||||
showToast(`Agent 触发上下文压缩(${action})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 增量合并校验失败(丢包/队列丢弃)时:拉一次全量状态重同步
|
||||
const resync = async () => {
|
||||
if (!active) return;
|
||||
try {
|
||||
const full = await api(`/api/tool-calls/${encodeURIComponent(sessionId)}/live`);
|
||||
handleSnapshot(full);
|
||||
} catch { /* 忽略:下一条增量仍会继续尝试对齐 */ }
|
||||
};
|
||||
|
||||
const handleSnapshot = (liveData) => {
|
||||
if (!active || !liveData) return;
|
||||
if (liveData.started_at && !turnStartedAt) {
|
||||
@@ -1155,39 +1472,57 @@ function startPollLiveToolCalls(sessionId, typingEl, injectFn) {
|
||||
if (liveUiDeps.updatePendingOverlay && Array.isArray(liveData.human_requests)) {
|
||||
liveUiDeps.updatePendingOverlay(liveData.human_requests);
|
||||
}
|
||||
// Todo 浮窗:从 tool_calls 记录派生(展示类工具的通用 adapter)
|
||||
if (liveUiDeps.updateTodoOverlayFromRecords && Array.isArray(liveData.tool_calls)) {
|
||||
liveUiDeps.updateTodoOverlayFromRecords(liveData.tool_calls);
|
||||
}
|
||||
// 收集思考/反思内容(backend 条目带 thought/reflection 双字段,二者可同时存在)
|
||||
if (liveData.reflections && liveData.reflections.length > lastReflectionCount) {
|
||||
const newOnes = liveData.reflections.slice(lastReflectionCount);
|
||||
const pos = liveData.tool_calls ? liveData.tool_calls.length : -1;
|
||||
newOnes.forEach((r) => {
|
||||
if (!r) return;
|
||||
if (r.thought) collectedReflections.push({ content: r.thought, between_calls: pos, kind: "thought" });
|
||||
if (r.reflection) collectedReflections.push({ content: r.reflection, between_calls: pos, kind: "reflection" });
|
||||
// 旧格式兼容:仅有 content 时按思考处理
|
||||
if (!r.thought && !r.reflection && r.content) {
|
||||
collectedReflections.push({ content: r.content, between_calls: pos, kind: "thought" });
|
||||
}
|
||||
});
|
||||
lastReflectionCount = liveData.reflections.length;
|
||||
}
|
||||
// 处理工具调用(rAF 合并注入,高频快照不逐条重建)
|
||||
if (typingEl && injectFn && liveData.tool_calls && liveData.tool_calls.length > 0) {
|
||||
for (const tc of liveData.tool_calls) {
|
||||
if (tc.name === "__summarizer__" || tc.name === "summarizer") {
|
||||
if (!_shownSummarizerToasts) _shownSummarizerToasts = new Set();
|
||||
const key = tc.name + (tc.args ? JSON.stringify(tc.args) : '');
|
||||
if (!_shownSummarizerToasts.has(key)) {
|
||||
_shownSummarizerToasts.add(key);
|
||||
const action = (tc.args && tc.args.action) || "compress";
|
||||
showToast(`Agent 触发上下文压缩(${action})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (liveData.type === "delta") {
|
||||
// ===== 增量:只含新增/变更记录与新增反思(体积 O(变化量)) =====
|
||||
if (Array.isArray(liveData.added)) {
|
||||
liveData.added.forEach((call) => {
|
||||
if (!call || !call.id) return;
|
||||
// 按 id 去重追加(防御重复推送/全量后残留增量)
|
||||
if (!liveCalls.some((c) => c && c.id === call.id)) liveCalls.push(call);
|
||||
});
|
||||
}
|
||||
scheduleInject(liveData.tool_calls);
|
||||
if (Array.isArray(liveData.updated)) {
|
||||
liveData.updated.forEach(({ call }) => {
|
||||
if (!call || !call.id) return;
|
||||
const idx = liveCalls.findIndex((c) => c && c.id === call.id);
|
||||
if (idx >= 0) liveCalls[idx] = call;
|
||||
});
|
||||
}
|
||||
collectReflections(liveData.reflections);
|
||||
liveReflectionCount += Array.isArray(liveData.reflections) ? liveData.reflections.length : 0;
|
||||
// 版本计数:记录/反思确实变化时才安排注入(仅人工请求变化的快照跳过整表 diff)
|
||||
if ((Array.isArray(liveData.added) && liveData.added.length) ||
|
||||
(Array.isArray(liveData.updated) && liveData.updated.length) ||
|
||||
(Array.isArray(liveData.reflections) && liveData.reflections.length)) {
|
||||
_liveVersion++;
|
||||
}
|
||||
// 仅当 todo_list 记录变化时才重扫全部记录派生 todo(避免每个快照都全量扫描)
|
||||
const todoTouched =
|
||||
(Array.isArray(liveData.added) && liveData.added.some((c) => c && c.name === "todo_list")) ||
|
||||
(Array.isArray(liveData.updated) && liveData.updated.some((u) => u && u.call && u.call.name === "todo_list"));
|
||||
if (todoTouched && liveUiDeps.updateTodoOverlayFromRecords) liveUiDeps.updateTodoOverlayFromRecords(liveCalls);
|
||||
summarizerToast(liveData.added || []);
|
||||
showLiveEvents(liveData.events);
|
||||
// 对齐校验:记录数/反思数与服务端不一致 → 全量重同步
|
||||
if ((liveData.total_calls != null && liveCalls.length !== liveData.total_calls) ||
|
||||
(liveData.total_reflections != null && liveReflectionCount !== liveData.total_reflections)) {
|
||||
resync();
|
||||
return;
|
||||
}
|
||||
// 处理工具调用(rAF 合并注入,增量 DOM 更新)
|
||||
if (typingEl && injectFn && liveCalls.length) scheduleInject(liveCalls);
|
||||
} else {
|
||||
// ===== 全量:连接初始快照 / 轮询回退 / 重同步 =====
|
||||
liveCalls = Array.isArray(liveData.tool_calls) ? liveData.tool_calls.slice() : [];
|
||||
collectedReflections.length = 0;
|
||||
liveReflectionCount = Array.isArray(liveData.reflections) ? liveData.reflections.length : 0;
|
||||
collectReflections(liveData.reflections);
|
||||
_liveVersion++;
|
||||
if (liveUiDeps.updateTodoOverlayFromRecords) liveUiDeps.updateTodoOverlayFromRecords(liveCalls);
|
||||
summarizerToast(liveCalls);
|
||||
showLiveEvents(liveData.events);
|
||||
if (typingEl && injectFn && liveCalls.length) scheduleInject(liveCalls);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1227,7 +1562,9 @@ function startPollLiveToolCalls(sessionId, typingEl, injectFn) {
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
if (_injectRafId) { cancelAnimationFrame(_injectRafId); _injectRafId = 0; }
|
||||
if (_injectTimerId) { clearTimeout(_injectTimerId); _injectTimerId = 0; }
|
||||
if (_injectRafId && _injectRafId !== 1) cancelAnimationFrame(_injectRafId);
|
||||
_injectRafId = 0;
|
||||
_pendingInject = null;
|
||||
if (eventSource) { eventSource.close(); eventSource = null; }
|
||||
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
||||
|
||||
@@ -10,6 +10,7 @@ const WEEKS = 26; // 182 / 7
|
||||
|
||||
let _rgb = null; // 缓存的 --accent RGB
|
||||
let _usage = null; // 最近一次 /api/stats/usage 数据
|
||||
let _usageLoadedAt = 0; // _usage 加载时间戳(TTL 缓存用)
|
||||
let _view = "matrix"; // matrix | bars
|
||||
let _selectedDate = ""; // 柱状图选中的日期 YYYY-MM-DD
|
||||
let _models = []; // 全局模型列表(按用量降序,用于柱内分色稳定)
|
||||
@@ -113,9 +114,14 @@ function _card() {
|
||||
}
|
||||
|
||||
async function _loadUsage() {
|
||||
// TTL 缓存:欢迎区每次显示(新建/切换会话、回到空会话)都会调用 refreshUsageCard,
|
||||
// 短时间重复拉取 182 天统计并整卡重建没有必要;60s 内复用上次数据。
|
||||
const now = Date.now();
|
||||
if (_usage && now - (_usageLoadedAt || 0) < 60000) return;
|
||||
try {
|
||||
const data = await api("/api/stats/usage");
|
||||
_usage = data || {};
|
||||
_usageLoadedAt = Date.now();
|
||||
_models = Object.entries((data && data.by_model) || {})
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([m]) => m);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 工作空间列表统一从后端 workspace_config.json 读写,不再使用 localStorage
|
||||
|
||||
import { $, showToast } from './utils.js';
|
||||
import { showConfirm } from './dialog.js';
|
||||
import { showConfirm, showPrompt } from './dialog.js';
|
||||
import { api } from './api.js';
|
||||
import { state } from './state.js';
|
||||
|
||||
@@ -247,14 +247,19 @@ export async function addWorkspace() {
|
||||
_addingWorkspace = true;
|
||||
try {
|
||||
let folderPath = "";
|
||||
let hasNativePicker = false;
|
||||
try {
|
||||
const { pickFolder } = await import('./electron-api.js');
|
||||
folderPath = await pickFolder();
|
||||
hasNativePicker = true;
|
||||
} catch {
|
||||
// 非 Electron 环境或调用失败,回退到手动输入
|
||||
}
|
||||
if (!folderPath) {
|
||||
folderPath = prompt("请输入工作空间的绝对路径:", "");
|
||||
// Electron 原生选择器已取消:直接结束(Electron 不支持浏览器 prompt,取消后不再弹输入框)
|
||||
if (hasNativePicker) return;
|
||||
// Web 环境回退:用应用内输入弹窗替代原生 prompt
|
||||
folderPath = await showPrompt("请输入工作空间的绝对路径:", "");
|
||||
}
|
||||
if (!folderPath) return;
|
||||
folderPath = folderPath.trim();
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"monaco-editor": "^0.45.0"
|
||||
}
|
||||
}
|
||||
+440
-90
@@ -14,9 +14,11 @@ import json
|
||||
import mimetypes
|
||||
import os
|
||||
import queue as _queue_module
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
@@ -24,7 +26,8 @@ from typing import Any
|
||||
|
||||
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse, Response, StreamingResponse
|
||||
_SRC = Path(__file__).resolve().parent.parent.parent
|
||||
if str(_SRC) not in sys.path:
|
||||
sys.path.insert(0, str(_SRC))
|
||||
@@ -181,6 +184,27 @@ def _ensure_session_in_list(session_id: str, ids: list[str]) -> list[str]:
|
||||
return ids
|
||||
|
||||
|
||||
def _guess_mime(fp: str) -> str:
|
||||
"""按扩展名猜测 MIME(mimetypes 优先,图片/音频回退映射表)。"""
|
||||
mime, _ = mimetypes.guess_type(fp)
|
||||
if mime:
|
||||
return mime
|
||||
ext = os.path.splitext(fp)[1].lower()
|
||||
if ext in IMAGE_FILE_EXTENSIONS:
|
||||
return {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
|
||||
".ico": "image/x-icon", ".svg": "image/svg+xml"}.get(ext, "image/jpeg")
|
||||
if ext in _AUDIO_EXT:
|
||||
return {
|
||||
".wav": "audio/wav",
|
||||
".mp3": "audio/mpeg",
|
||||
".m4a": "audio/mp4",
|
||||
".flac": "audio/flac",
|
||||
".ogg": "audio/ogg",
|
||||
}.get(ext, "application/octet-stream")
|
||||
return "application/octet-stream"
|
||||
|
||||
|
||||
def _data_url_for_file(fp: str) -> str | None:
|
||||
"""将本地文件读入为 data URL,供前端直接展示(不单独提供文件下载路由)。
|
||||
|
||||
@@ -191,31 +215,14 @@ def _data_url_for_file(fp: str) -> str | None:
|
||||
data URL 字符串,失败返回 None。
|
||||
|
||||
系统定位:
|
||||
_serialize_content 将附件转为内联 data URL 以嵌入 API 响应。
|
||||
_serialize_content 中无法相对化到会话目录的附件(如 cron 记录附件)回退内联。
|
||||
|
||||
可扩展性:
|
||||
可增加缓存、大小限制、权限检查。
|
||||
"""
|
||||
if not fp or not os.path.isfile(fp):
|
||||
return None
|
||||
mime, _ = mimetypes.guess_type(fp)
|
||||
if not mime:
|
||||
ext = os.path.splitext(fp)[1].lower()
|
||||
if ext in IMAGE_FILE_EXTENSIONS:
|
||||
mime = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
|
||||
".ico": "image/x-icon",
|
||||
".svg": "image/svg+xml"}.get(ext, "image/jpeg")
|
||||
elif ext in _AUDIO_EXT:
|
||||
mime = {
|
||||
".wav": "audio/wav",
|
||||
".mp3": "audio/mpeg",
|
||||
".m4a": "audio/mp4",
|
||||
".flac": "audio/flac",
|
||||
".ogg": "audio/ogg",
|
||||
}.get(ext, "application/octet-stream")
|
||||
else:
|
||||
mime = "application/octet-stream"
|
||||
mime = _guess_mime(fp)
|
||||
try:
|
||||
with open(fp, "rb") as f:
|
||||
raw = f.read()
|
||||
@@ -225,22 +232,81 @@ def _data_url_for_file(fp: str) -> str | None:
|
||||
return f"data:{mime};base64,{b64}"
|
||||
|
||||
|
||||
def _serialize_content(content: Any, *, meta: dict[str, Any] | None = None, role: str | None = None) -> Any:
|
||||
"""将一条消息中的 content 转为 JSON 友好结构;文件以内联 data URL 输出。
|
||||
def _media_url_for_part(fp: str, session_id: str) -> str | None:
|
||||
"""附件相对化 URL:附件已被重写进会话目录时输出 /api/media 安全 URL。
|
||||
|
||||
输入:
|
||||
fp: 附件绝对路径。
|
||||
session_id: 所属会话 ID(为空或路径不在会话目录内时返回 None → data URL 回退)。
|
||||
|
||||
输出:
|
||||
/api/media?session=...&file=... URL;无法相对化时返回 None。
|
||||
"""
|
||||
if not session_id or not fp:
|
||||
return None
|
||||
try:
|
||||
from agent.utils.agent_utils import SESSIONS_ROOT
|
||||
except Exception:
|
||||
return None
|
||||
session_dir = os.path.normpath(os.path.join(SESSIONS_ROOT, session_id))
|
||||
norm = os.path.normpath(fp)
|
||||
try:
|
||||
within = os.path.commonpath([norm, session_dir]) == session_dir
|
||||
except ValueError: # 不同盘符等
|
||||
within = False
|
||||
if not within or norm == session_dir:
|
||||
return None
|
||||
rel = os.path.relpath(norm, session_dir).replace("\\", "/")
|
||||
return f"/api/media?session={urllib.parse.quote(session_id)}&file={urllib.parse.quote(rel)}"
|
||||
|
||||
|
||||
def _resolve_media_path(path_arg: str) -> Path | None:
|
||||
"""解析并校验工具媒体路径:resolved 路径必须位于会话根或 cron 根之下。
|
||||
|
||||
历史实现为 `Path(path).resolve()` 后直接返回,等于任意文件读取;
|
||||
改为根目录守卫(与 ui_session 附件加载的 commonpath 模式一致)。
|
||||
|
||||
输入:
|
||||
path_arg: 请求传入的文件路径(工具记录持久化的绝对路径)。
|
||||
|
||||
输出:
|
||||
位于根目录内的文件 Path;越界 / 不存在返回 None。
|
||||
"""
|
||||
try:
|
||||
from agent.utils.agent_utils import SESSIONS_ROOT
|
||||
from agent.core.storage import CRON_ROOT
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
p = Path(path_arg).resolve()
|
||||
except Exception:
|
||||
return None
|
||||
if not p.is_file():
|
||||
return None
|
||||
for root in (SESSIONS_ROOT, CRON_ROOT):
|
||||
try:
|
||||
if os.path.commonpath([str(p), os.path.normpath(str(Path(root).resolve()))]) == os.path.normpath(str(Path(root).resolve())):
|
||||
return p
|
||||
except ValueError: # 不同盘符等
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _serialize_content(content: Any, *, meta: dict[str, Any] | None = None, role: str | None = None, session_id: str = "") -> Any:
|
||||
"""将一条消息中的 content 转为 JSON 友好结构;附件输出 /api/media URL。
|
||||
|
||||
输入:
|
||||
content: 原始 content(str、list 或 dict)。
|
||||
meta: 消息的 meta 信息(如音频时长、自动播放等)。
|
||||
role: 消息角色(user/assistant),用于控制自动播放行为。
|
||||
session_id: 会话 ID;附件可相对化到会话目录时输出 URL,
|
||||
否则回退内联 data URL(如 cron 记录附件在 cron 目录下)。
|
||||
|
||||
输出:
|
||||
序列化后的 content(str 或 list of dict)。
|
||||
|
||||
系统定位:
|
||||
_serialize_messages 中逐条转换,供前端直接渲染。
|
||||
|
||||
可扩展性:
|
||||
可支持更多文件类型(如视频)的 data URL 输出。
|
||||
"""
|
||||
meta = meta or {}
|
||||
if isinstance(content, str):
|
||||
@@ -266,26 +332,27 @@ def _serialize_content(content: Any, *, meta: dict[str, Any] | None = None, role
|
||||
fp = fp or piece.get("audio_path")
|
||||
if not fp or not os.path.isfile(fp):
|
||||
continue
|
||||
data_url = _data_url_for_file(fp)
|
||||
if not data_url:
|
||||
url = _media_url_for_part(fp, session_id)
|
||||
if url is None:
|
||||
url = _data_url_for_file(fp)
|
||||
if not url:
|
||||
continue
|
||||
ext = os.path.splitext(fp)[1].lower()
|
||||
if ext in IMAGE_FILE_EXTENSIONS:
|
||||
parts.append({"type": "image", "url": data_url, "path": fp, "name": display_name or os.path.basename(fp)})
|
||||
parts.append({"type": "image", "url": url, "name": display_name or os.path.basename(fp)})
|
||||
elif is_typed_audio and ext in _AUDIO_EXT:
|
||||
# 仅助手 TTS 语音回复(piece.type == "audio")保留 audio part 与 duration;
|
||||
# 用户上传的音频文件(无 type 或 type != "audio")按普通文件展示。
|
||||
audio_meta = meta.get("audio") if isinstance(meta.get("audio"), dict) else {}
|
||||
parts.append({
|
||||
"type": "audio",
|
||||
"url": data_url,
|
||||
"path": fp,
|
||||
"url": url,
|
||||
"name": display_name or os.path.basename(fp),
|
||||
"autoplay": bool(meta.get("autoplay") or audio_meta.get("autoplay")) if role == "assistant" else False,
|
||||
"duration_seconds": audio_meta.get("duration_seconds"),
|
||||
})
|
||||
else:
|
||||
parts.append({"type": "file", "url": data_url, "path": fp, "name": display_name or os.path.basename(fp)})
|
||||
parts.append({"type": "file", "url": url, "name": display_name or os.path.basename(fp)})
|
||||
return parts
|
||||
|
||||
|
||||
@@ -320,7 +387,7 @@ def _serialize_messages(hist: list[dict[str, Any]] | None, session_id: str = "")
|
||||
meta = dict(m.get("meta") or {})
|
||||
meta.setdefault("output_type", "text" if role != "assistant" else meta.get("output_type") or "text")
|
||||
|
||||
serialized_content = _serialize_content(content, meta=meta, role=role)
|
||||
serialized_content = _serialize_content(content, meta=meta, role=role, session_id=session_id)
|
||||
|
||||
out.append({"role": role, "content": serialized_content, "meta": meta})
|
||||
|
||||
@@ -486,15 +553,29 @@ async def lifespan(app):
|
||||
get_scheduler().start()
|
||||
except Exception as e:
|
||||
print(f"[lifespan] 启动 CronScheduler 失败: {e}")
|
||||
try:
|
||||
# 服务启动清理:杀掉上次崩溃/异常退出遗留的终端进程树(资源边界)
|
||||
from agent.tools.terminal_tasks import startup_cleanup
|
||||
startup_cleanup()
|
||||
except Exception:
|
||||
pass
|
||||
yield
|
||||
try:
|
||||
from agent.cron.scheduler import get_scheduler
|
||||
get_scheduler().shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
# 服务退出:杀全部遗留的后台/前台进程树(资源边界兜底)
|
||||
from agent.tools.terminal_tasks import kill_all_running
|
||||
kill_all_running()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
app = FastAPI(title="minor web chat", version="1.0.0", lifespan=lifespan)
|
||||
# GZip:历史消息响应含 base64 内联附件(几 MB 级),压缩后传输+解析均更快
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1024, compresslevel=5)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
@@ -715,25 +796,64 @@ def api_session_branch(
|
||||
}
|
||||
|
||||
|
||||
def _messages_etag(session_id: str) -> str | None:
|
||||
"""会话消息轻量指纹(turn 文件数 + 最大 mtime):列目录 stat,不读内容。
|
||||
|
||||
输入:
|
||||
session_id: 会话 ID。
|
||||
|
||||
输出:
|
||||
弱 ETag(W/"...");会话目录不存在时返回 None。
|
||||
"""
|
||||
try:
|
||||
from agent.utils.agent_utils import SESSIONS_ROOT
|
||||
except Exception:
|
||||
return None
|
||||
session_dir = os.path.join(SESSIONS_ROOT, session_id)
|
||||
if not os.path.isdir(session_dir):
|
||||
return None
|
||||
count = 0
|
||||
max_mtime = 0
|
||||
try:
|
||||
for name in os.listdir(session_dir):
|
||||
if not name.startswith("turn_"):
|
||||
continue
|
||||
count += 1
|
||||
try:
|
||||
mt = os.path.getmtime(os.path.join(session_dir, name))
|
||||
if mt > max_mtime:
|
||||
max_mtime = mt
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
return None
|
||||
if count == 0:
|
||||
return None
|
||||
return f'W/"{session_id}_{count}_{int(max_mtime * 1000)}"'
|
||||
|
||||
|
||||
@app.get("/api/sessions/{session_id}/messages")
|
||||
def api_session_messages(session_id: str) -> dict[str, Any]:
|
||||
def api_session_messages(session_id: str, request: Request):
|
||||
"""切换会话:加载指定会话的消息列表与全局会话列表。
|
||||
|
||||
输入:
|
||||
session_id: 路径参数,目标会话 ID。
|
||||
request: 请求对象(If-None-Match 条件请求,与前端的会话缓存配合)。
|
||||
|
||||
输出:
|
||||
{"sessionId": str, "sessions": list[str], "messages": list[dict]}
|
||||
|
||||
系统定位:
|
||||
前端切换会话时调用。
|
||||
ETag 命中时返回 304(消息未变,前端直接用缓存)。
|
||||
"""
|
||||
if not session_id:
|
||||
raise HTTPException(status_code=400, detail="缺少 session_id")
|
||||
etag = _messages_etag(session_id)
|
||||
inm = request.headers.get("If-None-Match") if request else None
|
||||
if etag and inm and (inm.strip() == etag or inm.strip().replace("W/", "") == etag.replace("W/", "")):
|
||||
return Response(status_code=304, headers={"ETag": etag})
|
||||
hist = ui.load_ui_messages_for_session(session_id)
|
||||
ids = _ensure_session_in_list(session_id, ui.list_session_ids_ordered())
|
||||
from agent.history.tool_call_recorder import get_session_tokens
|
||||
return {
|
||||
payload = {
|
||||
"sessionId": session_id,
|
||||
"sessions": ids,
|
||||
"session_workspaces": _session_workspaces_map(ids),
|
||||
@@ -741,6 +861,9 @@ def api_session_messages(session_id: str) -> dict[str, Any]:
|
||||
"tokens": get_session_tokens(session_id),
|
||||
"thinking_level": _session_thinking_level(session_id),
|
||||
}
|
||||
if etag:
|
||||
return JSONResponse(payload, headers={"ETag": etag})
|
||||
return payload
|
||||
|
||||
|
||||
@app.post("/api/sessions/clear")
|
||||
@@ -769,14 +892,20 @@ def api_clear_session(session_id: str = Form(...)) -> dict[str, Any]:
|
||||
|
||||
|
||||
@app.post("/api/sessions/delete")
|
||||
def api_delete_session(session_id: str = Form(...)) -> dict[str, Any]:
|
||||
def api_delete_session(
|
||||
session_id: str = Form(...),
|
||||
current_session_id: str = Form(""),
|
||||
) -> dict[str, Any]:
|
||||
"""删除整个会话(含文件与历史),并回退到默认会话。
|
||||
|
||||
输入:
|
||||
session_id: 表单字段,会话 ID。
|
||||
current_session_id: 前端当前查看的会话 ID(可选)。仅当被删会话等于
|
||||
当前会话(或未传该字段,兼容旧调用)时才返回回退会话的完整消息历史;
|
||||
否则仅返回轻量会话列表,省去全量历史 + base64 附件的序列化开销。
|
||||
|
||||
输出:
|
||||
{"sessionId": str, "sessions": list[str], "messages": list[dict]}
|
||||
{"sessionId": str, "sessions": list[str], "messages": list[dict] | None}
|
||||
|
||||
系统定位:
|
||||
前端“删除会话”按钮调用。
|
||||
@@ -817,15 +946,27 @@ def api_delete_session(session_id: str = Form(...)) -> dict[str, Any]:
|
||||
pass
|
||||
|
||||
threading.Thread(target=_delayed_sweep, daemon=True).start()
|
||||
sid, ids, hist = _bootstrap()
|
||||
from agent.history.tool_call_recorder import get_session_tokens
|
||||
# 仅当删除的是前端当前会话时,前端需要回退会话的消息/令牌/思考档位;
|
||||
# 否则只需更新会话 ID 列表(hist 置 None,前端不消费)
|
||||
deleted_active = (not current_session_id) or current_session_id == session_id
|
||||
if deleted_active:
|
||||
sid, ids, hist = _bootstrap()
|
||||
from agent.history.tool_call_recorder import get_session_tokens
|
||||
tokens = get_session_tokens(sid)
|
||||
thinking_level = _session_thinking_level(sid)
|
||||
else:
|
||||
ids = ui.list_session_ids_ordered()
|
||||
sid = ids[0] if ids else ""
|
||||
hist = None
|
||||
tokens = None
|
||||
thinking_level = None
|
||||
return {
|
||||
"sessionId": sid,
|
||||
"sessions": ids,
|
||||
"session_workspaces": _session_workspaces_map(ids),
|
||||
"messages": hist,
|
||||
"tokens": get_session_tokens(sid),
|
||||
"thinking_level": _session_thinking_level(sid),
|
||||
"tokens": tokens,
|
||||
"thinking_level": thinking_level,
|
||||
}
|
||||
|
||||
|
||||
@@ -959,7 +1100,12 @@ def _chat_start_core(session_id: str, query: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"sessionId": session_id,
|
||||
"sessions": ids,
|
||||
"messages": _serialize_messages(new_hist, session_id),
|
||||
"session_workspaces": _session_workspaces_map(ids),
|
||||
# 增量契约:只返回本次新增的用户消息(canonical 版本,含最终附件 URL),
|
||||
# 前端据此替换乐观渲染的占位消息
|
||||
"new_message": _serialize_messages(new_hist[-1:], session_id)[0] if new_hist else None,
|
||||
"base_count": before,
|
||||
"total": len(new_hist),
|
||||
}
|
||||
|
||||
|
||||
@@ -989,6 +1135,7 @@ def _chat_complete_core(session_id: str, output_type: str = "text", agent_mode:
|
||||
detail="没有待完成的用户消息,请先调用 /api/chat/start",
|
||||
)
|
||||
normalized_mode = agent_mode if agent_mode in {"agent", "plan"} else "agent"
|
||||
before = len(hist)
|
||||
turn_runner.start_turn(session_id, [*hist], normalized_mode)
|
||||
result = turn_runner.wait_turn(session_id)
|
||||
if result is None or "error" in result:
|
||||
@@ -999,7 +1146,12 @@ def _chat_complete_core(session_id: str, output_type: str = "text", agent_mode:
|
||||
return {
|
||||
"sessionId": session_id,
|
||||
"sessions": ids,
|
||||
"messages": _serialize_messages(new_hist, session_id),
|
||||
"session_workspaces": _session_workspaces_map(ids),
|
||||
# 增量契约:仅返回本轮新增消息(通常为 1 条 assistant),
|
||||
# 前端据此追加渲染,不再全量传输/重解析历史
|
||||
"new_messages": _serialize_messages(new_hist[before:], session_id),
|
||||
"base_count": before,
|
||||
"total": len(new_hist),
|
||||
}
|
||||
|
||||
|
||||
@@ -1083,6 +1235,7 @@ def _stream_chat_complete_core(session_id: str, output_type: str = "text", agent
|
||||
return
|
||||
|
||||
normalized_mode = agent_mode if agent_mode in {"agent", "plan"} else "agent"
|
||||
before = len(hist)
|
||||
turn_runner.start_turn(session_id, [*hist], normalized_mode)
|
||||
result = turn_runner.wait_turn(session_id)
|
||||
if result is None or "error" in result:
|
||||
@@ -1092,18 +1245,26 @@ def _stream_chat_complete_core(session_id: str, output_type: str = "text", agent
|
||||
ui.prune_memory_session_ids()
|
||||
ids = _ensure_session_in_list(session_id, ui.list_session_ids_ordered())
|
||||
|
||||
# 提取最终 assistant 消息文本
|
||||
# 提取最终 assistant 消息文本(仅序列化增量,避免整表重解析)
|
||||
final_text = ""
|
||||
messages = _serialize_messages(new_hist, session_id)
|
||||
for m in reversed(messages):
|
||||
delta = _serialize_messages(new_hist[before:], session_id)
|
||||
for m in reversed(delta):
|
||||
if m.get("role") == "assistant":
|
||||
final_text = m.get("content", "")
|
||||
if isinstance(final_text, list):
|
||||
final_text = "".join(p.get("text", "") for p in final_text if isinstance(p, dict) and p.get("type") == "text")
|
||||
break
|
||||
|
||||
# 先发送文本消息(含完整的 messages、sessions)
|
||||
yield f"data: {json.dumps({'type': 'text', 'text': str(final_text), 'messages': messages, 'sessionId': session_id, 'sessions': ids}, ensure_ascii=False)}\n\n"
|
||||
# 先发送文本消息(增量契约:仅本轮新增消息 + 计数,前端据此追加渲染)
|
||||
yield f"data: {json.dumps({
|
||||
'type': 'text',
|
||||
'text': str(final_text),
|
||||
'new_messages': delta,
|
||||
'base_count': before,
|
||||
'total': len(new_hist),
|
||||
'sessionId': session_id,
|
||||
'sessions': ids,
|
||||
}, ensure_ascii=False)}\n\n"
|
||||
|
||||
# 语音模式:流式 TTS
|
||||
if output_type == "voice" and final_text.strip():
|
||||
@@ -1199,15 +1360,17 @@ def api_chat_abort(session_id: str = Form(...)) -> dict[str, Any]:
|
||||
# 若不在此处补写,下方 _serialize_messages 里 get_turn_meta 读到的是
|
||||
# 增量持久化的半成品记录(只有 started_at、无 duration),前端回退按
|
||||
# 工具时间戳推算总耗时,而单条计时已移除 → 本轮用时显示为 0。
|
||||
# execute_agent 随后的重复调用是安全的:live 记录已被 end_live_turn
|
||||
# 弹出,save_aborted_turn 对空记录为 no-op,不会覆盖这里的完整 meta。
|
||||
# 注意:这里只补写 meta,不能调用 end_live_turn —— graph 仍在后台执行,
|
||||
# 在途的工具调用会通过 record_tool_call_live 重建 _live_records;
|
||||
# 若在此弹出记录,execute_agent 收尾时的 save_aborted_turn 会把完整
|
||||
# 工具记录覆盖成仅剩尾部几条(暂停后只保存了一轮工具调用)。
|
||||
# 记录弹出与轮次清理由 execute_agent 的 save_aborted_turn + end_live_turn 完成。
|
||||
try:
|
||||
from agent.utils.agent_utils import get_last_turn_id
|
||||
from agent.history.tool_call_recorder import save_aborted_turn, end_live_turn
|
||||
from agent.history.tool_call_recorder import save_aborted_turn
|
||||
live_turn_id = get_last_turn_id(session_id)
|
||||
if live_turn_id:
|
||||
save_aborted_turn(session_id, live_turn_id)
|
||||
end_live_turn(session_id)
|
||||
except Exception:
|
||||
pass
|
||||
# 不 rollback,保留当前轮已完成的工具调用记录,追加暂停消息结束本轮
|
||||
@@ -1331,9 +1494,10 @@ def api_human_action(
|
||||
def api_get_agent_configs() -> dict[str, Any]:
|
||||
"""获取所有 agent 配置列表。
|
||||
|
||||
输出: {"agents": list[dict], "config_path": str}
|
||||
输出: {"agents": list[dict], "main_default_tools": list[str],
|
||||
"config_path": str}
|
||||
"""
|
||||
from agent.core.config_manager import load_models
|
||||
from agent.core.config_manager import load_models, MAIN_DEFAULT_TOOLS
|
||||
configs = load_agent_configs()
|
||||
models = {m["id"]: m for m in load_models()}
|
||||
return {
|
||||
@@ -1343,6 +1507,7 @@ def api_get_agent_configs() -> dict[str, Any]:
|
||||
"enabled": c.enabled,
|
||||
"role": c.role,
|
||||
"trajectory_rounds": c.trajectory_rounds} for c in configs],
|
||||
"main_default_tools": list(MAIN_DEFAULT_TOOLS),
|
||||
"models": list(models.values()),
|
||||
"config_path": str(AGENT_CONFIG_PATH),
|
||||
}
|
||||
@@ -1928,6 +2093,7 @@ async def api_stream_live_tool_calls(session_id: str, request: Request):
|
||||
from agent.history.tool_call_recorder import (
|
||||
subscribe_live, unsubscribe_live,
|
||||
get_live_tool_calls, get_live_reflections, get_turn_started_at,
|
||||
_build_live_snapshot,
|
||||
)
|
||||
from agent.core.human_request import get_human_requests_for_session
|
||||
|
||||
@@ -1935,13 +2101,9 @@ async def api_stream_live_tool_calls(session_id: str, request: Request):
|
||||
|
||||
async def event_stream():
|
||||
try:
|
||||
# 发送初始快照(连接时可能已有数据)
|
||||
initial = {
|
||||
"tool_calls": get_live_tool_calls(session_id),
|
||||
"reflections": get_live_reflections(session_id),
|
||||
"started_at": get_turn_started_at(session_id),
|
||||
"human_requests": get_human_requests_for_session(session_id),
|
||||
}
|
||||
# 连接建立时推送一次完整快照(含 type=full 标记),
|
||||
# 之后的队列消息均为增量 delta(只含新增/变更记录与新增反思)
|
||||
initial = _build_live_snapshot(session_id)
|
||||
yield f"event: update\ndata: {json.dumps(initial, ensure_ascii=False)}\n\n"
|
||||
|
||||
while True:
|
||||
@@ -1949,8 +2111,8 @@ async def api_stream_live_tool_calls(session_id: str, request: Request):
|
||||
break
|
||||
try:
|
||||
# 在线程中等待队列,超时后发送心跳
|
||||
snapshot = await asyncio.to_thread(q.get, True, 3.0)
|
||||
yield f"event: update\ndata: {json.dumps(snapshot, ensure_ascii=False)}\n\n"
|
||||
delta = await asyncio.to_thread(q.get, True, 3.0)
|
||||
yield f"event: update\ndata: {json.dumps(delta, ensure_ascii=False)}\n\n"
|
||||
except _queue_module.Empty:
|
||||
# 心跳保活
|
||||
yield f"event: ping\ndata: \n\n"
|
||||
@@ -1968,6 +2130,86 @@ async def api_stream_live_tool_calls(session_id: str, request: Request):
|
||||
)
|
||||
|
||||
|
||||
# ---------- 终端后台任务 API ----------
|
||||
@app.get("/api/terminal/tasks/{session_id}/live")
|
||||
def api_get_terminal_tasks(session_id: str) -> dict[str, Any]:
|
||||
"""获取某会话的后台终端任务列表(轮询兜底用,SSE 断连时回退)。"""
|
||||
try:
|
||||
from agent.tools.terminal_tasks import live_snapshot
|
||||
return live_snapshot(session_id)
|
||||
except Exception as e:
|
||||
return {"type": "full", "tasks": [], "outputs": [], "error": str(e)}
|
||||
|
||||
|
||||
@app.get("/api/terminal/tasks/{session_id}/stream")
|
||||
async def api_stream_terminal_tasks(session_id: str, request: Request):
|
||||
"""SSE 流式推送终端任务更新(任务新增/状态变化/输出增量 + 心跳)。
|
||||
|
||||
数据源:terminal_tasks 注册表的 pub/sub(与工具调用 SSE 独立通道)。
|
||||
"""
|
||||
from agent.tools.terminal_tasks import subscribe_tasks, unsubscribe_tasks, live_snapshot
|
||||
|
||||
q = subscribe_tasks(session_id)
|
||||
|
||||
async def event_stream():
|
||||
try:
|
||||
initial = live_snapshot(session_id)
|
||||
yield f"event: update\ndata: {json.dumps(initial, ensure_ascii=False)}\n\n"
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
try:
|
||||
payload = await asyncio.to_thread(q.get, True, 3.0)
|
||||
yield f"event: update\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
except _queue_module.Empty:
|
||||
yield f"event: ping\ndata: \n\n"
|
||||
finally:
|
||||
unsubscribe_tasks(session_id, q)
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Connection": "keep-alive",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/terminal/tasks/{task_id}/kill")
|
||||
def api_kill_terminal_task(task_id: int, session_id: str = Form("")) -> dict[str, Any]:
|
||||
"""终止一个后台终端任务(杀整个进程树)。"""
|
||||
try:
|
||||
from agent.tools.terminal_tasks import kill_task
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
ok = kill_task(int(task_id), session_id=session_id or "")
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="任务不存在或不属于该会话")
|
||||
return {"ok": True, "taskId": int(task_id), "status": "killed"}
|
||||
|
||||
|
||||
@app.post("/api/terminal/tasks/{session_id}/close")
|
||||
def api_close_terminal_tasks(session_id: str) -> dict[str, Any]:
|
||||
"""终止某会话的全部运行中终端任务(应用退出确认后调用)。"""
|
||||
try:
|
||||
from agent.tools.terminal_tasks import kill_all_session
|
||||
return {"ok": True, "killed": kill_all_session(session_id)}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
@app.get("/api/terminal/running-count")
|
||||
def api_get_terminal_running_count() -> dict[str, Any]:
|
||||
"""全局运行中的后台终端任务数(Electron 退出确认用)。"""
|
||||
try:
|
||||
from agent.tools.terminal_tasks import get_running_count
|
||||
return {"running": get_running_count()}
|
||||
except Exception as e:
|
||||
return {"running": 0, "error": str(e)}
|
||||
|
||||
|
||||
@app.get("/api/tool-image")
|
||||
def api_get_tool_image(path: str, session: str = "") -> FileResponse:
|
||||
"""提供工具调用历史中的图片文件。
|
||||
@@ -1979,14 +2221,10 @@ def api_get_tool_image(path: str, session: str = "") -> FileResponse:
|
||||
输出:
|
||||
图片文件响应。
|
||||
"""
|
||||
try:
|
||||
img_path = Path(path).resolve()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="无效的图片路径")
|
||||
if not img_path.is_file():
|
||||
img_path = _resolve_media_path(path)
|
||||
if img_path is None:
|
||||
raise HTTPException(status_code=404, detail="工具调用图片不存在")
|
||||
mime, _ = mimetypes.guess_type(str(img_path))
|
||||
return FileResponse(img_path, media_type=mime or "image/png")
|
||||
return FileResponse(img_path, media_type=_guess_mime(str(img_path)) or "image/png", headers={"Cache-Control": "public, max-age=86400"})
|
||||
|
||||
|
||||
@app.get("/api/tool-file")
|
||||
@@ -1994,30 +2232,91 @@ def api_get_tool_file(path: str, session: str = "") -> FileResponse:
|
||||
"""提供工具调用历史中的非图片文件(下载或播放)。
|
||||
|
||||
输入:
|
||||
path: 文件路径(可为 turn 目录下的落盘文件或原始文件路径)。
|
||||
path: 文件路径(turn 目录下的落盘文件或原始文件路径)。
|
||||
session: 兼容参数。
|
||||
|
||||
输出:
|
||||
带 Content-Disposition 的文件响应,浏览器可下载或播放。
|
||||
"""
|
||||
try:
|
||||
file_path = Path(path).resolve()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="无效的文件路径")
|
||||
if not file_path.is_file():
|
||||
file_path = _resolve_media_path(path)
|
||||
if file_path is None:
|
||||
raise HTTPException(status_code=404, detail="文件不存在")
|
||||
mime, _ = mimetypes.guess_type(str(file_path))
|
||||
media_type = mime or "application/octet-stream"
|
||||
media_type = _guess_mime(str(file_path))
|
||||
# 图片类 inline 展示,其余触发下载
|
||||
if media_type.startswith("image/") or media_type.startswith("audio/") or media_type.startswith("video/"):
|
||||
return FileResponse(file_path, media_type=media_type)
|
||||
return FileResponse(file_path, media_type=media_type, headers={"Cache-Control": "public, max-age=86400"})
|
||||
return FileResponse(
|
||||
file_path,
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": f'attachment; filename="{file_path.name}"'},
|
||||
headers={
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
"Content-Disposition": f'attachment; filename="{file_path.name}"',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ---------- 会话附件媒体路由 ----------
|
||||
_SESSION_ID_RE = re.compile(r"^\d{8}_\d{6}(_\d+)?$")
|
||||
|
||||
|
||||
@app.get("/api/media")
|
||||
def api_get_media(session: str = "", file: str = "", request: Request = None) -> Response:
|
||||
"""提供会话附件文件(图片/音频/文件,由序列化结果中的 /api/media URL 加载)。
|
||||
|
||||
与 /api/tool-image 的区别:接收会话 ID + 相对路径(附件序列化时已重写进
|
||||
会话根目录),带根目录守卫且不泄露服务器绝对路径。
|
||||
|
||||
输入:
|
||||
session: 会话 ID(时间戳格式)。
|
||||
file: 相对会话目录的路径(禁止绝对路径与 .. 穿越)。
|
||||
request: 请求对象(If-None-Match 条件请求,返回 304)。
|
||||
|
||||
输出:
|
||||
文件响应:媒体类 inline 展示,其余 attachment 下载;带 ETag/Cache-Control,
|
||||
条件请求命中时返回 304(Starlette 1.x FileResponse 不内置 304,手动处理)。
|
||||
"""
|
||||
if not session or not file:
|
||||
raise HTTPException(status_code=400, detail="缺少 session 或 file 参数")
|
||||
if not _SESSION_ID_RE.match(session):
|
||||
raise HTTPException(status_code=400, detail="非法的会话 ID")
|
||||
if os.path.isabs(file):
|
||||
raise HTTPException(status_code=400, detail="file 必须是相对路径")
|
||||
# 会话元数据文件(turn/tool 记录/session_meta)不属附件,禁止直接读取
|
||||
if os.path.basename(file.replace("\\", "/")).startswith(("turn_", "tool_", "session_")):
|
||||
raise HTTPException(status_code=400, detail="非法的文件路径")
|
||||
try:
|
||||
from agent.utils.agent_utils import SESSIONS_ROOT
|
||||
except Exception:
|
||||
raise HTTPException(status_code=404, detail="媒体文件不存在")
|
||||
session_dir = os.path.normpath(os.path.join(SESSIONS_ROOT, session))
|
||||
abs_file = os.path.normpath(os.path.join(session_dir, file.replace("/", os.sep)))
|
||||
try:
|
||||
within = os.path.commonpath([abs_file, session_dir]) == session_dir
|
||||
except ValueError: # 不同盘符等
|
||||
within = False
|
||||
if not within:
|
||||
raise HTTPException(status_code=400, detail="非法的文件路径")
|
||||
if not os.path.isfile(abs_file):
|
||||
raise HTTPException(status_code=404, detail="媒体文件不存在")
|
||||
cache_headers = {"Cache-Control": "public, max-age=86400"}
|
||||
# 附件经去重命名后不可变:以 stat 指纹做弱 ETag,条件请求命中直接 304
|
||||
try:
|
||||
st = os.stat(abs_file)
|
||||
etag = 'W/"%s-%s"' % (int(st.st_mtime_ns), st.st_size)
|
||||
except OSError:
|
||||
etag = ""
|
||||
if etag:
|
||||
cache_headers["ETag"] = etag
|
||||
inm = (request.headers.get("If-None-Match") or "") if request else ""
|
||||
if inm and (inm == etag or inm == etag.replace("W/", "")):
|
||||
return Response(status_code=304, headers=cache_headers)
|
||||
media_type = _guess_mime(abs_file)
|
||||
if media_type.startswith("image/") or media_type.startswith("audio/") or media_type.startswith("video/"):
|
||||
return FileResponse(abs_file, media_type=media_type, headers=cache_headers)
|
||||
cache_headers["Content-Disposition"] = f'attachment; filename="{os.path.basename(file)}"'
|
||||
return FileResponse(abs_file, media_type=media_type, headers=cache_headers)
|
||||
|
||||
|
||||
# ---------- Skill 管理 API ----------
|
||||
@app.get("/api/skills")
|
||||
def api_list_skills() -> dict[str, Any]:
|
||||
@@ -2257,6 +2556,33 @@ async def save_theme_config(request: Request):
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/config/user-image")
|
||||
async def save_user_image(file: UploadFile = File(...)) -> dict:
|
||||
"""上传用户自定义图片(头像 / 背景图),保存到 web/image/ 并返回可访问 URL。
|
||||
|
||||
背景:此前头像/背景图以 base64 写入 theme_config.json,主题配置可达数 MB,
|
||||
每次设置改动都要序列化并整体回写(卡顿来源)。改为上传落盘、配置只存 URL。
|
||||
|
||||
输入:
|
||||
multipart/form-data 的 file 字段(jpg/jpeg/png/webp/gif)。
|
||||
输出:
|
||||
{"url": "/image/uimg_<时间戳>.<ext>"}。
|
||||
"""
|
||||
import time as _time
|
||||
if not file or not file.filename:
|
||||
raise HTTPException(status_code=400, detail="缺少文件")
|
||||
ext = Path(file.filename).suffix.lower()
|
||||
if ext not in (".jpg", ".jpeg", ".png", ".webp", ".gif"):
|
||||
raise HTTPException(status_code=400, detail=f"不支持的图片格式: {ext}")
|
||||
name = f"uimg_{int(_time.time() * 1000)}{ext}"
|
||||
target = _IMAGE_DIR / name # 与 /image/{asset_name} 单段路由兼容
|
||||
data = await file.read()
|
||||
if not data:
|
||||
raise HTTPException(status_code=400, detail="文件为空")
|
||||
target.write_bytes(data)
|
||||
return {"url": f"/image/{name}"}
|
||||
|
||||
|
||||
@app.get("/api/bg-image")
|
||||
def serve_local_bg_image(path: str = "") -> FileResponse:
|
||||
"""提供本地任意路径的图片文件(用于自定义背景图)。
|
||||
@@ -2386,7 +2712,10 @@ _NO_CACHE = {"Cache-Control": "no-cache"}
|
||||
@app.get("/")
|
||||
def index_page() -> FileResponse:
|
||||
"""返回前端主页面 index.html。"""
|
||||
return FileResponse(_WEB_DIR / "index.html", headers=_NO_CACHE)
|
||||
index = _WEB_DIR / "index.html"
|
||||
if not index.is_file():
|
||||
raise HTTPException(status_code=404, detail="缺少 index.html")
|
||||
return FileResponse(index, headers=_NO_CACHE)
|
||||
|
||||
|
||||
# ==================== 定时任务(Cron)API ====================
|
||||
@@ -2409,7 +2738,8 @@ def _serialize_cron_messages(task_id: str) -> list[dict[str, Any]]:
|
||||
content = m.get("content")
|
||||
meta = dict(m.get("meta") or {})
|
||||
meta.setdefault("output_type", "text")
|
||||
serialized_content = _serialize_content(content, meta=meta, role=role)
|
||||
# cron 附件在 cron 目录(与 sessions 隔离),无 session_id → 回退内联 data URL
|
||||
serialized_content = _serialize_content(content, meta=meta, role=role, session_id="")
|
||||
out.append({"role": role, "content": serialized_content, "meta": meta})
|
||||
|
||||
# 为每条带 turn_id 的 assistant 消息挂载工具调用记录(统一存储,后端无关)
|
||||
@@ -2794,25 +3124,45 @@ async def api_cron_internal_finished(request: Request) -> dict[str, Any]:
|
||||
@app.get("/app.css")
|
||||
def serve_css() -> FileResponse:
|
||||
"""返回样式文件 app.css。"""
|
||||
return FileResponse(_WEB_DIR / "app.css", headers=_NO_CACHE)
|
||||
css_file = _WEB_DIR / "app.css"
|
||||
if not css_file.is_file():
|
||||
raise HTTPException(status_code=404, detail="缺少 app.css")
|
||||
return FileResponse(css_file, headers=_NO_CACHE)
|
||||
|
||||
|
||||
@app.get("/app.js")
|
||||
def serve_js() -> FileResponse:
|
||||
"""返回前端脚本 app.js。"""
|
||||
return FileResponse(_WEB_DIR / "app.js", headers=_NO_CACHE)
|
||||
js_file = _WEB_DIR / "app.js"
|
||||
if not js_file.is_file():
|
||||
raise HTTPException(status_code=404, detail="缺少 app.js")
|
||||
return FileResponse(js_file, headers=_NO_CACHE)
|
||||
|
||||
|
||||
@app.get("/js/{filename:path}")
|
||||
def serve_js_module(filename: str) -> FileResponse:
|
||||
"""返回前端 JS 模块文件。"""
|
||||
return FileResponse(_WEB_DIR / "js" / filename, headers=_NO_CACHE)
|
||||
"""返回前端 JS 模块文件(缺失返回 404,防目录穿越)。"""
|
||||
path = (_WEB_DIR / "js" / filename).resolve()
|
||||
try:
|
||||
path.relative_to((_WEB_DIR / "js").resolve())
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail="JS 资源不存在") from exc
|
||||
if not path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"web/js 目录下缺少 {filename}")
|
||||
return FileResponse(path, headers=_NO_CACHE)
|
||||
|
||||
|
||||
@app.get("/css/{filename:path}")
|
||||
def serve_css_module(filename: str) -> FileResponse:
|
||||
"""返回前端 CSS 模块文件。"""
|
||||
return FileResponse(_WEB_DIR / "css" / filename, headers=_NO_CACHE)
|
||||
"""返回前端 CSS 模块文件(缺失返回 404,防目录穿越)。"""
|
||||
path = (_WEB_DIR / "css" / filename).resolve()
|
||||
try:
|
||||
path.relative_to((_WEB_DIR / "css").resolve())
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail="CSS 资源不存在") from exc
|
||||
if not path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"web/css 目录下缺少 {filename}")
|
||||
return FileResponse(path, headers=_NO_CACHE)
|
||||
|
||||
|
||||
@app.get("/fonts/{filename}")
|
||||
|
||||
Reference in new issue
Block a user