更新压缩机制,提高前缀缓存命中率,优化UI渲染机制,提高帧数

This commit is contained in:
minorQAQ committed 2026-08-25 10:13:50 +08:00
1 parent 6131c2563e
commit 0ce72f74f1
175 files changed
+7621 -2611

No files matched your search

+6 -4
View File
@@ -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:
+90 -3
View File
@@ -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": "",
+15
View File
@@ -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",
+81
View File
@@ -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
View File
@@ -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
View File
@@ -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})
+1 -1
View File
@@ -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)
+64
View File
@@ -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)
+8
View File
@@ -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)
+63 -9
View File
@@ -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": []
}
+416 -89
View File
@@ -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
View File
@@ -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}}}}
+12
View File
@@ -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()
+4 -2
View File
@@ -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(),
+2 -1
View File
@@ -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
+85 -25
View File
@@ -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,
-61
View File
@@ -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)
+73 -14
View File
@@ -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,
+55 -3
View File
@@ -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()
+44
View File
@@ -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)
+621
View File
@@ -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
],
}
+46
View File
@@ -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)
File diff suppressed because it is too large. Load diff
+199 -117
View File
@@ -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
+2 -1
View File
@@ -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")
+1 -1
View File
@@ -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()
-1
View File
@@ -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
View File
@@ -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");
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
-472
View File
@@ -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;
}
}
-279
View File
@@ -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);
}
-7
View File
@@ -92,11 +92,4 @@
/* ===== 响应式 ===== */
@media (max-width: 640px) {
.todo-list-overlay {
left: 1rem;
right: 1rem;
transform: none;
min-width: unset;
max-width: unset;
}
}
+1 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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;
-102
View File
@@ -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;
}
+13 -7
View File
@@ -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;
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}
-42
View File
@@ -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 -1
View File
@@ -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';
+7 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 === '&' ? '&amp;' : ch === '<' ? '&lt;' : ch === '>' ? '&gt;' : 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&gt;</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
View File
@@ -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
View File
@@ -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));
-35
View File
@@ -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
View File
@@ -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; }
+6
View File
@@ -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);
+7 -2
View File
@@ -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();
+2
View File
@@ -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
View File
@@ -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}")