优化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 break
if model_config: if model_config:
# enable_thinking: true 时传入空 extra_body 恢复模型原生行为(启用思考) # 深度思考由全局 THINKING_LEVEL 档位控制(low/high 关闭,xhigh/max/ultra 开启),
# 默认不传 extra_body,由 ChatQwen 使用禁用思考的默认值 # 取代原先 per-model 的 enable_thinking 开关。true 档位传空 extra_body 恢复模型
if model_config.get("enable_thinking"): # 原生行为(启用思考);否则不传,由 ChatQwen 使用禁用思考的默认值。
extra_body = {} from agent.utils.env_utils import get_thinking_extra_body
else: extra_body = get_thinking_extra_body()
extra_body = None
return ChatQwen( return ChatQwen(
model=model_config.get("model", ""), model=model_config.get("model", ""),
api_key=model_config.get("api_key", ""), api_key=model_config.get("api_key", ""),
+2 -1
View File
@@ -2,5 +2,6 @@
"current": "", "current": "",
"list": [ "list": [
"C:\\Users\\86166\\Desktop\\Agent_Learning_minor\\Agent\\src\\workspace" "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"], "options": ["approve", "skip"] if is_sub else ["approve", "reject", "skip"],
"agent_name": agent_name, "agent_name": agent_name,
"is_sub_agent": is_sub, "is_sub_agent": is_sub,
# 工作空间访问策略:越界审批原因(无则为空串)
"policy_note": entry.get("policy_note") or "",
"resolved": False, "resolved": False,
}) })
return actions return actions
+4 -1
View File
@@ -363,6 +363,7 @@ def _default_env_config() -> dict:
"IMAGE_GEN_URL": "http://localhost:8904", "IMAGE_GEN_URL": "http://localhost:8904",
"LLM_CONTEXT_WINDOW": "262144", "LLM_CONTEXT_WINDOW": "262144",
"COMPRESS_RATE": "0.6", "COMPRESS_RATE": "0.6",
"THINKING_LEVEL": "low",
"IMG_SIZE": "768", "IMG_SIZE": "768",
"RAG_CHUNK_SIZE": "500", "RAG_CHUNK_SIZE": "500",
"RAG_CHUNK_OVERLAP": "50", "RAG_CHUNK_OVERLAP": "50",
@@ -508,13 +509,15 @@ def get_gui_llm():
if model_cfg is None: if model_cfg is None:
return None return None
from agent.core.llm import ChatQwen 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( return ChatQwen(
model=model_cfg["model"], model=model_cfg["model"],
api_key=model_cfg["api_key"], api_key=model_cfg["api_key"],
base_url=model_cfg["base_url"], base_url=model_cfg["base_url"],
timeout=model_cfg.get("timeout", 60), timeout=model_cfg.get("timeout", 60),
max_retries=model_cfg.get("max_retries", 0), 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 循环内上下文压缩 ---------- # ---------- ReAct 循环内上下文压缩 ----------
def _extract_total_tokens(response: Any) -> int: def _extract_usage(response: Any) -> dict:
"""从模型 response 中提取 total_tokens。""" """从模型 response 提取完整 usage 字典(prompt_tokens/completion_tokens/total_tokens)。"""
usage = None usage = None
if hasattr(response, "response_metadata"): if hasattr(response, "response_metadata"):
meta = response.response_metadata meta = response.response_metadata
@@ -220,7 +220,50 @@ def _extract_total_tokens(response: Any) -> int:
meta = response.usage_metadata meta = response.usage_metadata
if isinstance(meta, dict): if isinstance(meta, dict):
usage = meta 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: def _compute_next_cursor(session_id: str) -> int:
@@ -238,7 +281,7 @@ def _compute_next_cursor(session_id: str) -> int:
return -1 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 用量判断下一步动作。 """根据 LLM 返回的 token 用量判断下一步动作。
返回值 (trigger, total_tokens, threshold): 返回值 (trigger, total_tokens, threshold):
@@ -247,7 +290,8 @@ def _maybe_trigger_compress(response: Any) -> tuple[str, int, int]:
"sub_oom" —— 子 Agent 达到阈值(子 Agent 不压缩),由 agent 节点注入 "sub_oom" —— 子 Agent 达到阈值(子 Agent 不压缩),由 agent 节点注入
整理提示词,让子 Agent 整理任务进度直接返回主 Agent。 整理提示词,让子 Agent 整理任务进度直接返回主 Agent。
同时将 total_tokens 存入 tool_call_recorder 供前端实时展示(仅主 Agent)。 同时将 total_tokens 与估算的三类占比(消息/工具/系统提示词)存入
tool_call_recorder 供前端环形指示展示(仅主 Agent)。
""" """
total = _extract_total_tokens(response) total = _extract_total_tokens(response)
if total <= 0: if total <= 0:
@@ -260,7 +304,8 @@ def _maybe_trigger_compress(response: Any) -> tuple[str, int, int]:
_sid = get_current_session() or "" _sid = get_current_session() or ""
is_sub = is_sub_agent_context() is_sub = is_sub_agent_context()
if _sid and not is_sub: 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: except Exception:
pass pass
@@ -438,7 +483,7 @@ def make_call_model_node(
# 基于模型返回 token 用量判断:主 Agent 标记压缩 / 子 Agent 超限整理。 # 基于模型返回 token 用量判断:主 Agent 标记压缩 / 子 Agent 超限整理。
# total_tokens 同时存入 session_tokens 供前端轮询(仅主 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": if trigger == "sub_oom":
# 子 Agent 不压缩:注入整理提示词重新调用,让模型整理任务进度直接 # 子 Agent 不压缩:注入整理提示词重新调用,让模型整理任务进度直接
# 返回主 Agent(提示词内附 OOM 溢出信息)。 # 返回主 Agent(提示词内附 OOM 溢出信息)。
+18 -1
View File
@@ -15,10 +15,12 @@ from typing import Any, Literal, Sequence
from langchain_core.messages import BaseMessage from langchain_core.messages import BaseMessage
from langgraph.graph import END 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.loop_detector import should_force_end as _loop_should_force_end
from agent.core.state import AgentState from agent.core.state import AgentState
from agent.core.tool_policy import classify_tool_execution 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 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) tool_call = normalize_tool_call(raw_tool_call)
if classify_tool_execution(tool_call.get("name"), tool_call.get("args") or {}) == "confirm": if classify_tool_execution(tool_call.get("name"), tool_call.get("args") or {}) == "confirm":
return END return END
# 权限审查模式:越界操作暂停图,交由前端人工审批
if _policy_decision(tool_call.get("name"), tool_call.get("args") or {}) == "approve":
return END
return "tools" return "tools"
return END 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) 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": 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) 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 return None
+14 -15
View File
@@ -264,13 +264,6 @@ def _persist_messages_from_pending_turn(
history.add_message(final_assistant) 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( def _invoke_after_manual_tool_result(
runtime: AgentRuntime, runtime: AgentRuntime,
messages: list[Any], messages: list[Any],
@@ -791,6 +784,9 @@ def continue_after_human_action(
approval_id: pending_action.id。 approval_id: pending_action.id。
decision: approve | reject | edit | skip。 decision: approve | reject | edit | skip。
- approve: 执行工具/确认续跑;skip: 不执行工具但继续图(ToolMessage 标记跳过)。 - approve: 执行工具/确认续跑;skip: 不执行工具但继续图(ToolMessage 标记跳过)。
- reject: 与手动暂停一致——暂停当前 Agent 运行、保留之前的运行记录、
前端追加"已被用户手动暂停"消息(对三种审批统一生效:权限不足 /
workspace 越界 / 人机交互)。
- edit: human_interaction selection 提交文本。 - edit: human_interaction selection 提交文本。
instruction: selection 时为用户输入文本。 instruction: selection 时为用户输入文本。
@@ -814,18 +810,21 @@ def continue_after_human_action(
agent_mode = pending.get("agent_mode", "agent") agent_mode = pending.get("agent_mode", "agent")
# ===== 子 Agent HITL 恢复(approve / skip,无 reject)===== # ===== 拒绝:统一三种审批(权限不足 / workspace 越界 / 人机交互)=====
if pending.get("is_sub_agent"): # 与手动暂停(/api/chat/abort)一致:暂停当前 Agent 运行、保留之前的运行记录,
return _resume_sub_agent(runtime, pending, chat_history, session_id, approval_id, decision, instruction, agent_mode) # 前端收到"已被用户手动暂停"消息。不恢复图执行。
if decision == "reject":
if decision == "reject" and pending.get("type") != "human_interaction": last_msg = chat_history[-1] if chat_history else None
content = "已拒绝,本次待确认操作不会执行。" if not (last_msg and last_msg.get("role") == "assistant" and last_msg.get("content") == "已被用户手动暂停"):
chat_history.append({"role": "assistant", "content": content, "meta": {}}) chat_history.append({"role": "assistant", "content": "已被用户手动暂停", "meta": {"paused": True}})
_persist_pending_rejection(session_id, pending, content)
if session_id: if session_id:
sync_turn_from_chat_history(session_id, chat_history, "") sync_turn_from_chat_history(session_id, chat_history, "")
return 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 不支持跳过 ===== # ===== 跳过:仅 tool_call 类型支持,human_interaction 不支持跳过 =====
if decision == "skip" and pending.get("type") != "human_interaction": if decision == "skip" and pending.get("type") != "human_interaction":
tool_call = dict(pending.get("tool_call") or {}) 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}", "") _post_finished(task_id, "failed", f"存储初始化失败: {e}", "")
return 1 return 1
# 1.1 标记无人值守:工作空间访问策略的 approval 模式在 cron 下退化为拦截
# (待审批项存于子进程内存、随进程退出丢失,且无人在线审批)
try:
from agent.core.workspace_policy import mark_headless
mark_headless()
except Exception:
pass
# 2. 构建 AgentRuntime # 2. 构建 AgentRuntime
try: try:
from agent.agents.agent_runtime import build_all_agent_runtimes, get_main_agent_runtime 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) ---------- # ---------- 当前会话 token 用量(来自 LLM response usage_metadata) ----------
_session_tokens_lock = threading.Lock() _session_tokens_lock = threading.Lock()
_session_tokens: dict[str, int] = {} # session_id → total_tokens _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 _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" 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 文件)。""" """将 token 写入存储(优先 MySQL,回退 JSON 文件)。"""
# mysql 后端:写入 sessions.extra.tokens # mysql 后端:写入 sessions.extra.tokens
try: try:
@@ -116,6 +117,8 @@ def _persist_token(session_id: str, tokens: int) -> None:
if session_storage.is_mysql(): if session_storage.is_mysql():
existing = session_storage.load_session_extra(session_id) or {} existing = session_storage.load_session_extra(session_id) or {}
existing["tokens"] = tokens existing["tokens"] = tokens
if breakdown:
existing["token_breakdown"] = breakdown
session_storage.save_session_extra(session_id, existing) session_storage.save_session_extra(session_id, existing)
return return
except Exception: except Exception:
@@ -123,7 +126,10 @@ def _persist_token(session_id: str, tokens: int) -> None:
# json 后端:写入文件 # json 后端:写入文件
tf = _token_file(session_id) tf = _token_file(session_id)
tf.parent.mkdir(parents=True, exist_ok=True) 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: def _load_token_from_disk(session_id: str) -> int:
@@ -147,11 +153,17 @@ def _load_token_from_disk(session_id: str) -> int:
return 0 return 0
def set_session_tokens(session_id: str, total_tokens: int) -> None: def set_session_tokens(session_id: str, total_tokens: int, breakdown: dict | None = None) -> None:
"""存储当前会话的 LLM 最新 token 用量(内存 + 磁盘)。""" """存储当前会话的 LLM 最新 token 用量(内存 + 磁盘)。
breakdown: {"messages": float, "tools": float, "system": float} 三类估算占比,
由 nodes._estimate_token_breakdown 计算。
"""
with _session_tokens_lock: with _session_tokens_lock:
_session_tokens[session_id] = total_tokens _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: def get_session_tokens(session_id: str) -> int:
@@ -170,16 +182,47 @@ def get_session_tokens(session_id: str) -> int:
return 0 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: def clear_session_tokens(session_id: str) -> None:
"""清除指定会话的 token 记录(内存 + 存储)。""" """清除指定会话的 token 记录(内存 + 存储)。"""
with _session_tokens_lock: with _session_tokens_lock:
_session_tokens.pop(session_id, None) _session_tokens.pop(session_id, None)
_session_token_breakdowns.pop(session_id, None)
# mysql 后端:设置 tokens=0 # mysql 后端:设置 tokens=0
try: try:
from agent.history import session_storage from agent.history import session_storage
if session_storage.is_mysql(): if session_storage.is_mysql():
existing = session_storage.load_session_extra(session_id) or {} existing = session_storage.load_session_extra(session_id) or {}
existing["tokens"] = 0 existing["tokens"] = 0
existing.pop("token_breakdown", None)
session_storage.save_session_extra(session_id, existing) session_storage.save_session_extra(session_id, existing)
return return
except Exception: except Exception:
+10 -8
View File
@@ -5,7 +5,7 @@ _BASE_PROMPT = """你是一个具备多模态理解能力的桌面操作助手
- 只有在信息确实无法从当前对话或上传内容中获取,且必须通过外部工具才能获得时,才调用工具。 - 只有在信息确实无法从当前对话或上传内容中获取,且必须通过外部工具才能获得时,才调用工具。
- 当你调用工具收到截图或返回结果后,必须仔细观察返回内容,基于实际结果继续推进任务,严禁脱离实际结果凭空编造。 - 当你调用工具收到截图或返回结果后,必须仔细观察返回内容,基于实际结果继续推进任务,严禁脱离实际结果凭空编造。
- 对于同一个页面下的GUI操作,必须将连续操作合并为一次 gui_tool 调用(actions 列表传入多个动作),严禁逐个单步调用。仅当页面发生跳转/加载后需要重新观察时,才允许分步调用。 - 对于同一个页面下的GUI操作,必须将连续操作合并为一次 gui_tool 调用(actions 列表传入多个动作),严禁逐个单步调用。仅当页面发生跳转/加载后需要重新观察时,才允许分步调用。
- 对于需要多次操作才能完成的任务,应主动使用 todo_list 工具规划步骤并逐步推进,并在任务完成后将todo_list中的所有项标记为已完成。 - 【任务规划】收到任务后,先判断任务复杂度:仅当任务为复杂任务(如多步骤、多阶段、流程较长或产出物较多)时,才调用 todo_list 工具将任务拆解为清晰的执行步骤列表,然后按步骤逐步执行;每完成一步,用 todo_list 标记进度(done_step),任务完成后将所有步骤标记为已完成。简单任务可直接执行,无需调用 todo_list。
- 当任务完成时,向用户总结执行过程和结果。 - 当任务完成时,向用户总结执行过程和结果。
""".strip() """.strip()
@@ -15,6 +15,7 @@ _WS_TAIL = """\n\n【工作空间配置】
- 你产生的代码、脚本、图片、文档等文件,默认保存到工作空间目录下。 - 你产生的代码、脚本、图片、文档等文件,默认保存到工作空间目录下。
- 使用 terminal_execute 工具时,如果不指定 working_dir,默认在工作空间执行命令。 - 使用 terminal_execute 工具时,如果不指定 working_dir,默认在工作空间执行命令。
- 使用 doc_tool 创建/修改文件时,文件路径请使用工作空间的绝对路径。 - 使用 doc_tool 创建/修改文件时,文件路径请使用工作空间的绝对路径。
- 写操作限制:doc_tool 的创建/修改/删除、terminal_execute 的文件变更目标必须位于工作空间内;超出范围的访问会被拦截或需要用户审批(取决于当前访问模式)。
""" """
# 当前运行时的工作空间路径(可通过 API 动态修改) # 当前运行时的工作空间路径(可通过 API 动态修改)
@@ -131,14 +132,14 @@ DB_SYSTEM_PROMT = """
请记住,你是一个协作伙伴,目标是让用户以最小的学习成本从数据库中获得有价值的信息。开始工作吧! 请记住,你是一个协作伙伴,目标是让用户以最小的学习成本从数据库中获得有价值的信息。开始工作吧!
""".strip() """.strip()
# Plan 模式核心规则(todo_list 强制绑定) # Plan 模式核心规则(复杂任务:写计划到 md 文件 -> send_file 交给用户确认 -> 确认后执行)
_PLAN_CORE_RULES = """ _PLAN_CORE_RULES = """
【Plan 模式核心规则】 【Plan 模式核心规则】(适用于复杂任务)
- **首要任务**:收到用户请求后,你必须**第一步就调用 todo_list 工具**,将用户的任务拆解为非常细致、可执行的步骤列表。每个步骤应该具体到单个操作(如"打开浏览器"、"搜索XXX"、"截取屏幕"、"分析结果"等)。 - **信息确认**:制定计划前,若用户的任务描述模糊、存在歧义,或任务中有任何需要注意并与用户确认的点(如目标范围、偏好、时间限制、交付格式等),**必须先调用 human_interaction 工具**,交由用户确认并补充信息,确认清楚后再制定计划。
- **规划要求**:步骤拆解必须详尽,覆盖从开始到完成的完整流程。不要跳过任何中间步骤。步骤描述要清晰具体,便于后续逐一执行和跟踪。 - **计划落盘**:将完整执行计划直接写入工作空间下的 `.minorAgent` 目录,文件名为「任务名.md」(如 `周报生成.md`),使用 doc_tool 创建/更新该文件(`.minorAgent` 目录不存在时 doc_tool 会自动创建)。计划内容必须包含:任务目标、执行步骤、每步的产出物。
- **规划完成后**:生成 todolist 后,向用户简要汇报规划结果,然后**立即开始按步骤执行**,不要等待用户确认。 - **计划确认**:计划写好后,**调用 send_file 工具将计划文件发送给用户查看**,并说明该计划需用户确认无异议后才会执行;在用户明确确认之前,不得开始执行计划。
- **执行过程中**:每完成一个步骤,调用 todo_list 标记该步骤为已完成(done_step),让用户实时看到进度。 - **执行推进**:用户确认无异议后,按计划逐步执行。每完成一步,用 doc_tool 更新 `.minorAgent\\{任务名}.md` 中的进度(将已完成步骤标记为 [x]),让用户实时看到进展。
- **任务完成后**:确认所有步骤已完成,向用户总结执行过程和结果。 - **任务完成**:确认所有步骤已完成,向用户总结执行过程和结果,并告知计划文件的存放位置。
""".strip() """.strip()
# Plan 模式提示词 = Plan 模式核心规则 + 当前 Agent 模式基础提示词 # Plan 模式提示词 = Plan 模式核心规则 + 当前 Agent 模式基础提示词
@@ -183,6 +184,7 @@ CRON_MODE_PROMPT = """
- 你产生的代码、脚本、图片、文档等文件,默认保存到工作空间目录下。 - 你产生的代码、脚本、图片、文档等文件,默认保存到工作空间目录下。
- 使用 terminal_execute 工具时,如果不指定 working_dir,默认在工作空间执行命令。 - 使用 terminal_execute 工具时,如果不指定 working_dir,默认在工作空间执行命令。
- 使用 doc_tool 创建/修改文件时,文件路径请使用工作空间的绝对路径。 - 使用 doc_tool 创建/修改文件时,文件路径请使用工作空间的绝对路径。
- 写操作限制:doc_tool 的创建/修改/删除、terminal_execute 的文件变更目标必须位于工作空间内。无人值守场景下超出范围的访问会被直接拦截,请务必使用工作空间内的路径。
""".strip() """.strip()
# ---------- 反思注入提示(作为 synthetic HumanMessage 注入到对话上下文)---------- # ---------- 反思注入提示(作为 synthetic HumanMessage 注入到对话上下文)----------
+270 -60
View File
@@ -1,29 +1,35 @@
"""Skill 管理器:负责 skill 的增删改查及附件管理。 """Skill 管理器:负责 skill 的增删改查、附件管理及 zip 导入。
每个 skill 是一个文件夹,位于 Skills 目录下,结构如下:: 每个 skill 是一个文件夹,位于 Skills 目录下,结构如下::
skills/ skills/
{skill_name}/ {skill_name}/
skill.json - 基本信息与描述 skill.json - 基本信息与描述(name/description/tags/enable)
skill.md - 主体内容(指南) skill.md - 主体内容(指南)
attachments/ - 可选附件(.py/.docx/.csv/.xlsx 等) ... - 可选附件(直接放在 skill 文件夹下,不强制 attachments/ 子目录)
系统定位: 系统定位:
为 skill_router 工具和前端 API 提供统一的数据读写接口。 为 skill_router 工具和前端 API 提供统一的数据读写接口。
可扩展性: 可扩展性:
可增加版本校验、导入/导出、标签过滤等功能。 可增加标签过滤、导出等功能。
""" """
from __future__ import annotations from __future__ import annotations
import io
import json import json
import os import os
import re
import shutil import shutil
import zipfile
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
SKILLS_DIR = Path(__file__).resolve().parent SKILLS_DIR = Path(__file__).resolve().parent
# 视为元数据而非附件的文件(附件列举时排除)
_SKILL_META_FILES = {"skill.json", "skill.md", "SKILL.md"}
def _ensure_skills_dir() -> None: def _ensure_skills_dir() -> None:
"""确保 skills 目录存在。""" """确保 skills 目录存在。"""
@@ -32,20 +38,16 @@ def _ensure_skills_dir() -> None:
# ---------- Skill 信息数据类 ---------- # ---------- Skill 信息数据类 ----------
class SkillInfo: class SkillInfo:
"""单个 Skill 的基本信息。""" """单个 Skill 的基本信息(不含 version/author)。"""
name: str name: str
description: str description: str
version: str
author: str
tags: list[str] tags: list[str]
enable: bool enable: bool
def __init__(self, name: str = "", description: str = "", version: str = "1.0.0", def __init__(self, name: str = "", description: str = "", tags: list[str] | None = None,
author: str = "", tags: list[str] | None = None, enable: bool = True): enable: bool = True):
self.name = name self.name = name
self.description = description self.description = description
self.version = version
self.author = author
self.tags = tags or [] self.tags = tags or []
self.enable = enable self.enable = enable
@@ -53,8 +55,6 @@ class SkillInfo:
return { return {
"name": self.name, "name": self.name,
"description": self.description, "description": self.description,
"version": self.version,
"author": self.author,
"tags": self.tags, "tags": self.tags,
"enable": self.enable, "enable": self.enable,
} }
@@ -64,20 +64,39 @@ class SkillInfo:
return cls( return cls(
name=d.get("name", name), name=d.get("name", name),
description=d.get("description", ""), description=d.get("description", ""),
version=d.get("version", "1.0.0"),
author=d.get("author", ""),
tags=d.get("tags", []), tags=d.get("tags", []),
enable=d.get("enable", True), 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 ---------- # ---------- CRUD ----------
def list_skills() -> list[dict]: def list_skills() -> list[dict]:
"""列出所有 skill 的基本信息(不含主体内容)。 """列出所有 skill 的基本信息(不含主体内容)。
输出: 输出:
list[dict]: 每项含 name, description, version, author, tags, has_attachments list[dict]: 每项含 name, description, tags, has_attachments, attachment_files
""" """
_ensure_skills_dir() _ensure_skills_dir()
result = [] result = []
@@ -92,16 +111,14 @@ def list_skills() -> list[dict]:
meta = json.load(f) meta = json.load(f)
except (json.JSONDecodeError, OSError): except (json.JSONDecodeError, OSError):
continue continue
meta = _strip_legacy_meta(meta, json_path)
meta["name"] = entry.name meta["name"] = entry.name
meta.setdefault("description", "") meta.setdefault("description", "")
meta.setdefault("version", "1.0.0")
meta.setdefault("author", "")
meta.setdefault("tags", []) meta.setdefault("tags", [])
meta.setdefault("enable", True) meta.setdefault("enable", True)
# 检查是否有附件 attachment_files = _list_attachments(entry.name)
attach_dir = entry / "attachments" meta["has_attachments"] = bool(attachment_files)
meta["has_attachments"] = attach_dir.is_dir() and bool(list(attach_dir.iterdir())) meta["attachment_files"] = attachment_files
meta["attachment_files"] = _list_attachments(entry.name)
result.append(meta) result.append(meta)
return result return result
@@ -110,7 +127,7 @@ def get_skill(name: str) -> dict | None:
"""获取单个 skill 的完整信息(含主体内容和附件列表)。 """获取单个 skill 的完整信息(含主体内容和附件列表)。
输出: 输出:
dict 或 None,字段: name, description, version, author, tags, dict 或 None,字段: name, description, tags, enable,
content (skill.md 内容), attachment_files content (skill.md 内容), attachment_files
""" """
skill_dir = SKILLS_DIR / name skill_dir = SKILLS_DIR / name
@@ -126,6 +143,7 @@ def get_skill(name: str) -> dict | None:
meta = json.load(f) meta = json.load(f)
except (json.JSONDecodeError, OSError): except (json.JSONDecodeError, OSError):
pass pass
meta = _strip_legacy_meta(meta, json_path)
content = "" content = ""
if md_path.is_file(): if md_path.is_file():
@@ -138,8 +156,6 @@ def get_skill(name: str) -> dict | None:
return { return {
"name": name, "name": name,
"description": meta.get("description", ""), "description": meta.get("description", ""),
"version": meta.get("version", "1.0.0"),
"author": meta.get("author", ""),
"tags": meta.get("tags", []), "tags": meta.get("tags", []),
"enable": meta.get("enable", True), "enable": meta.get("enable", True),
"content": content, "content": content,
@@ -148,7 +164,6 @@ def get_skill(name: str) -> dict | None:
def create_or_update_skill(name: str, description: str = "", content: str = "", 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: tags: list[str] | None = None, enable: bool = True) -> bool:
"""创建或更新一个 skill。 """创建或更新一个 skill。
@@ -156,9 +171,7 @@ def create_or_update_skill(name: str, description: str = "", content: str = "",
name: skill 名称(即文件夹名)。 name: skill 名称(即文件夹名)。
description: 简述。 description: 简述。
content: skill.md 的内容。 content: skill.md 的内容。
version: 版本号。 tags: 标签列表(可选,便于分级筛选)。
author: 作者。
tags: 标签列表。
enable: 是否启用此 skill,默认 True。 enable: 是否启用此 skill,默认 True。
输出: 输出:
@@ -171,12 +184,10 @@ def create_or_update_skill(name: str, description: str = "", content: str = "",
skill_dir = SKILLS_DIR / name skill_dir = SKILLS_DIR / name
os.makedirs(skill_dir, exist_ok=True) os.makedirs(skill_dir, exist_ok=True)
# 写 skill.json # 写 skill.json(不保留 version/author)
meta = { meta = {
"name": name, "name": name,
"description": description, "description": description,
"version": version,
"author": author,
"tags": tags or [], "tags": tags or [],
"enable": enable, "enable": enable,
} }
@@ -211,26 +222,38 @@ def delete_skill(name: str) -> bool:
return True return True
# ---------- 附件管理 ---------- # ---------- 附件管理(以 skill 目录为根,不强制 attachments/ 子目录) ----------
def _attachments_dir(skill_name: str) -> Path: def _skill_dir(skill_name: str) -> Path:
return SKILLS_DIR / skill_name / "attachments" return SKILLS_DIR / skill_name
def _list_attachments(skill_name: str) -> list[str]: def _list_attachments(skill_name: str) -> list[str]:
"""列出某 skill 的所有附件文件名。""" """列出某 skill 的所有附件相对路径(排除 skill.json/skill.md)。"""
ad = _attachments_dir(skill_name) sd = _skill_dir(skill_name)
if not ad.is_dir(): if not sd.is_dir():
return [] return []
result = [] 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: 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.append(rel.replace("\\", "/"))
result.sort() result.sort()
return result 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: def add_attachment(skill_name: str, filename: str, content_bytes: bytes) -> bool:
"""上传/更新一个附件文件到 skill。 """上传/更新一个附件文件到 skill。
@@ -242,16 +265,13 @@ def add_attachment(skill_name: str, filename: str, content_bytes: bytes) -> bool
输出: 输出:
成功返回 True。 成功返回 True。
""" """
skill_dir = SKILLS_DIR / skill_name sd = _skill_dir(skill_name)
if not skill_dir.is_dir(): if not sd.is_dir():
return False return False
ad = _attachments_dir(skill_name) safe_name = _safe_relpath(filename)
os.makedirs(ad, exist_ok=True) if safe_name is None or safe_name.lower() in _SKILL_META_FILES:
# 安全检查:防止路径穿越
safe_name = filename.replace("\\", "/").strip("/")
if ".." in safe_name:
return False return False
target = ad / safe_name target = sd / safe_name
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
try: try:
with open(target, "wb") as f: with open(target, "wb") as f:
@@ -267,18 +287,18 @@ def delete_attachment(skill_name: str, filename: str) -> bool:
输出: 输出:
存在且删除成功返回 True。 存在且删除成功返回 True。
""" """
ad = _attachments_dir(skill_name) sd = _skill_dir(skill_name)
safe_name = filename.replace("\\", "/").strip("/") safe_name = _safe_relpath(filename)
if ".." in safe_name: if safe_name is None:
return False return False
target = ad / safe_name target = sd / safe_name
if not target.is_file(): if not target.is_file():
return False return False
try: try:
os.remove(target) os.remove(target)
# 移除空父目录 # 移除空父目录
parent = target.parent parent = target.parent
while parent != ad and parent != SKILLS_DIR: while parent != sd and parent != SKILLS_DIR:
try: try:
parent.rmdir() parent.rmdir()
except OSError: 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: def get_attachment_bytes(skill_name: str, filename: str) -> bytes | None:
"""获取附件文件的二进制内容。""" """获取附件文件的二进制内容。"""
ad = _attachments_dir(skill_name) sd = _skill_dir(skill_name)
safe_name = filename.replace("\\", "/").strip("/") safe_name = _safe_relpath(filename)
if ".." in safe_name: if safe_name is None:
return None return None
target = ad / safe_name target = sd / safe_name
if not target.is_file(): if not target.is_file():
return None return None
try: try:
@@ -305,6 +325,197 @@ def get_attachment_bytes(skill_name: str, filename: str) -> bytes | None:
return 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 使用的批量加载 ---------- # ---------- 供 skill_router 使用的批量加载 ----------
def load_all_skill_contents() -> dict[str, str]: 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.json 中 enable 为 false 的 skill 会被跳过。
输出: 输出:
{skill_name: {name, description, version, author, tags, enable}, ...} {skill_name: {name, description, tags, enable}, ...}
""" """
_ensure_skills_dir() _ensure_skills_dir()
result = {} result = {}
@@ -365,10 +576,9 @@ def load_all_skill_metas() -> dict[str, dict]:
# 跳过禁用的 skill # 跳过禁用的 skill
if meta.get("enable") is False: if meta.get("enable") is False:
continue continue
meta = _strip_legacy_meta(meta, json_path)
meta["name"] = entry.name meta["name"] = entry.name
meta.setdefault("description", "") meta.setdefault("description", "")
meta.setdefault("version", "0.0")
meta.setdefault("author", "minor")
meta.setdefault("tags", []) meta.setdefault("tags", [])
meta.setdefault("enable", True) meta.setdefault("enable", True)
result[entry.name] = meta result[entry.name] = meta
@@ -1,8 +1,6 @@
{ {
"name": "netease_mail_read", "name": "netease_mail_read",
"description": "网易邮箱邮件读取操作指南。当用户任务涉及查看邮件、读取邮件、收邮件、查阅163邮箱等操作时匹配此 Skill。", "description": "网易邮箱邮件读取操作指南。当用户任务涉及查看邮件、读取邮件、收邮件、查阅163邮箱等操作时匹配此 Skill。",
"version": "1.0.0",
"author": "minor",
"tags": [ "tags": [
"网易邮箱", "网易邮箱",
"邮件", "邮件",
@@ -1,8 +1,6 @@
{ {
"name": "netease_mail_send", "name": "netease_mail_send",
"description": "网易邮箱邮件发送操作指南。当用户任务涉及发送邮件、写邮件、通过163邮箱发信等操作时匹配此 Skill。", "description": "网易邮箱邮件发送操作指南。当用户任务涉及发送邮件、写邮件、通过163邮箱发信等操作时匹配此 Skill。",
"version": "1.0.0",
"author": "minor",
"tags": [ "tags": [
"网易邮箱", "网易邮箱",
"邮件", "邮件",
+9 -4
View File
@@ -1,8 +1,13 @@
{ {
"name": "ppt_maker", "name": "ppt_maker",
"description": "PPT 制作 Skill v3.0 — HTML-only 路线,生成视觉精美、带动态效果的演示文稿 HTML 页面。内置 15+ 预设风格、完整设计系统和 data-anim 动效引擎。触发词: 做PPT、生成PPT、写幻灯片、演示文稿、presentation。", "description": "PPT 制作 Skill v3.0 — HTML-only 路线,生成视觉精美、带动态效果的演示文稿 HTML 页面。内置 15+ 预设风格、完整设计系统和 data-anim 动效引擎。触发词: 做PPT、生成PPT、写幻灯片、演示文稿、presentation。",
"version": "3.0.0", "tags": [
"author": "minor", "ppt",
"tags": ["ppt", "html", "演示文稿", "幻灯片", "动画", "生产力"], "html",
"演示文稿",
"幻灯片",
"动画",
"生产力"
],
"enable": true "enable": true
} }
-2
View File
@@ -1,8 +1,6 @@
{ {
"name": "taste", "name": "taste",
"description": "设计审美 Skill — 反 AI 模板化设计规范。适用于 PPT 制作中的配色/字体/排版审美决策,以及前端落地页、作品集、品牌站的 UI 设计。触发词: 美观、好看、设计感、审美、不模板化、漂亮、改进样式。详细规范见 skill.md。", "description": "设计审美 Skill — 反 AI 模板化设计规范。适用于 PPT 制作中的配色/字体/排版审美决策,以及前端落地页、作品集、品牌站的 UI 设计。触发词: 美观、好看、设计感、审美、不模板化、漂亮、改进样式。详细规范见 skill.md。",
"version": "1.0.0",
"author": "minor",
"tags": [ "tags": [
"审美", "审美",
"设计", "设计",
@@ -1,8 +1,6 @@
{ {
"name": "wechat_send_message", "name": "wechat_send_message",
"description": "微信消息发送操作指南。当用户任务涉及通过微信发送消息、给某人发微信、微信通知等操作时匹配此 Skill。", "description": "微信消息发送操作指南。当用户任务涉及通过微信发送消息、给某人发微信、微信通知等操作时匹配此 Skill。",
"version": "1.0.0",
"author": "minor",
"tags": [ "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: def _create_plain_text(file_path: str, content: str) -> str:
if os.path.exists(file_path): if os.path.exists(file_path):
return f"[ERROR] 文件已存在: {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: with open(file_path, "w", encoding="utf-8") as f:
f.write(content) f.write(content)
return f"[OK] 已创建文件: {file_path}" 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: def _create_tabular(file_path: str, content: str) -> str:
if os.path.exists(file_path): if os.path.exists(file_path):
return f"[ERROR] 文件已存在: {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() ext = pathlib.Path(file_path).suffix.lower()
if ext == ".csv": if ext == ".csv":
with open(file_path, "w", encoding="utf-8-sig", newline="") as f: 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}" return f"[ERROR] 文件已存在: {file_path}"
from docx import Document from docx import Document
from docx.shared import Inches from docx.shared import Inches
# 自动创建缺失的父目录
parent = os.path.dirname(file_path)
if parent:
os.makedirs(parent, exist_ok=True)
doc = Document() doc = Document()
# 清洗 Markdown 标记后写入文本段落 # 清洗 Markdown 标记后写入文本段落
cleaned = _strip_markdown(content) cleaned = _strip_markdown(content)
+26 -1
View File
@@ -52,7 +52,7 @@ def _refresh_globals():
global WORKING_DIR, WORKSPACE_DIR, USER_PYTHON_PATH global WORKING_DIR, WORKSPACE_DIR, USER_PYTHON_PATH
global LLM_BASE_URL, LLM_API_KEY, LLM_MODEL, LLM_TIMEOUT global LLM_BASE_URL, LLM_API_KEY, LLM_MODEL, LLM_TIMEOUT
global STREAMING_TTS_URL, ASR_BASE_URL, RAG_BASE_URL 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 IMG_SIZE, RAG_CHUNK_SIZE, RAG_CHUNK_OVERLAP, GROUNDING_WIDTH, GROUNDING_HEIGHT
global SEND_FILE_SIZE_LIMIT global SEND_FILE_SIZE_LIMIT
@@ -71,6 +71,9 @@ def _refresh_globals():
RAG_BASE_URL = _get_env("RAG_BASE_URL") RAG_BASE_URL = _get_env("RAG_BASE_URL")
LLM_CONTEXT_WINDOW = int(os.getenv("LLM_CONTEXT_WINDOW", 262144)) LLM_CONTEXT_WINDOW = int(os.getenv("LLM_CONTEXT_WINDOW", 262144))
COMPRESS_RATE = float(os.getenv("COMPRESS_RATE", 0.6)) 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)) IMG_SIZE = int(os.getenv("IMG_SIZE", 768))
RAG_CHUNK_SIZE = int(os.getenv("RAG_CHUNK_SIZE", 500)) RAG_CHUNK_SIZE = int(os.getenv("RAG_CHUNK_SIZE", 500))
RAG_CHUNK_OVERLAP = int(os.getenv("RAG_CHUNK_OVERLAP", 50)) 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 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: def _load_config() -> dict:
"""加载 env_config.json,不存在时回退到 .env 文件。 """加载 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 工具时调用。 由 ``core/runtime`` 和 ``tools/agent_call`` 在需要手动 invoke 工具时调用。
同时是工具执行前的唯一钳点:工作空间访问策略(限制访问 / 无人值守审批
退化)在此拦截越界操作,覆盖图内执行、人工确认恢复循环与审批后重放路径。
""" """
from langchain_core.messages import ToolMessage 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 = { tool_call_input = {
"name": tool_name, "name": tool_name,
"args": tool_args, "args": tool_args,
+112 -11
View File
@@ -13,7 +13,7 @@ import { setRenderAttachmentChips as audioSetRenderChips } from './js/audio.js';
import { import {
renderMessages, renderMessage, renderAttachmentChips, scrollChatToBottom, renderMessages, renderMessage, renderAttachmentChips, scrollChatToBottom,
appendThinkingIndicator, showPendingActionInThinking, streamAssistantMessage, appendThinkingIndicator, showPendingActionInThinking, streamAssistantMessage,
renderAudioBubble, renderAudioBubble, addTextQuote, initChatQuoteMenu,
} from './js/chat-render.js'; } from './js/chat-render.js';
import { setRenderPersistentToolCalls } from './js/chat-render.js'; import { setRenderPersistentToolCalls } from './js/chat-render.js';
@@ -47,7 +47,7 @@ import {
import { setRenderSkills } from './js/settings.js'; import { setRenderSkills } from './js/settings.js';
// ---- Skills ---- // ---- Skills ----
import { renderSkills, bindSkillEditorEvents } from './js/skills.js'; import { renderSkills, bindSkillEditorEvents, bindImportMenu } from './js/skills.js';
// ---- Edit 模式 ---- // ---- Edit 模式 ----
import { initEditMode, sendEditChat, switchToMode, acceptAllModifications, rejectAllModifications, getDocModifications, getCurrentTaskModifications, renderDocTabs, loadDocContent, setCronModeHooks } from './js/edit-mode.js'; 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 { initTheme } from './js/themes.js';
import { initI18n } from './js/i18n.js'; import { initI18n } from './js/i18n.js';
// ---- Token 进度条 & 压缩 ---- // ---- Token 环形指示器(替代原进度条)----
import { initTokenBar, refreshTokens, stopTokenPolling } from './js/token-bar.js'; import { initTokenRing, refreshTokens, stopTokenPolling } from './js/token-ring.js';
// ---- 工作空间 ---- // ---- 工作空间 ----
import { initWorkspace, bindWorkspaceEvents } from './js/workspace.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 注入 // toolcalls 的持久化渲染由 setRenderPersistentToolCalls 注入
import { renderPersistentToolCalls } from './js/toolcalls.js'; import { renderPersistentToolCalls } from './js/toolcalls.js';
@@ -147,7 +153,6 @@ setRollbackDeps({
// ===== Composer 居中/底部动画逻辑 ===== // ===== Composer 居中/底部动画逻辑 =====
export function setComposerCentered(centered) { export function setComposerCentered(centered) {
const composer = document.querySelector(".composer"); const composer = document.querySelector(".composer");
const tokenBar = document.getElementById("tokenBar");
const chatPlaceholder = document.getElementById("chatPlaceholder"); const chatPlaceholder = document.getElementById("chatPlaceholder");
const chatMessages = document.getElementById("chatMessages"); const chatMessages = document.getElementById("chatMessages");
const mainContentWrap = document.getElementById("mainContentWrap"); const mainContentWrap = document.getElementById("mainContentWrap");
@@ -157,7 +162,6 @@ export function setComposerCentered(centered) {
if (centered) { if (centered) {
composer.classList.add("composer--centered"); composer.classList.add("composer--centered");
composer.classList.remove("composer--bottom"); composer.classList.remove("composer--bottom");
if (tokenBar) tokenBar.classList.add("token-bar--hidden");
if (chatPlaceholder) chatPlaceholder.style.display = "none"; if (chatPlaceholder) chatPlaceholder.style.display = "none";
if (mainContentWrap) mainContentWrap.classList.add("main-content-wrap--collapsed"); if (mainContentWrap) mainContentWrap.classList.add("main-content-wrap--collapsed");
if (welcomeView) { if (welcomeView) {
@@ -171,7 +175,6 @@ export function setComposerCentered(centered) {
} else { } else {
composer.classList.remove("composer--centered"); composer.classList.remove("composer--centered");
composer.classList.add("composer--bottom"); composer.classList.add("composer--bottom");
if (tokenBar) tokenBar.classList.remove("token-bar--hidden");
if (chatPlaceholder) chatPlaceholder.style.display = ""; if (chatPlaceholder) chatPlaceholder.style.display = "";
if (mainContentWrap) mainContentWrap.classList.remove("main-content-wrap--collapsed"); if (mainContentWrap) mainContentWrap.classList.remove("main-content-wrap--collapsed");
if (welcomeView) welcomeView.hidden = true; if (welcomeView) welcomeView.hidden = true;
@@ -528,6 +531,15 @@ function _setupComposerDrop() {
// edit 模式内部文件拖拽(application/doc-path)由 edit-mode 处理,不重复添加 // edit 模式内部文件拖拽(application/doc-path)由 edit-mode 处理,不重复添加
if (e.dataTransfer.types.includes("application/doc-path")) return; 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(避免浏览器生成的通用文件名) // 聊天区图片拖拽:从自定义数据创建 File(避免浏览器生成的通用文件名)
if (e.dataTransfer.types.includes("application/x-chat-image")) { if (e.dataTransfer.types.includes("application/x-chat-image")) {
const imgUrl = e.dataTransfer.getData("application/x-chat-image"); const imgUrl = e.dataTransfer.getData("application/x-chat-image");
@@ -728,9 +740,91 @@ initActionBar({
}, },
}); });
// 设置内联下拉框:输出模式、Agent 模式 // 工作空间访问模式(图标触发器,document 委托,含连击保护)
setupInlineDropdown("outputModeTrigger", "outputModePanel"); bindAccessModeEvents();
setupInlineDropdown("agentModeTrigger", "agentModePanel");
// 思考模式档位(图标触发器 + 渐变色滑条,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 中动态构建,只需绑定触发器 // 模型选择在 populateModelSelect 中动态构建,只需绑定触发器
const modelTrigger = $("modelTrigger"); const modelTrigger = $("modelTrigger");
@@ -869,10 +963,14 @@ if (drawerBackdrop) {
// Skills 编辑器事件绑定 // Skills 编辑器事件绑定
bindSkillEditorEvents(); bindSkillEditorEvents();
bindImportMenu();
// Edit 模式初始化 // Edit 模式初始化
initEditMode(); initEditMode();
// 聊天区文字引用(左键选中 + 右键引用 / 拖拽到输入区)
initChatQuoteMenu();
// Cron 模式:注入进入/离开钩子,避免 edit-mode ↔ cron 循环依赖 // Cron 模式:注入进入/离开钩子,避免 edit-mode ↔ cron 循环依赖
setCronModeHooks({ onEnter: enterCronMode, onLeave: leaveCronMode }); setCronModeHooks({ onEnter: enterCronMode, onLeave: leaveCronMode });
// Cron 模式初始化(绑定列表/弹窗事件) // Cron 模式初始化(绑定列表/弹窗事件)
@@ -907,10 +1005,13 @@ initI18n();
// ===== 启动 ===== // ===== 启动 =====
bootstrap().then(() => { bootstrap().then(() => {
initTokenBar(); initTokenRing();
populateModelSelect(); populateModelSelect();
initWorkspace(); initWorkspace();
initAccessMode();
initThinkLevel();
loadNickname(); loadNickname();
initServicesStatus();
}).catch((e) => { }).catch((e) => {
const chatPlaceholder = $("chatPlaceholder"); const chatPlaceholder = $("chatPlaceholder");
if (chatPlaceholder) { if (chatPlaceholder) {
+57 -23
View File
@@ -381,39 +381,63 @@
overflow-x: visible; overflow-x: visible;
} }
/* ===== 回退按钮 ===== */ /* ===== 消息操作按钮行(复制 / 撤回 / 重试,无边框图标按钮) ===== */
.msg.user { .msg.user {
flex-direction: row; flex-direction: column;
align-items: center; 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; flex-shrink: 0;
width: 24px; width: 22px;
height: 24px; height: 22px;
margin-right: 6px; padding: 0;
border: none; border: none;
border-radius: 6px; outline: none;
background: var(--bg-tertiary); background: transparent;
color: var(--muted); border-radius: 5px;
cursor: pointer; cursor: pointer;
font-size: 0.78rem; display: inline-flex;
line-height: 1;
display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
opacity: 0.35; transition: background 0.15s ease, transform 0.12s ease;
transition: opacity 0.18s, background 0.18s, color 0.18s, transform 0.18s, border-color 0.18s;
z-index: 3;
} }
.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; opacity: 1;
color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, transparent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
} }
.chat-rollback-btn:hover { .chat-feedback-btn.is-active img {
background: color-mix(in srgb, var(--accent) 18%, transparent); filter: invert(24%) sepia(70%) saturate(2000%) hue-rotate(160deg) brightness(94%) contrast(92%);
color: var(--accent);
transform: scale(1.1);
} }
/* ===== 欢迎页(空会话居中,在 main-content-wrap 外) ===== */ /* ===== 欢迎页(空会话居中,在 main-content-wrap 外) ===== */
@@ -573,3 +597,13 @@
opacity: 0; opacity: 0;
transform: translateY(-6px); 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; margin: 0 auto;
} }
.composer--centered .token-bar {
display: none !important;
}
/* 过渡到正常状态 */ /* 过渡到正常状态 */
.composer--bottom { .composer--bottom {
margin-top: 0.45rem; margin-top: 0.45rem;
@@ -901,6 +897,15 @@
color: var(--danger); 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 ===== */ /* ===== 引用 bar ===== */
.composer-refs { .composer-refs {
display: flex; display: flex;
@@ -1323,107 +1328,265 @@
to { opacity: 1; } to { opacity: 1; }
} }
/* ===== Token 上下文进度条 ===== */ /* ===== 语音开关按钮(文字/语音)===== */
.token-bar { .voice-toggle-btn {
display: flex; width: 1.7rem;
height: 1.7rem;
padding: 0;
display: inline-flex;
align-items: center; align-items: center;
gap: 8px; justify-content: center;
margin-top: 4px; border-radius: 8px;
padding: 0 4px; border: none;
transition: opacity 0.25s ease; outline: none;
background: transparent;
cursor: pointer;
opacity: 0.4;
transition: opacity 0.15s ease, background 0.15s ease;
} }
.token-bar.token-bar--hidden { .voice-toggle-btn:hover {
display: none !important; 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; position: relative;
height: 5px; height: 5px;
border-radius: 3px; border-radius: 3px;
background: var(--divider); background: var(--divider);
overflow: visible; overflow: visible;
touch-action: none;
} }
.token-ring-rate-fill {
.token-bar-seg {
position: absolute; position: absolute;
top: 0; top: 0;
height: 100%;
left: 0; left: 0;
border-radius: 3px 0 0 3px; height: 100%;
transition: width 0.35s ease; border-radius: 3px;
background: var(--accent);
opacity: 0.55;
pointer-events: none; pointer-events: none;
transition: width 0.5s ease;
} }
.token-bar-seg--green { /* 压缩阈值刻度线(静态展示,阈值在环境变量中修改) */
background: var(--success, #22c55e); .token-ring-threshold {
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 {
position: absolute; position: absolute;
top: 50%; top: -2px;
transform: translate(-50%, -50%); width: 2px;
z-index: 10; height: 9px;
cursor: ew-resize; border-radius: 1px;
user-select: none; background: var(--warning);
transition: transform 0.15s ease; opacity: 0.85;
pointer-events: none;
transform: translateX(-50%);
} }
.token-handle:hover { .token-ring-rate-label {
transform: translate(-50%, -50%) scale(1.3); font-size: 0.68rem;
} color: var(--muted);
.token-handle.dragging { margin: 0.3rem 0 0.4rem;
transform: translate(-50%, -50%) scale(1.45); font-variant-numeric: tabular-nums;
} }
.token-ring-breakdown {
.token-handle-dot { border-top: 1px solid var(--divider);
width: 8px; padding-top: 0.35rem;
height: 14px; }
border-radius: 2px; .token-ring-breakdown-row {
background: var(--accent, #0e7490); display: flex;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent); align-items: baseline;
transition: background 0.2s, box-shadow 0.2s; gap: 0.35rem;
} padding: 0.12rem 0;
.token-handle:hover .token-handle-dot, font-size: 0.75rem;
.token-handle.dragging .token-handle-dot { }
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 18%, transparent); .token-ring-breakdown-dot {
} font-weight: 700;
color: var(--accent);
.token-handle--compress .token-handle-dot { width: 0.6rem;
background: var(--accent, #0e7490); text-align: center;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent); flex: none;
} }
.token-ring-breakdown-key {
.token-handle.overshot .token-handle-dot { flex: 1;
animation: handlePulse 0.6s ease-in-out infinite alternate; color: var(--text);
} }
@keyframes handlePulse { .token-ring-breakdown-val {
from { box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger) 15%, transparent); } color: var(--muted);
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;
font-variant-numeric: tabular-nums; 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 样式 */ /* 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; 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 卡片:参数只读展示 ===== */ /* ===== tool_call 卡片:参数只读展示 ===== */
.pending-overlay-tool-name { .pending-overlay-tool-name {
font-size: 0.8rem; font-size: 0.8rem;
+76
View File
@@ -519,6 +519,7 @@
/* ===== 环境变量配置 ===== */ /* ===== 环境变量配置 ===== */
.env-config-item { .env-config-item {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.6rem; gap: 0.6rem;
@@ -526,6 +527,47 @@
flex-wrap: wrap; 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 { .env-config-item label {
min-width: 150px; min-width: 150px;
font-size: 0.8rem; font-size: 0.8rem;
@@ -947,3 +989,37 @@
.settings-panel .btn-ghost:hover { .settings-panel .btn-ghost:hover {
background: var(--divider); 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" rel="stylesheet"
/> />
<link rel="icon" href="/image/favicon.svg" sizes="any" /> <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> </head>
<body> <body>
<div class="bg-scene" id="bgScene" aria-hidden="true"> <div class="bg-scene" id="bgScene" aria-hidden="true">
@@ -167,18 +167,17 @@
></textarea> ></textarea>
<div class="composer-input-bottom-row"> <div class="composer-input-bottom-row">
<div class="composer-input-bottom-left"> <div class="composer-input-bottom-left">
<div class="inline-drop-wrap" id="outputModeWrap"> <button type="button" class="voice-toggle-btn" id="voiceToggleBtn" title="文字/语音回复开关" aria-label="语音回复开关" aria-pressed="false">
<button type="button" class="inline-drop-trigger" id="outputModeTrigger" aria-label="Agent 输出模式">文字模式</button> <img src="/image/文本语音 .svg" alt="语音" aria-hidden="true" />
<div class="inline-drop-panel" id="outputModePanel" hidden> </button>
<div class="inline-drop-item is-selected" data-value="text">文字模式</div> <div class="inline-drop-wrap" id="accessModeWrap">
<div class="inline-drop-item" data-value="voice">语音模式</div> <button type="button" class="inline-drop-trigger inline-drop-trigger--icon" id="accessModeTrigger" aria-label="工作空间访问模式" title="工作空间访问模式:限制访问 / 权限审查 / 完全访问">
</div> <img src="/image/限制访问.svg" alt="访问模式" id="accessModeIcon" />
</div> </button>
<div class="inline-drop-wrap" id="agentModeWrap"> <div class="inline-drop-panel" id="accessModePanel" hidden>
<button type="button" class="inline-drop-trigger" id="agentModeTrigger" aria-label="Agent 工作模式">agent</button> <div class="inline-drop-item is-selected access-mode-item--restricted" data-value="restricted">限制访问</div>
<div class="inline-drop-panel" id="agentModePanel" hidden> <div class="inline-drop-item access-mode-item--approval" data-value="approval">权限审查</div>
<div class="inline-drop-item is-selected" data-value="agent">agent</div> <div class="inline-drop-item access-mode-item--full" data-value="full">完全访问</div>
<div class="inline-drop-item" data-value="plan">plan</div>
</div> </div>
</div> </div>
<span class="input-actions-sep"></span> <span class="input-actions-sep"></span>
@@ -196,10 +195,39 @@
</button> </button>
</div> </div>
<div class="composer-input-bottom-right"> <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"> <div class="inline-drop-wrap" id="modelSelectWrap">
<button type="button" class="inline-drop-trigger inline-drop-trigger--model" id="modelTrigger" aria-label="模型选择">模型</button> <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 class="inline-drop-panel" id="modelPanel" hidden></div>
</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"> <div class="ws-btn-wrap" id="wsBtnWrap">
<button type="button" class="ws-btn" id="wsBtn" title="选择工作空间" aria-label="选择工作空间"> <button type="button" class="ws-btn" id="wsBtn" title="选择工作空间" aria-label="选择工作空间">
<img src="/image/文件夹.svg" alt="" aria-hidden="true" /> <img src="/image/文件夹.svg" alt="" aria-hidden="true" />
@@ -227,18 +255,6 @@
</div> </div>
</div> </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> </footer>
</main> </main>
</div> </div>
@@ -275,6 +291,7 @@
<label class="cron-field-label" for="cronEditPrompt">任务提示词</label> <label class="cron-field-label" for="cronEditPrompt">任务提示词</label>
<textarea id="cronEditPrompt" placeholder="触发时投递给 Agent 的指令,如:查询今日天气并生成晨报"></textarea> <textarea id="cronEditPrompt" placeholder="触发时投递给 Agent 的指令,如:查询今日天气并生成晨报"></textarea>
<span class="cron-field-hint">该提示词将在每次触发时投递给 Agent 自主执行。</span> <span class="cron-field-hint">该提示词将在每次触发时投递给 Agent 自主执行。</span>
<span class="cron-field-hint cron-field-hint--warn" id="cronAccessModeHint" hidden>⚠️ 定时任务无人值守执行:当前访问模式为「限制访问/权限审查」时,超出工作空间的操作会被拦截或退化拦截,建议先切换为「完全访问」。</span>
</div> </div>
<div class="cron-field"> <div class="cron-field">
<span class="cron-field-label">触发方式</span> <span class="cron-field-label">触发方式</span>
@@ -373,8 +390,10 @@
<div class="settings-panel" data-panel="skills" id="skillsPanel" hidden> <div class="settings-panel" data-panel="skills" id="skillsPanel" hidden>
<div class="settings-toolbar"> <div class="settings-toolbar">
<button type="button" class="btn btn-primary" id="addSkillBtn">+ 创建 Skill</button> <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> </div>
<input type="file" id="importSkillZipInput" accept=".zip" hidden />
<input type="file" id="importSkillFolderInput" webkitdirectory multiple hidden />
<div class="skills-list" id="skillsList"></div> <div class="skills-list" id="skillsList"></div>
<!-- Skill 编辑/创建弹窗 --> <!-- Skill 编辑/创建弹窗 -->
<div class="skill-editor-overlay" id="skillEditorOverlay" hidden> <div class="skill-editor-overlay" id="skillEditorOverlay" hidden>
@@ -393,15 +412,7 @@
<input type="text" id="skillDescInput" placeholder="简要描述此 skill 的用途" /> <input type="text" id="skillDescInput" placeholder="简要描述此 skill 的用途" />
</div> </div>
<div class="setting-field"> <div class="setting-field">
<label>版本 (version)</label> <label>标签 (tags,逗号分隔,可选)</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>
<input type="text" id="skillTagsInput" placeholder="e.g. python, 自动化, GUI" /> <input type="text" id="skillTagsInput" placeholder="e.g. python, 自动化, GUI" />
</div> </div>
<div class="setting-field"> <div class="setting-field">
@@ -574,6 +585,6 @@
_showJsError('Promise: ' + (e.reason?.message || String(e.reason))); _showJsError('Promise: ' + (e.reason?.message || String(e.reason)));
}); });
</script> </script>
<script type="module" src="/app.js?v=2"></script> <script type="module" src="/app.js?v=6"></script>
</body> </body>
</html> </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.title = "停止录音";
micBtn.setAttribute("aria-label", "停止录音"); micBtn.setAttribute("aria-label", "停止录音");
} else { } else {
micBtn.title = "使用麦克风录音"; micBtn.title = micBtn.disabled ? "语音转文字(ASR)服务不可用" : "使用麦克风录音";
micBtn.setAttribute("aria-label", "使用麦克风录音"); micBtn.setAttribute("aria-label", "使用麦克风录音");
} }
} }
@@ -88,6 +88,19 @@ async function startMicRecording() {
} }
if (state.sending) return; 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 = [ const preferredTypes = [
"audio/wav", "audio/wav",
"audio/mp4", "audio/mp4",
@@ -134,7 +147,7 @@ async function toggleMicRecording() {
} else { } else {
const { sendChat } = await import('./send.js'); const { sendChat } = await import('./send.js');
await sendChat(); await sendChat();
const { refreshTokens } = await import('./token-bar.js'); const { refreshTokens } = await import('./token-ring.js');
refreshTokens(); refreshTokens();
} }
} }
+384 -19
View File
@@ -1,6 +1,6 @@
// chat-render.js -- 消息渲染 / 流式输出 / 音频气泡(新版:无气泡 + 媒体行 + agent-run-block) // 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 { state } from './state.js';
import { import {
showImagePreview, showImagePreview,
@@ -50,11 +50,13 @@ function renderAttachmentChips() {
const refImages = []; const refImages = [];
const refFiles = []; const refFiles = [];
const refFolderItems = []; const refFolderItems = [];
const refQuotes = [];
state.pendingFiles.forEach((f, i) => { state.pendingFiles.forEach((f, i) => {
if (f.__isRef) { if (f.__isRef) {
if (f.type === 'ref/image') refImages.push({ file: f, idx: i }); 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/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 refFiles.push({ file: f, idx: i });
} else { } else {
const kind = inferUploadKind(f); const kind = inferUploadKind(f);
@@ -189,6 +191,23 @@ function renderAttachmentChips() {
hasContent = true; 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) { if (hasContent || imagesArea.children.length > 0 || chipsArea.children.length > 0) {
wrap.classList.add("has-content"); wrap.classList.add("has-content");
} else { } else {
@@ -955,29 +974,15 @@ function renderMessage(msg, allowAutoplay = false) {
}; };
if (msg.role === "user") { 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); const contentEl = renderUserContent(msg.content, baseOptions);
wrap.appendChild(contentEl); wrap.appendChild(contentEl);
wrap.appendChild(renderMessageActions("user", msg, wrap));
} else { } else {
// 助手消息 // 助手消息:内容 + 下方操作按钮(复制 / 重试)
const contentEl = renderAssistantContent(msg, allowAutoplay); const contentEl = renderAssistantContent(msg, allowAutoplay);
wrap.appendChild(contentEl); wrap.appendChild(contentEl);
wrap.appendChild(renderMessageActions("assistant", msg, wrap));
if (msg.meta && msg.meta.pending_action) { if (msg.meta && msg.meta.pending_action) {
// pending_action 不再内嵌在气泡中,由 pending-overlay 浮窗展示 // pending_action 不再内嵌在气泡中,由 pending-overlay 浮窗展示
} }
@@ -986,6 +991,362 @@ function renderMessage(msg, allowAutoplay = false) {
return wrap; 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) { function renderMessages(messages, allowAutoplay = false) {
if (!chatMessages || !chatPlaceholder) return; if (!chatMessages || !chatPlaceholder) return;
const list = messages || []; const list = messages || [];
@@ -1110,6 +1471,9 @@ async function streamAssistantMessage(msg) {
// pending_action 不再内嵌在气泡中,由 pending-overlay 浮窗展示 // pending_action 不再内嵌在气泡中,由 pending-overlay 浮窗展示
} }
// 操作按钮行(复制 / 重试)
wrap.appendChild(renderMessageActions("assistant", msg, wrap));
scrollChatToBottom(); scrollChatToBottom();
} }
@@ -1149,4 +1513,5 @@ export {
appendThinkingIndicator, showPendingActionInThinking, appendThinkingIndicator, showPendingActionInThinking,
streamAssistantMessage, streamAssistantMessage,
renderVoiceTranscribingPlaceholder, renderVoiceTranscribingPlaceholder,
addTextQuote, initChatQuoteMenu,
}; };
+12
View File
@@ -1170,6 +1170,18 @@ export function openCronEditor(taskId) {
state._cronEditingTaskId = taskId || ""; state._cronEditingTaskId = taskId || "";
$("cronEditTitle").textContent = 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 ?? ""; }; 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 类型:展示参数(只读)+ 决策按钮 ===== // ===== tool_call 类型:展示参数(只读)+ 决策按钮 =====
if (pendingType === "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(只读) // 参数 JSON(只读)
if (pending.args !== undefined && pending.args !== null) { if (pending.args !== undefined && pending.args !== null) {
let argsStr; 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); card.appendChild(cardBody);
return card; return card;
} }
+1 -2
View File
@@ -28,7 +28,6 @@ async function handleHumanAction(approvalId, decision, instruction, card) {
const sendBtn = $("sendBtn"); const sendBtn = $("sendBtn");
if (sendBtn) sendBtn.disabled = true; if (sendBtn) sendBtn.disabled = true;
if (card) card.classList.add("pending-action-card--resolved"); if (card) card.classList.add("pending-action-card--resolved");
const outputModeTrigger = $("outputModeTrigger");
// 复用 sendChat 留下的现有 thinking 气泡(不删除不重建) // 复用 sendChat 留下的现有 thinking 气泡(不删除不重建)
const chatMessages = $("chatMessages"); const chatMessages = $("chatMessages");
@@ -54,7 +53,7 @@ async function handleHumanAction(approvalId, decision, instruction, card) {
fd.append("approval_id", approvalId); fd.append("approval_id", approvalId);
fd.append("decision", decision); fd.append("decision", decision);
fd.append("instruction", instruction || ""); 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); const stopToolPoll = startPollLiveToolCalls(state.sessionId, typingEl, injectLiveToolCallsFn);
+12 -7
View File
@@ -45,22 +45,25 @@ export function setRollbackDeps(deps) {
/** /**
* 将聊天历史回撤到指定消息处 * 将聊天历史回撤到指定消息处
* @param {number} rollbackIndex - 要回退到的消息在渲染列表中的索引(包含该消息) * @param {number} rollbackIndex - 要回退到的消息在渲染列表中的索引(包含该消息)
* @param {object} [options] - { skipConfirm: boolean } 跳过确认弹窗(重试等场景)
*/ */
export async function rollbackChat(rollbackIndex) { export async function rollbackChat(rollbackIndex, options = {}) {
// 保护:Agent 运行中不允许回撤 // 保护:Agent 运行中不允许回撤
if (state.sending) { if (state.sending) {
showToast("Agent 运行中,无法回撤。请先暂停任务。"); showToast("Agent 运行中,无法回撤。请先暂停任务。");
return; return false;
} }
if (!state.sessionId) { if (!state.sessionId) {
showToast("无当前会话"); showToast("无当前会话");
return; return false;
} }
const confirmed = await showConfirm( if (!options.skipConfirm) {
"确认回退到此消息?\n\n将清除此消息之后的所有对话历史、TodoList、人机交互内容、工具调用记录。" const confirmed = await showConfirm(
); "确认回退到此消息?\n\n将清除此消息之后的所有对话历史、TodoList、人机交互内容、工具调用记录。"
if (!confirmed) return; );
if (!confirmed) return false;
}
try { try {
// 1. 调用后端回撤 API // 1. 调用后端回撤 API
@@ -92,8 +95,10 @@ export async function rollbackChat(rollbackIndex) {
} }
showToast("已回退到该消息"); showToast("已回退到该消息");
return true;
} catch (e) { } catch (e) {
showToast("回退失败: " + (e.message || "未知错误")); showToast("回退失败: " + (e.message || "未知错误"));
return false;
} }
} }
+45 -12
View File
@@ -93,8 +93,6 @@ export function setSendDeps(deps) {
const textInput = $("textInput"); const textInput = $("textInput");
const fileInput = $("fileInput"); const fileInput = $("fileInput");
const sendBtn = $("sendBtn"); const sendBtn = $("sendBtn");
const outputModeTrigger = $("outputModeTrigger");
const agentModeTrigger = $("agentModeTrigger");
function setSendBtnPauseMode(pauseMode) { function setSendBtnPauseMode(pauseMode) {
const btn = sendBtn || $("sendBtn"); const btn = sendBtn || $("sendBtn");
@@ -150,11 +148,28 @@ async function handlePauseClick() {
_pausing = false; _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() { async function sendChat() {
if (!state.sessionId || state.sending) return; if (!state.sessionId || state.sending) return;
const myGen = ++_sendGeneration; // 标记本次 sendChat,防止旧实例的 finally/catch 污染状态 const myGen = ++_sendGeneration; // 标记本次 sendChat,防止旧实例的 finally/catch 污染状态
const trimmed = (textInput || {}).value ? textInput.value.trim() : ""; 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(); if (onSendStartFn) onSendStartFn();
_lastSendTime = Date.now(); _lastSendTime = Date.now();
@@ -208,7 +223,7 @@ async function sendChat() {
if (hasVoiceInput) { if (hasVoiceInput) {
// 语音输入:先渲染已有消息(含普通文件 + 用户输入的文本),再追加"语音转文字中"占位 // 语音输入:先渲染已有消息(含普通文件 + 用户输入的文本),再追加"语音转文字中"占位
const optimisticContent = buildOptimisticUserContent(trimmed, fileParts); const optimisticContent = buildOptimisticUserContent(effectiveText, fileParts);
if (renderMessagesFn) { if (renderMessagesFn) {
if (optimisticContent != null) { if (optimisticContent != null) {
renderMessagesFn([...priorMessages, { role: "user", content: optimisticContent }]); renderMessagesFn([...priorMessages, { role: "user", content: optimisticContent }]);
@@ -219,27 +234,29 @@ async function sendChat() {
renderVoiceTranscribingPlaceholder(); renderVoiceTranscribingPlaceholder();
} else { } else {
// 仅普通文件:乐观渲染图片/文件 // 仅普通文件:乐观渲染图片/文件
const optimisticContent = buildOptimisticUserContent(trimmed, fileParts); const optimisticContent = buildOptimisticUserContent(effectiveText, fileParts);
if (optimisticContent != null && renderMessagesFn) { if (optimisticContent != null && renderMessagesFn) {
renderMessagesFn([...priorMessages, { role: "user", content: optimisticContent }]); renderMessagesFn([...priorMessages, { role: "user", content: optimisticContent }]);
} }
} }
if (textInput) textInput.value = ""; if (textInput) textInput.value = "";
state.pendingFiles.length = 0; state.pendingFiles.length = 0;
clearQuoteRefs();
if (fileInput) fileInput.value = ""; if (fileInput) fileInput.value = "";
if (renderAttachmentChipsFn) renderAttachmentChipsFn(); if (renderAttachmentChipsFn) renderAttachmentChipsFn();
} else if (trimmed) { } else if (effectiveText) {
// 纯文本消息:立即乐观渲染用户消息,避免等待 API /start 响应 // 纯文本/引用消息:立即乐观渲染用户消息,避免等待 API /start 响应
if (renderMessagesFn) { if (renderMessagesFn) {
renderMessagesFn([...priorMessages, { role: "user", content: trimmed }]); renderMessagesFn([...priorMessages, { role: "user", content: effectiveText }]);
} }
if (textInput) textInput.value = ""; if (textInput) textInput.value = "";
clearQuoteRefs();
} }
state.abortController = new AbortController(); state.abortController = new AbortController();
const fd = new FormData(); const fd = new FormData();
fd.append("session_id", state.sessionId); fd.append("session_id", state.sessionId);
fd.append("text", trimmed); fd.append("text", effectiveText);
// 普通文件追加到 files 字段;语音输入单独走 voice_input 字段(后端阻塞 ASR) // 普通文件追加到 files 字段;语音输入单独走 voice_input 字段(后端阻塞 ASR)
regularFiles.forEach((f) => fd.append("files", f, f.name)); regularFiles.forEach((f) => fd.append("files", f, f.name));
if (voiceInputFile) { if (voiceInputFile) {
@@ -255,11 +272,12 @@ async function sendChat() {
if (!hadOptimisticMedia) { if (!hadOptimisticMedia) {
if (textInput) textInput.value = ""; if (textInput) textInput.value = "";
state.pendingFiles.length = 0; state.pendingFiles.length = 0;
clearQuoteRefs();
if (fileInput) fileInput.value = ""; if (fileInput) fileInput.value = "";
if (renderAttachmentChipsFn) renderAttachmentChipsFn(); if (renderAttachmentChipsFn) renderAttachmentChipsFn();
} }
if (renderSessionsFn) renderSessionsFn(); if (renderSessionsFn) renderSessionsFn();
const _isVoice = (outputModeTrigger && outputModeTrigger.dataset.value === "voice"); const _isVoice = state.outputMode === "voice";
// 语音模式下只追加用户消息气泡(保留旧消息),完整列表等流式 text 事件再刷新 // 语音模式下只追加用户消息气泡(保留旧消息),完整列表等流式 text 事件再刷新
// 但若已通过乐观渲染显示了用户消息(含语音输入占位),则重新渲染全部消息以替换占位 // 但若已通过乐观渲染显示了用户消息(含语音输入占位),则重新渲染全部消息以替换占位
if (_isVoice && renderMessagesFn && !hadOptimisticMedia) { if (_isVoice && renderMessagesFn && !hadOptimisticMedia) {
@@ -306,8 +324,10 @@ async function sendChat() {
const fd2 = new FormData(); const fd2 = new FormData();
fd2.append("session_id", state.sessionId); fd2.append("session_id", state.sessionId);
fd2.append("output_type", outputModeTrigger ? (outputModeTrigger.dataset.value || "text") : "text"); fd2.append("output_type", state.outputMode || "text");
fd2.append("agent_mode", agentModeTrigger ? (agentModeTrigger.dataset.value || "agent") : "agent"); // 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) { if (state.selectedModelId) {
fd2.append("model_id", state.selectedModelId); fd2.append("model_id", state.selectedModelId);
} }
@@ -485,6 +505,19 @@ async function sendChat() {
if (renderAttachmentChipsFn) renderAttachmentChipsFn(); if (renderAttachmentChipsFn) renderAttachmentChipsFn();
if (renderMessagesFn) renderMessagesFn(priorMessages); 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 (typingEl) _cleanupTypingEl(typingEl);
if (!(e && e.name === "AbortError")) { if (!(e && e.name === "AbortError")) {
showToast(e.message || String(e)); 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 { api } from './api.js';
import { state } from './state.js'; import { state } from './state.js';
import { updatePendingOverlay, clearPendingOverlay } from './pending-overlay.js'; import { updatePendingOverlay, clearPendingOverlay } from './pending-overlay.js';
import { refreshTokens } from './token-bar.js'; import { refreshTokens } from './token-ring.js';
let renderSessionsFnRef = null; let renderSessionsFnRef = null;
let renderMessagesFn = null; let renderMessagesFn = null;
@@ -175,6 +175,15 @@ async function selectSession(id) {
if (!id || id === state.sessionId) return; if (!id || id === state.sessionId) return;
if (state.sending) return; // Agent 运行中禁止切换会话 if (state.sending) return; // Agent 运行中禁止切换会话
const data = await api(`/api/sessions/${encodeURIComponent(id)}/messages`); 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.sessionId = data.sessionId;
state.sessions.length = 0; state.sessions.length = 0;
state.sessions.push(...(data.sessions || [])); state.sessions.push(...(data.sessions || []));
@@ -182,7 +191,7 @@ async function selectSession(id) {
if (clearTodoOverlayFn) clearTodoOverlayFn(); if (clearTodoOverlayFn) clearTodoOverlayFn();
clearPendingOverlay(); clearPendingOverlay();
if (renderSessionsFnRef) renderSessionsFnRef(); if (renderSessionsFnRef) renderSessionsFnRef();
if (renderMessagesFn) renderMessagesFn(data.messages); if (renderMessagesFn) renderMessagesFn(data.messages || []);
updatePendingOverlay(data.pending_actions || null); updatePendingOverlay(data.pending_actions || null);
refreshTokens(); refreshTokens();
// 设置居中状态 // 设置居中状态
+44 -27
View File
@@ -1,6 +1,6 @@
// settings.js -- 设置面板:Agent / Tool / Env / GUI / Models / Theme // 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 { showConfirm } from './dialog.js';
import { api } from './api.js'; import { api } from './api.js';
import { state } from './state.js'; import { state } from './state.js';
@@ -694,6 +694,41 @@ function buildMysqlConfigSection() {
return section; 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() { function renderEnvConfig() {
if (!envListEl) return; if (!envListEl) return;
envListEl.innerHTML = ""; envListEl.innerHTML = "";
@@ -725,15 +760,14 @@ function renderEnvConfig() {
table.className = "model-table"; table.className = "model-table";
const thead = document.createElement("div"); const thead = document.createElement("div");
thead.className = "model-table-head"; 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); table.appendChild(thead);
state.cachedModels.forEach((m, mi) => { state.cachedModels.forEach((m, mi) => {
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "model-table-row"; row.className = "model-table-row";
row.innerHTML = `<span class="col-name" title="${escapeHtml(m.name)}">${escapeHtml(m.name)}</span> 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-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-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>`;
const actions = document.createElement("span"); const actions = document.createElement("span");
actions.className = "col-actions"; actions.className = "col-actions";
const editBtn = document.createElement("button"); const editBtn = document.createElement("button");
@@ -785,26 +819,8 @@ function renderEnvConfig() {
modalFields[key] = inp; modalFields[key] = inp;
modalBody.appendChild(div); modalBody.appendChild(div);
}); });
const thinkingDiv = document.createElement("div"); // 深度思考改由 composer 的思考档位(low/high/xhigh/max/ultra)全局控制,此处不再提供开关
thinkingDiv.className = "modal-field"; modalBody.appendChild(document.createComment("thinking-toggle-removed"));
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);
modalBox.appendChild(modalBody); modalBox.appendChild(modalBody);
const modalActions = document.createElement("div"); const modalActions = document.createElement("div");
modalActions.className = "model-modal-actions"; modalActions.className = "model-modal-actions";
@@ -826,7 +842,6 @@ function renderEnvConfig() {
base_url: modalFields.base_url.value.trim(), base_url: modalFields.base_url.value.trim(),
timeout: parseFloat(modalFields.timeout.value) || 60, timeout: parseFloat(modalFields.timeout.value) || 60,
max_retries: 0, 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 (!data.name || !data.model) { showToast("\u540D\u79F0\u548C\u6A21\u578B\u540D\u4E0D\u80FD\u4E3A\u7A7A"); return; }
if (state.editingModelIdx >= 0) { if (state.editingModelIdx >= 0) {
@@ -893,6 +908,7 @@ function renderEnvConfig() {
}); });
row.appendChild(lbl); row.appendChild(lbl);
row.appendChild(sel); row.appendChild(sel);
attachEnvHelp(row, key);
// 选 mysql 时紧随其后渲染连接配置区块 // 选 mysql 时紧随其后渲染连接配置区块
if (sel.value === "mysql") { if (sel.value === "mysql") {
const mysqlSection = buildMysqlConfigSection(); const mysqlSection = buildMysqlConfigSection();
@@ -936,6 +952,7 @@ function renderEnvConfig() {
wrap.appendChild(applyBtn); wrap.appendChild(applyBtn);
row.appendChild(lbl); row.appendChild(lbl);
row.appendChild(wrap); row.appendChild(wrap);
attachEnvHelp(row, key);
envListEl.appendChild(row); envListEl.appendChild(row);
return; return;
} }
@@ -986,6 +1003,8 @@ function renderEnvConfig() {
row.appendChild(fileBtn); row.appendChild(fileBtn);
} }
attachEnvHelp(row, key);
envListEl.appendChild(row); envListEl.appendChild(row);
}); });
} }
@@ -1002,14 +1021,12 @@ function openModelModal(idx) {
fields.api_key.value = m.api_key || ""; fields.api_key.value = m.api_key || "";
fields.base_url.value = m.base_url || ""; fields.base_url.value = m.base_url || "";
fields.timeout.value = m.timeout || 60; fields.timeout.value = m.timeout || 60;
fields.enable_thinking.checked = !!m.enable_thinking;
} else { } else {
fields.name.value = ""; fields.name.value = "";
fields.model.value = ""; fields.model.value = "";
fields.api_key.value = ""; fields.api_key.value = "";
fields.base_url.value = ""; fields.base_url.value = "";
fields.timeout.value = 60; fields.timeout.value = 60;
fields.enable_thinking.checked = false;
} }
overlay.classList.add("show"); 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 { showConfirm } from './dialog.js';
import { api } from './api.js'; import { api } from './api.js';
import { state } from './state.js'; import { state } from './state.js';
@@ -10,8 +12,6 @@ const skillEditorOverlay = $("skillEditorOverlay");
const skillEditorTitle = $("skillEditorTitle"); const skillEditorTitle = $("skillEditorTitle");
const skillNameInput = $("skillNameInput"); const skillNameInput = $("skillNameInput");
const skillDescInput = $("skillDescInput"); const skillDescInput = $("skillDescInput");
const skillVersionInput = $("skillVersionInput");
const skillAuthorInput = $("skillAuthorInput");
const skillTagsInput = $("skillTagsInput"); const skillTagsInput = $("skillTagsInput");
const skillContentInput = $("skillContentInput"); const skillContentInput = $("skillContentInput");
const skillAttachmentsList = $("skillAttachmentsList"); const skillAttachmentsList = $("skillAttachmentsList");
@@ -22,6 +22,9 @@ const skillEditorCancel = $("skillEditorCancel");
const skillEditorClose = $("skillEditorClose"); const skillEditorClose = $("skillEditorClose");
const skillEnableCheck = $("skillEnableCheck"); const skillEnableCheck = $("skillEnableCheck");
// 导入临时状态:kind = "zip" | "folder";folder 时 files 为 {相对路径: File}
let _pendingImport = null;
function setSkillEditorStatus(msg, type) { function setSkillEditorStatus(msg, type) {
if (!skillEditorStatus) return; if (!skillEditorStatus) return;
skillEditorStatus.textContent = msg; skillEditorStatus.textContent = msg;
@@ -31,35 +34,31 @@ function setSkillEditorStatus(msg, type) {
function openSkillEditor(skillNameToEdit) { function openSkillEditor(skillNameToEdit) {
state.editingSkillName = skillNameToEdit || ""; state.editingSkillName = skillNameToEdit || "";
state.editingSkillAttachments.length = 0; state.editingSkillAttachments.length = 0;
_pendingImport = null;
if (state.editingSkillName) { if (state.editingSkillName) {
skillEditorTitle.textContent = "\u7F16\u8F91 Skill: " + state.editingSkillName; skillEditorTitle.textContent = "编辑 Skill: " + state.editingSkillName;
skillNameInput.value = state.editingSkillName; skillNameInput.value = state.editingSkillName;
skillNameInput.disabled = true; skillNameInput.disabled = true;
api("/api/skills/" + encodeURIComponent(state.editingSkillName)).then((res) => { api("/api/skills/" + encodeURIComponent(state.editingSkillName)).then((res) => {
const s = res.skill; const s = res.skill;
skillDescInput.value = s.description || ""; skillDescInput.value = s.description || "";
skillVersionInput.value = s.version || "1.0.0";
skillAuthorInput.value = s.author || "";
skillTagsInput.value = (s.tags || []).join(", "); skillTagsInput.value = (s.tags || []).join(", ");
skillContentInput.value = s.content || ""; skillContentInput.value = s.content || "";
skillEnableCheck.checked = s.enable !== false; skillEnableCheck.checked = s.enable !== false;
state.editingSkillAttachments.push(...(s.attachment_files || [])); state.editingSkillAttachments.push(...(s.attachment_files || []));
renderSkillAttachments(); renderSkillAttachments();
}).catch((e) => { }).catch((e) => {
setSkillEditorStatus("\u52A0\u8F7D\u5931\u8D25: " + e.message, "error"); setSkillEditorStatus("加载失败: " + e.message, "error");
}); });
} else { } else {
skillEditorTitle.textContent = "\u521B\u5EFA Skill"; skillEditorTitle.textContent = "创建 Skill";
skillNameInput.value = ""; skillNameInput.value = "";
skillNameInput.disabled = false; skillNameInput.disabled = false;
skillDescInput.value = ""; skillDescInput.value = "";
skillVersionInput.value = "1.0.0";
skillAuthorInput.value = "";
skillTagsInput.value = ""; skillTagsInput.value = "";
skillContentInput.value = ""; skillContentInput.value = "";
skillEnableCheck.checked = true; skillEnableCheck.checked = true;
state.editingSkillAttachments.length = 0;
renderSkillAttachments(); renderSkillAttachments();
} }
setSkillEditorStatus(""); setSkillEditorStatus("");
@@ -70,8 +69,10 @@ function closeSkillEditor() {
skillEditorOverlay.hidden = true; skillEditorOverlay.hidden = true;
state.editingSkillName = ""; state.editingSkillName = "";
state.editingSkillAttachments.length = 0; state.editingSkillAttachments.length = 0;
_pendingImport = null;
} }
/** 渲染附件 chips;导入预览模式下:zip 只读展示,folder 可移除 */
function renderSkillAttachments() { function renderSkillAttachments() {
if (!skillAttachmentsList) return; if (!skillAttachmentsList) return;
skillAttachmentsList.innerHTML = ""; skillAttachmentsList.innerHTML = "";
@@ -80,22 +81,38 @@ function renderSkillAttachments() {
chip.className = "skill-attachment-item"; chip.className = "skill-attachment-item";
const nameSpan = document.createElement("span"); const nameSpan = document.createElement("span");
nameSpan.textContent = fname; 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"); const delBtn = document.createElement("button");
delBtn.textContent = "\u00D7"; delBtn.textContent = "\u00D7";
delBtn.title = "\u5220\u9664\u9644\u4EF6"; delBtn.title = "删除附件";
delBtn.addEventListener("click", async () => { 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 { try {
await api("/api/skills/" + encodeURIComponent(state.editingSkillName) + "/attachments/" + encodeURIComponent(fname), { method: "DELETE" }); await api("/api/skills/" + encodeURIComponent(state.editingSkillName) + "/attachments/" + encodeURIComponent(fname), { method: "DELETE" });
const idx = state.editingSkillAttachments.indexOf(fname); const idx = state.editingSkillAttachments.indexOf(fname);
if (idx >= 0) state.editingSkillAttachments.splice(idx, 1); if (idx >= 0) state.editingSkillAttachments.splice(idx, 1);
renderSkillAttachments(); renderSkillAttachments();
setSkillEditorStatus("\u9644\u4EF6\u5DF2\u5220\u9664", "success"); setSkillEditorStatus("附件已删除", "success");
} catch (e) { } catch (e) {
setSkillEditorStatus("\u5220\u9664\u5931\u8D25: " + e.message, "error"); setSkillEditorStatus("删除失败: " + e.message, "error");
} }
}); });
chip.appendChild(nameSpan);
chip.appendChild(delBtn); chip.appendChild(delBtn);
skillAttachmentsList.appendChild(chip); skillAttachmentsList.appendChild(chip);
}); });
@@ -104,42 +121,75 @@ function renderSkillAttachments() {
async function saveSkill() { async function saveSkill() {
const name = (skillNameInput.value || "").trim(); const name = (skillNameInput.value || "").trim();
if (!name) { if (!name) {
setSkillEditorStatus("\u540D\u79F0\u4E0D\u80FD\u4E3A\u7A7A", "error"); setSkillEditorStatus("名称不能为空", "error");
return; 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(); const formData = new FormData();
formData.append("name", name); formData.append("name", name);
formData.append("description", skillDescInput.value); formData.append("description", skillDescInput.value);
formData.append("content", skillContentInput.value); formData.append("content", skillContentInput.value);
formData.append("version", skillVersionInput.value); formData.append("tags", tags);
formData.append("author", skillAuthorInput.value); formData.append("enable", enable);
formData.append("tags", skillTagsInput.value);
formData.append("enable", skillEnableCheck.checked ? "true" : "false");
try { try {
await api("/api/skills", { method: "POST", body: formData }); await api("/api/skills", { method: "POST", body: formData });
if (state.editingSkillName && state.editingSkillName !== name) { if (state.editingSkillName && state.editingSkillName !== name) {
try { await api("/api/skills/" + encodeURIComponent(state.editingSkillName), { method: "DELETE" }); } catch {} 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; state.editingSkillName = name;
skillNameInput.disabled = true; skillNameInput.disabled = true;
_pendingImport = null;
await renderSkills(); await renderSkills();
} catch (e) { } catch (e) {
setSkillEditorStatus("\u4FDD\u5B58\u5931\u8D25: " + e.message, "error"); setSkillEditorStatus("保存失败: " + e.message, "error");
} }
} }
async function deleteSkill(name) { 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 { try {
await api("/api/skills/" + encodeURIComponent(name), { method: "DELETE" }); await api("/api/skills/" + encodeURIComponent(name), { method: "DELETE" });
await renderSkills(); await renderSkills();
const setStatus = (await import('./settings.js')).setStatus; const setStatus = (await import('./settings.js')).setStatus;
if (setStatus) setStatus("Skill \u5DF2\u5220\u9664", "success"); if (setStatus) setStatus("Skill 已删除", "success");
} catch (e) { } 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.length = 0;
state.cachedSkills.push(...(res.skills || [])); state.cachedSkills.push(...(res.skills || []));
} catch (e) { } 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; return;
} }
skillsListEl.innerHTML = ""; skillsListEl.innerHTML = "";
if (state.cachedSkills.length === 0) { 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; return;
} }
@@ -171,7 +221,7 @@ async function renderSkills() {
if (skill.enable === false) { if (skill.enable === false) {
const badge = document.createElement("span"); 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.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); h4.appendChild(badge);
} }
const actions = document.createElement("div"); const actions = document.createElement("div");
@@ -179,12 +229,12 @@ async function renderSkills() {
const editBtn = document.createElement("button"); const editBtn = document.createElement("button");
editBtn.className = "btn btn-secondary"; editBtn.className = "btn btn-secondary";
editBtn.style.cssText = "padding:0.2rem 0.5rem;font-size:0.75rem;"; editBtn.style.cssText = "padding:0.2rem 0.5rem;font-size:0.75rem;";
editBtn.textContent = "\u7F16\u8F91"; editBtn.textContent = "编辑";
editBtn.addEventListener("click", () => openSkillEditor(skill.name)); editBtn.addEventListener("click", () => openSkillEditor(skill.name));
const delBtn = document.createElement("button"); const delBtn = document.createElement("button");
delBtn.className = "btn btn-danger"; delBtn.className = "btn btn-danger";
delBtn.style.cssText = "padding:0.2rem 0.5rem;font-size:0.75rem;"; delBtn.style.cssText = "padding:0.2rem 0.5rem;font-size:0.75rem;";
delBtn.textContent = "\u5220\u9664"; delBtn.textContent = "删除";
delBtn.addEventListener("click", () => deleteSkill(skill.name)); delBtn.addEventListener("click", () => deleteSkill(skill.name));
actions.appendChild(editBtn); actions.appendChild(editBtn);
actions.appendChild(delBtn); actions.appendChild(delBtn);
@@ -193,12 +243,7 @@ async function renderSkills() {
const desc = document.createElement("div"); const desc = document.createElement("div");
desc.className = "skill-card-desc"; desc.className = "skill-card-desc";
desc.textContent = skill.description || "(\u65E0\u63CF\u8FF0)"; desc.textContent = skill.description || "(无描述)";
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>' : '');
const tagsWrap = document.createElement("div"); const tagsWrap = document.createElement("div");
tagsWrap.className = "skill-card-tags"; tagsWrap.className = "skill-card-tags";
@@ -223,13 +268,165 @@ async function renderSkills() {
card.appendChild(header); card.appendChild(header);
card.appendChild(desc); card.appendChild(desc);
card.appendChild(meta);
card.appendChild(tagsWrap); card.appendChild(tagsWrap);
if (attachWrap) card.appendChild(attachWrap); if (attachWrap) card.appendChild(attachWrap);
skillsListEl.appendChild(card); 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() { function bindSkillEditorEvents() {
const addSkillBtn = $("addSkillBtn"); const addSkillBtn = $("addSkillBtn");
if (addSkillBtn) addSkillBtn.addEventListener("click", () => openSkillEditor("")); if (addSkillBtn) addSkillBtn.addEventListener("click", () => openSkillEditor(""));
@@ -240,7 +437,7 @@ function bindSkillEditorEvents() {
if (e.target === skillEditorOverlay) closeSkillEditor(); if (e.target === skillEditorOverlay) closeSkillEditor();
}); });
} }
if (skillEditorSave) skillEditorSave.addEventListener("click", saveSkill); if (skillEditorSave) skillEditorSave.addEventListener("click", withClickGuard(saveSkill));
const skillEnableLabel = $("skillEnableLabel"); const skillEnableLabel = $("skillEnableLabel");
if (skillEnableLabel && skillEnableCheck) { if (skillEnableLabel && skillEnableCheck) {
@@ -250,13 +447,13 @@ function bindSkillEditorEvents() {
if (skillFileInput) { if (skillFileInput) {
skillFileInput.addEventListener("change", async () => { skillFileInput.addEventListener("change", async () => {
if (!state.editingSkillName) { if (!state.editingSkillName) {
setSkillEditorStatus("\u8BF7\u5148\u4FDD\u5B58 Skill \u540E\u518D\u4E0A\u4F20\u9644\u4EF6", "error"); setSkillEditorStatus("请先保存 Skill 后再上传附件", "error");
skillFileInput.value = ""; skillFileInput.value = "";
return; return;
} }
const files = skillFileInput.files; const files = skillFileInput.files;
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
setSkillEditorStatus("\u6B63\u5728\u4E0A\u4F20...", ""); setSkillEditorStatus("正在上传...", "");
let successCount = 0; let successCount = 0;
let failCount = 0; let failCount = 0;
for (const file of files) { for (const file of files) {
@@ -275,9 +472,9 @@ function bindSkillEditorEvents() {
skillFileInput.value = ""; skillFileInput.value = "";
renderSkillAttachments(); renderSkillAttachments();
if (failCount === 0) { if (failCount === 0) {
setSkillEditorStatus("\u4E0A\u4F20\u6210\u529F " + successCount + " \u4E2A\u6587\u4EF6", "success"); setSkillEditorStatus("上传成功 " + successCount + " 个文件", "success");
} else { } else {
setSkillEditorStatus("\u4E0A\u4F20: " + successCount + " \u6210\u529F, " + failCount + " \u5931\u8D25", "error"); setSkillEditorStatus("上传: " + successCount + " 成功, " + failCount + " 失败", "error");
} }
}); });
} }
@@ -295,5 +492,5 @@ function bindSkillEditorEvents() {
export { export {
renderSkills, openSkillEditor, closeSkillEditor, renderSkills, openSkillEditor, closeSkillEditor,
saveSkill, deleteSkill, renderSkillAttachments, saveSkill, deleteSkill, renderSkillAttachments,
setSkillEditorStatus, bindSkillEditorEvents, setSkillEditorStatus, bindSkillEditorEvents, bindImportMenu,
}; };
+7
View File
@@ -74,10 +74,17 @@ export const state = {
// 模型选择 // 模型选择
selectedModelId: "", selectedModelId: "",
// 输出模式(文字/语音,由 文本语音.svg 按钮开关控制)
outputMode: "text",
// 思考模式档位:low | high | xhigh | max | ultra
thinkingLevel: "low",
// 工作空间 // 工作空间
workspacePath: "", // 当前选中的工作空间路径(空=使用默认) workspacePath: "", // 当前选中的工作空间路径(空=使用默认)
workspaceList: [], // 已保存的工作空间路径列表 workspaceList: [], // 已保存的工作空间路径列表
workspaceDefault: "", // 默认工作空间路径 workspaceDefault: "", // 默认工作空间路径
accessMode: "restricted", // 工作空间访问模式:restricted(限制访问) | approval(权限审查) | full(完全访问)
// Edit 模式 // Edit 模式
editMode: false, 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 { export {
$, $,
escapeHtml, escapeHtml,
@@ -173,4 +208,6 @@ export {
makeId, codeBlockNode, makeId, codeBlockNode,
showToast, showToast,
dedupePendingFiles, dedupePendingFiles,
withClickGuard,
closeInlinePanelsExcept,
}; };
+295 -19
View File
@@ -64,7 +64,7 @@ def _read_ws_config() -> dict:
return json.load(f) return json.load(f)
except (json.JSONDecodeError, OSError): except (json.JSONDecodeError, OSError):
pass pass
return {"current": "", "list": []} return {"current": "", "list": [], "access_mode": "restricted"}
def _write_ws_config(data: dict) -> bool: 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: except Exception:
return out return out
# 按 turn_id 为每条 assistant 消息挂载对应轮次的工具调用 # 按 turn_id 为每条 assistant 消息挂载对应轮次的工具调用与轮次级元数据
has_turn_ids = False has_turn_ids = False
for m in out: for m in out:
if m.get("role") != "assistant": 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: if not turn_id:
continue continue
has_turn_ids = True has_turn_ids = True
m_meta = m.setdefault("meta", {})
try: try:
tool_calls = get_tool_calls_for_turn(session_id, turn_id) tool_calls = get_tool_calls_for_turn(session_id, turn_id)
except Exception: except Exception:
tool_calls = [] tool_calls = []
if tool_calls: if tool_calls:
_inject_sub_tool_calls(tool_calls, session_id, turn_id, get_sub_agent_traces_for_turn) _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 m_meta["tool_calls"] = tool_calls
try: # 无条件挂载 turn_meta(started_at/ended_at/duration),供前端显示回复开始时间
turn_meta = get_turn_meta(session_id, turn_id) try:
except Exception: turn_meta = get_turn_meta(session_id, turn_id)
turn_meta = None except Exception:
if turn_meta: turn_meta = None
m_meta["turn_meta"] = turn_meta if turn_meta:
m_meta["turn_meta"] = turn_meta
# 兼容旧消息(无 turn_id):将最新轮工具调用附加到最后一条 assistant # 兼容旧消息(无 turn_id):将最新轮工具调用附加到最后一条 assistant
if not has_turn_ids: 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": []} 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") @app.get("/api/sessions/{session_id}/messages")
def api_session_messages(session_id: str) -> dict[str, Any]: 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]: 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: 当前选中的工作空间路径(空字符串表示使用默认值) current: 当前选中的工作空间路径(空字符串表示使用默认值)
list: 已保存的工作空间路径列表 list: 已保存的工作空间路径列表
default: env_config 中的默认工作空间路径 default: env_config 中的默认工作空间路径
snapshots_base: 快照存储基础目录 snapshots_base: 快照存储基础目录
access_mode: 工作空间访问模式(restricted | approval | full)
""" """
from agent.utils.env_utils import get_workspace_dir from agent.utils.env_utils import get_workspace_dir
from agent.memory.system_prompt import _current_workspace_dir from agent.memory.system_prompt import _current_workspace_dir
from agent.utils.agent_utils import SESSIONS_ROOT from agent.utils.agent_utils import SESSIONS_ROOT
from agent.core.workspace_policy import get_access_mode
import os import os
cfg = _read_ws_config() cfg = _read_ws_config()
current = _current_workspace_dir or cfg.get("current", "") current = _current_workspace_dir or cfg.get("current", "")
@@ -1175,9 +1326,27 @@ def api_get_workspace() -> dict[str, Any]:
"list": valid, "list": valid,
"default": get_workspace_dir(), "default": get_workspace_dir(),
"snapshots_base": os.path.join(SESSIONS_ROOT, ".minor_snapshots").replace("\\", "/"), "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") @app.post("/api/workspace/select")
def api_select_workspace(data: dict[str, Any]) -> dict[str, Any]: def api_select_workspace(data: dict[str, Any]) -> dict[str, Any]:
"""选择工作空间。 """选择工作空间。
@@ -1331,6 +1500,33 @@ def health() -> dict[str, str]:
return {"status": "ok"} 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") @app.get("/api/pick-folder")
def api_pick_folder() -> dict[str, Any]: 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} 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 ---------- # ---------- 会话 Token 用量 API ----------
@app.get("/api/chat/tokens/{session_id}") @app.get("/api/chat/tokens/{session_id}")
def api_get_session_tokens(session_id: str) -> dict[str, Any]: 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} "compress_rate": float}
""" """
from agent.utils.env_utils import LLM_CONTEXT_WINDOW, COMPRESS_RATE 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 # 直接读取 LLM 每次调用后存储的真实 total_tokens
tokens = get_session_tokens(session_id) 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, "context_window": LLM_CONTEXT_WINDOW,
"compress_threshold": compress_threshold, "compress_threshold": compress_threshold,
"compress_rate": COMPRESS_RATE, "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(...), name: str = Form(...),
description: str = Form(""), description: str = Form(""),
content: str = Form(""), content: str = Form(""),
version: str = Form("1.0.0"),
author: str = Form(""),
tags: str = Form(""), tags: str = Form(""),
enable: str = Form("true"), enable: str = Form("true"),
) -> dict[str, Any]: ) -> dict[str, Any]:
"""创建或更新一个 skill。 """创建或更新一个 skill(不保留 version/author)。
输入: 输入:
name: skill 名称(必填)。 name: skill 名称(必填)。
description: 简述。 description: 简述。
content: skill.md 的 markdown 内容。 content: skill.md 的 markdown 内容。
version: 版本号。 tags: 逗号分隔的标签字符串(可选,便于分级筛选)。
author: 作者。
tags: 逗号分隔的标签字符串。
enable: 是否启用,默认 "true"。 enable: 是否启用,默认 "true"。
输出: {"success": bool, "skill": dict} 输出: {"success": bool, "skill": dict}
@@ -1640,8 +1865,6 @@ async def api_create_or_update_skill(
name=name.strip(), name=name.strip(),
description=description, description=description,
content=content, content=content,
version=version,
author=author,
tags=tag_list, tags=tag_list,
enable=enable_bool, enable=enable_bool,
) )
@@ -1652,6 +1875,59 @@ async def api_create_or_update_skill(
return {"success": True, "skill": 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}") @app.delete("/api/skills/{name}")
def api_delete_skill(name: str) -> dict[str, Any]: def api_delete_skill(name: str) -> dict[str, Any]:
"""删除一个 skill 及其所有附件。 """删除一个 skill 及其所有附件。