diff --git a/src/agent/agents/agent_runtime.py b/src/agent/agents/agent_runtime.py index e60f780..37bd89b 100644 --- a/src/agent/agents/agent_runtime.py +++ b/src/agent/agents/agent_runtime.py @@ -43,12 +43,11 @@ def _build_llm_for_agent(cfg: AgentConfig): break if model_config: - # enable_thinking: true 时传入空 extra_body 恢复模型原生行为(启用思考) - # 默认不传 extra_body,由 ChatQwen 使用禁用思考的默认值 - if model_config.get("enable_thinking"): - extra_body = {} - else: - extra_body = None + # 深度思考由全局 THINKING_LEVEL 档位控制(low/high 关闭,xhigh/max/ultra 开启), + # 取代原先 per-model 的 enable_thinking 开关。true 档位传空 extra_body 恢复模型 + # 原生行为(启用思考);否则不传,由 ChatQwen 使用禁用思考的默认值。 + from agent.utils.env_utils import get_thinking_extra_body + extra_body = get_thinking_extra_body() return ChatQwen( model=model_config.get("model", ""), api_key=model_config.get("api_key", ""), diff --git a/src/agent/config/workspace_config.json b/src/agent/config/workspace_config.json index b25f95f..4e10a96 100644 --- a/src/agent/config/workspace_config.json +++ b/src/agent/config/workspace_config.json @@ -2,5 +2,6 @@ "current": "", "list": [ "C:\\Users\\86166\\Desktop\\Agent_Learning_minor\\Agent\\src\\workspace" - ] + ], + "access_mode": "full" } \ No newline at end of file diff --git a/src/agent/core/approvals.py b/src/agent/core/approvals.py index 9706e02..7da5621 100644 --- a/src/agent/core/approvals.py +++ b/src/agent/core/approvals.py @@ -190,6 +190,8 @@ def get_pending_actions_for_session(session_id: str) -> list[dict[str, Any]]: "options": ["approve", "skip"] if is_sub else ["approve", "reject", "skip"], "agent_name": agent_name, "is_sub_agent": is_sub, + # 工作空间访问策略:越界审批原因(无则为空串) + "policy_note": entry.get("policy_note") or "", "resolved": False, }) return actions diff --git a/src/agent/core/config_manager.py b/src/agent/core/config_manager.py index a84e108..82e1aff 100644 --- a/src/agent/core/config_manager.py +++ b/src/agent/core/config_manager.py @@ -363,6 +363,7 @@ def _default_env_config() -> dict: "IMAGE_GEN_URL": "http://localhost:8904", "LLM_CONTEXT_WINDOW": "262144", "COMPRESS_RATE": "0.6", + "THINKING_LEVEL": "low", "IMG_SIZE": "768", "RAG_CHUNK_SIZE": "500", "RAG_CHUNK_OVERLAP": "50", @@ -508,13 +509,15 @@ def get_gui_llm(): if model_cfg is None: return None from agent.core.llm import ChatQwen + # 深度思考由全局 THINKING_LEVEL 档位控制(与主 Agent 一致),取代 per-model enable_thinking + from agent.utils.env_utils import get_thinking_extra_body return ChatQwen( model=model_cfg["model"], api_key=model_cfg["api_key"], base_url=model_cfg["base_url"], timeout=model_cfg.get("timeout", 60), max_retries=model_cfg.get("max_retries", 0), - extra_body={} if model_cfg.get("enable_thinking") else None, + extra_body=get_thinking_extra_body(), ) diff --git a/src/agent/core/nodes.py b/src/agent/core/nodes.py index e0137b2..4626cd2 100644 --- a/src/agent/core/nodes.py +++ b/src/agent/core/nodes.py @@ -209,8 +209,8 @@ def _is_first_agent_call(messages: list) -> bool: # ---------- ReAct 循环内上下文压缩 ---------- -def _extract_total_tokens(response: Any) -> int: - """从模型 response 中提取 total_tokens。""" +def _extract_usage(response: Any) -> dict: + """从模型 response 提取完整 usage 字典(prompt_tokens/completion_tokens/total_tokens)。""" usage = None if hasattr(response, "response_metadata"): meta = response.response_metadata @@ -220,7 +220,50 @@ def _extract_total_tokens(response: Any) -> int: meta = response.usage_metadata if isinstance(meta, dict): usage = meta - return usage.get("total_tokens", 0) if usage else 0 + return usage if isinstance(usage, dict) else {} + + +def _extract_total_tokens(response: Any) -> int: + """从模型 response 中提取 total_tokens。""" + return _extract_usage(response).get("total_tokens", 0) or 0 + + +def _estimate_token_breakdown(messages: list[Any], usage: dict) -> dict[str, float]: + """估算三类上下文的 token 占用(按输入字符占比分摊 prompt_tokens)。 + + 分类规则: + system —— SystemMessage(系统提示词、压缩摘要) + tools —— ToolMessage 与含 tool_ctx 的历史工具调用 HumanMessage + messages—— 其余(用户/助手对话),另计入 completion_tokens + + 说明: LLM API 不返回按来源分类的 token 数,此值为字符占比估算, + 仅用于前端环形指示的占比展示。 + """ + sys_chars = tool_chars = msg_chars = 0 + for m in messages or []: + text = str(getattr(m, "content", "") or "") + if isinstance(m, SystemMessage): + sys_chars += len(text) + elif isinstance(m, ToolMessage) or ( + isinstance(m, HumanMessage) and (m.additional_kwargs or {}).get("tool_ctx") + ): + tool_chars += len(text) + else: + msg_chars += len(text) + total_chars = sys_chars + tool_chars + msg_chars + prompt_tokens = usage.get("prompt_tokens", 0) or 0 + completion_tokens = usage.get("completion_tokens", 0) or 0 + if total_chars <= 0: + return {"messages": float(completion_tokens), "tools": 0.0, "system": 0.0} + + def _share(chars: int) -> float: + return round(prompt_tokens * chars / total_chars, 1) + + return { + "messages": round(_share(msg_chars) + completion_tokens, 1), + "tools": _share(tool_chars), + "system": _share(sys_chars), + } def _compute_next_cursor(session_id: str) -> int: @@ -238,7 +281,7 @@ def _compute_next_cursor(session_id: str) -> int: return -1 -def _maybe_trigger_compress(response: Any) -> tuple[str, int, int]: +def _maybe_trigger_compress(response: Any, messages: list[Any] | None = None) -> tuple[str, int, int]: """根据 LLM 返回的 token 用量判断下一步动作。 返回值 (trigger, total_tokens, threshold): @@ -247,7 +290,8 @@ def _maybe_trigger_compress(response: Any) -> tuple[str, int, int]: "sub_oom" —— 子 Agent 达到阈值(子 Agent 不压缩),由 agent 节点注入 整理提示词,让子 Agent 整理任务进度直接返回主 Agent。 - 同时将 total_tokens 存入 tool_call_recorder 供前端实时展示(仅主 Agent)。 + 同时将 total_tokens 与估算的三类占比(消息/工具/系统提示词)存入 + tool_call_recorder 供前端环形指示展示(仅主 Agent)。 """ total = _extract_total_tokens(response) if total <= 0: @@ -260,7 +304,8 @@ def _maybe_trigger_compress(response: Any) -> tuple[str, int, int]: _sid = get_current_session() or "" is_sub = is_sub_agent_context() if _sid and not is_sub: - set_session_tokens(_sid, total) + breakdown = _estimate_token_breakdown(messages or [], _extract_usage(response)) + set_session_tokens(_sid, total, breakdown=breakdown) except Exception: pass @@ -438,7 +483,7 @@ def make_call_model_node( # 基于模型返回 token 用量判断:主 Agent 标记压缩 / 子 Agent 超限整理。 # total_tokens 同时存入 session_tokens 供前端轮询(仅主 Agent)。 - trigger, _total, _threshold = _maybe_trigger_compress(response) + trigger, _total, _threshold = _maybe_trigger_compress(response, messages_for_llm) if trigger == "sub_oom": # 子 Agent 不压缩:注入整理提示词重新调用,让模型整理任务进度直接 # 返回主 Agent(提示词内附 OOM 溢出信息)。 diff --git a/src/agent/core/routing.py b/src/agent/core/routing.py index 0d038a7..40fbd69 100644 --- a/src/agent/core/routing.py +++ b/src/agent/core/routing.py @@ -15,10 +15,12 @@ from typing import Any, Literal, Sequence from langchain_core.messages import BaseMessage from langgraph.graph import END -from agent.core.approvals import build_approval_meta, build_human_interaction_meta +from agent.core.approvals import PENDING_TOOL_APPROVALS, build_approval_meta, build_human_interaction_meta from agent.core.loop_detector import should_force_end as _loop_should_force_end from agent.core.state import AgentState from agent.core.tool_policy import classify_tool_execution +from agent.core.workspace_policy import check_violation as _policy_check_violation +from agent.core.workspace_policy import decision as _policy_decision from agent.utils.tool_call_utils import normalize_tool_call @@ -58,6 +60,9 @@ def should_continue(state: AgentState) -> Literal["tools", "__end__"]: tool_call = normalize_tool_call(raw_tool_call) if classify_tool_execution(tool_call.get("name"), tool_call.get("args") or {}) == "confirm": return END + # 权限审查模式:越界操作暂停图,交由前端人工审批 + if _policy_decision(tool_call.get("name"), tool_call.get("args") or {}) == "approve": + return END return "tools" return END @@ -96,4 +101,16 @@ def first_pending_tool_meta(messages: Sequence[BaseMessage], session_id: str) -> return build_human_interaction_meta(session_id, tool_call, messages, agent_name=agent_name) if classify_tool_execution(tool_call.get("name"), tool_call.get("args") or {}) == "confirm": return build_approval_meta(session_id, tool_call, messages, agent_name=agent_name) + # 权限审查模式:越界操作注册审批(复用 tool_call 审批卡片),并携带越界原因 + if _policy_decision(tool_call.get("name"), tool_call.get("args") or {}) == "approve": + meta = build_approval_meta(session_id, tool_call, messages, agent_name=agent_name) + approval_id = (meta.get("pending_action") or {}).get("id") + if approval_id and approval_id in PENDING_TOOL_APPROVALS: + reason = (_policy_check_violation(tool_call.get("name"), tool_call.get("args") or {}) + or "检测到超出工作空间范围的操作") + PENDING_TOOL_APPROVALS[approval_id]["policy_note"] = reason + pa = meta["pending_action"] + pa["title"] = "待审批:检测到超出工作空间范围的操作" + pa["policy_note"] = reason + return meta return None diff --git a/src/agent/core/runtime.py b/src/agent/core/runtime.py index 5c216d1..e409aae 100644 --- a/src/agent/core/runtime.py +++ b/src/agent/core/runtime.py @@ -264,13 +264,6 @@ def _persist_messages_from_pending_turn( history.add_message(final_assistant) -def _persist_pending_rejection(session_id: str, pending: dict[str, Any], content: str) -> None: - """将用户拒绝待确认操作这一轮写入历史,避免下一轮上下文断层。""" - prior_messages = messages_from_dict(pending.get("messages") or []) - prior_messages.append(AIMessage(content=content)) - _persist_messages_from_pending_turn(session_id, prior_messages, pending) - - def _invoke_after_manual_tool_result( runtime: AgentRuntime, messages: list[Any], @@ -791,6 +784,9 @@ def continue_after_human_action( approval_id: pending_action.id。 decision: approve | reject | edit | skip。 - approve: 执行工具/确认续跑;skip: 不执行工具但继续图(ToolMessage 标记跳过)。 + - reject: 与手动暂停一致——暂停当前 Agent 运行、保留之前的运行记录、 + 前端追加"已被用户手动暂停"消息(对三种审批统一生效:权限不足 / + workspace 越界 / 人机交互)。 - edit: human_interaction selection 提交文本。 instruction: selection 时为用户输入文本。 @@ -814,18 +810,21 @@ def continue_after_human_action( agent_mode = pending.get("agent_mode", "agent") - # ===== 子 Agent HITL 恢复(approve / skip,无 reject)===== - if pending.get("is_sub_agent"): - return _resume_sub_agent(runtime, pending, chat_history, session_id, approval_id, decision, instruction, agent_mode) - - if decision == "reject" and pending.get("type") != "human_interaction": - content = "已拒绝,本次待确认操作不会执行。" - chat_history.append({"role": "assistant", "content": content, "meta": {}}) - _persist_pending_rejection(session_id, pending, content) + # ===== 拒绝:统一三种审批(权限不足 / workspace 越界 / 人机交互)===== + # 与手动暂停(/api/chat/abort)一致:暂停当前 Agent 运行、保留之前的运行记录, + # 前端收到"已被用户手动暂停"消息。不恢复图执行。 + if decision == "reject": + last_msg = chat_history[-1] if chat_history else None + if not (last_msg and last_msg.get("role") == "assistant" and last_msg.get("content") == "已被用户手动暂停"): + chat_history.append({"role": "assistant", "content": "已被用户手动暂停", "meta": {"paused": True}}) if session_id: sync_turn_from_chat_history(session_id, chat_history, "") return chat_history + # ===== 子 Agent HITL 恢复(approve / skip,无 reject)===== + if pending.get("is_sub_agent"): + return _resume_sub_agent(runtime, pending, chat_history, session_id, approval_id, decision, instruction, agent_mode) + # ===== 跳过:仅 tool_call 类型支持,human_interaction 不支持跳过 ===== if decision == "skip" and pending.get("type") != "human_interaction": tool_call = dict(pending.get("tool_call") or {}) diff --git a/src/agent/core/workspace_policy.py b/src/agent/core/workspace_policy.py new file mode 100644 index 0000000..3dd7cb9 --- /dev/null +++ b/src/agent/core/workspace_policy.py @@ -0,0 +1,356 @@ +"""工作空间访问策略:doc_tool / terminal_execute 的越界拦截与审批。 + +系统定位: + 工作空间选中后(见 ``web/server.py`` 的 /api/workspace 系列接口),对文档写操作 + (doc_tool 的 create/update/delete)与终端文件操作(terminal_execute 的 + working_dir / 命令变更目标)做路径沙箱校验,把"提示词劝导"变为"工具层强制"。 + + 访问模式 (access_mode,持久化于 workspace_config.json): + - restricted: 越界直接拦截,返回错误文案给 Agent 自行纠正。 + - approval: 越界时暂停图,交由前端人工审批(复用 HITL 审批卡片)。 + - full: 不做越界审查。 + + 无人值守(cron 子进程,``cron/runner.py`` 调用 ``mark_headless``)时, + approval 模式自动退化为 restricted:待审批项存于子进程内存、随进程退出丢失, + 无人在线审批,退化为拦截避免静默失败。 + + 注意: shell 命令无法完全沙箱(任意代码可执行),terminal_execute 的命令扫描为 + 保守启发式,仅拦截明显的越界文件操作;强保证来自 doc_tool 路径校验与 + working_dir 校验。 + +可扩展性: + - 新增需审查的工具时,扩展 ``check_violation`` 分支与 ``_POLICY_TOOLS``。 + - 可将访问模式改为按工作空间独立存储(当前为全局单值)。 +""" +from __future__ import annotations + +import json +import os +import pathlib +import re +from typing import Any, Literal, Optional + +# ============================================================ +# 访问模式 +# ============================================================ + +_ACCESS_MODES: tuple[str, ...] = ("restricted", "approval", "full") +_DEFAULT_MODE: str = "restricted" + +_WS_CONFIG_PATH = pathlib.Path(__file__).resolve().parents[1] / "config" / "workspace_config.json" + +# 无人值守标记环境变量(cron 子进程设置) +_HEADLESS_ENV = "AGENT_HEADLESS" + +# 进程内缓存:server.py 写模式时同步更新;get_access_mode 每次读文件兜底, +# 保证 cron 子进程能拿到用户最新选择(子进程缓存初始为 None → 读文件)。 +_mode_cache: str | None = None + + +def _read_ws_config() -> dict: + """读取 workspace_config.json;失败返回空字典。""" + if _WS_CONFIG_PATH.is_file(): + try: + with open(_WS_CONFIG_PATH, "r", encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + pass + return {} + + +def _write_ws_config(data: dict) -> bool: + """写入 workspace_config.json;失败返回 False。""" + try: + with open(_WS_CONFIG_PATH, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + return True + except OSError: + return False + + +def get_access_mode() -> str: + """读取当前访问模式(restricted | approval | full)。 + + 优先读配置文件(即时生效);读失败回退进程内缓存,再回退默认值。 + """ + global _mode_cache + try: + mode = _read_ws_config().get("access_mode", _DEFAULT_MODE) + if mode in _ACCESS_MODES: + return mode + except Exception: + pass + if _mode_cache in _ACCESS_MODES: + return _mode_cache + return _DEFAULT_MODE + + +def set_access_mode(mode: str) -> bool: + """设置访问模式并持久化到 workspace_config.json。 + + 输入: + mode: restricted | approval | full。 + + 输出: + 持久化成功返回 True,非法模式或写入失败返回 False。 + """ + global _mode_cache + if mode not in _ACCESS_MODES: + return False + cfg = _read_ws_config() + cfg["access_mode"] = mode + ok = _write_ws_config(cfg) + if ok: + _mode_cache = mode + return ok + + +# ============================================================ +# 无人值守标记(cron 子进程) +# ============================================================ + +def mark_headless() -> None: + """标记当前进程为无人值守(cron 子进程),approval 模式退化为拦截。""" + os.environ[_HEADLESS_ENV] = "1" + + +def is_headless() -> bool: + """当前进程是否无人值守(无人在线审批)。""" + return os.environ.get(_HEADLESS_ENV) == "1" + + +# ============================================================ +# 路径解析与包含判断 +# ============================================================ + +def get_workspace_root() -> str: + """获取当前工作空间根路径(运行时选中优先,回退 env_config 默认)。""" + try: + from agent.memory.system_prompt import _current_workspace_dir + if _current_workspace_dir and os.path.isabs(_current_workspace_dir): + return os.path.normpath(_current_workspace_dir) + except Exception: + pass + from agent.utils.env_utils import get_workspace_dir + return os.path.normpath(get_workspace_dir()) + + +def _resolve_target(path: str, workspace_root: str) -> str | None: + """将用户提供的路径解析为真实绝对路径;解析失败返回 None。 + + 相对路径按工作空间解析(而非进程 cwd);随后用 pathlib.Path.resolve() + 消化 ``..`` 与符号链接/junction,避免路径逃逸。 + """ + if not path or not isinstance(path, str): + return None + raw = path.strip().strip('"').strip("'") + if not raw: + return None + target = raw if os.path.isabs(raw) else os.path.join(workspace_root, raw) + try: + return str(pathlib.Path(target).resolve()) + except (OSError, ValueError): + return None + + +def _is_within(root: str, target: str) -> bool: + """判断 target 是否位于 root(含子目录)内。 + + 使用 normcase 处理 Windows 大小写不敏感;跨盘符(commonpath 抛 + ValueError)一律视为越界。 + """ + root_norm = os.path.normcase(os.path.abspath(root)) + target_norm = os.path.normcase(os.path.abspath(target)) + try: + common = os.path.commonpath([root_norm, target_norm]) + except ValueError: # 不同盘符(如 C: vs D:) + return False + return common == root_norm + + +# ============================================================ +# 越界检测 +# ============================================================ + +# doc_tool 写操作(read 放行:允许读取工作空间外的参考文档/上传附件) +_WRITE_ACTIONS = {"create", "update", "delete"} + + +def check_doc_tool(args: dict[str, Any]) -> str | None: + """doc_tool 越界检测:create/update/delete 的 file_path 必须位于工作空间内。 + + 输入: + args: 工具调用参数字典。 + + 输出: + 越界原因字符串;无越界返回 None。 + """ + action = args.get("action") + if action not in _WRITE_ACTIONS: + return None + file_path = args.get("file_path") + if not file_path or not isinstance(file_path, str): + return None # 缺路径交由工具自身报错,不在此处误拦 + root = get_workspace_root() + target = _resolve_target(file_path, root) + if target is None: + return f"无法解析目标路径: {file_path}" + if not _is_within(root, target): + return f"目标路径 '{file_path}' 不在工作空间 '{root}' 内(解析为 '{target}')" + return None + + +# 文件变更类动词(小写;含尾部空格避免误匹配普通单词) +_MUTATION_VERBS: tuple[str, ...] = ( + "remove-item", "remove ", "rm ", "rd ", "ri ", "del ", "erase ", + "new-item", "ni ", "mkdir", "md ", "set-content", "sc ", "add-content", + "ac ", "out-file", "copy-item", "copy ", "cp ", "move-item", "mv ", "mi ", + "set-location", "cd ", "pushd ", +) + +# Windows 绝对路径(盘符或 UNC),不含引号与空白 +_ABS_WIN_RE = re.compile(r"""(?|&;()]+|\\\\[^\s"'<>|&;()]+)""") +# 路径中的 .. 组件(如 ..\foo、../foo、cd ..) +_TRAVERSAL_RE = re.compile(r"""(?:^|[\s"'=;(])(\.\.(?=[\s\\/'"<>&|;)]|$))""") + + +def _path_escapes(path: str, root: str) -> bool: + """判断命令中的路径参数是否解析到工作空间外(绝对路径或 ``..`` 遍历)。 + + 相对路径(相对工作空间)解析后仍在工作空间内,不算越界。 + """ + if not path: + return False + if _TRAVERSAL_RE.search(path): + return True + if re.match(r"""[A-Za-z]:[\\/]|\\\\""", path): + target = _resolve_target(path, root) + if target is None: + return True + return not _is_within(root, target) + return False + + +def _check_command_paths(command: str, root: str) -> str | None: + """扫描命令中文件变更类动词/重定向指向的路径是否越界。 + + 启发式:对每个变更动词,用 shlex 分词其后全部参数(posix=False 保留 + Windows 反斜杠路径),逐个检查是否解析到工作空间外;重定向 ``>``/``>>`` + 的目标单独检查。无法完全沙箱 shell,仅拦截明显越界目标。 + """ + import shlex + lowered = command.lower() + for verb in _MUTATION_VERBS: + idx = lowered.find(verb) + while idx != -1: + rest = command[idx + len(verb):] + try: + tokens = shlex.split(rest, posix=False) + except ValueError: + tokens = re.findall(r"""[^\s"'<>|&;()]+""", rest) + for token in tokens: + # 去掉尾部常见符号(; | & ( 等) + candidate = token.rstrip(";|&()<>,") + if _path_escapes(candidate, root): + return (f"命令中 '{verb.strip()}' 的目标路径 '{candidate}' " + f"超出工作空间 '{root}'") + idx = lowered.find(verb, idx + 1) + # 重定向 > / >> 的目标 + for m in re.finditer(r""">>?[ \t]*("([^"]*)"|'([^']*)'|([^\s"'<>|&;()]+))""", command): + candidate = m.group(2) or m.group(3) or m.group(4) or "" + if _path_escapes(candidate, root): + return f"命令重定向目标路径 '{candidate}' 超出工作空间 '{root}'" + return None + + +def check_terminal_execute(args: dict[str, Any]) -> str | None: + """terminal_execute 越界检测:working_dir 与命令中的文件变更目标。 + + 输入: + args: 工具调用参数字典。 + + 输出: + 越界原因字符串;无越界返回 None。 + """ + root = get_workspace_root() + working_dir = args.get("working_dir") + if working_dir and isinstance(working_dir, str): + target = _resolve_target(working_dir, root) + if target is None: + return f"无法解析工作目录: {working_dir}" + if not _is_within(root, target): + return (f"工作目录 '{working_dir}' 不在工作空间 '{root}' 内" + f"(解析为 '{target}')") + command = args.get("command") + if command and isinstance(command, str): + reason = _check_command_paths(command, root) + if reason: + return reason + return None + + +# 需审查的工具(其余工具不审查) +_POLICY_TOOLS = ("doc_tool", "terminal_execute") + + +def check_violation(tool_name: str | None, args: dict[str, Any]) -> str | None: + """返回工具调用的越界原因;无越界或无需审查返回 None。 + + 输入: + tool_name: 工具名。 + args: 工具调用参数字典。 + """ + if tool_name == "doc_tool": + return check_doc_tool(args) + if tool_name == "terminal_execute": + return check_terminal_execute(args) + return None + + +def decision(tool_name: str | None, args: dict[str, Any]) -> Literal["allow", "block", "approve"]: + """根据访问模式与越界情况决定处理方式。 + + 输出: + - "allow": 放行(full 模式或未越界)。 + - "block": 拦截(restricted 模式,或 approval 模式且无人值守)。 + - "approve": 暂停待人工审批(approval 模式、交互会话)。 + """ + if get_access_mode() == "full": + return "allow" + if check_violation(tool_name, args) is None: + return "allow" + if get_access_mode() == "restricted": + return "block" + if is_headless(): + return "block" # 无人值守(cron)无人在线审批,退化为拦截 + return "approve" + + +# ============================================================ +# 拦截文案 +# ============================================================ + +_MODE_NAMES = { + "restricted": "限制访问", + "approval": "权限审查", + "full": "完全访问", +} + + +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 "检测到超出工作空间范围的操作" + mode = get_access_mode() + mode_name = _MODE_NAMES.get(mode, mode) + root = get_workspace_root() + detail = "" + if tool_name == "doc_tool" and args.get("file_path"): + detail = f"\n目标文件: {args.get('file_path')}" + elif tool_name == "terminal_execute" and args.get("command"): + detail = f"\n命令: {(args.get('command') or '')[:200]}" + return ( + f"[ERROR] 该操作已按访问模式「{mode_name}」拦截:超出工作空间范围。\n" + f"原因: {reason}\n" + f"工作空间: {root}{detail}\n" + f"请将目标路径调整到工作空间目录下后重试。" + ) diff --git a/src/agent/cron/runner.py b/src/agent/cron/runner.py index e356fbb..9bdae5b 100644 --- a/src/agent/cron/runner.py +++ b/src/agent/cron/runner.py @@ -192,6 +192,14 @@ def main() -> int: _post_finished(task_id, "failed", f"存储初始化失败: {e}", "") return 1 + # 1.1 标记无人值守:工作空间访问策略的 approval 模式在 cron 下退化为拦截 + # (待审批项存于子进程内存、随进程退出丢失,且无人在线审批) + try: + from agent.core.workspace_policy import mark_headless + mark_headless() + except Exception: + pass + # 2. 构建 AgentRuntime try: from agent.agents.agent_runtime import build_all_agent_runtimes, get_main_agent_runtime diff --git a/src/agent/history/tool_call_recorder.py b/src/agent/history/tool_call_recorder.py index f1f16b4..fb59150 100644 --- a/src/agent/history/tool_call_recorder.py +++ b/src/agent/history/tool_call_recorder.py @@ -100,6 +100,7 @@ _subscribers_lock = threading.Lock() # ---------- 当前会话 token 用量(来自 LLM response usage_metadata) ---------- _session_tokens_lock = threading.Lock() _session_tokens: dict[str, int] = {} # session_id → total_tokens +_session_token_breakdowns: dict[str, dict] = {} # session_id → {messages, tools, system} _session_tokens_loaded: set[str] = set() # 已从磁盘加载过的 session_id @@ -108,7 +109,7 @@ def _token_file(session_id: str) -> Path: return TOOL_CALLING_ROOT / session_id / "_session_tokens.json" -def _persist_token(session_id: str, tokens: int) -> None: +def _persist_token(session_id: str, tokens: int, breakdown: dict | None = None) -> None: """将 token 写入存储(优先 MySQL,回退 JSON 文件)。""" # mysql 后端:写入 sessions.extra.tokens try: @@ -116,6 +117,8 @@ def _persist_token(session_id: str, tokens: int) -> None: if session_storage.is_mysql(): existing = session_storage.load_session_extra(session_id) or {} existing["tokens"] = tokens + if breakdown: + existing["token_breakdown"] = breakdown session_storage.save_session_extra(session_id, existing) return except Exception: @@ -123,7 +126,10 @@ def _persist_token(session_id: str, tokens: int) -> None: # json 后端:写入文件 tf = _token_file(session_id) tf.parent.mkdir(parents=True, exist_ok=True) - tf.write_text(json.dumps({"session_id": session_id, "tokens": tokens}), encoding="utf-8") + payload = {"session_id": session_id, "tokens": tokens} + if breakdown: + payload["breakdown"] = breakdown + tf.write_text(json.dumps(payload), encoding="utf-8") def _load_token_from_disk(session_id: str) -> int: @@ -147,11 +153,17 @@ def _load_token_from_disk(session_id: str) -> int: return 0 -def set_session_tokens(session_id: str, total_tokens: int) -> None: - """存储当前会话的 LLM 最新 token 用量(内存 + 磁盘)。""" +def set_session_tokens(session_id: str, total_tokens: int, breakdown: dict | None = None) -> None: + """存储当前会话的 LLM 最新 token 用量(内存 + 磁盘)。 + + breakdown: {"messages": float, "tools": float, "system": float} 三类估算占比, + 由 nodes._estimate_token_breakdown 计算。 + """ with _session_tokens_lock: _session_tokens[session_id] = total_tokens - _persist_token(session_id, total_tokens) + if breakdown: + _session_token_breakdowns[session_id] = dict(breakdown) + _persist_token(session_id, total_tokens, breakdown) def get_session_tokens(session_id: str) -> int: @@ -170,16 +182,47 @@ def get_session_tokens(session_id: str) -> int: return 0 +def get_session_token_breakdown(session_id: str) -> dict: + """获取当前会话的三类 token 估算占比(消息/工具/系统提示词)。 + + 内存优先,回退磁盘(JSON 文件的 breakdown / MySQL 的 token_breakdown)。 + """ + with _session_tokens_lock: + if session_id in _session_token_breakdowns: + return dict(_session_token_breakdowns[session_id]) + try: + from agent.history import session_storage + if session_storage.is_mysql(): + extra = session_storage.load_session_extra(session_id) or {} + bd = extra.get("token_breakdown") + if isinstance(bd, dict): + return bd + except Exception: + pass + tf = _token_file(session_id) + if tf.exists(): + try: + data = json.loads(tf.read_text(encoding="utf-8")) + bd = data.get("breakdown") + if isinstance(bd, dict): + return bd + except Exception: + pass + return {"messages": 0.0, "tools": 0.0, "system": 0.0} + + def clear_session_tokens(session_id: str) -> None: """清除指定会话的 token 记录(内存 + 存储)。""" with _session_tokens_lock: _session_tokens.pop(session_id, None) + _session_token_breakdowns.pop(session_id, None) # mysql 后端:设置 tokens=0 try: from agent.history import session_storage if session_storage.is_mysql(): existing = session_storage.load_session_extra(session_id) or {} existing["tokens"] = 0 + existing.pop("token_breakdown", None) session_storage.save_session_extra(session_id, existing) return except Exception: diff --git a/src/agent/memory/system_prompt.py b/src/agent/memory/system_prompt.py index 3420f99..c480d04 100644 --- a/src/agent/memory/system_prompt.py +++ b/src/agent/memory/system_prompt.py @@ -5,7 +5,7 @@ _BASE_PROMPT = """你是一个具备多模态理解能力的桌面操作助手 - 只有在信息确实无法从当前对话或上传内容中获取,且必须通过外部工具才能获得时,才调用工具。 - 当你调用工具收到截图或返回结果后,必须仔细观察返回内容,基于实际结果继续推进任务,严禁脱离实际结果凭空编造。 - 对于同一个页面下的GUI操作,必须将连续操作合并为一次 gui_tool 调用(actions 列表传入多个动作),严禁逐个单步调用。仅当页面发生跳转/加载后需要重新观察时,才允许分步调用。 -- 对于需要多次操作才能完成的任务,应主动使用 todo_list 工具规划步骤并逐步推进,并在任务完成后将todo_list中的所有项标记为已完成。 +- 【任务规划】收到任务后,先判断任务复杂度:仅当任务为复杂任务(如多步骤、多阶段、流程较长或产出物较多)时,才调用 todo_list 工具将任务拆解为清晰的执行步骤列表,然后按步骤逐步执行;每完成一步,用 todo_list 标记进度(done_step),任务完成后将所有步骤标记为已完成。简单任务可直接执行,无需调用 todo_list。 - 当任务完成时,向用户总结执行过程和结果。 """.strip() @@ -15,6 +15,7 @@ _WS_TAIL = """\n\n【工作空间配置】 - 你产生的代码、脚本、图片、文档等文件,默认保存到工作空间目录下。 - 使用 terminal_execute 工具时,如果不指定 working_dir,默认在工作空间执行命令。 - 使用 doc_tool 创建/修改文件时,文件路径请使用工作空间的绝对路径。 +- 写操作限制:doc_tool 的创建/修改/删除、terminal_execute 的文件变更目标必须位于工作空间内;超出范围的访问会被拦截或需要用户审批(取决于当前访问模式)。 """ # 当前运行时的工作空间路径(可通过 API 动态修改) @@ -131,14 +132,14 @@ DB_SYSTEM_PROMT = """ 请记住,你是一个协作伙伴,目标是让用户以最小的学习成本从数据库中获得有价值的信息。开始工作吧! """.strip() -# Plan 模式核心规则(todo_list 强制绑定) +# Plan 模式核心规则(复杂任务:写计划到 md 文件 -> send_file 交给用户确认 -> 确认后执行) _PLAN_CORE_RULES = """ -【Plan 模式核心规则】 -- **首要任务**:收到用户请求后,你必须**第一步就调用 todo_list 工具**,将用户的任务拆解为非常细致、可执行的步骤列表。每个步骤应该具体到单个操作(如"打开浏览器"、"搜索XXX"、"截取屏幕"、"分析结果"等)。 -- **规划要求**:步骤拆解必须详尽,覆盖从开始到完成的完整流程。不要跳过任何中间步骤。步骤描述要清晰具体,便于后续逐一执行和跟踪。 -- **规划完成后**:生成 todolist 后,向用户简要汇报规划结果,然后**立即开始按步骤执行**,不要等待用户确认。 -- **执行过程中**:每完成一个步骤,调用 todo_list 标记该步骤为已完成(done_step),让用户实时看到进度。 -- **任务完成后**:确认所有步骤已完成,向用户总结执行过程和结果。 +【Plan 模式核心规则】(适用于复杂任务) +- **信息确认**:制定计划前,若用户的任务描述模糊、存在歧义,或任务中有任何需要注意并与用户确认的点(如目标范围、偏好、时间限制、交付格式等),**必须先调用 human_interaction 工具**,交由用户确认并补充信息,确认清楚后再制定计划。 +- **计划落盘**:将完整执行计划直接写入工作空间下的 `.minorAgent` 目录,文件名为「任务名.md」(如 `周报生成.md`),使用 doc_tool 创建/更新该文件(`.minorAgent` 目录不存在时 doc_tool 会自动创建)。计划内容必须包含:任务目标、执行步骤、每步的产出物。 +- **计划确认**:计划写好后,**调用 send_file 工具将计划文件发送给用户查看**,并说明该计划需用户确认无异议后才会执行;在用户明确确认之前,不得开始执行计划。 +- **执行推进**:用户确认无异议后,按计划逐步执行。每完成一步,用 doc_tool 更新 `.minorAgent\\{任务名}.md` 中的进度(将已完成步骤标记为 [x]),让用户实时看到进展。 +- **任务完成**:确认所有步骤已完成,向用户总结执行过程和结果,并告知计划文件的存放位置。 """.strip() # Plan 模式提示词 = Plan 模式核心规则 + 当前 Agent 模式基础提示词 @@ -183,6 +184,7 @@ CRON_MODE_PROMPT = """ - 你产生的代码、脚本、图片、文档等文件,默认保存到工作空间目录下。 - 使用 terminal_execute 工具时,如果不指定 working_dir,默认在工作空间执行命令。 - 使用 doc_tool 创建/修改文件时,文件路径请使用工作空间的绝对路径。 +- 写操作限制:doc_tool 的创建/修改/删除、terminal_execute 的文件变更目标必须位于工作空间内。无人值守场景下超出范围的访问会被直接拦截,请务必使用工作空间内的路径。 """.strip() # ---------- 反思注入提示(作为 synthetic HumanMessage 注入到对话上下文)---------- diff --git a/src/agent/skills/__init__.py b/src/agent/skills/__init__.py index a25004e..6ae13bd 100644 --- a/src/agent/skills/__init__.py +++ b/src/agent/skills/__init__.py @@ -1,29 +1,35 @@ -"""Skill 管理器:负责 skill 的增删改查及附件管理。 +"""Skill 管理器:负责 skill 的增删改查、附件管理及 zip 导入。 每个 skill 是一个文件夹,位于 Skills 目录下,结构如下:: skills/ {skill_name}/ - skill.json - 基本信息与描述 + skill.json - 基本信息与描述(name/description/tags/enable) skill.md - 主体内容(指南) - attachments/ - 可选附件(.py/.docx/.csv/.xlsx 等) + ... - 可选附件(直接放在 skill 文件夹下,不强制 attachments/ 子目录) 系统定位: 为 skill_router 工具和前端 API 提供统一的数据读写接口。 可扩展性: - 可增加版本校验、导入/导出、标签过滤等功能。 + 可增加标签过滤、导出等功能。 """ from __future__ import annotations +import io import json import os +import re import shutil +import zipfile from pathlib import Path from typing import Any SKILLS_DIR = Path(__file__).resolve().parent +# 视为元数据而非附件的文件(附件列举时排除) +_SKILL_META_FILES = {"skill.json", "skill.md", "SKILL.md"} + def _ensure_skills_dir() -> None: """确保 skills 目录存在。""" @@ -32,20 +38,16 @@ def _ensure_skills_dir() -> None: # ---------- Skill 信息数据类 ---------- class SkillInfo: - """单个 Skill 的基本信息。""" + """单个 Skill 的基本信息(不含 version/author)。""" name: str description: str - version: str - author: str tags: list[str] enable: bool - def __init__(self, name: str = "", description: str = "", version: str = "1.0.0", - author: str = "", tags: list[str] | None = None, enable: bool = True): + def __init__(self, name: str = "", description: str = "", tags: list[str] | None = None, + enable: bool = True): self.name = name self.description = description - self.version = version - self.author = author self.tags = tags or [] self.enable = enable @@ -53,8 +55,6 @@ class SkillInfo: return { "name": self.name, "description": self.description, - "version": self.version, - "author": self.author, "tags": self.tags, "enable": self.enable, } @@ -64,20 +64,39 @@ class SkillInfo: return cls( name=d.get("name", name), description=d.get("description", ""), - version=d.get("version", "1.0.0"), - author=d.get("author", ""), tags=d.get("tags", []), enable=d.get("enable", True), ) +# ---------- 元数据清理(历史遗留的 version/author 不再保留) ---------- + +def _strip_legacy_meta(meta: dict, json_path: Path | None = None) -> dict: + """从元数据中剥离历史遗留的 version/author 字段("不保留"语义)。 + + 若 json_path 提供且文件确含这两个字段,则原地重写文件完成物理清理。 + """ + changed = False + for key in ("version", "author"): + if key in meta: + meta.pop(key, None) + changed = True + if changed and json_path is not None: + try: + with open(json_path, "w", encoding="utf-8") as f: + json.dump(meta, f, ensure_ascii=False, indent=2) + except OSError: + pass + return meta + + # ---------- CRUD ---------- def list_skills() -> list[dict]: """列出所有 skill 的基本信息(不含主体内容)。 输出: - list[dict]: 每项含 name, description, version, author, tags, has_attachments + list[dict]: 每项含 name, description, tags, has_attachments, attachment_files """ _ensure_skills_dir() result = [] @@ -92,16 +111,14 @@ def list_skills() -> list[dict]: meta = json.load(f) except (json.JSONDecodeError, OSError): continue + meta = _strip_legacy_meta(meta, json_path) meta["name"] = entry.name meta.setdefault("description", "") - meta.setdefault("version", "1.0.0") - meta.setdefault("author", "") meta.setdefault("tags", []) meta.setdefault("enable", True) - # 检查是否有附件 - attach_dir = entry / "attachments" - meta["has_attachments"] = attach_dir.is_dir() and bool(list(attach_dir.iterdir())) - meta["attachment_files"] = _list_attachments(entry.name) + attachment_files = _list_attachments(entry.name) + meta["has_attachments"] = bool(attachment_files) + meta["attachment_files"] = attachment_files result.append(meta) return result @@ -110,7 +127,7 @@ def get_skill(name: str) -> dict | None: """获取单个 skill 的完整信息(含主体内容和附件列表)。 输出: - dict 或 None,字段: name, description, version, author, tags, + dict 或 None,字段: name, description, tags, enable, content (skill.md 内容), attachment_files """ skill_dir = SKILLS_DIR / name @@ -126,6 +143,7 @@ def get_skill(name: str) -> dict | None: meta = json.load(f) except (json.JSONDecodeError, OSError): pass + meta = _strip_legacy_meta(meta, json_path) content = "" if md_path.is_file(): @@ -138,8 +156,6 @@ def get_skill(name: str) -> dict | None: return { "name": name, "description": meta.get("description", ""), - "version": meta.get("version", "1.0.0"), - "author": meta.get("author", ""), "tags": meta.get("tags", []), "enable": meta.get("enable", True), "content": content, @@ -148,7 +164,6 @@ def get_skill(name: str) -> dict | None: def create_or_update_skill(name: str, description: str = "", content: str = "", - version: str = "0.0", author: str = "minor", tags: list[str] | None = None, enable: bool = True) -> bool: """创建或更新一个 skill。 @@ -156,9 +171,7 @@ def create_or_update_skill(name: str, description: str = "", content: str = "", name: skill 名称(即文件夹名)。 description: 简述。 content: skill.md 的内容。 - version: 版本号。 - author: 作者。 - tags: 标签列表。 + tags: 标签列表(可选,便于分级筛选)。 enable: 是否启用此 skill,默认 True。 输出: @@ -171,12 +184,10 @@ def create_or_update_skill(name: str, description: str = "", content: str = "", skill_dir = SKILLS_DIR / name os.makedirs(skill_dir, exist_ok=True) - # 写 skill.json + # 写 skill.json(不保留 version/author) meta = { "name": name, "description": description, - "version": version, - "author": author, "tags": tags or [], "enable": enable, } @@ -211,26 +222,38 @@ def delete_skill(name: str) -> bool: return True -# ---------- 附件管理 ---------- +# ---------- 附件管理(以 skill 目录为根,不强制 attachments/ 子目录) ---------- -def _attachments_dir(skill_name: str) -> Path: - return SKILLS_DIR / skill_name / "attachments" +def _skill_dir(skill_name: str) -> Path: + return SKILLS_DIR / skill_name def _list_attachments(skill_name: str) -> list[str]: - """列出某 skill 的所有附件文件名。""" - ad = _attachments_dir(skill_name) - if not ad.is_dir(): + """列出某 skill 的所有附件相对路径(排除 skill.json/skill.md)。""" + sd = _skill_dir(skill_name) + if not sd.is_dir(): return [] result = [] - for root, dirs, files in os.walk(ad): + for root, dirs, files in os.walk(sd): + # 跳过 __pycache__ 等缓存目录 + dirs[:] = [d for d in dirs if d != "__pycache__"] for fname in files: - rel = os.path.relpath(os.path.join(root, fname), ad) + if fname in _SKILL_META_FILES and root == str(sd): + continue + rel = os.path.relpath(os.path.join(root, fname), sd) result.append(rel.replace("\\", "/")) result.sort() return result +def _safe_relpath(filename: str) -> str | None: + """规范化附件相对路径并做穿越防护;非法返回 None。""" + safe = filename.replace("\\", "/").strip("/") + if not safe or safe.startswith("/") or ".." in safe.split("/"): + return None + return safe + + def add_attachment(skill_name: str, filename: str, content_bytes: bytes) -> bool: """上传/更新一个附件文件到 skill。 @@ -242,16 +265,13 @@ def add_attachment(skill_name: str, filename: str, content_bytes: bytes) -> bool 输出: 成功返回 True。 """ - skill_dir = SKILLS_DIR / skill_name - if not skill_dir.is_dir(): + sd = _skill_dir(skill_name) + if not sd.is_dir(): return False - ad = _attachments_dir(skill_name) - os.makedirs(ad, exist_ok=True) - # 安全检查:防止路径穿越 - safe_name = filename.replace("\\", "/").strip("/") - if ".." in safe_name: + safe_name = _safe_relpath(filename) + if safe_name is None or safe_name.lower() in _SKILL_META_FILES: return False - target = ad / safe_name + target = sd / safe_name target.parent.mkdir(parents=True, exist_ok=True) try: with open(target, "wb") as f: @@ -267,18 +287,18 @@ def delete_attachment(skill_name: str, filename: str) -> bool: 输出: 存在且删除成功返回 True。 """ - ad = _attachments_dir(skill_name) - safe_name = filename.replace("\\", "/").strip("/") - if ".." in safe_name: + sd = _skill_dir(skill_name) + safe_name = _safe_relpath(filename) + if safe_name is None: return False - target = ad / safe_name + target = sd / safe_name if not target.is_file(): return False try: os.remove(target) # 移除空父目录 parent = target.parent - while parent != ad and parent != SKILLS_DIR: + while parent != sd and parent != SKILLS_DIR: try: parent.rmdir() except OSError: @@ -291,11 +311,11 @@ def delete_attachment(skill_name: str, filename: str) -> bool: def get_attachment_bytes(skill_name: str, filename: str) -> bytes | None: """获取附件文件的二进制内容。""" - ad = _attachments_dir(skill_name) - safe_name = filename.replace("\\", "/").strip("/") - if ".." in safe_name: + sd = _skill_dir(skill_name) + safe_name = _safe_relpath(filename) + if safe_name is None: return None - target = ad / safe_name + target = sd / safe_name if not target.is_file(): return None try: @@ -305,6 +325,197 @@ def get_attachment_bytes(skill_name: str, filename: str) -> bytes | None: return None +# ---------- zip 导入(前端先解析展示,点保存后才落盘) ---------- + +def _parse_frontmatter(md: str) -> tuple[dict, str]: + """解析 skill.md 头部的 frontmatter,仅保留 name/description,其余丢弃。 + + 支持 ``description: >`` 折叠多行风格与普通单行 ``key: value``。 + + 输入: + md: skill.md 全文。 + + 输出: + (meta, body);meta 含 name/description(可能为空),body 为 frontmatter 之后的正文。 + """ + if not md.startswith("---"): + return {}, md + end = md.find("\n---", 3) + if end == -1: + return {}, md + head = md[3:end] + body = md[end + 4:].lstrip("\n") + + meta: dict[str, list[str]] = {} + cur_key: str | None = None + for line in head.splitlines(): + if not line.strip(): + continue + m = re.match(r"^([A-Za-z_][\w-]*)\s*:\s*(.*)$", line) + if m: + key = m.group(1).strip().lower() + val = m.group(2).strip() + if key in ("name", "description"): + cur_key = key + meta[key] = [val] + else: + cur_key = None # 其余字段直接丢弃 + elif cur_key in ("name", "description") and (line.startswith(" ") or line.startswith("\t")): + # 折叠/续行(description: > 后缩进的各行) + meta[cur_key].append(line.strip()) + + result = {} + for key in ("name", "description"): + vals = [v for v in meta.get(key, []) if v] + if not vals: + result[key] = "" + continue + if key == "name": + result[key] = vals[0].strip().strip("\"'") + else: + # description 折叠续行以空格连接 + result[key] = " ".join(vals).strip().strip("\"'") + return result, body + + +def _find_skill_md_in_zip(zf: zipfile.ZipFile) -> tuple[str | None, str | None]: + """在 zip 中定位 skill.md/SKILL.md(一级子目录下优先,根目录兜底)。 + + 输出: + (entry_name, top_dir);top_dir 为共同的一级目录前缀(可能为空)。 + """ + names = [n for n in zf.namelist() if not n.endswith("/")] + if not names: + return None, None + # 根目录下的 skill.md + for n in names: + if "/" not in n and n.lower() == "skill.md": + return n, "" + # 一级子目录下的 skill.md({dir}/skill.md) + for n in names: + parts = n.split("/") + if len(parts) == 2 and parts[1].lower() == "skill.md": + return n, parts[0] + return None, None + + +def parse_skill_zip(zip_bytes: bytes) -> dict | None: + """解析 skill zip 包(不落盘),提取 name/description/content 与附件列表。 + + 输入: + zip_bytes: zip 文件字节。 + + 输出: + {"name", "description", "content", "attachments": [相对路径...]}; + 未找到 skill.md/SKILL.md 时返回 None。 + """ + try: + zf = zipfile.ZipFile(io.BytesIO(zip_bytes)) + except zipfile.BadZipFile: + return None + with zf: + md_entry, top_dir = _find_skill_md_in_zip(zf) + if md_entry is None: + return None + try: + md_text = zf.read(md_entry).decode("utf-8", errors="replace") + except KeyError: + return None + meta, body = _parse_frontmatter(md_text) + # 附件:一级子目录下除 skill.md/skill.json 之外的其余文件(不深入展开子目录内部) + attachments = [] + for n in zf.namelist(): + if n.endswith("/") or n == md_entry: + continue + parts = n.split("/") + if top_dir: + if parts[0] != top_dir: + continue + rel = "/".join(parts[1:]) + else: + rel = n + if rel.lower() in ("skill.md", "skill.json"): + continue + attachments.append(rel) + attachments.sort() + return { + "name": meta.get("name", ""), + "description": meta.get("description", ""), + "content": body, + "attachments": attachments, + } + + +def import_skill_zip(name: str, zip_bytes: bytes, description: str = "", + content: str = "", tags: list[str] | None = None, + enable: bool = True) -> bool: + """导入 skill zip 包到 skills 目录(含附件,保持原相对路径)。 + + 输入: + name: skill 名称(前端解析后用户可修改)。 + zip_bytes: zip 文件字节。 + description/content/tags/enable: 用户在前端编辑后的覆盖值; + content 为空时使用 zip 内 skill.md 原文。 + + 输出: + 成功返回 True。 + """ + if not name or not name.strip(): + return False + name = name.strip().replace(" ", "_") + try: + zf = zipfile.ZipFile(io.BytesIO(zip_bytes)) + except zipfile.BadZipFile: + return False + with zf: + md_entry, top_dir = _find_skill_md_in_zip(zf) + if md_entry is None: + return False + try: + md_text = zf.read(md_entry).decode("utf-8", errors="replace") + except KeyError: + return False + meta, body = _parse_frontmatter(md_text) + final_content = content if content else body + final_description = description if description else meta.get("description", "") + + skill_dir = SKILLS_DIR / name + if skill_dir.exists(): + shutil.rmtree(skill_dir, ignore_errors=True) + os.makedirs(skill_dir, exist_ok=True) + + ok = create_or_update_skill(name, final_description, final_content, tags or [], enable) + if not ok: + return False + + # 提取附件(跳过 skill.md/skill.json;剥离一级目录前缀;穿越防护) + try: + zf = zipfile.ZipFile(io.BytesIO(zip_bytes)) + with zf: + for n in zf.namelist(): + if n.endswith("/") or n == md_entry: + continue + parts = n.split("/") + if top_dir: + if parts[0] != top_dir: + continue + rel = "/".join(parts[1:]) + else: + rel = n + if rel.lower() in ("skill.md", "skill.json") or not rel: + continue + safe = _safe_relpath(rel) + if safe is None: + continue + target = skill_dir / safe + target.parent.mkdir(parents=True, exist_ok=True) + with open(target, "wb") as f: + f.write(zf.read(n)) + except (zipfile.BadZipFile, OSError): + pass + return True + + # ---------- 供 skill_router 使用的批量加载 ---------- def load_all_skill_contents() -> dict[str, str]: @@ -347,7 +558,7 @@ def load_all_skill_metas() -> dict[str, dict]: skill.json 中 enable 为 false 的 skill 会被跳过。 输出: - {skill_name: {name, description, version, author, tags, enable}, ...} + {skill_name: {name, description, tags, enable}, ...} """ _ensure_skills_dir() result = {} @@ -365,10 +576,9 @@ def load_all_skill_metas() -> dict[str, dict]: # 跳过禁用的 skill if meta.get("enable") is False: continue + meta = _strip_legacy_meta(meta, json_path) meta["name"] = entry.name meta.setdefault("description", "") - meta.setdefault("version", "0.0") - meta.setdefault("author", "minor") meta.setdefault("tags", []) meta.setdefault("enable", True) result[entry.name] = meta diff --git a/src/agent/skills/netease_mail_read/skill.json b/src/agent/skills/netease_mail_read/skill.json index 4175aa1..ab6cb82 100644 --- a/src/agent/skills/netease_mail_read/skill.json +++ b/src/agent/skills/netease_mail_read/skill.json @@ -1,8 +1,6 @@ { "name": "netease_mail_read", "description": "网易邮箱邮件读取操作指南。当用户任务涉及查看邮件、读取邮件、收邮件、查阅163邮箱等操作时匹配此 Skill。", - "version": "1.0.0", - "author": "minor", "tags": [ "网易邮箱", "邮件", diff --git a/src/agent/skills/netease_mail_send/skill.json b/src/agent/skills/netease_mail_send/skill.json index 6d2bff0..056843e 100644 --- a/src/agent/skills/netease_mail_send/skill.json +++ b/src/agent/skills/netease_mail_send/skill.json @@ -1,8 +1,6 @@ { "name": "netease_mail_send", "description": "网易邮箱邮件发送操作指南。当用户任务涉及发送邮件、写邮件、通过163邮箱发信等操作时匹配此 Skill。", - "version": "1.0.0", - "author": "minor", "tags": [ "网易邮箱", "邮件", diff --git a/src/agent/skills/ppt_maker/skill.json b/src/agent/skills/ppt_maker/skill.json index cc7c204..6458c88 100644 --- a/src/agent/skills/ppt_maker/skill.json +++ b/src/agent/skills/ppt_maker/skill.json @@ -1,8 +1,13 @@ { "name": "ppt_maker", "description": "PPT 制作 Skill v3.0 — HTML-only 路线,生成视觉精美、带动态效果的演示文稿 HTML 页面。内置 15+ 预设风格、完整设计系统和 data-anim 动效引擎。触发词: 做PPT、生成PPT、写幻灯片、演示文稿、presentation。", - "version": "3.0.0", - "author": "minor", - "tags": ["ppt", "html", "演示文稿", "幻灯片", "动画", "生产力"], + "tags": [ + "ppt", + "html", + "演示文稿", + "幻灯片", + "动画", + "生产力" + ], "enable": true -} +} \ No newline at end of file diff --git a/src/agent/skills/taste/skill.json b/src/agent/skills/taste/skill.json index 8d65817..f30ab2b 100644 --- a/src/agent/skills/taste/skill.json +++ b/src/agent/skills/taste/skill.json @@ -1,8 +1,6 @@ { "name": "taste", "description": "设计审美 Skill — 反 AI 模板化设计规范。适用于 PPT 制作中的配色/字体/排版审美决策,以及前端落地页、作品集、品牌站的 UI 设计。触发词: 美观、好看、设计感、审美、不模板化、漂亮、改进样式。详细规范见 skill.md。", - "version": "1.0.0", - "author": "minor", "tags": [ "审美", "设计", diff --git a/src/agent/skills/wechat_send_message/skill.json b/src/agent/skills/wechat_send_message/skill.json index 1eacaf0..590794b 100644 --- a/src/agent/skills/wechat_send_message/skill.json +++ b/src/agent/skills/wechat_send_message/skill.json @@ -1,8 +1,6 @@ { "name": "wechat_send_message", "description": "微信消息发送操作指南。当用户任务涉及通过微信发送消息、给某人发微信、微信通知等操作时匹配此 Skill。", - "version": "1.0.0", - "author": "minor", "tags": [ "微信", "消息", diff --git a/src/agent/tools/doc.py b/src/agent/tools/doc.py index 9f94137..20e7d21 100644 --- a/src/agent/tools/doc.py +++ b/src/agent/tools/doc.py @@ -139,6 +139,10 @@ def _read_plain_text(file_path: str, grep_pattern: str | None = None, def _create_plain_text(file_path: str, content: str) -> str: if os.path.exists(file_path): return f"[ERROR] 文件已存在: {file_path}" + # 自动创建缺失的父目录(配合 plan 模式写入 .minorAgent 等场景) + parent = os.path.dirname(file_path) + if parent: + os.makedirs(parent, exist_ok=True) with open(file_path, "w", encoding="utf-8") as f: f.write(content) return f"[OK] 已创建文件: {file_path}" @@ -259,6 +263,10 @@ def _read_tabular(file_path: str, sheet_name: str | None = None, def _create_tabular(file_path: str, content: str) -> str: if os.path.exists(file_path): return f"[ERROR] 文件已存在: {file_path}" + # 自动创建缺失的父目录 + parent = os.path.dirname(file_path) + if parent: + os.makedirs(parent, exist_ok=True) ext = pathlib.Path(file_path).suffix.lower() if ext == ".csv": with open(file_path, "w", encoding="utf-8-sig", newline="") as f: @@ -384,6 +392,10 @@ def _create_docx(file_path: str, content: str, image_paths: list[str] | None = N return f"[ERROR] 文件已存在: {file_path}" from docx import Document from docx.shared import Inches + # 自动创建缺失的父目录 + parent = os.path.dirname(file_path) + if parent: + os.makedirs(parent, exist_ok=True) doc = Document() # 清洗 Markdown 标记后写入文本段落 cleaned = _strip_markdown(content) diff --git a/src/agent/utils/env_utils.py b/src/agent/utils/env_utils.py index e4d877e..c9ff702 100644 --- a/src/agent/utils/env_utils.py +++ b/src/agent/utils/env_utils.py @@ -52,7 +52,7 @@ def _refresh_globals(): global WORKING_DIR, WORKSPACE_DIR, USER_PYTHON_PATH global LLM_BASE_URL, LLM_API_KEY, LLM_MODEL, LLM_TIMEOUT global STREAMING_TTS_URL, ASR_BASE_URL, RAG_BASE_URL - global LLM_CONTEXT_WINDOW, COMPRESS_RATE + 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,9 @@ def _refresh_globals(): RAG_BASE_URL = _get_env("RAG_BASE_URL") 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") + if THINKING_LEVEL not in THINKING_LEVELS: + THINKING_LEVEL = "low" IMG_SIZE = int(os.getenv("IMG_SIZE", 768)) RAG_CHUNK_SIZE = int(os.getenv("RAG_CHUNK_SIZE", 500)) RAG_CHUNK_OVERLAP = int(os.getenv("RAG_CHUNK_OVERLAP", 50)) @@ -79,6 +82,28 @@ def _refresh_globals(): SEND_FILE_SIZE_LIMIT = int(os.getenv("SEND_FILE_SIZE_LIMIT", 30)) * 1024 * 1024 +# ---------- 深度思考档位 ---------- +# low=agent+不思考 high=plan+不思考 xhigh=agent+思考 max=plan+思考 +# ultra 为未来 react→审批图预留占位,暂按"agent+思考"处理 +THINKING_LEVELS: tuple[str, ...] = ("low", "high", "xhigh", "max", "ultra") +_THINKING_ENABLED_LEVELS: frozenset[str] = frozenset({"xhigh", "max", "ultra"}) +THINKING_LEVEL: str = "low" + + +def thinking_enabled(level: str | None = None) -> bool: + """判断思考档位是否启用深度思考。""" + lv = (level or THINKING_LEVEL or "low").lower() + return lv in _THINKING_ENABLED_LEVELS + + +def get_thinking_extra_body(level: str | None = None) -> dict | None: + """根据思考档位返回 ChatQwen 的 extra_body。 + + 返回空 dict {} 表示恢复模型原生行为(启用思考);None 表示禁用思考(默认)。 + """ + return {} if thinking_enabled(level) else None + + def _load_config() -> dict: """加载 env_config.json,不存在时回退到 .env 文件。 diff --git a/src/agent/utils/tool_call_utils.py b/src/agent/utils/tool_call_utils.py index 2605e95..c830443 100644 --- a/src/agent/utils/tool_call_utils.py +++ b/src/agent/utils/tool_call_utils.py @@ -67,9 +67,20 @@ def invoke_tool_and_build_message(tool, tool_name: str, tool_args: dict, tool_ca 系统定位: 由 ``core/runtime`` 和 ``tools/agent_call`` 在需要手动 invoke 工具时调用。 + 同时是工具执行前的唯一钳点:工作空间访问策略(限制访问 / 无人值守审批 + 退化)在此拦截越界操作,覆盖图内执行、人工确认恢复循环与审批后重放路径。 """ from langchain_core.messages import ToolMessage + # 工作空间越界拦截:策略决策为 block 时在工具真正执行前返回错误 + try: + from agent.core.workspace_policy import build_block_message as _policy_block_message + from agent.core.workspace_policy import decision as _policy_decision + if _policy_decision(tool_name, tool_args) == "block": + return _policy_block_message(tool_name, tool_args), None + except Exception: + pass # 策略模块异常不应阻断工具执行 + tool_call_input = { "name": tool_name, "args": tool_args, diff --git a/src/web/app.js b/src/web/app.js index 24ea397..7a5f94e 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -13,7 +13,7 @@ import { setRenderAttachmentChips as audioSetRenderChips } from './js/audio.js'; import { renderMessages, renderMessage, renderAttachmentChips, scrollChatToBottom, appendThinkingIndicator, showPendingActionInThinking, streamAssistantMessage, - renderAudioBubble, + renderAudioBubble, addTextQuote, initChatQuoteMenu, } from './js/chat-render.js'; import { setRenderPersistentToolCalls } from './js/chat-render.js'; @@ -47,7 +47,7 @@ import { import { setRenderSkills } from './js/settings.js'; // ---- Skills ---- -import { renderSkills, bindSkillEditorEvents } from './js/skills.js'; +import { renderSkills, bindSkillEditorEvents, bindImportMenu } from './js/skills.js'; // ---- Edit 模式 ---- import { initEditMode, sendEditChat, switchToMode, acceptAllModifications, rejectAllModifications, getDocModifications, getCurrentTaskModifications, renderDocTabs, loadDocContent, setCronModeHooks } from './js/edit-mode.js'; @@ -75,12 +75,18 @@ import { saveDocSnapshot, undoDocSnapshot, getDocSnapshots, setDocContent, getDo import { initTheme } from './js/themes.js'; import { initI18n } from './js/i18n.js'; -// ---- Token 进度条 & 压缩 ---- -import { initTokenBar, refreshTokens, stopTokenPolling } from './js/token-bar.js'; +// ---- Token 环形指示器(替代原进度条)---- +import { initTokenRing, refreshTokens, stopTokenPolling } from './js/token-ring.js'; // ---- 工作空间 ---- import { initWorkspace, bindWorkspaceEvents } from './js/workspace.js'; +// ---- 工作空间访问模式 ---- +import { initAccessMode, bindAccessModeEvents } from './js/access-mode.js'; + +// ---- 思考模式档位 ---- +import { initThinkLevel, bindThinkModeEvents } from './js/think-level.js'; + // ===== 连线:注入跨模块依赖 ===== // toolcalls 的持久化渲染由 setRenderPersistentToolCalls 注入 import { renderPersistentToolCalls } from './js/toolcalls.js'; @@ -147,7 +153,6 @@ setRollbackDeps({ // ===== Composer 居中/底部动画逻辑 ===== export function setComposerCentered(centered) { const composer = document.querySelector(".composer"); - const tokenBar = document.getElementById("tokenBar"); const chatPlaceholder = document.getElementById("chatPlaceholder"); const chatMessages = document.getElementById("chatMessages"); const mainContentWrap = document.getElementById("mainContentWrap"); @@ -157,7 +162,6 @@ export function setComposerCentered(centered) { if (centered) { composer.classList.add("composer--centered"); composer.classList.remove("composer--bottom"); - if (tokenBar) tokenBar.classList.add("token-bar--hidden"); if (chatPlaceholder) chatPlaceholder.style.display = "none"; if (mainContentWrap) mainContentWrap.classList.add("main-content-wrap--collapsed"); if (welcomeView) { @@ -171,7 +175,6 @@ export function setComposerCentered(centered) { } else { composer.classList.remove("composer--centered"); composer.classList.add("composer--bottom"); - if (tokenBar) tokenBar.classList.remove("token-bar--hidden"); if (chatPlaceholder) chatPlaceholder.style.display = ""; if (mainContentWrap) mainContentWrap.classList.remove("main-content-wrap--collapsed"); if (welcomeView) welcomeView.hidden = true; @@ -528,6 +531,15 @@ function _setupComposerDrop() { // edit 模式内部文件拖拽(application/doc-path)由 edit-mode 处理,不重复添加 if (e.dataTransfer.types.includes("application/doc-path")) return; + // 聊天区选中文字拖拽 → 添加为引用(用户引用) + if (e.dataTransfer.types.includes("application/x-chat-quote")) { + const q = e.dataTransfer.getData("application/x-chat-quote"); + if (q && q.trim()) { + addTextQuote(q.trim()); + } + return; + } + // 聊天区图片拖拽:从自定义数据创建 File(避免浏览器生成的通用文件名) if (e.dataTransfer.types.includes("application/x-chat-image")) { const imgUrl = e.dataTransfer.getData("application/x-chat-image"); @@ -728,9 +740,91 @@ initActionBar({ }, }); -// 设置内联下拉框:输出模式、Agent 模式 -setupInlineDropdown("outputModeTrigger", "outputModePanel"); -setupInlineDropdown("agentModeTrigger", "agentModePanel"); +// 工作空间访问模式(图标触发器,document 委托,含连击保护) +bindAccessModeEvents(); + +// 思考模式档位(图标触发器 + 渐变色滑条,document 委托) +bindThinkModeEvents(); + +// 文字/语音回复开关(document 委托 + 连击保护) +// 默认文字模式;切换到语音模式前先检查 TTS 服务连通性,不通则提示并保持文字模式 +let _voiceToggling = false; +let _lastVoiceToggleAt = 0; + +document.addEventListener("click", (e) => { + if (!e.target.closest || !e.target.closest("#voiceToggleBtn")) return; + toggleOutputMode(); +}); + +async function toggleOutputMode() { + const now = Date.now(); + if (_voiceToggling || now - _lastVoiceToggleAt < 400) return; + _lastVoiceToggleAt = now; + _voiceToggling = true; + try { + const target = state.outputMode === "voice" ? "text" : "voice"; + if (target === "voice" && !(await checkTtsService())) { + return; // TTS 不可用:提示并保持文字模式 + } + state.outputMode = target; + updateVoiceToggleUi(); + } finally { + _voiceToggling = false; + } +} + +function updateVoiceToggleUi() { + const btn = $("voiceToggleBtn"); + if (!btn) return; + const isVoice = state.outputMode === "voice"; + btn.classList.toggle("is-active", isVoice); + btn.setAttribute("aria-pressed", isVoice ? "true" : "false"); + btn.title = isVoice ? "语音回复(点击切换为文字)" : "文字回复(点击切换为语音)"; +} + +async function checkTtsService() { + try { + const st = await api("/api/services/status"); + if (st && st.tts && st.tts.ok) return true; + showToast("语音(TTS)服务不可用,无法切换为语音模式"); + } catch { + showToast("无法检测语音(TTS)服务状态"); + } + return false; +} + +// ===== 启动时检测 ASR / TTS 服务,决定麦克风与语音开关是否可用 ===== +function applyServicesStatus(st) { + const asrOk = !!(st && st.asr && st.asr.ok); + const ttsOk = !!(st && st.tts && st.tts.ok); + + const mic = $("micBtn"); + if (mic) { + mic.disabled = !asrOk; + mic.title = asrOk ? "使用麦克风录音" : "语音转文字(ASR)服务不可用"; + } + const voice = $("voiceToggleBtn"); + if (voice) { + voice.disabled = !ttsOk; + voice.title = ttsOk + ? (state.outputMode === "voice" ? "语音回复(点击切换为文字)" : "文字回复(点击切换为语音)") + : "语音(TTS)服务不可用"; + if (!ttsOk && state.outputMode === "voice") { + // 启动时 TTS 不可用则回退为文字模式 + state.outputMode = "text"; + updateVoiceToggleUi(); + } + } +} + +async function initServicesStatus() { + try { + const st = await api("/api/services/status"); + applyServicesStatus(st); + } catch { + // 检测失败时不限制按钮,点击时仍有兜底检查 + } +} // 模型选择在 populateModelSelect 中动态构建,只需绑定触发器 const modelTrigger = $("modelTrigger"); @@ -869,10 +963,14 @@ if (drawerBackdrop) { // Skills 编辑器事件绑定 bindSkillEditorEvents(); +bindImportMenu(); // Edit 模式初始化 initEditMode(); +// 聊天区文字引用(左键选中 + 右键引用 / 拖拽到输入区) +initChatQuoteMenu(); + // Cron 模式:注入进入/离开钩子,避免 edit-mode ↔ cron 循环依赖 setCronModeHooks({ onEnter: enterCronMode, onLeave: leaveCronMode }); // Cron 模式初始化(绑定列表/弹窗事件) @@ -907,10 +1005,13 @@ initI18n(); // ===== 启动 ===== bootstrap().then(() => { - initTokenBar(); + initTokenRing(); populateModelSelect(); initWorkspace(); + initAccessMode(); + initThinkLevel(); loadNickname(); + initServicesStatus(); }).catch((e) => { const chatPlaceholder = $("chatPlaceholder"); if (chatPlaceholder) { diff --git a/src/web/css/chat.css b/src/web/css/chat.css index 94b4758..a4a7397 100644 --- a/src/web/css/chat.css +++ b/src/web/css/chat.css @@ -381,39 +381,63 @@ overflow-x: visible; } -/* ===== 回退按钮 ===== */ +/* ===== 消息操作按钮行(复制 / 撤回 / 重试,无边框图标按钮) ===== */ .msg.user { - flex-direction: row; - align-items: center; + flex-direction: column; + align-items: flex-end; } -.chat-rollback-btn { +.msg-actions { + display: inline-flex; + gap: 0.15rem; + align-items: center; + opacity: 0.45; + transition: opacity 0.18s ease; + padding-top: 0.05rem; +} +.msg-actions--user { + align-self: flex-end; +} +.msg-actions--assistant { + align-self: flex-start; +} +.msg:hover .msg-actions, +.msg-actions:focus-within { + opacity: 1; +} +.chat-action-btn { flex-shrink: 0; - width: 24px; - height: 24px; - margin-right: 6px; + width: 22px; + height: 22px; + padding: 0; border: none; - border-radius: 6px; - background: var(--bg-tertiary); - color: var(--muted); + outline: none; + background: transparent; + border-radius: 5px; cursor: pointer; - font-size: 0.78rem; - line-height: 1; - display: flex; + display: inline-flex; align-items: center; justify-content: center; - opacity: 0.35; - transition: opacity 0.18s, background 0.18s, color 0.18s, transform 0.18s, border-color 0.18s; - z-index: 3; + transition: background 0.15s ease, transform 0.12s ease; } -.msg.user:hover .chat-rollback-btn { +.chat-action-btn img { + width: 14px; + height: 14px; + display: block; +} +.chat-action-btn:hover { + background: color-mix(in srgb, var(--accent) 14%, transparent); +} +.chat-action-btn:active { + transform: scale(0.92); +} + +/* 点赞 / 点踩 激活态 */ +.chat-feedback-btn.is-active { opacity: 1; - color: var(--accent); - background: color-mix(in srgb, var(--accent) 8%, transparent); + background: color-mix(in srgb, var(--accent) 16%, transparent); } -.chat-rollback-btn:hover { - background: color-mix(in srgb, var(--accent) 18%, transparent); - color: var(--accent); - transform: scale(1.1); +.chat-feedback-btn.is-active img { + filter: invert(24%) sepia(70%) saturate(2000%) hue-rotate(160deg) brightness(94%) contrast(92%); } /* ===== 欢迎页(空会话居中,在 main-content-wrap 外) ===== */ @@ -573,3 +597,13 @@ opacity: 0; transform: translateY(-6px); } + +/* ===== AI 回复开始时间(分支按钮右侧)===== */ +.msg-start-time { + font-size: 0.66rem; + color: var(--muted); + font-variant-numeric: tabular-nums; + align-self: center; + margin-left: 0.15rem; + user-select: none; +} diff --git a/src/web/css/composer.css b/src/web/css/composer.css index 8c502de..d7923d8 100644 --- a/src/web/css/composer.css +++ b/src/web/css/composer.css @@ -189,10 +189,6 @@ margin: 0 auto; } -.composer--centered .token-bar { - display: none !important; -} - /* 过渡到正常状态 */ .composer--bottom { margin-top: 0.45rem; @@ -901,6 +897,15 @@ color: var(--danger); } +/* 聊天区文字引用 chip(用户引用) */ +.ref-chip--quote { + background: color-mix(in srgb, var(--accent) 18%, transparent); +} + +.ref-chip--quote .ref-chip-icon { + opacity: 0.85; +} + /* ===== 引用 bar ===== */ .composer-refs { display: flex; @@ -1323,107 +1328,265 @@ to { opacity: 1; } } -/* ===== Token 上下文进度条 ===== */ -.token-bar { - display: flex; +/* ===== 语音开关按钮(文字/语音)===== */ +.voice-toggle-btn { + width: 1.7rem; + height: 1.7rem; + padding: 0; + display: inline-flex; align-items: center; - gap: 8px; - margin-top: 4px; - padding: 0 4px; - transition: opacity 0.25s ease; + justify-content: center; + border-radius: 8px; + border: none; + outline: none; + background: transparent; + cursor: pointer; + opacity: 0.4; + transition: opacity 0.15s ease, background 0.15s ease; } -.token-bar.token-bar--hidden { - display: none !important; +.voice-toggle-btn:hover { + opacity: 0.75; + background: color-mix(in srgb, var(--text) 6%, transparent); +} +.voice-toggle-btn.is-active { + opacity: 0.95; + background: color-mix(in srgb, var(--accent) 14%, transparent); +} +.voice-toggle-btn:disabled, +.mic-btn:disabled { + opacity: 0.25; + cursor: not-allowed; +} +.voice-toggle-btn img { + width: 0.95rem; + height: 0.95rem; + display: block; } -.token-bar-track { - flex: 1; +/* ===== 图标型内联下拉触发器(访问模式 / 思考模式)===== */ +.inline-drop-trigger--icon { + width: 1.7rem; + height: 1.7rem; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} +.inline-drop-trigger--icon img { + width: 0.95rem; + height: 0.95rem; + display: block; +} + +/* ===== 访问模式面板:三种模式彩色文字选项(与图标颜色一致:绿/黄/橙红)===== */ +.access-mode-item--restricted { color: var(--success); } +.access-mode-item--restricted.is-selected { background: color-mix(in srgb, var(--success) 14%, transparent); } +.access-mode-item--approval { color: var(--warning); } +.access-mode-item--approval.is-selected { background: color-mix(in srgb, var(--warning) 16%, transparent); } +.access-mode-item--full { color: var(--danger); } +.access-mode-item--full.is-selected { background: color-mix(in srgb, var(--danger) 14%, transparent); } + +/* ===== 思考模式面板:档位标签 + 横向渐变色滑动条 ===== + 面板右对齐向左展开(按钮位于右侧工具栏,避免伸出窗口右缘被截断) */ +.think-mode-panel { + width: 164px; + right: 0; + left: auto; + padding: 0.6rem 0.65rem 0.55rem; +} +.think-mode-labels { + display: flex; + justify-content: space-between; + margin-bottom: 0.4rem; +} +.think-mode-label { + border: none; + background: transparent; + color: var(--muted); + font-size: 0.62rem; + font-family: var(--font-family, inherit); + 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 { + color: var(--text); +} +.think-mode-label.is-active { + 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 { + position: relative; + display: inline-flex; + align-items: center; +} +.token-ring-btn { + width: 1.7rem; + height: 1.7rem; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 8px; + border: none; + outline: none; + background: transparent; + cursor: pointer; + opacity: 0.55; + transition: opacity 0.15s ease, background 0.15s ease; +} +.token-ring-btn:hover { + opacity: 0.95; + background: color-mix(in srgb, var(--text) 6%, transparent); +} +.token-ring-svg { + display: block; +} +.token-ring-bg { + fill: none; + stroke: var(--border); + stroke-width: 7; +} +.token-ring-fill { + fill: none; + stroke: var(--accent); + stroke-width: 7; + stroke-linecap: round; + /* 从顶部开始:旋转 -90° 由 transform 实现 */ + transform: rotate(-90deg); + transform-origin: 50% 50%; + transition: stroke-dashoffset 0.5s ease; +} + +/* ===== Token 环形 hover 面板 ===== */ +.token-ring-panel { + position: absolute; + bottom: calc(100% + 8px); + right: 0; + width: 232px; + padding: 0.6rem 0.7rem; + border-radius: var(--radius, 12px); + background: var(--surface); + border: 1px solid var(--border); + box-shadow: 0 8px 24px var(--shadow); + z-index: 60; + font-size: 0.78rem; +} +.token-ring-capacity { + font-weight: 600; + color: var(--text); + margin-bottom: 0.45rem; + font-variant-numeric: tabular-nums; +} +.token-ring-rate-track { position: relative; height: 5px; border-radius: 3px; background: var(--divider); overflow: visible; + touch-action: none; } - -.token-bar-seg { +.token-ring-rate-fill { position: absolute; top: 0; - height: 100%; left: 0; - border-radius: 3px 0 0 3px; - transition: width 0.35s ease; + height: 100%; + border-radius: 3px; + background: var(--accent); + opacity: 0.55; pointer-events: none; + transition: width 0.5s ease; } -.token-bar-seg--green { - background: var(--success, #22c55e); - opacity: 0.45; - z-index: 1; - border-radius: 3px 0 0 3px; -} -.token-bar-seg--red { - background: var(--danger, #ef4444); - opacity: 0.85; - z-index: 1; - border-radius: 0 3px 3px 0; -} - -.token-bar-fill { - display: none; -} - -.token-handle { +/* 压缩阈值刻度线(静态展示,阈值在环境变量中修改) */ +.token-ring-threshold { position: absolute; - top: 50%; - transform: translate(-50%, -50%); - z-index: 10; - cursor: ew-resize; - user-select: none; - transition: transform 0.15s ease; + top: -2px; + width: 2px; + height: 9px; + border-radius: 1px; + background: var(--warning); + opacity: 0.85; + pointer-events: none; + transform: translateX(-50%); } -.token-handle:hover { - transform: translate(-50%, -50%) scale(1.3); -} -.token-handle.dragging { - transform: translate(-50%, -50%) scale(1.45); -} - -.token-handle-dot { - width: 8px; - height: 14px; - border-radius: 2px; - background: var(--accent, #0e7490); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); - transition: background 0.2s, box-shadow 0.2s; -} -.token-handle:hover .token-handle-dot, -.token-handle.dragging .token-handle-dot { - box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 18%, transparent); -} - -.token-handle--compress .token-handle-dot { - background: var(--accent, #0e7490); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent); -} - -.token-handle.overshot .token-handle-dot { - animation: handlePulse 0.6s ease-in-out infinite alternate; -} -@keyframes handlePulse { - from { box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger) 15%, transparent); } - to { box-shadow: 0 0 0 5px color-mix(in srgb, var(--danger) 10%, transparent); } -} - -.token-label { - font-size: 0.66rem; - font-weight: 500; - white-space: nowrap; - min-width: 78px; - text-align: right; +.token-ring-rate-label { + font-size: 0.68rem; + color: var(--muted); + margin: 0.3rem 0 0.4rem; + font-variant-numeric: tabular-nums; +} +.token-ring-breakdown { + border-top: 1px solid var(--divider); + padding-top: 0.35rem; +} +.token-ring-breakdown-row { + display: flex; + align-items: baseline; + gap: 0.35rem; + padding: 0.12rem 0; + font-size: 0.75rem; +} +.token-ring-breakdown-dot { + font-weight: 700; + color: var(--accent); + width: 0.6rem; + text-align: center; + flex: none; +} +.token-ring-breakdown-key { + flex: 1; + color: var(--text); +} +.token-ring-breakdown-val { + color: var(--muted); font-variant-numeric: tabular-nums; - opacity: 0.6; - transition: opacity 0.25s, color 0.25s; } - -.token-label--safe { color: var(--success, #22c55e); } -.token-label--warn { color: var(--warning, #f59e0b); } -.token-label--danger { color: var(--danger, #ef4444); } diff --git a/src/web/css/cron.css b/src/web/css/cron.css index 9c9e818..0f5339c 100644 --- a/src/web/css/cron.css +++ b/src/web/css/cron.css @@ -1015,3 +1015,10 @@ } /* once 选择器中的 time-picker 复用 .cron-time-picker 样式 */ + +/* ===== 访问模式提示(警告样式)===== */ +.cron-field-hint--warn { + display: block; + margin-top: 0.3rem; + color: var(--warning); +} diff --git a/src/web/css/pending-overlay.css b/src/web/css/pending-overlay.css index e03ace1..6610b0c 100644 --- a/src/web/css/pending-overlay.css +++ b/src/web/css/pending-overlay.css @@ -146,6 +146,20 @@ white-space: pre-wrap; } +/* 工作空间越界审批提示(访问策略 policy_note) */ +.pending-overlay-note { + font-size: 0.78rem; + color: #b45309; + background: color-mix(in srgb, #f59e0b 12%, transparent); + border: 1px solid color-mix(in srgb, #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; diff --git a/src/web/css/settings.css b/src/web/css/settings.css index 3a48146..81d3356 100644 --- a/src/web/css/settings.css +++ b/src/web/css/settings.css @@ -519,6 +519,7 @@ /* ===== 环境变量配置 ===== */ .env-config-item { + position: relative; display: flex; align-items: center; gap: 0.6rem; @@ -526,6 +527,47 @@ flex-wrap: wrap; } +/* 环境变量 "?" 帮助按钮与说明 popover */ +.env-help-btn { + flex: none; + width: 1.25rem; + height: 1.25rem; + padding: 0; + border-radius: 50%; + border: 1px solid var(--border); + background: color-mix(in srgb, var(--text) 5%, transparent); + color: var(--muted); + font-size: 0.72rem; + font-weight: 700; + line-height: 1; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease; +} +.env-help-btn:hover { + color: var(--accent); + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, transparent); +} +.env-help-popover { + position: absolute; + right: 0; + top: calc(100% + 6px); + z-index: 80; + width: 260px; + padding: 0.55rem 0.65rem; + border-radius: 10px; + background: var(--surface); + border: 1px solid var(--border); + box-shadow: 0 8px 24px var(--shadow); + font-size: 0.78rem; + line-height: 1.5; + color: var(--text); + white-space: pre-wrap; +} + .env-config-item label { min-width: 150px; font-size: 0.8rem; @@ -947,3 +989,37 @@ .settings-panel .btn-ghost:hover { background: var(--divider); } + +/* ===== Skill 导入菜单 ===== */ +.skill-import-menu { + position: absolute; + z-index: 90; + top: calc(100% + 4px); + left: 0; + min-width: 130px; + padding: 0.3rem; + border-radius: 10px; + background: var(--surface); + border: 1px solid var(--border); + box-shadow: 0 8px 24px var(--shadow); + display: flex; + flex-direction: column; + gap: 0.15rem; +} +.skill-import-menu button { + border: none; + background: transparent; + color: var(--text); + font-size: 0.8rem; + text-align: left; + padding: 0.4rem 0.55rem; + border-radius: 6px; + cursor: pointer; + transition: background 0.15s ease; +} +.skill-import-menu button:hover { + background: color-mix(in srgb, var(--accent) 12%, transparent); +} +.settings-toolbar { + position: relative; +} diff --git a/src/web/image/分支.svg b/src/web/image/分支.svg new file mode 100644 index 0000000..c0e6c64 --- /dev/null +++ b/src/web/image/分支.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/web/image/复制.svg b/src/web/image/复制.svg new file mode 100644 index 0000000..c8c3a3b --- /dev/null +++ b/src/web/image/复制.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/web/image/完全访问.svg b/src/web/image/完全访问.svg new file mode 100644 index 0000000..5352fc5 --- /dev/null +++ b/src/web/image/完全访问.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/web/image/思考模式.svg b/src/web/image/思考模式.svg new file mode 100644 index 0000000..a718ebf --- /dev/null +++ b/src/web/image/思考模式.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/web/image/撤回.svg b/src/web/image/撤回.svg new file mode 100644 index 0000000..577e7d2 --- /dev/null +++ b/src/web/image/撤回.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/web/image/文本语音 .svg b/src/web/image/文本语音 .svg new file mode 100644 index 0000000..49b5390 --- /dev/null +++ b/src/web/image/文本语音 .svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/web/image/权限审查.svg b/src/web/image/权限审查.svg new file mode 100644 index 0000000..b216477 --- /dev/null +++ b/src/web/image/权限审查.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/web/image/点赞.svg b/src/web/image/点赞.svg new file mode 100644 index 0000000..c5a012d --- /dev/null +++ b/src/web/image/点赞.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/web/image/点踩.svg b/src/web/image/点踩.svg new file mode 100644 index 0000000..364f359 --- /dev/null +++ b/src/web/image/点踩.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/web/image/重试.svg b/src/web/image/重试.svg new file mode 100644 index 0000000..d5d145c --- /dev/null +++ b/src/web/image/重试.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/web/image/限制访问.svg b/src/web/image/限制访问.svg new file mode 100644 index 0000000..5645365 --- /dev/null +++ b/src/web/image/限制访问.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/web/index.html b/src/web/index.html index 4ce6a87..4457c93 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -10,7 +10,7 @@ rel="stylesheet" /> - +