优化agent配置,修复已知问题

This commit is contained in:
minorQAQ committed 2026-08-12 17:48:17 +08:00
1 parent 744cae8f54
commit 9acb38c9e6
55 files changed
+3009 -623

No files matched your search

+5 -6
View File
@@ -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", ""),
+2 -1
View File
@@ -2,5 +2,6 @@
"current": "",
"list": [
"C:\\Users\\86166\\Desktop\\Agent_Learning_minor\\Agent\\src\\workspace"
]
],
"access_mode": "full"
}
+2
View File
@@ -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
+4 -1
View File
@@ -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(),
)
+52 -7
View File
@@ -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 溢出信息)。
+18 -1
View File
@@ -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
+14 -15
View File
@@ -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 {})
+356
View File
@@ -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"""(?<![A-Za-z0-9\\/])([A-Za-z]:[\\/][^\s"'<>|&;()]+|\\\\[^\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"请将目标路径调整到工作空间目录下后重试。"
)
+8
View File
@@ -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
+48 -5
View File
@@ -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:
+10 -8
View File
@@ -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 注入到对话上下文)----------
+270 -60
View File
@@ -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
@@ -1,8 +1,6 @@
{
"name": "netease_mail_read",
"description": "网易邮箱邮件读取操作指南。当用户任务涉及查看邮件、读取邮件、收邮件、查阅163邮箱等操作时匹配此 Skill。",
"version": "1.0.0",
"author": "minor",
"tags": [
"网易邮箱",
"邮件",
@@ -1,8 +1,6 @@
{
"name": "netease_mail_send",
"description": "网易邮箱邮件发送操作指南。当用户任务涉及发送邮件、写邮件、通过163邮箱发信等操作时匹配此 Skill。",
"version": "1.0.0",
"author": "minor",
"tags": [
"网易邮箱",
"邮件",
+9 -4
View File
@@ -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
}
}
-2
View File
@@ -1,8 +1,6 @@
{
"name": "taste",
"description": "设计审美 Skill — 反 AI 模板化设计规范。适用于 PPT 制作中的配色/字体/排版审美决策,以及前端落地页、作品集、品牌站的 UI 设计。触发词: 美观、好看、设计感、审美、不模板化、漂亮、改进样式。详细规范见 skill.md。",
"version": "1.0.0",
"author": "minor",
"tags": [
"审美",
"设计",
@@ -1,8 +1,6 @@
{
"name": "wechat_send_message",
"description": "微信消息发送操作指南。当用户任务涉及通过微信发送消息、给某人发微信、微信通知等操作时匹配此 Skill。",
"version": "1.0.0",
"author": "minor",
"tags": [
"微信",
"消息",
+12
View File
@@ -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)
+26 -1
View File
@@ -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 文件。
+11
View File
@@ -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,
+112 -11
View File
@@ -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) {
+57 -23
View File
@@ -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;
}
+252 -89
View File
@@ -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); }
+7
View File
@@ -1015,3 +1015,10 @@
}
/* once 选择器中的 time-picker 复用 .cron-time-picker 样式 */
/* ===== 访问模式提示(警告样式)===== */
.cron-field-hint--warn {
display: block;
margin-top: 0.3rem;
color: var(--warning);
}
+14
View File
@@ -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;
+76
View File
@@ -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;
}
+1
View File
@@ -0,0 +1 @@
<?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="1786457268471" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2661" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M824.32 959.146667c-61.44 0-110.933333-49.493333-110.933333-110.933334s49.493333-110.933333 110.933333-110.933333 110.933333 49.493333 110.933333 110.933333-49.493333 110.933333-110.933333 110.933334z m0-170.666667c-32.426667 0-59.733333 27.306667-59.733333 59.733333s27.306667 59.733333 59.733333 59.733334 59.733333-27.306667 59.733333-59.733334-27.306667-59.733333-59.733333-59.733333zM354.986667 959.146667c-61.44 0-110.933333-49.493333-110.933334-110.933334s49.493333-110.933333 110.933334-110.933333 110.933333 49.493333 110.933333 110.933333-49.493333 110.933333-110.933333 110.933334z m0-170.666667c-32.426667 0-59.733333 27.306667-59.733334 59.733333s27.306667 59.733333 59.733334 59.733334 59.733333-27.306667 59.733333-59.733334-27.306667-59.733333-59.733333-59.733333zM354.986667 276.48c-61.44 0-110.933333-49.493333-110.933334-110.933333s49.493333-110.933333 110.933334-110.933334 110.933333 49.493333 110.933333 110.933334-49.493333 110.933333-110.933333 110.933333z m0-170.666667c-32.426667 0-59.733333 27.306667-59.733334 59.733334s27.306667 59.733333 59.733334 59.733333 59.733333-27.306667 59.733333-59.733333-27.306667-59.733333-59.733333-59.733334z" fill="#707070" p-id="2662"></path><path d="M824.32 788.48c-13.653333 0-25.6-11.946667-25.6-25.6v-85.333333c0-68.266667-110.933333-114.346667-218.453333-157.013334-75.093333-30.72-150.186667-61.44-199.68-104.106666v348.16c0 13.653333-11.946667 25.6-25.6 25.6s-25.6-11.946667-25.6-25.6v-512c0-13.653333 11.946667-25.6 25.6-25.6s25.6 11.946667 25.6 25.6v63.146666c0 68.266667 110.933333 114.346667 218.453333 157.013334 122.88 51.2 250.88 102.4 250.88 204.8v85.333333c0 13.653333-11.946667 25.6-25.6 25.6z" fill="#707070" p-id="2663"></path></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+1
View File
@@ -0,0 +1 @@
<?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="1786454978466" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7126" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M753.312 698.848 753.312 383.6736c0-62.048-50.3104-112.3264-112.288-112.3264L395.3024 271.3472 324.288 271.3472l-53.7344 0-29.536 0c-62.048 0-112.288 50.272-112.288 112.3264l0 400.0512c0 61.9776 50.24 112.2752 112.288 112.2752l400.0128 0c62.048 0 112.288-50.2976 112.288-112.2752l0-31.0656 0.8768 0 0-54.0352C753.888 698.624 753.6768 698.7776 753.312 698.848zM660.6016 681.728l0 42.5984c0 0.864-0.2368 1.792-0.2368 2.6688-1.4272 42.3552-36.064 76.3008-78.7328 76.3008L300.3392 803.296c-43.5968 0-78.9056-35.3664-78.9056-78.9696L221.4336 442.9888c0-42.2336 33.2608-76.4928 75.0144-78.5984 1.2928-0.064 2.5984-0.3712 3.8912-0.3712l41.4272 0L581.632 364.0192c43.6608 0 78.9696 35.4048 78.9696 78.9696L660.6016 681.728zM895.1232 555.5008 895.1232 240.3264c0-62.048-50.3104-112.3264-112.288-112.3264L537.1136 128 466.0992 128l-53.7344 0-29.536 0c-62.048 0-112.288 50.272-112.288 112.3264l0 4.4736 115.0784 0c13.4912-13.8112 32.0256-22.72 52.6464-23.7568 1.2864-0.064 2.592-0.3712 3.8848-0.3712l41.4272 0 239.8656 0c43.6608 0 78.9696 35.4048 78.9696 78.9696l0 238.7328 0 42.5984c0 0.864-0.2368 1.792-0.2368 2.6688-0.8768 25.9136-14.208 48.6336-34.176 62.4l0 106.6048 14.8352 0c62.048 0 112.288-50.2976 112.288-112.2752l0-31.0656L896 609.3056l0-54.0352C895.6992 555.2768 895.488 555.424 895.1232 555.5008z" fill="#707070" p-id="7127"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+1
View File
@@ -0,0 +1 @@
<?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="1786500512310" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11182" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M830.179556 256c0-2.844444-1.137778-5.575111-3.242667-7.566222a11.150222 11.150222 0 0 0-7.736889-3.128889c-98.304 0-214.812444-55.466667-295.253333-123.733333a18.773333 18.773333 0 0 0-23.893334 0C420.067556 189.44 303.104 245.304889 204.8 245.304889c-2.901333 0-5.688889 1.137778-7.736889 3.128889a10.581333 10.581333 0 0 0-3.242667 7.566222v298.666667c0 98.190222 34.986667 170.211556 90.282667 225.28 49.208889 49.038222 115.712 85.731556 191.260445 114.176l32.881777 11.719111 0.739556 0.284444a11.264 11.264 0 0 0 7.281778 0.113778c88.860444-30.151111 167.480889-70.144 223.630222-126.179556 55.296-55.182222 90.282667-127.203556 90.282667-225.393777V256z m-317.724445 394.695111a32.426667 32.426667 0 0 1 32.881778 31.971556c0 17.635556-14.791111 32.028444-32.881778 32.028444H512A32.483556 32.483556 0 0 1 479.061333 682.666667 32.426667 32.426667 0 0 1 512 650.695111h0.455111zM479.004444 512V341.333333A32.426667 32.426667 0 0 1 512 309.361778 32.426667 32.426667 0 0 1 544.938667 341.333333v170.666667c0 17.635556-14.791111 32.028444-32.938667 32.028444A32.483556 32.483556 0 0 1 479.061333 512z m416.938667 42.666667c0 115.086222-41.870222 203.093333-108.999111 270.051555-66.218667 66.048-155.591111 110.250667-249.230222 141.994667l-0.284445 0.113778c-16.497778 5.461333-34.360889 5.233778-50.744889-0.568889-93.866667-31.459556-183.409778-75.548444-249.742222-141.596445C169.927111 757.76 128 669.752889 128 554.666667V256c0-19.797333 8.078222-38.798222 22.528-52.792889a77.937778 77.937778 0 0 1 54.272-21.902222c77.141333 0 179.427556-46.364444 252.188444-108.088889l0.284445-0.284444a85.902222 85.902222 0 0 1 103.537778-4.437334l5.916444 4.437334 0.284445 0.227555c73.159111 62.179556 175.047111 108.145778 252.188444 108.145778 20.366222 0 39.879111 7.907556 54.272 21.902222 14.449778 13.994667 22.528 32.995556 22.528 52.792889v298.666667z" fill="#f36716" p-id="11183"></path></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+1
View File
@@ -0,0 +1 @@
<?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="1786499869460" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9284" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M298.666667 256c0 10.112 1.706667 19.712 4.906666 28.586667a42.666667 42.666667 0 0 1-42.666666 56.874666 85.333333 85.333333 0 0 0-68.565334 142.08 42.666667 42.666667 0 0 1 0.042667 56.917334 85.376 85.376 0 0 0 36.864 137.941333 42.666667 42.666667 0 0 1 28.586667 48.426667 106.666667 106.666667 0 0 0 210.432 35.157333c0.256-1.834667 0.597333-3.541333 1.066666-5.248V256a85.333333 85.333333 0 1 0-170.666666 0z m256 500.736c0.426667 1.706667 0.853333 3.413333 1.066666 5.205333a106.666667 106.666667 0 1 0 210.432-35.114666 42.666667 42.666667 0 0 1 28.586667-48.426667 85.376 85.376 0 0 0 36.864-137.941333 42.666667 42.666667 0 0 1 0-56.917334 85.333333 85.333333 0 0 0-68.565333-142.08 42.666667 42.666667 0 0 1-42.624-56.874666A85.333333 85.333333 0 1 0 554.666667 256v500.736zM384 85.333333a170.24 170.24 0 0 1 128 57.770667 170.666667 170.666667 0 0 1 298.581333 118.229333A170.752 170.752 0 0 1 915.84 512 170.538667 170.538667 0 0 1 853.333333 745.173333v1.493334a192 192 0 0 1-341.333333 120.661333A192 192 0 0 1 170.666667 746.666667v-1.493334A170.538667 170.538667 0 0 1 108.16 512a170.752 170.752 0 0 1 105.258667-250.624A170.666667 170.666667 0 0 1 384 85.333333z" fill="#8a8a8a" p-id="9285"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+1
View File
@@ -0,0 +1 @@
<?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="1786455341288" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1702" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M329.344 470.656L178.752 320l150.592-150.656 45.312 45.312L301.248 288H608q106.048 0 180.992 75.008Q864 437.952 864 544q0 106.048-75.008 180.992Q714.048 800 608 800H320v-64h288q79.552 0 135.744-56.256 56.256-56.192 56.256-135.744 0-79.552-56.256-135.744Q687.552 352 608 352H301.248l73.408 73.344-45.312 45.312z" p-id="1703" fill="#707070"></path></svg>

After

Width:  |  Height:  |  Size: 685 B

+1
View File
@@ -0,0 +1 @@
<?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="1786499156004" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6668" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M874.666667 450.773333V256a128 128 0 0 0-128-128H234.666667a128 128 0 0 0-128 128v512a128 128 0 0 0 128 128h213.333333a42.666667 42.666667 0 0 0 0-85.333333H234.666667a42.346667 42.346667 0 0 1-30.165334-12.501334A42.346667 42.346667 0 0 1 192 768V256c0.021333-11.925333 4.693333-22.314667 12.501333-30.165333A42.346667 42.346667 0 0 1 234.666667 213.333333h512c11.925333 0.021333 22.314667 4.693333 30.165333 12.501334 7.786667 7.850667 12.48 18.24 12.501333 30.165333v194.773333a42.666667 42.666667 0 0 0 85.333334 0z" p-id="6669" fill="#707070"></path><path d="M618.666667 341.333333H362.666667a42.666667 42.666667 0 0 0 0 85.333334h85.333333v213.333333a42.666667 42.666667 0 0 0 85.333333 0V426.666667h85.333334a42.666667 42.666667 0 0 0 0-85.333334zM746.666667 597.333333a42.666667 42.666667 0 0 0-42.666667 42.666667v213.333333a42.666667 42.666667 0 0 0 85.333333 0V640a42.666667 42.666667 0 0 0-42.666666-42.666667zM618.666667 661.333333a42.666667 42.666667 0 0 0-42.666667 42.666667v85.333333a42.666667 42.666667 0 0 0 85.333333 0v-85.333333a42.666667 42.666667 0 0 0-42.666666-42.666667zM874.666667 661.333333a42.666667 42.666667 0 0 0-42.666667 42.666667v85.333333a42.666667 42.666667 0 0 0 85.333333 0v-85.333333a42.666667 42.666667 0 0 0-42.666666-42.666667z" p-id="6670" fill="#707070"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+1
View File
@@ -0,0 +1 @@
<?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="1786500546268" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="14054" width="200" height="200" xmlns:xlink="http://www.w3.org/1999/xlink"><path d="M422.399 601.6V493.323c-79.111-34.622-134.4-113.655-134.4-205.323 0-123.711 100.54-224 224-224 123.711 0 224 100.545 224 224 0 91.814-55.379 170.725-134.4 205.313V601.6h201.472c61.925 0 112.128 49.716 112.128 112.003V825.6H108.801V713.598c0-61.855 50.444-111.998 112.126-111.998h201.472zM153.601 870.4h716.793V960H153.6v-89.6z" p-id="14055" fill="#f4ec5b"></path></svg>

After

Width:  |  Height:  |  Size: 703 B

+1
View File
@@ -0,0 +1 @@
<?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="1786456570550" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9808" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M153.6 449.163636H209.454545c109.381818 0 167.563636-86.109091 167.563637-176.872727V174.545455c0-4.654545 0-11.636364 2.327273-16.29091 9.309091-51.2 58.181818-86.109091 109.381818-76.8l13.963636 2.327273c46.545455 6.981818 86.109091 37.236364 104.727273 79.127273 18.618182 39.563636 25.6 83.781818 18.618182 125.672727l-9.309091 72.145455v2.327272c0 6.981818 4.654545 11.636364 11.636363 11.636364h244.363637c44.218182 6.981818 74.472727 46.545455 69.818182 93.090909L861.090909 872.727273c-4.654545 39.563636-39.563636 69.818182-81.454545 69.818182H172.218182c-37.236364 0-67.490909-30.254545-69.818182-67.49091l-18.618182-353.745454c-2.327273-39.563636 27.927273-72.145455 67.490909-72.145455h2.327273z m160.581818 423.563637h465.454546c4.654545 0 11.636364-4.654545 11.636363-9.309091l81.454546-402.618182c0-6.981818-4.654545-11.636364-9.309091-13.963636H628.363636c-44.218182 0-81.454545-37.236364-81.454545-81.454546v-11.636363l9.309091-72.145455c4.654545-30.254545 0-60.509091-13.963637-86.109091-9.309091-23.272727-30.254545-37.236364-53.527272-41.890909l-13.963637-2.327273c-13.963636-2.327273-25.6 6.981818-27.927272 18.618182v100.072727c0 100.072727-53.527273 200.145455-158.254546 235.054546l25.6 367.709091zM200.145455 516.654545h-23.272728c-13.963636 0-23.272727 11.636364-23.272727 23.272728L162.909091 849.454545c0 11.636364 11.636364 23.272727 23.272727 23.272728h34.909091c13.963636 0 23.272727-9.309091 23.272727-23.272728v-2.327272l-20.945454-307.2c-2.327273-11.636364-11.636364-23.272727-23.272727-23.272728z" fill="#707070" p-id="9809"></path></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+1
View File
@@ -0,0 +1 @@
<?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="1786456527821" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8742" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M346.7776 102.4a188.416 188.416 0 0 0-125.0304 45.8752 193.8432 193.8432 0 0 0-67.328 116.3776l-50.2784 272.896-0.2048 1.0752c-5.5296 32.768 3.9936 66.048 25.856 90.7264 22.2208 25.9584 50.5344 38.912 84.224 38.5024h188.1088v115.3024c0 50.7904 20.4288 91.5456 60.928 121.4464 13.7728 10.1888 28.416 15.872 43.8784 16.896 15.4624 0.9216 31.0784-2.9696 46.848-11.1616 25.4976-13.312 44.4928-34.5088 56.9344-62.976l111.7184-255.2832h89.6512a104.96 104.96 0 0 0 77.4656-32.6144c20.9408-20.736 32.512-49.152 32.0512-78.6432V213.6576c0-30.6688-10.752-57.0368-32.0512-78.6432A104.96 104.96 0 0 0 812.032 102.4H346.7776z m4.608 76.8h311.6544v364.4928l-115.3024 266.9568c-6.2464 14.4384-15.1552 24.6272-26.7264 30.72-8.8064 4.7616-16.5376 4.5568-23.2448-0.512-22.1184-16.4864-33.1776-38.7584-33.1776-66.8672V627.712a26.7264 26.7264 0 0 0-26.3168-27.0336H224.9216a42.7008 42.7008 0 0 1-34.6112-15.872 44.8 44.8 0 0 1-10.5472-36.7616l47.7696-262.9632c5.5808-30.72 20.0192-56.0128 43.3664-75.9296 23.3472-19.968 50.176-29.9008 80.5376-29.9008z m388.5056-0.1536h55.808a48.64 48.64 0 0 1 34.7136 13.6704 43.52 43.52 0 0 1 14.3872 32.8704v242.9952a43.6224 43.6224 0 0 1-14.336 32.8704 48.7936 48.7936 0 0 1-34.816 13.6192h-55.808V179.0464z" p-id="8743" fill="#707070"></path><path d="M640 128h-102.4v51.2h102.4z" p-id="8744" fill="#707070"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+1
View File
@@ -0,0 +1 @@
<?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="1786454951142" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5823" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M726.31616669 553.08337403c0 65.77789307-25.36193848 127.55126953-71.41387939 174.04815673a240.49346924 240.49346924 0 0 1-172.3425293 72.15545655 240.49346924 240.49346924 0 0 1-172.26837158-72.15545655 245.83282471 245.83282471 0 0 1-71.41387939-174.04815673c0-65.70373536 25.36193848-127.55126953 71.41387939-174.04815674a240.7901001 240.7901001 0 0 1 172.26837158-72.15545655c0.29663086 0-52.50366211 70.15319825-52.50366211 70.15319824a30.77545166 30.77545166 0 0 0 24.10125732 49.24072266 30.25634766 30.25634766 0 0 0 24.10125732-12.01354981l95.44097901-125.77148436a30.77545166 30.77545166 0 0 0-5.63598633-43.01147462L441.55054169 147.58898925a30.10803223 30.10803223 0 0 0-42.49237061 5.63598634 30.77545166 30.77545166 0 0 0 5.63598633 43.01147461l64.07226563 49.61151123c-36.18896484 1.55731201-71.33972168 9.56634521-104.56237793 23.87878417A306.19720459 306.19720459 0 0 0 202.24359588 433.31866455 307.75451661 307.75451661 0 0 0 178.29065399 553.00921631a307.75451661 307.75451661 0 0 0 185.83923341 283.2824707 299.2263794 299.2263794 0 0 0 118.50402831 24.24957276c41.08337403 0 80.98022461-8.15734864 118.50402833-24.17541505A306.19720459 306.19720459 0 0 0 762.95007782 672.92224122a307.75451661 307.75451661 0 0 0 24.0270996-119.76470948 30.55297852 30.55297852 0 0 0-30.33050537-30.77545166 30.55297852 30.55297852 0 0 0-30.40466308 30.70129395z" p-id="5824" fill="#707070"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+1
View File
@@ -0,0 +1 @@
<?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="1786500608721" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="15422" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M395.53 418a30.07 30.07 0 0 1-4.46-5.71 142.84 142.84 0 0 0-24.36 79.88c0 78.73 64.08 142.81 142.8 142.81a141.51 141.51 0 0 0 79.26-24.1 23.73 23.73 0 0 1-2.85-2.5z m113.89-68.63a141.76 141.76 0 0 0-78.72 23.74 27.83 27.83 0 0 1 5.45 4.28l190.38 190.39c0.8 0.81 1.6 1.7 2.32 2.59a142.72 142.72 0 0 0-119.43-221z" p-id="15423" fill="#75f97e"></path><path d="M767.19 209.69c-39-3.39-77.92-11.87-113.53-27.13a521.88 521.88 0 0 1-120.32-55.87 1.66 1.66 0 0 1-1.69-1.7h-3.39c-10.18-5.09-22.05-5.09-32.22 1.7l-3.4 1.69a784 784 0 0 1-122 55.87C335 196.13 296.11 204.6 257.1 209.69h-1.7c-18.65 1.7-32.22 17-32.22 35.61v335.51c0 135.58 255.9 282.94 288 282.94 30.52 0 288-147.45 288-282.94V245.22a35 35 0 0 0-32-35.53zM650.8 633.56a199.86 199.86 0 1 1 58.64-141.38 198.5 198.5 0 0 1-58.64 141.38z" p-id="15424" fill="#75f97e"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+47 -36
View File
@@ -10,7 +10,7 @@
rel="stylesheet"
/>
<link rel="icon" href="/image/favicon.svg" sizes="any" />
<link rel="stylesheet" href="/app.css?v=2" />
<link rel="stylesheet" href="/app.css?v=6" />
</head>
<body>
<div class="bg-scene" id="bgScene" aria-hidden="true">
@@ -167,18 +167,17 @@
></textarea>
<div class="composer-input-bottom-row">
<div class="composer-input-bottom-left">
<div class="inline-drop-wrap" id="outputModeWrap">
<button type="button" class="inline-drop-trigger" id="outputModeTrigger" aria-label="Agent 输出模式">文字模式</button>
<div class="inline-drop-panel" id="outputModePanel" hidden>
<div class="inline-drop-item is-selected" data-value="text">文字模式</div>
<div class="inline-drop-item" data-value="voice">语音模式</div>
</div>
</div>
<div class="inline-drop-wrap" id="agentModeWrap">
<button type="button" class="inline-drop-trigger" id="agentModeTrigger" aria-label="Agent 工作模式">agent</button>
<div class="inline-drop-panel" id="agentModePanel" hidden>
<div class="inline-drop-item is-selected" data-value="agent">agent</div>
<div class="inline-drop-item" data-value="plan">plan</div>
<button type="button" class="voice-toggle-btn" id="voiceToggleBtn" title="文字/语音回复开关" aria-label="语音回复开关" aria-pressed="false">
<img src="/image/文本语音 .svg" alt="语音" aria-hidden="true" />
</button>
<div class="inline-drop-wrap" id="accessModeWrap">
<button type="button" class="inline-drop-trigger inline-drop-trigger--icon" id="accessModeTrigger" aria-label="工作空间访问模式" title="工作空间访问模式:限制访问 / 权限审查 / 完全访问">
<img src="/image/限制访问.svg" alt="访问模式" id="accessModeIcon" />
</button>
<div class="inline-drop-panel" id="accessModePanel" hidden>
<div class="inline-drop-item is-selected access-mode-item--restricted" data-value="restricted">限制访问</div>
<div class="inline-drop-item access-mode-item--approval" data-value="approval">权限审查</div>
<div class="inline-drop-item access-mode-item--full" data-value="full">完全访问</div>
</div>
</div>
<span class="input-actions-sep"></span>
@@ -196,10 +195,39 @@
</button>
</div>
<div class="composer-input-bottom-right">
<div class="token-ring-wrap" id="tokenRingWrap">
<button type="button" class="token-ring-btn" id="tokenRingBtn" title="上下文 Token 占用" aria-label="上下文 Token 占用">
<svg class="token-ring-svg" id="tokenRingSvg" viewBox="0 0 30 30" width="13" height="13">
<circle class="token-ring-bg" cx="15" cy="15" r="11"></circle>
<circle class="token-ring-fill" id="tokenRingFill" cx="15" cy="15" r="11"></circle>
</svg>
</button>
<div class="token-ring-panel" id="tokenRingPanel" hidden>
<div class="token-ring-capacity" id="tokenRingCapacity">上下文容量 0k / 0k (0%)</div>
<div class="token-ring-rate-track" id="tokenRingRateTrack">
<div class="token-ring-rate-fill" id="tokenRingRateFill"></div>
<div class="token-ring-threshold" id="tokenRingThreshold"></div>
</div>
<div class="token-ring-rate-label" id="tokenRingRateLabel">压缩阈值 60%(在环境变量中修改)</div>
<div class="token-ring-breakdown" id="tokenRingBreakdown"></div>
</div>
</div>
<div class="inline-drop-wrap" id="modelSelectWrap">
<button type="button" class="inline-drop-trigger inline-drop-trigger--model" id="modelTrigger" aria-label="模型选择">模型</button>
<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">
<img src="/image/思考模式.svg" alt="思考" id="thinkModeIcon" />
</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>
</div>
<div class="ws-btn-wrap" id="wsBtnWrap">
<button type="button" class="ws-btn" id="wsBtn" title="选择工作空间" aria-label="选择工作空间">
<img src="/image/文件夹.svg" alt="" aria-hidden="true" />
@@ -227,18 +255,6 @@
</div>
</div>
</div>
<!-- Token 上下文进度条 -->
<div class="token-bar" id="tokenBar">
<div class="token-bar-track" id="tokenBarTrack">
<div class="token-bar-seg token-bar-seg--green" id="tokenBarSegGreen"></div>
<div class="token-bar-seg token-bar-seg--red" id="tokenBarSegRed"></div>
<div class="token-bar-fill" id="tokenBarFill"></div>
<div class="token-handle token-handle--compress" id="tokenHandleCompress" title="压缩阈值(拖拽调整)">
<div class="token-handle-dot"></div>
</div>
</div>
<span class="token-label token-label--safe" id="tokenLabel">0 / 0 tokens</span>
</div>
</footer>
</main>
</div>
@@ -275,6 +291,7 @@
<label class="cron-field-label" for="cronEditPrompt">任务提示词</label>
<textarea id="cronEditPrompt" placeholder="触发时投递给 Agent 的指令,如:查询今日天气并生成晨报"></textarea>
<span class="cron-field-hint">该提示词将在每次触发时投递给 Agent 自主执行。</span>
<span class="cron-field-hint cron-field-hint--warn" id="cronAccessModeHint" hidden>⚠️ 定时任务无人值守执行:当前访问模式为「限制访问/权限审查」时,超出工作空间的操作会被拦截或退化拦截,建议先切换为「完全访问」。</span>
</div>
<div class="cron-field">
<span class="cron-field-label">触发方式</span>
@@ -373,8 +390,10 @@
<div class="settings-panel" data-panel="skills" id="skillsPanel" hidden>
<div class="settings-toolbar">
<button type="button" class="btn btn-primary" id="addSkillBtn">+ 创建 Skill</button>
<span class="settings-path">Skills 目录:agent/skills/</span>
<button type="button" class="btn btn-ghost" id="importSkillBtn" title="导入 zip 或文件夹(自动解析 skill.md)">导入 Skill</button>
</div>
<input type="file" id="importSkillZipInput" accept=".zip" hidden />
<input type="file" id="importSkillFolderInput" webkitdirectory multiple hidden />
<div class="skills-list" id="skillsList"></div>
<!-- Skill 编辑/创建弹窗 -->
<div class="skill-editor-overlay" id="skillEditorOverlay" hidden>
@@ -393,15 +412,7 @@
<input type="text" id="skillDescInput" placeholder="简要描述此 skill 的用途" />
</div>
<div class="setting-field">
<label>版本 (version)</label>
<input type="text" id="skillVersionInput" value="1.0.0" placeholder="版本号" />
</div>
<div class="setting-field">
<label>作者 (author)</label>
<input type="text" id="skillAuthorInput" placeholder="作者名" />
</div>
<div class="setting-field">
<label>标签 (tags,逗号分隔)</label>
<label>标签 (tags,逗号分隔,可选)</label>
<input type="text" id="skillTagsInput" placeholder="e.g. python, 自动化, GUI" />
</div>
<div class="setting-field">
@@ -574,6 +585,6 @@
_showJsError('Promise: ' + (e.reason?.message || String(e.reason)));
});
</script>
<script type="module" src="/app.js?v=2"></script>
<script type="module" src="/app.js?v=6"></script>
</body>
</html>
+129
View File
@@ -0,0 +1,129 @@
// access-mode.js -- 工作空间访问模式选择器(限制访问 / 权限审查 / 完全访问)
// 模式持久化在后端 workspace_config.json(/api/workspace/access-mode),
// 由 agent/core/workspace_policy.py 消费,控制 doc_tool / terminal_execute 的越界处理。
// 触发器为彩色图标按钮(每种模式一个 SVG),面板内为三色文字选项。
// 交互通过 document 级事件委托实现,对元素绑定时序零依赖,并带连击保护。
import { $, showToast, closeInlinePanelsExcept } from './utils.js';
import { api } from './api.js';
import { state } from './state.js';
const MODE_LABELS = {
restricted: "限制访问",
approval: "权限审查",
full: "完全访问",
};
const MODE_ICONS = {
restricted: "/image/限制访问.svg",
approval: "/image/权限审查.svg",
full: "/image/完全访问.svg",
};
const MODE_TITLES = {
restricted: "限制访问:越界操作直接拦截",
approval: "权限审查:越界操作需人工审批",
full: "完全访问:不做越界审查",
};
let _panelOpen = false;
let _switching = false;
let _lastSelectAt = 0;
let _lastTriggerAt = 0;
/** 初始化访问模式(bootstrap 后调用):从后端加载并同步图标/下拉框 */
export async function initAccessMode() {
try {
const data = await api("/api/workspace");
if (data.access_mode && MODE_LABELS[data.access_mode]) {
state.accessMode = data.access_mode;
}
} catch { /* ignore */ }
syncAccessModeUI();
}
/** 将图标、触发器标题与下拉选中态同步到 state.accessMode */
export function syncAccessModeUI() {
const trigger = $("accessModeTrigger");
const icon = $("accessModeIcon");
const panel = $("accessModePanel");
if (!trigger || !panel) return;
const mode = MODE_LABELS[state.accessMode] ? state.accessMode : "restricted";
panel.querySelectorAll(".inline-drop-item").forEach((el) => {
el.classList.toggle("is-selected", el.dataset.value === mode);
});
if (icon) icon.src = MODE_ICONS[mode];
trigger.title = MODE_TITLES[mode] || "工作空间访问模式";
trigger.dataset.value = mode;
}
/** 绑定事件:document 级委托(触发器开关面板 + 选项点击),含连击保护 */
export function bindAccessModeEvents() {
document.addEventListener("click", (e) => {
const wrap = $("accessModeWrap");
const panel = $("accessModePanel");
if (!wrap || !panel) return;
const target = e.target;
// 触发器点击:开关面板(带连击保护)
if (target.closest && target.closest("#accessModeTrigger")) {
const now = Date.now();
if (now - _lastTriggerAt < 300) return;
_lastTriggerAt = now;
if (_panelOpen) {
closePanel();
return;
}
closeInlinePanelsExcept(panel);
panel.hidden = false;
_panelOpen = true;
return;
}
// 面板选项点击:切换模式(带连击保护)
if (target.closest && target.closest("#accessModePanel .inline-drop-item")) {
const item = target.closest("#accessModePanel .inline-drop-item");
selectMode(item.dataset.value);
return;
}
// 面板外点击 → 关闭
if (!wrap.contains(target)) {
closePanel();
}
});
}
function closePanel() {
const panel = $("accessModePanel");
if (panel) panel.hidden = true;
_panelOpen = false;
}
/** 切换访问模式并持久化(连击/重复提交保护) */
async function selectMode(mode) {
if (!MODE_LABELS[mode] || _switching) return;
const now = Date.now();
if (now - _lastSelectAt < 500) return;
_lastSelectAt = now;
_switching = true;
try {
const data = await api("/api/workspace/access-mode", {
method: "POST",
body: JSON.stringify({ mode }),
});
if (data && data.mode) {
state.accessMode = data.mode;
}
} catch (e) {
closePanel();
syncAccessModeUI();
showToast(e.message || String(e));
return;
} finally {
_switching = false;
}
closePanel();
syncAccessModeUI();
showToast(`工作空间访问模式已切换为「${MODE_LABELS[state.accessMode]}」`);
}
+15 -2
View File
@@ -25,7 +25,7 @@ function setRecordingUi(recording) {
micBtn.title = "停止录音";
micBtn.setAttribute("aria-label", "停止录音");
} else {
micBtn.title = "使用麦克风录音";
micBtn.title = micBtn.disabled ? "语音转文字(ASR)服务不可用" : "使用麦克风录音";
micBtn.setAttribute("aria-label", "使用麦克风录音");
}
}
@@ -88,6 +88,19 @@ async function startMicRecording() {
}
if (state.sending) return;
// 语音转文字依赖 ASR 服务:先检查连通性,不通则提示并中止录音
try {
const { api } = await import('./api.js');
const st = await api("/api/services/status");
if (!st || !st.asr || !st.asr.ok) {
showToast("语音转文字(ASR)服务不可用,无法录音");
return;
}
} catch {
showToast("无法检测语音转文字(ASR)服务状态");
return;
}
const preferredTypes = [
"audio/wav",
"audio/mp4",
@@ -134,7 +147,7 @@ async function toggleMicRecording() {
} else {
const { sendChat } = await import('./send.js');
await sendChat();
const { refreshTokens } = await import('./token-bar.js');
const { refreshTokens } = await import('./token-ring.js');
refreshTokens();
}
}
+384 -19
View File
@@ -1,6 +1,6 @@
// chat-render.js -- 消息渲染 / 流式输出 / 音频气泡(新版:无气泡 + 媒体行 + agent-run-block)
import { $, escapeHtml, getTextFromContent, getMediaParts, isAudioOnlyAssistantMessage, clamp, formatAudioDuration, getAudioBubbleWidth, codeBlockNode, inferUploadKind, dedupePendingFiles } from './utils.js';
import { $, escapeHtml, getTextFromContent, getMediaParts, isAudioOnlyAssistantMessage, clamp, formatAudioDuration, getAudioBubbleWidth, codeBlockNode, inferUploadKind, dedupePendingFiles, showToast } from './utils.js';
import { state } from './state.js';
import {
showImagePreview,
@@ -50,11 +50,13 @@ function renderAttachmentChips() {
const refImages = [];
const refFiles = [];
const refFolderItems = [];
const refQuotes = [];
state.pendingFiles.forEach((f, i) => {
if (f.__isRef) {
if (f.type === 'ref/image') refImages.push({ file: f, idx: i });
else if (f.type === 'ref/folder') refFolderItems.push({ file: f, idx: i });
else if (f.type === 'ref/quote') refQuotes.push({ file: f, idx: i });
else refFiles.push({ file: f, idx: i });
} else {
const kind = inferUploadKind(f);
@@ -189,6 +191,23 @@ function renderAttachmentChips() {
hasContent = true;
});
// 聊天区文字引用 chips(复用 ref-chip UI,注明为用户引用)
refQuotes.forEach(({ file, idx }) => {
const chip = document.createElement("span");
chip.className = "ref-chip ref-chip--quote";
chip.title = "用户引用:" + (file.refText || file.refName || "");
chip.innerHTML = `<img src="/image/回复.svg" alt="" class="ref-chip-icon" /> <span class="ref-chip-label">${escapeHtml(file.refName || file.name)}</span> <button type="button" class="ref-chip-close" aria-label="移除">\u00D7</button>`;
chip.querySelector("button").addEventListener("click", () => {
state.pendingFiles = state.pendingFiles.filter((_, j) => j !== idx);
if (file.refPath) {
state.pendingRefs = state.pendingRefs.filter((r) => r.path !== file.refPath);
}
renderAttachmentChips();
});
chipsArea.appendChild(chip);
hasContent = true;
});
if (hasContent || imagesArea.children.length > 0 || chipsArea.children.length > 0) {
wrap.classList.add("has-content");
} else {
@@ -955,29 +974,15 @@ function renderMessage(msg, allowAutoplay = false) {
};
if (msg.role === "user") {
// 用户消息:回退按钮 + 内容(无气泡)
const rollbackBtn = document.createElement("button");
rollbackBtn.className = "chat-rollback-btn";
rollbackBtn.textContent = "\u21A9";
rollbackBtn.title = "回退到此消息";
rollbackBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (!chatMessages) return;
const children = Array.from(chatMessages.children);
const idx = children.indexOf(wrap);
if (idx < 0) return;
if (rollbackChatFn) {
rollbackChatFn(idx);
}
});
wrap.appendChild(rollbackBtn);
// 用户消息:内容 + 下方操作按钮(复制 / 撤回)
const contentEl = renderUserContent(msg.content, baseOptions);
wrap.appendChild(contentEl);
wrap.appendChild(renderMessageActions("user", msg, wrap));
} else {
// 助手消息
// 助手消息:内容 + 下方操作按钮(复制 / 重试)
const contentEl = renderAssistantContent(msg, allowAutoplay);
wrap.appendChild(contentEl);
wrap.appendChild(renderMessageActions("assistant", msg, wrap));
if (msg.meta && msg.meta.pending_action) {
// pending_action 不再内嵌在气泡中,由 pending-overlay 浮窗展示
}
@@ -986,6 +991,362 @@ function renderMessage(msg, allowAutoplay = false) {
return wrap;
}
/* ===== 消息下方操作按钮行(无边框图标按钮) ===== */
function renderMessageActions(role, msg, wrap) {
const actions = document.createElement("div");
actions.className = "msg-actions msg-actions--" + role;
// 复制到剪贴板
const copyBtn = document.createElement("button");
copyBtn.type = "button";
copyBtn.className = "chat-action-btn";
copyBtn.title = "复制";
copyBtn.setAttribute("aria-label", "复制消息内容");
const copyImg = document.createElement("img");
copyImg.src = "/image/复制.svg";
copyImg.alt = "";
copyImg.draggable = false;
copyBtn.appendChild(copyImg);
copyBtn.addEventListener("click", (e) => {
e.stopPropagation();
const text = getTextFromContent(msg.content);
if (!text) {
showToast("没有可复制的内容");
return;
}
navigator.clipboard.writeText(text).then(() => {
showToast("已复制到剪贴板");
}).catch(() => {
showToast("复制失败");
});
});
actions.appendChild(copyBtn);
if (role === "user") {
// 撤回:回退到此消息
const rollbackBtn = document.createElement("button");
rollbackBtn.type = "button";
rollbackBtn.className = "chat-action-btn";
rollbackBtn.title = "撤回";
rollbackBtn.setAttribute("aria-label", "撤回此消息");
const rbImg = document.createElement("img");
rbImg.src = "/image/撤回.svg";
rbImg.alt = "";
rbImg.draggable = false;
rollbackBtn.appendChild(rbImg);
rollbackBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (!chatMessages) return;
const children = Array.from(chatMessages.children);
const idx = children.indexOf(wrap);
if (idx < 0) return;
if (rollbackChatFn) rollbackChatFn(idx);
});
actions.appendChild(rollbackBtn);
} else {
// 点赞 / 点踩(本地持久化,刷新/切换会话后保留)
const msgIdx = (() => {
if (!chatMessages) return -1;
return Array.from(chatMessages.children).indexOf(wrap);
})();
const fbKey = _feedbackKey(msg, msgIdx);
const fbMap0 = _loadFeedbackMap();
const curVal = fbMap0[fbKey] || "";
const likeBtn = document.createElement("button");
likeBtn.type = "button";
likeBtn.className = "chat-action-btn chat-feedback-btn";
likeBtn.title = "点赞";
likeBtn.setAttribute("aria-label", "点赞");
const likeImg = document.createElement("img");
likeImg.src = "/image/点赞.svg";
likeImg.alt = "";
likeImg.draggable = false;
likeBtn.appendChild(likeImg);
if (curVal === "like") likeBtn.classList.add("is-active");
const dislikeBtn = document.createElement("button");
dislikeBtn.type = "button";
dislikeBtn.className = "chat-action-btn chat-feedback-btn";
dislikeBtn.title = "点踩";
dislikeBtn.setAttribute("aria-label", "点踩");
const dislikeImg = document.createElement("img");
dislikeImg.src = "/image/点踩.svg";
dislikeImg.alt = "";
dislikeImg.draggable = false;
dislikeBtn.appendChild(dislikeImg);
if (curVal === "dislike") dislikeBtn.classList.add("is-active");
const applyFeedback = (val) => {
const map = _loadFeedbackMap();
const next = map[fbKey] === val ? "" : val;
map[fbKey] = next;
_saveFeedbackMap(map);
likeBtn.classList.toggle("is-active", next === "like");
dislikeBtn.classList.toggle("is-active", next === "dislike");
showToast(next === "like" ? "已点赞" : next === "dislike" ? "已点踩" : "已取消评价");
};
likeBtn.addEventListener("click", (e) => {
e.stopPropagation();
applyFeedback("like");
});
dislikeBtn.addEventListener("click", (e) => {
e.stopPropagation();
applyFeedback("dislike");
});
actions.appendChild(likeBtn);
actions.appendChild(dislikeBtn);
// 重试:删除本轮记录(撤回)并重新执行 agent(撤回 + 发送)
const retryBtn = document.createElement("button");
retryBtn.type = "button";
retryBtn.className = "chat-action-btn";
retryBtn.title = "重试";
retryBtn.setAttribute("aria-label", "重试本轮回复");
const rtImg = document.createElement("img");
rtImg.src = "/image/重试.svg";
rtImg.alt = "";
rtImg.draggable = false;
retryBtn.appendChild(rtImg);
retryBtn.addEventListener("click", (e) => {
e.stopPropagation();
handleRetry(wrap);
});
actions.appendChild(retryBtn);
// 分支:将当前回复及之前的所有历史复制到新会话
const branchBtn = document.createElement("button");
branchBtn.type = "button";
branchBtn.className = "chat-action-btn";
branchBtn.title = "分支";
branchBtn.setAttribute("aria-label", "复制历史到新会话");
const brImg = document.createElement("img");
brImg.src = "/image/分支.svg";
brImg.alt = "";
brImg.draggable = false;
branchBtn.appendChild(brImg);
branchBtn.addEventListener("click", (e) => {
e.stopPropagation();
handleBranch(wrap, msg);
});
actions.appendChild(branchBtn);
// 分支右侧:SSE 开始时间(即该轮 AI 回复开始的时间,时:分)
const turnMeta = (msg && msg.meta && msg.meta.turn_meta) || {};
if (turnMeta.started_at) {
const timeEl = document.createElement("span");
timeEl.className = "msg-start-time";
timeEl.textContent = _formatStartTime(turnMeta.started_at);
timeEl.title = "本轮回复开始时间";
actions.appendChild(timeEl);
}
}
return actions;
}
/** 将 Unix 秒时间戳格式化为 时:分(24 小时制,补零) */
function _formatStartTime(startedAt) {
const ts = Number(startedAt);
if (!Number.isFinite(ts) || ts <= 0) return "";
const d = new Date(ts * 1000);
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
return `${hh}:${mm}`;
}
/** 分支:将当前回复及之前的所有历史复制到新会话并跳转 */
async function handleBranch(wrap, msg) {
if (state.sending) {
showToast("Agent 运行中,无法分支。请先暂停任务。");
return;
}
if (!state.sessionId) {
showToast("无当前会话");
return;
}
const turnId = msg && msg.meta && msg.meta.turn_id;
if (!turnId) {
showToast("无法获取该回复的回合信息");
return;
}
try {
const { api } = await import("./api.js");
const fd = new FormData();
fd.append("session_id", state.sessionId);
fd.append("branch_turn_id", turnId);
const data = await api("/api/sessions/branch", { method: "POST", body: fd });
const { applySessionData } = await import("./sessions.js");
await applySessionData(data);
showToast("已创建分支会话,历史已复制到新会话");
} catch (err) {
showToast("分支失败: " + (err.message || "未知错误"));
}
}
/* ===== 点赞 / 点踩 本地持久化 ===== */
const FEEDBACK_STORAGE_KEY = "minor_agent_msg_feedback";
function _loadFeedbackMap() {
try {
return JSON.parse(localStorage.getItem(FEEDBACK_STORAGE_KEY) || "{}");
} catch {
return {};
}
}
function _saveFeedbackMap(map) {
try {
localStorage.setItem(FEEDBACK_STORAGE_KEY, JSON.stringify(map));
} catch {
/* 忽略写入失败 */
}
}
/** 生成消息级反馈的持久化键:优先 turn_id,缺失时退回消息索引 */
function _feedbackKey(msg, msgIndex) {
const tid = msg && msg.meta && msg.meta.turn_id;
if (tid) return `${state.sessionId}:turn:${tid}:assistant`;
return `${state.sessionId}:idx:${msgIndex}:assistant`;
}
/** 重试:回退到本轮的用户消息(含该消息),恢复输入后重新发送 */
async function handleRetry(wrap) {
if (state.sending) {
showToast("Agent 运行中,无法重试。请先暂停任务。");
return;
}
if (!state.sessionId) {
showToast("无当前会话");
return;
}
// 找到本轮对应的用户消息(当前回复之前最近的一条 user 消息)
let prev = wrap.previousElementSibling;
while (prev && !prev.classList.contains("user")) {
prev = prev.previousElementSibling;
}
if (!prev) {
showToast("未找到对应的用户消息");
return;
}
if (!chatMessages) return;
const children = Array.from(chatMessages.children);
const userIdx = children.indexOf(prev);
if (userIdx < 0) return;
// 撤回该轮(删除本轮用户消息及其后的记录,内容恢复到输入区)
let rolledBack = false;
if (rollbackChatFn) {
rolledBack = await rollbackChatFn(userIdx, { skipConfirm: true });
}
if (!rolledBack) return;
// 撤回成功后重新执行 agent(相当于撤回 + 发送)
const textInput = $("textInput");
const { sendChat } = await import("./send.js");
if (textInput && textInput.value.trim()) {
sendChat();
} else {
showToast("已撤回本轮消息,请在输入框确认内容后重新发送");
}
}
/* ===== 聊天区文字引用(引用为"用户引用",复用 ref-chip UI) ===== */
let _quoteSeq = 0;
/** 将选中的聊天文字添加为引用 chip(发送时以「用户引用」形式注入文本) */
function addTextQuote(text) {
const clean = String(text || "").trim();
if (!clean) {
showToast("请先选中要引用的文字");
return;
}
const id = "quote:" + Date.now().toString(36) + ":" + (_quoteSeq++).toString(36);
state.pendingRefs.push({ type: "quote", path: id, text: clean });
state.pendingFiles.push({
__isRef: true,
refPath: id,
refName: clean.length > 28 ? clean.slice(0, 28) + "…" : clean,
type: "ref/quote",
name: "引用",
refText: clean,
});
renderAttachmentChips();
const textInput = $("textInput");
if (textInput) textInput.focus();
showToast("已添加引用");
}
let _quoteMenuCloseBound = null;
function removeQuoteMenu() {
const m = document.querySelector(".chat-quote-menu");
if (m) m.remove();
// 清理文档级关闭监听,避免旧菜单的监听误删新菜单
if (_quoteMenuCloseBound) {
document.removeEventListener("mousedown", _quoteMenuCloseBound);
_quoteMenuCloseBound = null;
}
}
/** 初始化聊天区引用:右键选中文字显示「引用」菜单 + 选中文字拖拽到输入区 */
function initChatQuoteMenu() {
if (!chatMessages) return;
// 左键选中文字后右键 → 显示"引用"菜单
chatMessages.addEventListener("contextmenu", (e) => {
const sel = window.getSelection();
const text = sel ? sel.toString().trim() : "";
if (!text) return; // 无选中内容时保留默认右键菜单
if (!chatMessages.contains(sel.anchorNode)) return;
e.preventDefault();
removeQuoteMenu();
const menu = document.createElement("div");
menu.className = "doc-context-menu chat-quote-menu";
// 限制菜单在视口内,避免靠右/靠底时溢出不可点击
const menuWidth = 168; // 约等于 .doc-context-menu min-width(160) + 边距
const menuHeight = 48; // 单个菜单项的高度余量
const posX = Math.max(4, Math.min(e.clientX, window.innerWidth - menuWidth));
const posY = Math.max(4, Math.min(e.clientY, window.innerHeight - menuHeight));
menu.style.left = posX + "px";
menu.style.top = posY + "px";
const item = document.createElement("div");
item.className = "doc-context-menu-item";
item.textContent = "引用所选文字";
item.addEventListener("click", () => {
addTextQuote(text);
removeQuoteMenu();
});
menu.appendChild(item);
document.body.appendChild(menu);
const close = (ev) => {
// 点击菜单项时 item 的 click 处理器先执行(移除菜单),此处检测到已移除则跳过
if (menu.contains(ev.target)) return;
removeQuoteMenu();
};
_quoteMenuCloseBound = close;
setTimeout(() => document.addEventListener("click", close), 0);
});
// 左键选中文字后拖拽到输入区
chatMessages.addEventListener("dragstart", (e) => {
// 图片 / 文件 / 文档引用拖拽走各自的自定义数据,不干扰
if (
e.dataTransfer.types.includes("application/x-chat-image") ||
e.dataTransfer.types.includes("application/x-chat-file") ||
e.dataTransfer.types.includes("application/doc-path")
) return;
const sel = window.getSelection();
const text = sel ? sel.toString().trim() : "";
if (!text) return;
if (!chatMessages.contains(sel.anchorNode)) return;
e.dataTransfer.setData("application/x-chat-quote", text);
e.dataTransfer.setData("text/plain", text);
e.dataTransfer.effectAllowed = "copy";
});
}
function renderMessages(messages, allowAutoplay = false) {
if (!chatMessages || !chatPlaceholder) return;
const list = messages || [];
@@ -1110,6 +1471,9 @@ async function streamAssistantMessage(msg) {
// pending_action 不再内嵌在气泡中,由 pending-overlay 浮窗展示
}
// 操作按钮行(复制 / 重试)
wrap.appendChild(renderMessageActions("assistant", msg, wrap));
scrollChatToBottom();
}
@@ -1149,4 +1513,5 @@ export {
appendThinkingIndicator, showPendingActionInThinking,
streamAssistantMessage,
renderVoiceTranscribingPlaceholder,
addTextQuote, initChatQuoteMenu,
};
+12
View File
@@ -1170,6 +1170,18 @@ export function openCronEditor(taskId) {
state._cronEditingTaskId = taskId || "";
$("cronEditTitle").textContent = taskId ? "编辑定时任务" : "新建定时任务";
// 访问模式提示:定时任务无人值守,非「完全访问」时越界会被拦截/退化拦截
const accessHint = $("cronAccessModeHint");
if (accessHint) {
const modeLabel = { restricted: "限制访问", approval: "权限审查", full: "完全访问" }[state.accessMode] || state.accessMode;
if (state.accessMode === "full") {
accessHint.hidden = true;
} else {
accessHint.hidden = false;
accessHint.textContent = `⚠️ 定时任务无人值守执行:当前访问模式为「${modeLabel}」,超出工作空间的操作会被拦截或退化拦截,建议先切换为「完全访问」。`;
}
}
// 重置表单
const setVal = (id, v) => { const el = $(id); if (el) el.value = v ?? ""; };
+17
View File
@@ -145,6 +145,13 @@ function buildPendingCard(pending, isSubAgent) {
// ===== tool_call 类型:展示参数(只读)+ 决策按钮 =====
if (pendingType === "tool_call") {
// 工作空间越界审批提示(访问策略注入的 policy_note)
if (pending.policy_note) {
const noteEl = document.createElement("div");
noteEl.className = "pending-overlay-note";
noteEl.textContent = "⚠️ 越界操作待审批:" + unescapeNewlines(pending.policy_note);
cardBody.appendChild(noteEl);
}
// 参数 JSON(只读)
if (pending.args !== undefined && pending.args !== null) {
let argsStr;
@@ -285,6 +292,16 @@ function buildPendingCard(pending, isSubAgent) {
}
}
// 人机交互卡片统一提供"拒绝"按钮:与手动暂停一致,拒绝后暂停本轮运行
if (pendingType === "human_interaction") {
const rejectRow = document.createElement("div");
rejectRow.className = "pending-overlay-btn-row";
rejectRow.appendChild(makeDecisionBtn("拒绝", "reject", "", () => {
submitSingle(pending.id, "reject", "", card);
}));
cardBody.appendChild(rejectRow);
}
card.appendChild(cardBody);
return card;
}
+1 -2
View File
@@ -28,7 +28,6 @@ async function handleHumanAction(approvalId, decision, instruction, card) {
const sendBtn = $("sendBtn");
if (sendBtn) sendBtn.disabled = true;
if (card) card.classList.add("pending-action-card--resolved");
const outputModeTrigger = $("outputModeTrigger");
// 复用 sendChat 留下的现有 thinking 气泡(不删除不重建)
const chatMessages = $("chatMessages");
@@ -54,7 +53,7 @@ async function handleHumanAction(approvalId, decision, instruction, card) {
fd.append("approval_id", approvalId);
fd.append("decision", decision);
fd.append("instruction", instruction || "");
fd.append("output_type", outputModeTrigger ? (outputModeTrigger.dataset.value || "text") : "text");
fd.append("output_type", state.outputMode || "text");
const stopToolPoll = startPollLiveToolCalls(state.sessionId, typingEl, injectLiveToolCallsFn);
+12 -7
View File
@@ -45,22 +45,25 @@ export function setRollbackDeps(deps) {
/**
* 将聊天历史回撤到指定消息处
* @param {number} rollbackIndex - 要回退到的消息在渲染列表中的索引(包含该消息)
* @param {object} [options] - { skipConfirm: boolean } 跳过确认弹窗(重试等场景)
*/
export async function rollbackChat(rollbackIndex) {
export async function rollbackChat(rollbackIndex, options = {}) {
// 保护:Agent 运行中不允许回撤
if (state.sending) {
showToast("Agent 运行中,无法回撤。请先暂停任务。");
return;
return false;
}
if (!state.sessionId) {
showToast("无当前会话");
return;
return false;
}
const confirmed = await showConfirm(
"确认回退到此消息?\n\n将清除此消息之后的所有对话历史、TodoList、人机交互内容、工具调用记录。"
);
if (!confirmed) return;
if (!options.skipConfirm) {
const confirmed = await showConfirm(
"确认回退到此消息?\n\n将清除此消息之后的所有对话历史、TodoList、人机交互内容、工具调用记录。"
);
if (!confirmed) return false;
}
try {
// 1. 调用后端回撤 API
@@ -92,8 +95,10 @@ export async function rollbackChat(rollbackIndex) {
}
showToast("已回退到该消息");
return true;
} catch (e) {
showToast("回退失败: " + (e.message || "未知错误"));
return false;
}
}
+45 -12
View File
@@ -93,8 +93,6 @@ export function setSendDeps(deps) {
const textInput = $("textInput");
const fileInput = $("fileInput");
const sendBtn = $("sendBtn");
const outputModeTrigger = $("outputModeTrigger");
const agentModeTrigger = $("agentModeTrigger");
function setSendBtnPauseMode(pauseMode) {
const btn = sendBtn || $("sendBtn");
@@ -150,11 +148,28 @@ async function handlePauseClick() {
_pausing = false;
}
/** 将待发送的聊天区引用(用户引用)拼接为注入给 agent 的文本 */
function buildQuotesText() {
const quotes = (state.pendingRefs || []).filter((r) => r && r.type === "quote" && r.text && r.text.trim());
if (quotes.length === 0) return "";
return quotes.map((q) => "【用户引用】\n" + q.text.trim()).join("\n\n");
}
/** 清除已发送的引用(pendingRefs 与 pendingFiles 中的 ref/quote 条目) */
function clearQuoteRefs() {
state.pendingRefs = (state.pendingRefs || []).filter((r) => r && r.type !== "quote");
state.pendingFiles = state.pendingFiles.filter((f) => !(f && f.__isRef && f.type === "ref/quote"));
}
async function sendChat() {
if (!state.sessionId || state.sending) return;
const myGen = ++_sendGeneration; // 标记本次 sendChat,防止旧实例的 finally/catch 污染状态
const trimmed = (textInput || {}).value ? textInput.value.trim() : "";
if (!trimmed && state.pendingFiles.length === 0) return;
// 聊天区引用:以「用户引用」形式并入发送文本(agent 收到的内容为具体文字并注明来源)
const quotesText = buildQuotesText();
const effectiveText = quotesText ? (trimmed ? trimmed + "\n\n" + quotesText : quotesText) : trimmed;
const savedQuotes = (state.pendingRefs || []).filter((r) => r && r.type === "quote");
if (!effectiveText && state.pendingFiles.length === 0) return;
// 首次发送时,输入框从居中移至底部
if (onSendStartFn) onSendStartFn();
_lastSendTime = Date.now();
@@ -208,7 +223,7 @@ async function sendChat() {
if (hasVoiceInput) {
// 语音输入:先渲染已有消息(含普通文件 + 用户输入的文本),再追加"语音转文字中"占位
const optimisticContent = buildOptimisticUserContent(trimmed, fileParts);
const optimisticContent = buildOptimisticUserContent(effectiveText, fileParts);
if (renderMessagesFn) {
if (optimisticContent != null) {
renderMessagesFn([...priorMessages, { role: "user", content: optimisticContent }]);
@@ -219,27 +234,29 @@ async function sendChat() {
renderVoiceTranscribingPlaceholder();
} else {
// 仅普通文件:乐观渲染图片/文件
const optimisticContent = buildOptimisticUserContent(trimmed, fileParts);
const optimisticContent = buildOptimisticUserContent(effectiveText, fileParts);
if (optimisticContent != null && renderMessagesFn) {
renderMessagesFn([...priorMessages, { role: "user", content: optimisticContent }]);
}
}
if (textInput) textInput.value = "";
state.pendingFiles.length = 0;
clearQuoteRefs();
if (fileInput) fileInput.value = "";
if (renderAttachmentChipsFn) renderAttachmentChipsFn();
} else if (trimmed) {
// 纯文本消息:立即乐观渲染用户消息,避免等待 API /start 响应
} else if (effectiveText) {
// 纯文本/引用消息:立即乐观渲染用户消息,避免等待 API /start 响应
if (renderMessagesFn) {
renderMessagesFn([...priorMessages, { role: "user", content: trimmed }]);
renderMessagesFn([...priorMessages, { role: "user", content: effectiveText }]);
}
if (textInput) textInput.value = "";
clearQuoteRefs();
}
state.abortController = new AbortController();
const fd = new FormData();
fd.append("session_id", state.sessionId);
fd.append("text", trimmed);
fd.append("text", effectiveText);
// 普通文件追加到 files 字段;语音输入单独走 voice_input 字段(后端阻塞 ASR)
regularFiles.forEach((f) => fd.append("files", f, f.name));
if (voiceInputFile) {
@@ -255,11 +272,12 @@ async function sendChat() {
if (!hadOptimisticMedia) {
if (textInput) textInput.value = "";
state.pendingFiles.length = 0;
clearQuoteRefs();
if (fileInput) fileInput.value = "";
if (renderAttachmentChipsFn) renderAttachmentChipsFn();
}
if (renderSessionsFn) renderSessionsFn();
const _isVoice = (outputModeTrigger && outputModeTrigger.dataset.value === "voice");
const _isVoice = state.outputMode === "voice";
// 语音模式下只追加用户消息气泡(保留旧消息),完整列表等流式 text 事件再刷新
// 但若已通过乐观渲染显示了用户消息(含语音输入占位),则重新渲染全部消息以替换占位
if (_isVoice && renderMessagesFn && !hadOptimisticMedia) {
@@ -306,8 +324,10 @@ async function sendChat() {
const fd2 = new FormData();
fd2.append("session_id", state.sessionId);
fd2.append("output_type", outputModeTrigger ? (outputModeTrigger.dataset.value || "text") : "text");
fd2.append("agent_mode", agentModeTrigger ? (agentModeTrigger.dataset.value || "agent") : "agent");
fd2.append("output_type", state.outputMode || "text");
// agent/plan 模式由思考档位派生(low/xhigh/ultra→agent,high/max→plan)
const { getAgentModeFromLevel } = await import('./think-level.js');
fd2.append("agent_mode", getAgentModeFromLevel(state.thinkingLevel));
if (state.selectedModelId) {
fd2.append("model_id", state.selectedModelId);
}
@@ -485,6 +505,19 @@ async function sendChat() {
if (renderAttachmentChipsFn) renderAttachmentChipsFn();
if (renderMessagesFn) renderMessagesFn(priorMessages);
}
// 发送失败:恢复被清除的引用 chips(引用已并入文本并清空,失败时补回)
if (savedQuotes.length > 0) {
state.pendingRefs.push(...savedQuotes);
const haveRefs = new Set(
state.pendingFiles.filter((f) => f && f.__isRef && f.type === "ref/quote").map((f) => f.refPath)
);
savedFiles.forEach((f) => {
if (f && f.__isRef && f.type === "ref/quote" && !haveRefs.has(f.refPath)) {
state.pendingFiles.push(f);
}
});
if (renderAttachmentChipsFn) renderAttachmentChipsFn();
}
if (typingEl) _cleanupTypingEl(typingEl);
if (!(e && e.name === "AbortError")) {
showToast(e.message || String(e));
+11 -2
View File
@@ -5,7 +5,7 @@ import { showConfirm, showPrompt } from './dialog.js';
import { api } from './api.js';
import { state } from './state.js';
import { updatePendingOverlay, clearPendingOverlay } from './pending-overlay.js';
import { refreshTokens } from './token-bar.js';
import { refreshTokens } from './token-ring.js';
let renderSessionsFnRef = null;
let renderMessagesFn = null;
@@ -175,6 +175,15 @@ 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);
}
/**
* 应用一次会话数据(sessionId / sessions / messages / tokens / pending_actions)并刷新界面。
* 供「分支」等创建新会话后直接跳转使用。
*/
export async function applySessionData(data) {
if (!data || !data.sessionId) return;
state.sessionId = data.sessionId;
state.sessions.length = 0;
state.sessions.push(...(data.sessions || []));
@@ -182,7 +191,7 @@ async function selectSession(id) {
if (clearTodoOverlayFn) clearTodoOverlayFn();
clearPendingOverlay();
if (renderSessionsFnRef) renderSessionsFnRef();
if (renderMessagesFn) renderMessagesFn(data.messages);
if (renderMessagesFn) renderMessagesFn(data.messages || []);
updatePendingOverlay(data.pending_actions || null);
refreshTokens();
// 设置居中状态
+44 -27
View File
@@ -1,6 +1,6 @@
// settings.js -- 设置面板:Agent / Tool / Env / GUI / Models / Theme
import { $, escapeHtml, makeId, showToast } from './utils.js';
import { $, escapeHtml, makeId, showToast, withClickGuard } from './utils.js';
import { showConfirm } from './dialog.js';
import { api } from './api.js';
import { state } from './state.js';
@@ -694,6 +694,41 @@ function buildMysqlConfigSection() {
return section;
}
/**
* 给环境变量行右侧添加 "?" 帮助按钮(点击显示 ENV_DESC 说明的 popover,含连击保护)。
* @param {HTMLElement} row - .env-config-item 行容器
* @param {string} key - 环境变量名
*/
let _envHelpDocListenerBound = false;
function attachEnvHelp(row, key) {
const desc = ENV_DESC[key] || "该环境变量的用途暂无说明";
const btn = document.createElement("button");
btn.type = "button";
btn.className = "env-help-btn";
btn.textContent = "?";
btn.title = "查看说明";
const pop = document.createElement("div");
pop.className = "env-help-popover";
pop.textContent = desc;
pop.hidden = true;
btn.addEventListener("click", withClickGuard((e) => {
e.stopPropagation();
document.querySelectorAll(".env-help-popover").forEach((p) => { if (p !== pop) p.hidden = true; });
pop.hidden = !pop.hidden;
}));
row.appendChild(btn);
row.appendChild(pop);
if (!_envHelpDocListenerBound) {
_envHelpDocListenerBound = true;
document.addEventListener("click", (e) => {
if (e.target.closest && e.target.closest(".env-help-btn, .env-help-popover")) return;
document.querySelectorAll(".env-help-popover").forEach((p) => { p.hidden = true; });
});
}
return btn;
}
function renderEnvConfig() {
if (!envListEl) return;
envListEl.innerHTML = "";
@@ -725,15 +760,14 @@ function renderEnvConfig() {
table.className = "model-table";
const thead = document.createElement("div");
thead.className = "model-table-head";
thead.innerHTML = '<span class="col-name">\u540D\u79F0</span><span class="col-model">Model</span><span class="col-url">Base URL</span><span class="col-think">\u601D\u8003</span><span class="col-actions" style="justify-content:flex-end;color:inherit;font-size:inherit;">\u64CD\u4F5C</span>';
thead.innerHTML = '<span class="col-name">\u540D\u79F0</span><span class="col-model">Model</span><span class="col-url">Base URL</span><span class="col-actions" style="justify-content:flex-end;color:inherit;font-size:inherit;">\u64CD\u4F5C</span>';
table.appendChild(thead);
state.cachedModels.forEach((m, mi) => {
const row = document.createElement("div");
row.className = "model-table-row";
row.innerHTML = `<span class="col-name" title="${escapeHtml(m.name)}">${escapeHtml(m.name)}</span>
<span class="col-model" title="${escapeHtml(m.model)}">${escapeHtml(m.model)}</span>
<span class="col-url" title="${escapeHtml(m.base_url)}">${escapeHtml(m.base_url)}</span>
<span class="col-think" style="text-align:center;">${m.enable_thinking ? '<span title="\u5DF2\u542F\u7528\u601D\u8003" style="color:#f59e0b;">ON</span>' : '<span title="\u601D\u8003\u5DF2\u5173\u95ED" style="color:#94a3b8;">OFF</span>'}</span>`;
<span class="col-url" title="${escapeHtml(m.base_url)}">${escapeHtml(m.base_url)}</span>`;
const actions = document.createElement("span");
actions.className = "col-actions";
const editBtn = document.createElement("button");
@@ -785,26 +819,8 @@ function renderEnvConfig() {
modalFields[key] = inp;
modalBody.appendChild(div);
});
const thinkingDiv = document.createElement("div");
thinkingDiv.className = "modal-field";
const thinkingLabel = document.createElement("label");
thinkingLabel.textContent = "\u601D\u8003\u6A21\u5F0F";
const thinkingWrap = document.createElement("label");
thinkingWrap.className = "toggle-switch";
const thinkingCheckbox = document.createElement("input");
thinkingCheckbox.type = "checkbox";
const thinkingSlider = document.createElement("span");
thinkingSlider.className = "toggle-slider";
thinkingWrap.appendChild(thinkingCheckbox);
thinkingWrap.appendChild(thinkingSlider);
const thinkingHint = document.createElement("span");
thinkingHint.className = "toggle-hint";
thinkingHint.textContent = " \u5173\u95ED\u540E\u7981\u7528\u6A21\u578B\u601D\u8003\uFF08\u9ED8\u8BA4\u5173\u95ED\uFF0C\u51CF\u5C11\u5EF6\u8FDF\uFF09";
thinkingDiv.appendChild(thinkingLabel);
thinkingDiv.appendChild(thinkingWrap);
thinkingDiv.appendChild(thinkingHint);
modalFields["enable_thinking"] = thinkingCheckbox;
modalBody.appendChild(thinkingDiv);
// 深度思考改由 composer 的思考档位(low/high/xhigh/max/ultra)全局控制,此处不再提供开关
modalBody.appendChild(document.createComment("thinking-toggle-removed"));
modalBox.appendChild(modalBody);
const modalActions = document.createElement("div");
modalActions.className = "model-modal-actions";
@@ -826,7 +842,6 @@ function renderEnvConfig() {
base_url: modalFields.base_url.value.trim(),
timeout: parseFloat(modalFields.timeout.value) || 60,
max_retries: 0,
enable_thinking: modalFields.enable_thinking.checked,
};
if (!data.name || !data.model) { showToast("\u540D\u79F0\u548C\u6A21\u578B\u540D\u4E0D\u80FD\u4E3A\u7A7A"); return; }
if (state.editingModelIdx >= 0) {
@@ -893,6 +908,7 @@ function renderEnvConfig() {
});
row.appendChild(lbl);
row.appendChild(sel);
attachEnvHelp(row, key);
// 选 mysql 时紧随其后渲染连接配置区块
if (sel.value === "mysql") {
const mysqlSection = buildMysqlConfigSection();
@@ -936,6 +952,7 @@ function renderEnvConfig() {
wrap.appendChild(applyBtn);
row.appendChild(lbl);
row.appendChild(wrap);
attachEnvHelp(row, key);
envListEl.appendChild(row);
return;
}
@@ -986,6 +1003,8 @@ function renderEnvConfig() {
row.appendChild(fileBtn);
}
attachEnvHelp(row, key);
envListEl.appendChild(row);
});
}
@@ -1002,14 +1021,12 @@ function openModelModal(idx) {
fields.api_key.value = m.api_key || "";
fields.base_url.value = m.base_url || "";
fields.timeout.value = m.timeout || 60;
fields.enable_thinking.checked = !!m.enable_thinking;
} else {
fields.name.value = "";
fields.model.value = "";
fields.api_key.value = "";
fields.base_url.value = "";
fields.timeout.value = 60;
fields.enable_thinking.checked = false;
}
overlay.classList.add("show");
}
+243 -46
View File
@@ -1,6 +1,8 @@
// skills.js -- Skills 管理面板
// skills.js -- Skills 管理面板(创建/编辑/删除/导入 zip 或文件夹)
// 导入流程:前端先解析(zip 走后端 /api/skills/parse,文件夹纯前端解析),
// 填充编辑器并展示附件树(一级子目录,不深入展开),点击保存后才落盘。
import { $, escapeHtml } from './utils.js';
import { $, escapeHtml, withClickGuard } from './utils.js';
import { showConfirm } from './dialog.js';
import { api } from './api.js';
import { state } from './state.js';
@@ -10,8 +12,6 @@ const skillEditorOverlay = $("skillEditorOverlay");
const skillEditorTitle = $("skillEditorTitle");
const skillNameInput = $("skillNameInput");
const skillDescInput = $("skillDescInput");
const skillVersionInput = $("skillVersionInput");
const skillAuthorInput = $("skillAuthorInput");
const skillTagsInput = $("skillTagsInput");
const skillContentInput = $("skillContentInput");
const skillAttachmentsList = $("skillAttachmentsList");
@@ -22,6 +22,9 @@ const skillEditorCancel = $("skillEditorCancel");
const skillEditorClose = $("skillEditorClose");
const skillEnableCheck = $("skillEnableCheck");
// 导入临时状态:kind = "zip" | "folder";folder 时 files 为 {相对路径: File}
let _pendingImport = null;
function setSkillEditorStatus(msg, type) {
if (!skillEditorStatus) return;
skillEditorStatus.textContent = msg;
@@ -31,35 +34,31 @@ function setSkillEditorStatus(msg, type) {
function openSkillEditor(skillNameToEdit) {
state.editingSkillName = skillNameToEdit || "";
state.editingSkillAttachments.length = 0;
_pendingImport = null;
if (state.editingSkillName) {
skillEditorTitle.textContent = "\u7F16\u8F91 Skill: " + state.editingSkillName;
skillEditorTitle.textContent = "编辑 Skill: " + state.editingSkillName;
skillNameInput.value = state.editingSkillName;
skillNameInput.disabled = true;
api("/api/skills/" + encodeURIComponent(state.editingSkillName)).then((res) => {
const s = res.skill;
skillDescInput.value = s.description || "";
skillVersionInput.value = s.version || "1.0.0";
skillAuthorInput.value = s.author || "";
skillTagsInput.value = (s.tags || []).join(", ");
skillContentInput.value = s.content || "";
skillEnableCheck.checked = s.enable !== false;
state.editingSkillAttachments.push(...(s.attachment_files || []));
renderSkillAttachments();
}).catch((e) => {
setSkillEditorStatus("\u52A0\u8F7D\u5931\u8D25: " + e.message, "error");
setSkillEditorStatus("加载失败: " + e.message, "error");
});
} else {
skillEditorTitle.textContent = "\u521B\u5EFA Skill";
skillEditorTitle.textContent = "创建 Skill";
skillNameInput.value = "";
skillNameInput.disabled = false;
skillDescInput.value = "";
skillVersionInput.value = "1.0.0";
skillAuthorInput.value = "";
skillTagsInput.value = "";
skillContentInput.value = "";
skillEnableCheck.checked = true;
state.editingSkillAttachments.length = 0;
renderSkillAttachments();
}
setSkillEditorStatus("");
@@ -70,8 +69,10 @@ function closeSkillEditor() {
skillEditorOverlay.hidden = true;
state.editingSkillName = "";
state.editingSkillAttachments.length = 0;
_pendingImport = null;
}
/** 渲染附件 chips;导入预览模式下:zip 只读展示,folder 可移除 */
function renderSkillAttachments() {
if (!skillAttachmentsList) return;
skillAttachmentsList.innerHTML = "";
@@ -80,22 +81,38 @@ function renderSkillAttachments() {
chip.className = "skill-attachment-item";
const nameSpan = document.createElement("span");
nameSpan.textContent = fname;
chip.appendChild(nameSpan);
const isFolderPreview = _pendingImport && _pendingImport.kind === "folder";
const isZipPreview = _pendingImport && _pendingImport.kind === "zip";
if (isZipPreview) {
// zip 导入预览:附件随包原样落盘,不可单独删除
chip.title = "导入后随 zip 原样保存";
skillAttachmentsList.appendChild(chip);
return;
}
const delBtn = document.createElement("button");
delBtn.textContent = "\u00D7";
delBtn.title = "\u5220\u9664\u9644\u4EF6";
delBtn.title = "删除附件";
delBtn.addEventListener("click", async () => {
if (!await showConfirm("\u786E\u5B9A\u5220\u9664\u9644\u4EF6 " + fname + " \uFF1F")) return;
if (!await showConfirm("确定删除附件 " + fname + " ?")) return;
if (isFolderPreview && _pendingImport) {
// 文件夹导入预览:仅从待上传集合移除
delete _pendingImport.files[fname];
const idx = state.editingSkillAttachments.indexOf(fname);
if (idx >= 0) state.editingSkillAttachments.splice(idx, 1);
renderSkillAttachments();
return;
}
try {
await api("/api/skills/" + encodeURIComponent(state.editingSkillName) + "/attachments/" + encodeURIComponent(fname), { method: "DELETE" });
const idx = state.editingSkillAttachments.indexOf(fname);
if (idx >= 0) state.editingSkillAttachments.splice(idx, 1);
renderSkillAttachments();
setSkillEditorStatus("\u9644\u4EF6\u5DF2\u5220\u9664", "success");
setSkillEditorStatus("附件已删除", "success");
} catch (e) {
setSkillEditorStatus("\u5220\u9664\u5931\u8D25: " + e.message, "error");
setSkillEditorStatus("删除失败: " + e.message, "error");
}
});
chip.appendChild(nameSpan);
chip.appendChild(delBtn);
skillAttachmentsList.appendChild(chip);
});
@@ -104,42 +121,75 @@ function renderSkillAttachments() {
async function saveSkill() {
const name = (skillNameInput.value || "").trim();
if (!name) {
setSkillEditorStatus("\u540D\u79F0\u4E0D\u80FD\u4E3A\u7A7A", "error");
setSkillEditorStatus("名称不能为空", "error");
return;
}
setSkillEditorStatus("\u6B63\u5728\u4FDD\u5B58...", "");
setSkillEditorStatus("正在保存...", "");
const tags = skillTagsInput.value;
const enable = skillEnableCheck.checked ? "true" : "false";
// zip 导入:整包提交给 /api/skills/import(附件保持原相对路径)
if (_pendingImport && _pendingImport.kind === "zip" && _pendingImport.file) {
const fd = new FormData();
fd.append("file", _pendingImport.file);
fd.append("name", name);
fd.append("description", skillDescInput.value);
fd.append("content", skillContentInput.value);
fd.append("tags", tags);
fd.append("enable", enable);
try {
await api("/api/skills/import", { method: "POST", body: fd });
setSkillEditorStatus("导入成功", "success");
state.editingSkillName = name;
skillNameInput.disabled = true;
_pendingImport = null;
await renderSkills();
return;
} catch (e) {
setSkillEditorStatus("导入失败: " + e.message, "error");
return;
}
}
const formData = new FormData();
formData.append("name", name);
formData.append("description", skillDescInput.value);
formData.append("content", skillContentInput.value);
formData.append("version", skillVersionInput.value);
formData.append("author", skillAuthorInput.value);
formData.append("tags", skillTagsInput.value);
formData.append("enable", skillEnableCheck.checked ? "true" : "false");
formData.append("tags", tags);
formData.append("enable", enable);
try {
await api("/api/skills", { method: "POST", body: formData });
if (state.editingSkillName && state.editingSkillName !== name) {
try { await api("/api/skills/" + encodeURIComponent(state.editingSkillName), { method: "DELETE" }); } catch {}
}
setSkillEditorStatus("\u4FDD\u5B58\u6210\u529F", "success");
// 文件夹导入:逐个上传附件(filename 携带相对路径,如 templates/x.py)
if (_pendingImport && _pendingImport.kind === "folder" && _pendingImport.files) {
for (const [rel, file] of Object.entries(_pendingImport.files)) {
const fd = new FormData();
fd.append("file", file, rel);
await api("/api/skills/" + encodeURIComponent(name) + "/attachments", { method: "POST", body: fd });
}
}
setSkillEditorStatus("保存成功", "success");
state.editingSkillName = name;
skillNameInput.disabled = true;
_pendingImport = null;
await renderSkills();
} catch (e) {
setSkillEditorStatus("\u4FDD\u5B58\u5931\u8D25: " + e.message, "error");
setSkillEditorStatus("保存失败: " + e.message, "error");
}
}
async function deleteSkill(name) {
if (!await showConfirm("\u786E\u5B9A\u5220\u9664 Skill \"" + name + "\" \u53CA\u5176\u6240\u6709\u9644\u4EF6\uFF1F\u6B64\u64CD\u4F5C\u4E0D\u53EF\u6062\u590D\u3002")) return;
if (!await showConfirm("确定删除 Skill \"" + name + "\" 及其所有附件?此操作不可恢复。")) return;
try {
await api("/api/skills/" + encodeURIComponent(name), { method: "DELETE" });
await renderSkills();
const setStatus = (await import('./settings.js')).setStatus;
if (setStatus) setStatus("Skill \u5DF2\u5220\u9664", "success");
if (setStatus) setStatus("Skill 已删除", "success");
} catch (e) {
console.error("\u5220\u9664 Skill \u5931\u8D25:", e);
console.error("删除 Skill 失败:", e);
}
}
@@ -150,13 +200,13 @@ async function renderSkills() {
state.cachedSkills.length = 0;
state.cachedSkills.push(...(res.skills || []));
} catch (e) {
skillsListEl.innerHTML = '<p style="color:#dc2626;">\u52A0\u8F7D Skills \u5931\u8D25: ' + escapeHtml(e.message) + '</p>';
skillsListEl.innerHTML = '<p style="color:#dc2626;">加载 Skills 失败: ' + escapeHtml(e.message) + '</p>';
return;
}
skillsListEl.innerHTML = "";
if (state.cachedSkills.length === 0) {
skillsListEl.innerHTML = '<p style="color:#94a3b8;text-align:center;padding:2rem;">\u6682\u65E0 Skill\u3002<br>\u70B9\u51FB"+ \u521B\u5EFA Skill"\u5F00\u59CB\u521B\u5EFA\u4F60\u7684\u7B2C\u4E00\u4E2A\u64CD\u4F5C\u6307\u5357\u3002</p>';
skillsListEl.innerHTML = '<p style="color:#94a3b8;text-align:center;padding:2rem;">暂无 Skill。<br>点击"+ 创建 Skill"或"导入 Skill"开始。</p>';
return;
}
@@ -171,7 +221,7 @@ async function renderSkills() {
if (skill.enable === false) {
const badge = document.createElement("span");
badge.style.cssText = "font-size:0.65rem;color:#dc2626;background:rgba(220,38,38,0.1);padding:0.1rem 0.4rem;border-radius:999px;margin-left:0.4rem;font-weight:400;";
badge.textContent = "\u5DF2\u7981\u7528";
badge.textContent = "已禁用";
h4.appendChild(badge);
}
const actions = document.createElement("div");
@@ -179,12 +229,12 @@ async function renderSkills() {
const editBtn = document.createElement("button");
editBtn.className = "btn btn-secondary";
editBtn.style.cssText = "padding:0.2rem 0.5rem;font-size:0.75rem;";
editBtn.textContent = "\u7F16\u8F91";
editBtn.textContent = "编辑";
editBtn.addEventListener("click", () => openSkillEditor(skill.name));
const delBtn = document.createElement("button");
delBtn.className = "btn btn-danger";
delBtn.style.cssText = "padding:0.2rem 0.5rem;font-size:0.75rem;";
delBtn.textContent = "\u5220\u9664";
delBtn.textContent = "删除";
delBtn.addEventListener("click", () => deleteSkill(skill.name));
actions.appendChild(editBtn);
actions.appendChild(delBtn);
@@ -193,12 +243,7 @@ async function renderSkills() {
const desc = document.createElement("div");
desc.className = "skill-card-desc";
desc.textContent = skill.description || "(\u65E0\u63CF\u8FF0)";
const meta = document.createElement("div");
meta.className = "skill-card-meta";
meta.innerHTML = (skill.version ? '<span>v' + escapeHtml(skill.version) + '</span>' : '') +
(skill.author ? '<span>by ' + escapeHtml(skill.author) + '</span>' : '');
desc.textContent = skill.description || "(无描述)";
const tagsWrap = document.createElement("div");
tagsWrap.className = "skill-card-tags";
@@ -223,13 +268,165 @@ async function renderSkills() {
card.appendChild(header);
card.appendChild(desc);
card.appendChild(meta);
card.appendChild(tagsWrap);
if (attachWrap) card.appendChild(attachWrap);
skillsListEl.appendChild(card);
});
}
// ---------- 导入 ----------
/** 解析 skill.md 头部 frontmatter(仅 name/description,其余丢弃;支持 > 折叠多行) */
function parseSkillMdFrontmatter(md) {
let name = "";
let description = "";
let body = md;
const m = md.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
if (m) {
body = md.slice(m[0].length);
const collected = {};
let curKey = null;
m[1].split(/\r?\n/).forEach((line) => {
if (!line.trim()) return;
const kv = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
if (kv) {
curKey = kv[1].toLowerCase();
collected[curKey] = [kv[2].trim()];
} else if (curKey === "name" || curKey === "description") {
if (line.startsWith(" ") || line.startsWith("\t")) {
(collected[curKey] || (collected[curKey] = [])).push(line.trim());
}
}
});
const nameVals = (collected.name || []).filter(Boolean);
if (nameVals.length) name = nameVals[0].replace(/^["']|["']$/g, "");
const descVals = (collected.description || []).filter(Boolean);
if (descVals.length) description = descVals.join(" ").replace(/^["']|["']$/g, "");
}
return { name, description, body };
}
/** 打开导入菜单(zip / 文件夹 二选一) */
function bindImportMenu() {
const importBtn = $("importSkillBtn");
const zipInput = $("importSkillZipInput");
const folderInput = $("importSkillFolderInput");
if (!importBtn) return;
let menu = null;
const closeMenu = () => { if (menu && menu.parentNode) menu.parentNode.removeChild(menu); menu = null; };
document.addEventListener("click", (e) => {
if (menu && !menu.contains(e.target) && e.target !== importBtn) closeMenu();
});
importBtn.addEventListener("click", withClickGuard((e) => {
e.stopPropagation();
if (menu) { closeMenu(); return; }
menu = document.createElement("div");
menu.className = "skill-import-menu";
const zipItem = document.createElement("button");
zipItem.type = "button";
zipItem.textContent = "从 zip 导入";
zipItem.addEventListener("click", (ev) => {
ev.stopPropagation();
closeMenu();
if (zipInput) zipInput.click();
});
const folderItem = document.createElement("button");
folderItem.type = "button";
folderItem.textContent = "从文件夹导入";
folderItem.addEventListener("click", (ev) => {
ev.stopPropagation();
closeMenu();
if (folderInput) folderInput.click();
});
menu.appendChild(zipItem);
menu.appendChild(folderItem);
importBtn.parentNode.appendChild(menu);
}));
// zip:后端解析(不落盘),填充编辑器 + 展示附件树
if (zipInput) {
zipInput.addEventListener("change", withClickGuard(async () => {
const file = zipInput.files && zipInput.files[0];
zipInput.value = "";
if (!file) return;
setSkillEditorStatus("正在解析 zip...", "");
try {
const fd = new FormData();
fd.append("file", file);
const res = await api("/api/skills/parse", { method: "POST", body: fd });
if (!res.found || !res.skill) {
setSkillEditorStatus(res.error || "未找到 skill.md/SKILL.md", "error");
return;
}
const s = res.skill;
openSkillEditor("");
skillNameInput.value = s.name || "";
skillDescInput.value = s.description || "";
skillContentInput.value = s.content || "";
state.editingSkillAttachments.length = 0;
state.editingSkillAttachments.push(...(s.attachments || []));
_pendingImport = { kind: "zip", file, attachments: s.attachments || [] };
renderSkillAttachments();
setSkillEditorStatus("解析完成:请核对名称/描述/内容后点击保存(附件随 zip 原样保存)", "");
skillEditorOverlay.hidden = false;
} catch (err) {
setSkillEditorStatus("解析失败: " + (err.message || err), "error");
}
}));
}
// 文件夹:纯前端解析(webkitRelativePath)
if (folderInput) {
folderInput.addEventListener("change", withClickGuard(async () => {
const files = Array.from(folderInput.files || []);
folderInput.value = "";
if (files.length === 0) return;
// 定位一级目录下的 skill.md/SKILL.md
let skillMdFile = null;
const mdFiles = files.filter((f) => f.webkitRelativePath.split("/").length <= 2 && /^skill\.md$/i.test(f.name));
if (mdFiles.length === 0) {
setSkillEditorStatus("未在一级子目录下找到 skill.md/SKILL.md", "error");
return;
}
skillMdFile = mdFiles[0];
const mdText = await skillMdFile.text();
const { name, description, body } = parseSkillMdFrontmatter(mdText);
// 附件:一级子目录下除 skill.md 之外的树(不深入展开,子文件夹折叠为 "文件夹名/")
const dirName = skillMdFile.webkitRelativePath.split("/")[0];
const filesByRel = {};
const seenFirstLevel = new Set();
files.forEach((f) => {
const parts = f.webkitRelativePath.split("/");
if (parts[0] !== dirName) return;
if (parts.length === 2 && /^skill\.md$/i.test(f.name)) return; // 跳过 skill.md
if (parts.length === 2) {
filesByRel[parts[1]] = f;
seenFirstLevel.add(parts[1]);
} else if (parts.length > 2) {
// 子文件夹内的文件:折叠为 "子文件夹/" 一级展示,仍按完整相对路径上传
filesByRel[parts.slice(1).join("/")] = f;
seenFirstLevel.add(parts[1] + "/");
}
});
openSkillEditor("");
skillNameInput.value = name || dirName;
skillDescInput.value = description || "";
skillContentInput.value = body || "";
state.editingSkillAttachments.length = 0;
state.editingSkillAttachments.push(...Array.from(seenFirstLevel).sort());
_pendingImport = { kind: "folder", files: filesByRel };
renderSkillAttachments();
setSkillEditorStatus("解析完成:请核对名称/描述/内容后点击保存(附件将上传到 skill 文件夹下)", "");
skillEditorOverlay.hidden = false;
}));
}
}
function bindSkillEditorEvents() {
const addSkillBtn = $("addSkillBtn");
if (addSkillBtn) addSkillBtn.addEventListener("click", () => openSkillEditor(""));
@@ -240,7 +437,7 @@ function bindSkillEditorEvents() {
if (e.target === skillEditorOverlay) closeSkillEditor();
});
}
if (skillEditorSave) skillEditorSave.addEventListener("click", saveSkill);
if (skillEditorSave) skillEditorSave.addEventListener("click", withClickGuard(saveSkill));
const skillEnableLabel = $("skillEnableLabel");
if (skillEnableLabel && skillEnableCheck) {
@@ -250,13 +447,13 @@ function bindSkillEditorEvents() {
if (skillFileInput) {
skillFileInput.addEventListener("change", async () => {
if (!state.editingSkillName) {
setSkillEditorStatus("\u8BF7\u5148\u4FDD\u5B58 Skill \u540E\u518D\u4E0A\u4F20\u9644\u4EF6", "error");
setSkillEditorStatus("请先保存 Skill 后再上传附件", "error");
skillFileInput.value = "";
return;
}
const files = skillFileInput.files;
if (!files || files.length === 0) return;
setSkillEditorStatus("\u6B63\u5728\u4E0A\u4F20...", "");
setSkillEditorStatus("正在上传...", "");
let successCount = 0;
let failCount = 0;
for (const file of files) {
@@ -275,9 +472,9 @@ function bindSkillEditorEvents() {
skillFileInput.value = "";
renderSkillAttachments();
if (failCount === 0) {
setSkillEditorStatus("\u4E0A\u4F20\u6210\u529F " + successCount + " \u4E2A\u6587\u4EF6", "success");
setSkillEditorStatus("上传成功 " + successCount + " 个文件", "success");
} else {
setSkillEditorStatus("\u4E0A\u4F20: " + successCount + " \u6210\u529F, " + failCount + " \u5931\u8D25", "error");
setSkillEditorStatus("上传: " + successCount + " 成功, " + failCount + " 失败", "error");
}
});
}
@@ -295,5 +492,5 @@ function bindSkillEditorEvents() {
export {
renderSkills, openSkillEditor, closeSkillEditor,
saveSkill, deleteSkill, renderSkillAttachments,
setSkillEditorStatus, bindSkillEditorEvents,
setSkillEditorStatus, bindSkillEditorEvents, bindImportMenu,
};
+7
View File
@@ -74,10 +74,17 @@ export const state = {
// 模型选择
selectedModelId: "",
// 输出模式(文字/语音,由 文本语音.svg 按钮开关控制)
outputMode: "text",
// 思考模式档位:low | high | xhigh | max | ultra
thinkingLevel: "low",
// 工作空间
workspacePath: "", // 当前选中的工作空间路径(空=使用默认)
workspaceList: [], // 已保存的工作空间路径列表
workspaceDefault: "", // 默认工作空间路径
accessMode: "restricted", // 工作空间访问模式:restricted(限制访问) | approval(权限审查) | full(完全访问)
// Edit 模式
editMode: false,
+195
View File
@@ -0,0 +1,195 @@
// think-level.js -- 思考模式档位选择(low / high / xhigh / max / ultra)
// 档位同时决定运行模式与深度思考:
// low=agent+不思考 high=plan+不思考 xhigh=agent+思考 max=plan+思考
// ultra 为未来 react→审批图预留占位(暂按 agent+思考 处理)
// 持久化在后端 env_config.json 的 THINKING_LEVEL(/api/config/thinking-level),
// 由 agent_runtime._build_llm_for_agent 决定 ChatQwen 的 extra_body。
// 交互通过 document 级事件委托实现(触发器开关 + 档位点击 + 滑条拖动)。
import { $, showToast, closeInlinePanelsExcept } from './utils.js';
import { api } from './api.js';
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→审批图)",
};
/** 根据思考档位派生运行模式(low/xhigh/ultra→agent,high/max→plan) */
export function getAgentModeFromLevel(level) {
const lv = THINK_LEVELS.includes(level) ? level : "low";
return lv === "high" || lv === "max" ? "plan" : "agent";
}
/** 档位是否启用深度思考 */
export function getThinkingEnabled(level) {
const lv = THINK_LEVELS.includes(level) ? level : "low";
return lv === "xhigh" || lv === "max" || lv === "ultra";
}
let _panelOpen = false;
let _dragging = false;
let _committing = false;
let _lastCommitAt = 0;
let _lastTriggerAt = 0;
/** 初始化思考档位(bootstrap 后调用):从后端加载并同步滑条 */
export async function initThinkLevel() {
try {
const data = await api("/api/config/env");
const lv = data && data.env && data.env.THINKING_LEVEL;
if (THINK_LEVELS.includes(lv)) {
state.thinkingLevel = lv;
}
} catch { /* ignore */ }
renderLabels();
syncThinkLevelUI();
}
/** 渲染 5 档标签(横向布局:flex space-between,与滑条档位对齐) */
function renderLabels() {
const container = $("thinkModeLabels");
if (!container) return;
container.innerHTML = "";
THINK_LEVELS.forEach((lv) => {
const item = document.createElement("button");
item.type = "button";
item.className = "think-mode-label";
item.dataset.value = lv;
item.textContent = lv;
item.title = THINK_LEVEL_TIPS[lv] || lv;
container.appendChild(item);
});
}
/** 将滑条手柄/填充/标签选中态同步到 state.thinkingLevel(横向:左=low 右=ultra) */
export function syncThinkLevelUI() {
const handle = $("thinkModeHandle");
const fill = $("thinkModeFill");
const labels = document.querySelectorAll(".think-mode-label");
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);
});
if (trigger) {
trigger.title = THINK_LEVEL_TIPS[state.thinkingLevel] || "思考模式";
trigger.dataset.value = state.thinkingLevel;
}
}
/** 绑定事件:document 级委托(触发器开关 + 档位点击 + 滑条拖动) */
export function bindThinkModeEvents() {
document.addEventListener("click", (e) => {
const wrap = $("thinkModeWrap");
const panel = $("thinkModePanel");
if (!wrap || !panel) return;
const target = e.target;
// 触发器点击:开关面板(带连击保护)
if (target.closest && target.closest("#thinkModeTrigger")) {
const now = Date.now();
if (now - _lastTriggerAt < 300) return;
_lastTriggerAt = now;
if (_panelOpen) {
closePanel();
return;
}
closeInlinePanelsExcept(panel);
panel.hidden = false;
_panelOpen = true;
return;
}
// 档位标签点击:立即选择并提交
if (target.closest && target.closest(".think-mode-label")) {
const lv = target.closest(".think-mode-label").dataset.value;
if (!THINK_LEVELS.includes(lv)) return;
state.thinkingLevel = lv;
syncThinkLevelUI();
commitLevel(lv);
return;
}
// 面板外点击 → 关闭
if (!wrap.contains(target)) {
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() {
const panel = $("thinkModePanel");
if (panel) panel.hidden = true;
_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();
}
/** 提交档位到后端(连击/重复提交保护) */
async function commitLevel(level) {
if (_committing) return;
const now = Date.now();
if (now - _lastCommitAt < 500) return;
_lastCommitAt = now;
_committing = true;
try {
const data = await api("/api/config/thinking-level", {
method: "POST",
body: JSON.stringify({ level }),
});
if (data && data.level) {
state.thinkingLevel = data.level;
syncThinkLevelUI();
showToast(`思考模式已切换为「${data.level}」`);
}
} catch (e) {
showToast(e.message || String(e));
} finally {
_committing = false;
}
}
-211
View File
@@ -1,211 +0,0 @@
// token-bar.js -- Token 上下文进度条(压缩阈值游标)
// 在输入框下方展示当前 token 用量 / 最大上下文的动态进度条,
// 单个可拖拽游标对应 COMPRESS_RATE 压缩阈值
import { $, showToast } from './utils.js';
import { api } from './api.js';
import { state } from './state.js';
let _pollTimer = null;
let _settings = null; // { context_window, compress_rate }
let _currentTokens = 0;
let _dragging = false;
// ===== DOM refs =====
let _barTrack, _barFill, _barSegGreen, _barSegRed;
let _handleCompress;
let _labelText;
// ===== 初始化 =====
export function initTokenBar() {
_barTrack = $("tokenBarTrack");
_barFill = $("tokenBarFill");
_barSegGreen = $("tokenBarSegGreen");
_barSegRed = $("tokenBarSegRed");
_handleCompress = $("tokenHandleCompress");
_labelText = $("tokenLabel");
if (!_barTrack) return;
// 从 state 恢复当前会话的 token 用量
_currentTokens = state.currentTokens || 0;
// 加载压缩设置
loadSettings().then(() => {
updateBarUI();
attachHandleEvents();
});
// 立即拉一次最新数据,然后启动轮询
pollTokens();
startPolling();
}
// ===== 加载设置 =====
async function loadSettings() {
try {
const data = await api("/api/config/compress-settings");
_settings = data;
} catch {
_settings = { context_window: 262144, compress_rate: 0.6 };
}
}
// ===== 轮询 token 用量 =====
function startPolling() {
stopPolling();
_pollTimer = setInterval(pollTokens, 2000);
}
function stopPolling() {
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
}
async function pollTokens() {
if (!state.sessionId) return;
try {
const data = await api(`/api/chat/tokens/${state.sessionId}`);
_currentTokens = data.tokens || 0;
if (data.context_window) {
_settings = {
context_window: data.context_window,
compress_rate: data.compress_rate,
};
}
updateBarUI();
} catch {
// 静默失败
}
}
// ===== 更新进度条 =====
function updateBarUI() {
if (!_barTrack || !_settings) return;
const cw = _settings.context_window || 262144;
const compressRate = _settings.compress_rate || 0.6;
const tokens = _currentTokens;
// 进度条填充位置(cap at 100%)
const pct = Math.min(100, (tokens / cw) * 100);
const compressPct = compressRate * 100;
// 颜色分段:低于压缩阈值绿色,超过阈值红色
const greenW = Math.min(pct, compressPct);
const redW = Math.max(0, pct - compressPct);
if (_barSegGreen) _barSegGreen.style.width = `${greenW}%`;
if (_barSegRed) _barSegRed.style.width = `${redW}%`;
// 填充条隐藏(分段已承担可视化角色)
if (_barFill) _barFill.style.width = '0%';
// 游标位置
if (_handleCompress) _handleCompress.style.left = `${compressPct}%`;
// 标签文字
if (_labelText) {
_labelText.textContent = `${_fmtNum(tokens)} / ${_fmtNum(cw)} tokens`;
// 颜色提示
const cls = tokens > cw * compressRate ? 'danger' : 'safe';
_labelText.className = `token-label token-label--${cls}`;
}
// 游标超限反馈
if (_handleCompress) {
_handleCompress.classList.toggle('overshot', tokens > cw * compressRate);
}
}
function _fmtNum(n) {
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
return String(n);
}
// ===== 游标拖拽 =====
function attachHandleEvents() {
if (!_handleCompress || !_barTrack) return;
_handleCompress.addEventListener('mousedown', (e) => {
e.preventDefault();
_dragging = true;
_handleCompress.classList.add('dragging');
document.addEventListener('mousemove', onDragMove);
document.addEventListener('mouseup', onDragEnd);
});
// 触摸支持
_handleCompress.addEventListener('touchstart', (e) => {
e.preventDefault();
_dragging = true;
_handleCompress.classList.add('dragging');
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onDragEndTouch);
});
}
function getPctFromEvent(clientX) {
const rect = _barTrack.getBoundingClientRect();
return Math.max(5, Math.min(95, ((clientX - rect.left) / rect.width) * 100));
}
function onDragMove(e) {
if (!_dragging) return;
const pct = getPctFromEvent(e.clientX);
setHandlePct(pct);
}
function onTouchMove(e) {
if (!_dragging) return;
e.preventDefault();
const pct = getPctFromEvent(e.touches[0].clientX);
setHandlePct(pct);
}
function onDragEnd() {
endDrag();
document.removeEventListener('mousemove', onDragMove);
document.removeEventListener('mouseup', onDragEnd);
}
function onDragEndTouch() {
endDrag();
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onDragEndTouch);
}
function endDrag() {
if (!_dragging) return;
_dragging = false;
if (_handleCompress) _handleCompress.classList.remove('dragging');
// 保存到后端
saveRateSetting();
}
function setHandlePct(pct) {
const rate = Math.round(pct) / 100;
_settings.compress_rate = rate;
updateBarUI();
}
async function saveRateSetting() {
try {
await api("/api/config/compress-settings", {
method: "POST",
body: JSON.stringify({ compress_rate: _settings.compress_rate }),
});
} catch {
showToast("保存压缩设置失败");
}
}
// ===== 公开:外部调用 =====
/** 强制立即刷新 token 计数(发送消息后调用) */
export function refreshTokens() {
pollTokens();
}
/** 停止轮询(页面卸载时调用) */
export function stopTokenPolling() {
stopPolling();
}
+139
View File
@@ -0,0 +1,139 @@
// token-ring.js -- 上下文 Token 环形指示器(替代原横向进度条)
// 深色底环 + 从顶部顺时针填充的浅色弧线表示 token 占用率(模型选择器左侧)。
// 悬停弹出面板:
// 顶部:上下文容量 xxk/xxk (xx%)
// 简约进度条:token 用量填充 + 静态压缩阈值刻度线(阈值仅在环境变量中修改,不可拖动)
// 下方:三类占比降序列表(消息 / 工具 / 系统提示词),占用多的在上方
import { $, showToast, withClickGuard } from './utils.js';
import { api } from './api.js';
import { state } from './state.js';
let _pollTimer = null;
let _settings = null; // { context_window, compress_rate }
let _breakdown = { messages: 0, tools: 0, system: 0 };
const _RING_R = 11;
const _RING_C = 2 * Math.PI * _RING_R;
/** 初始化环形指示器(bootstrap 后调用) */
export function initTokenRing() {
const fill = $("tokenRingFill");
if (fill) fill.style.strokeDasharray = `${_RING_C}`;
bindRingEvents();
pollTokens();
startPolling();
}
function startPolling() {
stopTokenPolling();
_pollTimer = setInterval(pollTokens, 2000);
}
export function stopTokenPolling() {
if (_pollTimer) {
clearInterval(_pollTimer);
_pollTimer = null;
}
}
export function refreshTokens() {
pollTokens();
}
async function pollTokens() {
if (!state.sessionId) return;
try {
const data = await api(`/api/chat/tokens/${state.sessionId}`);
state.currentTokens = data.tokens || 0;
_settings = {
context_window: data.context_window || 262144,
compress_rate: data.compress_rate != null ? data.compress_rate : 0.6,
};
_breakdown = data.breakdown || { messages: 0, tools: 0, system: 0 };
updateRingUI();
} catch { /* 静默失败 */ }
}
function _fmtK(n) {
return `${(Number(n) / 1024).toFixed(1)}k`;
}
/** 更新圆环弧线、容量文案、用量进度条与占比列表 */
function updateRingUI() {
const cw = _settings ? _settings.context_window : 262144;
const tokens = state.currentTokens || 0;
const pct = Math.min(100, (tokens / cw) * 100);
const fill = $("tokenRingFill");
if (fill) fill.style.strokeDashoffset = `${_RING_C * (1 - pct / 100)}`;
const cap = $("tokenRingCapacity");
if (cap) cap.textContent = `上下文容量 ${_fmtK(tokens)} / ${_fmtK(cw)} (${pct.toFixed(1)}%)`;
// 用量进度条:填充 = token 占用率
const barFill = $("tokenRingRateFill");
if (barFill) barFill.style.width = `${pct}%`;
// 压缩阈值刻度线(静态,仅展示;阈值在环境变量中修改)
const rate = _settings ? _settings.compress_rate : 0.6;
const marker = $("tokenRingThreshold");
if (marker) marker.style.left = `${rate * 100}%`;
const rateLabel = $("tokenRingRateLabel");
if (rateLabel) rateLabel.textContent = `压缩阈值 ${Math.round(rate * 100)}%(在环境变量中修改)`;
renderBreakdown();
}
/** 占比列表:按占用降序(占用多的在上方) */
function renderBreakdown() {
const container = $("tokenRingBreakdown");
if (!container) return;
const rows = [
{ key: "消息", val: Number(_breakdown.messages) || 0 },
{ key: "工具", val: Number(_breakdown.tools) || 0 },
{ key: "系统提示词", val: Number(_breakdown.system) || 0 },
];
const total = rows.reduce((s, r) => s + r.val, 0);
rows.sort((a, b) => b.val - a.val);
container.innerHTML = "";
rows.forEach((r) => {
const row = document.createElement("div");
row.className = "token-ring-breakdown-row";
const dot = document.createElement("span");
dot.className = "token-ring-breakdown-dot";
dot.textContent = "·";
const key = document.createElement("span");
key.className = "token-ring-breakdown-key";
key.textContent = r.key;
const val = document.createElement("span");
val.className = "token-ring-breakdown-val";
val.textContent = `${total > 0 ? ((r.val / total) * 100).toFixed(1) : "0.0"}%`;
row.appendChild(dot);
row.appendChild(key);
row.appendChild(val);
container.appendChild(row);
});
}
/** 绑定圆环 hover 显示/隐藏与点击切换面板(含连击保护) */
function bindRingEvents() {
const wrap = $("tokenRingWrap");
const panel = $("tokenRingPanel");
if (!wrap || !panel) return;
wrap.addEventListener("mouseenter", () => { panel.hidden = false; });
wrap.addEventListener("mouseleave", () => { panel.hidden = true; });
const btn = $("tokenRingBtn");
if (btn) {
btn.addEventListener("click", withClickGuard((e) => {
e.stopPropagation();
panel.hidden = !panel.hidden;
}));
}
}
+37
View File
@@ -163,6 +163,41 @@ function dedupePendingFiles(files) {
});
}
/**
* 通用连击保护:在 timeWindow 毫秒内忽略重复触发。
* 用于按钮/选项等一次性交互,防止双击导致重复请求或重复弹窗。
* @param {Function} fn - 要执行的函数(可用 async)
* @param {number} ms - 保护时间窗(毫秒),默认 400
* @returns {Function} 包裹后的函数
*/
function withClickGuard(fn, ms = 400) {
let lastAt = 0;
let running = false;
return function (...args) {
const now = Date.now();
if (running || now - lastAt < ms) return;
lastAt = now;
const result = fn.apply(this, args);
if (result && typeof result.then === "function") {
running = true;
return result.finally(() => { running = false; });
}
return result;
};
}
/**
* 关闭除 keepEl 外的所有内联下拉面板(.inline-drop-panel),保证同时只开一个。
* @param {HTMLElement|null} keepEl - 保持打开的面板元素
*/
function closeInlinePanelsExcept(keepEl) {
document.querySelectorAll(".inline-drop-panel").forEach((p) => {
if (p === keepEl) return;
p.hidden = true;
if (p._trigger) p._trigger.classList.remove("is-open");
});
}
export {
$,
escapeHtml,
@@ -173,4 +208,6 @@ export {
makeId, codeBlockNode,
showToast,
dedupePendingFiles,
withClickGuard,
closeInlinePanelsExcept,
};
+295 -19
View File
@@ -64,7 +64,7 @@ def _read_ws_config() -> dict:
return json.load(f)
except (json.JSONDecodeError, OSError):
pass
return {"current": "", "list": []}
return {"current": "", "list": [], "access_mode": "restricted"}
def _write_ws_config(data: dict) -> bool:
@@ -294,7 +294,7 @@ def _serialize_messages(hist: list[dict[str, Any]] | None, session_id: str = "")
except Exception:
return out
# 按 turn_id 为每条 assistant 消息挂载对应轮次的工具调用
# 按 turn_id 为每条 assistant 消息挂载对应轮次的工具调用与轮次级元数据
has_turn_ids = False
for m in out:
if m.get("role") != "assistant":
@@ -303,20 +303,21 @@ def _serialize_messages(hist: list[dict[str, Any]] | None, session_id: str = "")
if not turn_id:
continue
has_turn_ids = True
m_meta = m.setdefault("meta", {})
try:
tool_calls = get_tool_calls_for_turn(session_id, turn_id)
except Exception:
tool_calls = []
if tool_calls:
_inject_sub_tool_calls(tool_calls, session_id, turn_id, get_sub_agent_traces_for_turn)
m_meta = m.setdefault("meta", {})
m_meta["tool_calls"] = tool_calls
try:
turn_meta = get_turn_meta(session_id, turn_id)
except Exception:
turn_meta = None
if turn_meta:
m_meta["turn_meta"] = turn_meta
# 无条件挂载 turn_meta(started_at/ended_at/duration),供前端显示回复开始时间
try:
turn_meta = get_turn_meta(session_id, turn_id)
except Exception:
turn_meta = None
if turn_meta:
m_meta["turn_meta"] = turn_meta
# 兼容旧消息(无 turn_id):将最新轮工具调用附加到最后一条 assistant
if not has_turn_ids:
@@ -485,6 +486,153 @@ def api_new_session() -> dict[str, Any]:
return {"sessionId": sid, "sessions": ids, "messages": [], "tokens": 0, "pending_actions": []}
def _branch_copy_attachment(abs_src: str, dst_dir: str, new_sid: str) -> str:
"""将附件复制到新会话目录,返回目标绝对路径(必要时避免重名)。
输入:
abs_src: 源附件绝对路径。
dst_dir: 新会话目录。
new_sid: 新会话 ID(用于冲突重命名)。
输出:
目标绝对路径。
"""
base = os.path.basename(abs_src)
dest = os.path.join(dst_dir, base)
if os.path.exists(dest) and os.path.normpath(dest) != os.path.normpath(abs_src):
stem, ext = os.path.splitext(base)
i = 2
while os.path.exists(dest):
dest = os.path.join(dst_dir, f"{stem}_{new_sid}_{i}{ext}")
i += 1
if os.path.normpath(dest) != os.path.normpath(abs_src):
shutil.copy2(abs_src, dest)
return dest
def _branch_rewrite_attachments(dst_dir: str, new_sid: str) -> None:
"""将分支复制后的 turn 文件中的附件引用复制到新会话目录并重写 relpath。
输入:
dst_dir: 新会话目录。
new_sid: 新会话 ID。
"""
from agent.utils.agent_utils import SESSIONS_ROOT, TURN_JSON_PREFIX
for name in os.listdir(dst_dir):
if not (name.startswith(TURN_JSON_PREFIX) and name.endswith(".json")):
continue
tp = os.path.join(dst_dir, name)
try:
with open(tp, "r", encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError):
continue
changed = False
for msg in data.get("messages", []):
role = msg.get("role")
if role == "user":
content = (msg.get("user") or {}).get("content") or []
for piece in content:
if not isinstance(piece, dict):
continue
rel = piece.get("relpath")
if not rel:
continue
abs_src = os.path.normpath(os.path.join(SESSIONS_ROOT, rel))
if not os.path.isfile(abs_src):
continue
dest = _branch_copy_attachment(abs_src, dst_dir, new_sid)
new_rel = os.path.relpath(dest, SESSIONS_ROOT).replace("\\", "/")
if new_rel != rel:
piece["relpath"] = new_rel
changed = True
elif role == "assistant":
asst = msg.get("assistant") or {}
audio_rel = asst.get("audio_relpath")
if audio_rel:
abs_src = os.path.normpath(os.path.join(SESSIONS_ROOT, audio_rel.replace("/", os.sep)))
if os.path.isfile(abs_src):
dest = _branch_copy_attachment(abs_src, dst_dir, new_sid)
new_rel = os.path.relpath(dest, SESSIONS_ROOT).replace("\\", "/")
if new_rel != audio_rel:
asst["audio_relpath"] = new_rel
changed = True
if changed:
with open(tp, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
@app.post("/api/sessions/branch")
def api_session_branch(
session_id: str = Form(...),
branch_turn_id: str = Form(""),
) -> dict[str, Any]:
"""将源会话中 branch_turn_id 及之前的所有历史复制到新会话(分支)。
输入:
session_id: 源会话 ID。
branch_turn_id: 分支点回合 ID(该回合及其之前的历史会被复制到新会话)。
输出:
{"sessionId": str, "sessions": list[str], "messages": list[dict], "tokens": int,
"pending_actions": list[dict]}
系统定位:
AI 回复的「分支」按钮:复制一份历史到新会话,用户可在此基础上继续对话。
"""
if not session_id:
raise HTTPException(status_code=400, detail="缺少 session_id")
# mysql 后端不落 turn/tool 文件,暂不支持文件级分支
try:
from agent.history import session_storage
if session_storage.is_mysql():
raise HTTPException(status_code=400, detail="mysql 存储后端暂不支持分支")
except Exception:
pass
from agent.utils.agent_utils import SESSIONS_ROOT, TURN_JSON_PREFIX
src_dir = os.path.join(SESSIONS_ROOT, session_id)
if not os.path.isdir(src_dir):
raise HTTPException(status_code=400, detail="源会话不存在")
# 创建新会话并复制 branch_turn_id 及之前的 turn/tool 文件
new_sid = ui.new_timestamp_session_id()
dst_dir = os.path.join(SESSIONS_ROOT, new_sid)
os.makedirs(dst_dir, exist_ok=True)
branch_turn_full = f"{TURN_JSON_PREFIX}{branch_turn_id}.json"
tool_branch_full = f"tool_{branch_turn_id}.json"
copied_turns = 0
try:
for name in sorted(os.listdir(src_dir)):
src_path = os.path.join(src_dir, name)
if name.startswith(TURN_JSON_PREFIX) and name.endswith(".json") and name <= branch_turn_full:
shutil.copy2(src_path, os.path.join(dst_dir, name))
copied_turns += 1
elif name.startswith("tool_") and name.endswith(".json") and name <= tool_branch_full:
shutil.copy2(src_path, os.path.join(dst_dir, name))
if copied_turns == 0:
raise ValueError("分支点回合不存在")
# 复制附件引用并重写 relpath,使新会话自包含
_branch_rewrite_attachments(dst_dir, new_sid)
except Exception:
shutil.rmtree(dst_dir, ignore_errors=True)
raise HTTPException(status_code=400, detail="无效的分支回合")
ids = _ensure_session_in_list(new_sid, ui.list_session_ids_ordered())
from agent.history.tool_call_recorder import get_session_tokens
return {
"sessionId": new_sid,
"sessions": ids,
"messages": _serialize_messages(ui.load_ui_messages_for_session(new_sid), new_sid),
"tokens": get_session_tokens(new_sid),
"pending_actions": _get_pending_actions(new_sid),
}
@app.get("/api/sessions/{session_id}/messages")
def api_session_messages(session_id: str) -> dict[str, Any]:
"""切换会话:加载指定会话的消息列表与全局会话列表。
@@ -1150,15 +1298,18 @@ def api_save_models(data: dict[str, Any]) -> dict[str, Any]:
def api_get_workspace() -> dict[str, Any]:
"""获取工作空间配置。
输出: {"current": str, "list": list[str], "default": str, "snapshots_base": str}
输出: {"current": str, "list": list[str], "default": str, "snapshots_base": str,
"access_mode": str}
current: 当前选中的工作空间路径(空字符串表示使用默认值)
list: 已保存的工作空间路径列表
default: env_config 中的默认工作空间路径
snapshots_base: 快照存储基础目录
access_mode: 工作空间访问模式(restricted | approval | full)
"""
from agent.utils.env_utils import get_workspace_dir
from agent.memory.system_prompt import _current_workspace_dir
from agent.utils.agent_utils import SESSIONS_ROOT
from agent.core.workspace_policy import get_access_mode
import os
cfg = _read_ws_config()
current = _current_workspace_dir or cfg.get("current", "")
@@ -1175,9 +1326,27 @@ def api_get_workspace() -> dict[str, Any]:
"list": valid,
"default": get_workspace_dir(),
"snapshots_base": os.path.join(SESSIONS_ROOT, ".minor_snapshots").replace("\\", "/"),
"access_mode": get_access_mode(),
}
@app.post("/api/workspace/access-mode")
def api_set_access_mode(data: dict[str, Any]) -> dict[str, Any]:
"""设置工作空间访问模式。
输入: {"mode": "restricted"|"approval"|"full"}
输出: {"success": bool, "mode": str}
"""
from agent.core.workspace_policy import _ACCESS_MODES, set_access_mode
mode = data.get("mode", "") if isinstance(data, dict) else ""
if mode not in _ACCESS_MODES:
raise HTTPException(status_code=400, detail="无效的访问模式(restricted | approval | full)")
ok = set_access_mode(mode)
if not ok:
raise HTTPException(status_code=500, detail="保存访问模式失败")
return {"success": True, "mode": mode}
@app.post("/api/workspace/select")
def api_select_workspace(data: dict[str, Any]) -> dict[str, Any]:
"""选择工作空间。
@@ -1331,6 +1500,33 @@ def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/api/services/status")
def api_services_status() -> dict[str, Any]:
"""探测 ASR 与 TTS 服务的连通性(供前端麦克风/语音模式切换前检查)。
探测方式: 对服务根路径发起 GET,任何 HTTP 响应(含 404)都视为服务在线;
连接失败/超时视为服务不可用。
输出: {"asr": {"ok": bool, "url": str}, "tts": {"ok": bool, "url": str}}
"""
import requests as _req
from agent.utils.env_utils import ASR_BASE_URL, STREAMING_TTS_URL
def _probe(url: str) -> bool:
if not url or not str(url).strip():
return False
try:
_req.get(str(url).rstrip("/") + "/", timeout=3)
return True
except Exception:
return False
return {
"asr": {"ok": _probe(ASR_BASE_URL), "url": ASR_BASE_URL or ""},
"tts": {"ok": _probe(STREAMING_TTS_URL), "url": STREAMING_TTS_URL or ""},
}
@app.get("/api/pick-folder")
def api_pick_folder() -> dict[str, Any]:
"""打开原生文件夹选择对话框,返回用户选择的文件夹绝对路径。
@@ -1432,6 +1628,37 @@ def api_save_compress_settings(data: dict[str, Any]) -> dict[str, Any]:
return {"success": updated}
# ---------- 深度思考档位 API ----------
@app.post("/api/config/thinking-level")
def api_set_thinking_level(data: dict[str, Any]) -> dict[str, Any]:
"""设置深度思考档位(low | high | xhigh | max | ultra),重建 Agent 运行时。
档位同时决定运行模式与思考开关:
low = agent + 不思考;high = plan + 不思考;
xhigh = agent + 思考;max = plan + 思考;
ultra 为未来 react→审批图预留占位(暂按 agent + 思考)。
输入: {"level": str}
输出: {"success": bool, "level": str}
"""
from agent.utils.env_utils import THINKING_LEVELS, _load_config
level = data.get("level", "") if isinstance(data, dict) else ""
if level not in THINKING_LEVELS:
raise HTTPException(status_code=400, detail="无效的思考档位(low | high | xhigh | max | ultra)")
config = load_env_config()
config["THINKING_LEVEL"] = level
if not save_env_config(config):
raise HTTPException(status_code=500, detail="保存思考档位失败")
_load_config()
# 重建 Agent 运行时使新的 extra_body 生效
try:
from agent.agents.agent_runtime import reload_all_agent_runtimes
reload_all_agent_runtimes()
except Exception:
pass
return {"success": True, "level": level}
# ---------- 会话 Token 用量 API ----------
@app.get("/api/chat/tokens/{session_id}")
def api_get_session_tokens(session_id: str) -> dict[str, Any]:
@@ -1441,7 +1668,7 @@ def api_get_session_tokens(session_id: str) -> dict[str, Any]:
"compress_rate": float}
"""
from agent.utils.env_utils import LLM_CONTEXT_WINDOW, COMPRESS_RATE
from agent.history.tool_call_recorder import get_session_tokens
from agent.history.tool_call_recorder import get_session_tokens, get_session_token_breakdown
# 直接读取 LLM 每次调用后存储的真实 total_tokens
tokens = get_session_tokens(session_id)
@@ -1452,6 +1679,8 @@ def api_get_session_tokens(session_id: str) -> dict[str, Any]:
"context_window": LLM_CONTEXT_WINDOW,
"compress_threshold": compress_threshold,
"compress_rate": COMPRESS_RATE,
# 三类估算占比(消息/工具/系统提示词),供前端环形指示展示
"breakdown": get_session_token_breakdown(session_id),
}
@@ -1613,20 +1842,16 @@ async def api_create_or_update_skill(
name: str = Form(...),
description: str = Form(""),
content: str = Form(""),
version: str = Form("1.0.0"),
author: str = Form(""),
tags: str = Form(""),
enable: str = Form("true"),
) -> dict[str, Any]:
"""创建或更新一个 skill。
"""创建或更新一个 skill(不保留 version/author)。
输入:
name: skill 名称(必填)。
description: 简述。
content: skill.md 的 markdown 内容。
version: 版本号。
author: 作者。
tags: 逗号分隔的标签字符串。
tags: 逗号分隔的标签字符串(可选,便于分级筛选)。
enable: 是否启用,默认 "true"。
输出: {"success": bool, "skill": dict}
@@ -1640,8 +1865,6 @@ async def api_create_or_update_skill(
name=name.strip(),
description=description,
content=content,
version=version,
author=author,
tags=tag_list,
enable=enable_bool,
)
@@ -1652,6 +1875,59 @@ async def api_create_or_update_skill(
return {"success": True, "skill": skill}
# ---------- Skill 导入(zip:先解析展示,保存时才落盘) ----------
@app.post("/api/skills/parse")
async def api_parse_skill_zip(file: UploadFile = File(...)) -> dict[str, Any]:
"""解析 skill zip 包(不落盘),返回 name/description/content 与附件列表。
输入: multipart file(zip)
输出: {"found": bool, "skill": {name, description, content, attachments} | None,
"error": str | None}
"""
if not file.filename:
raise HTTPException(status_code=400, detail="文件名为空")
zip_bytes = await file.read()
from agent.skills import parse_skill_zip
parsed = parse_skill_zip(zip_bytes)
if parsed is None:
return {"found": False, "skill": None,
"error": "未在 zip 的一级子目录下找到 skill.md/SKILL.md"}
return {"found": True, "skill": parsed, "error": None}
@app.post("/api/skills/import")
async def api_import_skill_zip(
file: UploadFile = File(...),
name: str = Form(""),
description: str = Form(""),
content: str = Form(""),
tags: str = Form(""),
enable: str = Form("true"),
) -> dict[str, Any]:
"""导入 skill zip 包到 skills 目录(skill.md/skill.json + 附件保持原相对路径)。
输入: multipart file(zip)+ name/description/content/tags/enable 覆盖值
输出: {"success": bool, "skill": dict | None}
"""
if not file.filename:
raise HTTPException(status_code=400, detail="文件名为空")
zip_bytes = await file.read()
from agent.skills import import_skill_zip, get_skill
tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else []
enable_bool = enable.lower() not in ("false", "0", "no", "")
ok = import_skill_zip(
name=name.strip(),
zip_bytes=zip_bytes,
description=description,
content=content,
tags=tag_list,
enable=enable_bool,
)
if not ok:
raise HTTPException(status_code=500, detail="导入 skill 失败(zip 无效或未找到 skill.md)")
return {"success": True, "skill": get_skill(name.strip())}
@app.delete("/api/skills/{name}")
def api_delete_skill(name: str) -> dict[str, Any]:
"""删除一个 skill 及其所有附件。