优化agent配置,修复已知问题
No files matched your search
@@ -43,12 +43,11 @@ def _build_llm_for_agent(cfg: AgentConfig):
|
||||
break
|
||||
|
||||
if model_config:
|
||||
# enable_thinking: true 时传入空 extra_body 恢复模型原生行为(启用思考)
|
||||
# 默认不传 extra_body,由 ChatQwen 使用禁用思考的默认值
|
||||
if model_config.get("enable_thinking"):
|
||||
extra_body = {}
|
||||
else:
|
||||
extra_body = None
|
||||
# 深度思考由全局 THINKING_LEVEL 档位控制(low/high 关闭,xhigh/max/ultra 开启),
|
||||
# 取代原先 per-model 的 enable_thinking 开关。true 档位传空 extra_body 恢复模型
|
||||
# 原生行为(启用思考);否则不传,由 ChatQwen 使用禁用思考的默认值。
|
||||
from agent.utils.env_utils import get_thinking_extra_body
|
||||
extra_body = get_thinking_extra_body()
|
||||
return ChatQwen(
|
||||
model=model_config.get("model", ""),
|
||||
api_key=model_config.get("api_key", ""),
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
"current": "",
|
||||
"list": [
|
||||
"C:\\Users\\86166\\Desktop\\Agent_Learning_minor\\Agent\\src\\workspace"
|
||||
]
|
||||
],
|
||||
"access_mode": "full"
|
||||
}
|
||||
@@ -190,6 +190,8 @@ def get_pending_actions_for_session(session_id: str) -> list[dict[str, Any]]:
|
||||
"options": ["approve", "skip"] if is_sub else ["approve", "reject", "skip"],
|
||||
"agent_name": agent_name,
|
||||
"is_sub_agent": is_sub,
|
||||
# 工作空间访问策略:越界审批原因(无则为空串)
|
||||
"policy_note": entry.get("policy_note") or "",
|
||||
"resolved": False,
|
||||
})
|
||||
return actions
|
||||
|
||||
@@ -363,6 +363,7 @@ def _default_env_config() -> dict:
|
||||
"IMAGE_GEN_URL": "http://localhost:8904",
|
||||
"LLM_CONTEXT_WINDOW": "262144",
|
||||
"COMPRESS_RATE": "0.6",
|
||||
"THINKING_LEVEL": "low",
|
||||
"IMG_SIZE": "768",
|
||||
"RAG_CHUNK_SIZE": "500",
|
||||
"RAG_CHUNK_OVERLAP": "50",
|
||||
@@ -508,13 +509,15 @@ def get_gui_llm():
|
||||
if model_cfg is None:
|
||||
return None
|
||||
from agent.core.llm import ChatQwen
|
||||
# 深度思考由全局 THINKING_LEVEL 档位控制(与主 Agent 一致),取代 per-model enable_thinking
|
||||
from agent.utils.env_utils import get_thinking_extra_body
|
||||
return ChatQwen(
|
||||
model=model_cfg["model"],
|
||||
api_key=model_cfg["api_key"],
|
||||
base_url=model_cfg["base_url"],
|
||||
timeout=model_cfg.get("timeout", 60),
|
||||
max_retries=model_cfg.get("max_retries", 0),
|
||||
extra_body={} if model_cfg.get("enable_thinking") else None,
|
||||
extra_body=get_thinking_extra_body(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -209,8 +209,8 @@ def _is_first_agent_call(messages: list) -> bool:
|
||||
|
||||
|
||||
# ---------- ReAct 循环内上下文压缩 ----------
|
||||
def _extract_total_tokens(response: Any) -> int:
|
||||
"""从模型 response 中提取 total_tokens。"""
|
||||
def _extract_usage(response: Any) -> dict:
|
||||
"""从模型 response 提取完整 usage 字典(prompt_tokens/completion_tokens/total_tokens)。"""
|
||||
usage = None
|
||||
if hasattr(response, "response_metadata"):
|
||||
meta = response.response_metadata
|
||||
@@ -220,7 +220,50 @@ def _extract_total_tokens(response: Any) -> int:
|
||||
meta = response.usage_metadata
|
||||
if isinstance(meta, dict):
|
||||
usage = meta
|
||||
return usage.get("total_tokens", 0) if usage else 0
|
||||
return usage if isinstance(usage, dict) else {}
|
||||
|
||||
|
||||
def _extract_total_tokens(response: Any) -> int:
|
||||
"""从模型 response 中提取 total_tokens。"""
|
||||
return _extract_usage(response).get("total_tokens", 0) or 0
|
||||
|
||||
|
||||
def _estimate_token_breakdown(messages: list[Any], usage: dict) -> dict[str, float]:
|
||||
"""估算三类上下文的 token 占用(按输入字符占比分摊 prompt_tokens)。
|
||||
|
||||
分类规则:
|
||||
system —— SystemMessage(系统提示词、压缩摘要)
|
||||
tools —— ToolMessage 与含 tool_ctx 的历史工具调用 HumanMessage
|
||||
messages—— 其余(用户/助手对话),另计入 completion_tokens
|
||||
|
||||
说明: LLM API 不返回按来源分类的 token 数,此值为字符占比估算,
|
||||
仅用于前端环形指示的占比展示。
|
||||
"""
|
||||
sys_chars = tool_chars = msg_chars = 0
|
||||
for m in messages or []:
|
||||
text = str(getattr(m, "content", "") or "")
|
||||
if isinstance(m, SystemMessage):
|
||||
sys_chars += len(text)
|
||||
elif isinstance(m, ToolMessage) or (
|
||||
isinstance(m, HumanMessage) and (m.additional_kwargs or {}).get("tool_ctx")
|
||||
):
|
||||
tool_chars += len(text)
|
||||
else:
|
||||
msg_chars += len(text)
|
||||
total_chars = sys_chars + tool_chars + msg_chars
|
||||
prompt_tokens = usage.get("prompt_tokens", 0) or 0
|
||||
completion_tokens = usage.get("completion_tokens", 0) or 0
|
||||
if total_chars <= 0:
|
||||
return {"messages": float(completion_tokens), "tools": 0.0, "system": 0.0}
|
||||
|
||||
def _share(chars: int) -> float:
|
||||
return round(prompt_tokens * chars / total_chars, 1)
|
||||
|
||||
return {
|
||||
"messages": round(_share(msg_chars) + completion_tokens, 1),
|
||||
"tools": _share(tool_chars),
|
||||
"system": _share(sys_chars),
|
||||
}
|
||||
|
||||
|
||||
def _compute_next_cursor(session_id: str) -> int:
|
||||
@@ -238,7 +281,7 @@ def _compute_next_cursor(session_id: str) -> int:
|
||||
return -1
|
||||
|
||||
|
||||
def _maybe_trigger_compress(response: Any) -> tuple[str, int, int]:
|
||||
def _maybe_trigger_compress(response: Any, messages: list[Any] | None = None) -> tuple[str, int, int]:
|
||||
"""根据 LLM 返回的 token 用量判断下一步动作。
|
||||
|
||||
返回值 (trigger, total_tokens, threshold):
|
||||
@@ -247,7 +290,8 @@ def _maybe_trigger_compress(response: Any) -> tuple[str, int, int]:
|
||||
"sub_oom" —— 子 Agent 达到阈值(子 Agent 不压缩),由 agent 节点注入
|
||||
整理提示词,让子 Agent 整理任务进度直接返回主 Agent。
|
||||
|
||||
同时将 total_tokens 存入 tool_call_recorder 供前端实时展示(仅主 Agent)。
|
||||
同时将 total_tokens 与估算的三类占比(消息/工具/系统提示词)存入
|
||||
tool_call_recorder 供前端环形指示展示(仅主 Agent)。
|
||||
"""
|
||||
total = _extract_total_tokens(response)
|
||||
if total <= 0:
|
||||
@@ -260,7 +304,8 @@ def _maybe_trigger_compress(response: Any) -> tuple[str, int, int]:
|
||||
_sid = get_current_session() or ""
|
||||
is_sub = is_sub_agent_context()
|
||||
if _sid and not is_sub:
|
||||
set_session_tokens(_sid, total)
|
||||
breakdown = _estimate_token_breakdown(messages or [], _extract_usage(response))
|
||||
set_session_tokens(_sid, total, breakdown=breakdown)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -438,7 +483,7 @@ def make_call_model_node(
|
||||
|
||||
# 基于模型返回 token 用量判断:主 Agent 标记压缩 / 子 Agent 超限整理。
|
||||
# total_tokens 同时存入 session_tokens 供前端轮询(仅主 Agent)。
|
||||
trigger, _total, _threshold = _maybe_trigger_compress(response)
|
||||
trigger, _total, _threshold = _maybe_trigger_compress(response, messages_for_llm)
|
||||
if trigger == "sub_oom":
|
||||
# 子 Agent 不压缩:注入整理提示词重新调用,让模型整理任务进度直接
|
||||
# 返回主 Agent(提示词内附 OOM 溢出信息)。
|
||||
|
||||
@@ -15,10 +15,12 @@ from typing import Any, Literal, Sequence
|
||||
from langchain_core.messages import BaseMessage
|
||||
from langgraph.graph import END
|
||||
|
||||
from agent.core.approvals import build_approval_meta, build_human_interaction_meta
|
||||
from agent.core.approvals import PENDING_TOOL_APPROVALS, build_approval_meta, build_human_interaction_meta
|
||||
from agent.core.loop_detector import should_force_end as _loop_should_force_end
|
||||
from agent.core.state import AgentState
|
||||
from agent.core.tool_policy import classify_tool_execution
|
||||
from agent.core.workspace_policy import check_violation as _policy_check_violation
|
||||
from agent.core.workspace_policy import decision as _policy_decision
|
||||
from agent.utils.tool_call_utils import normalize_tool_call
|
||||
|
||||
|
||||
@@ -58,6 +60,9 @@ def should_continue(state: AgentState) -> Literal["tools", "__end__"]:
|
||||
tool_call = normalize_tool_call(raw_tool_call)
|
||||
if classify_tool_execution(tool_call.get("name"), tool_call.get("args") or {}) == "confirm":
|
||||
return END
|
||||
# 权限审查模式:越界操作暂停图,交由前端人工审批
|
||||
if _policy_decision(tool_call.get("name"), tool_call.get("args") or {}) == "approve":
|
||||
return END
|
||||
return "tools"
|
||||
return END
|
||||
|
||||
@@ -96,4 +101,16 @@ def first_pending_tool_meta(messages: Sequence[BaseMessage], session_id: str) ->
|
||||
return build_human_interaction_meta(session_id, tool_call, messages, agent_name=agent_name)
|
||||
if classify_tool_execution(tool_call.get("name"), tool_call.get("args") or {}) == "confirm":
|
||||
return build_approval_meta(session_id, tool_call, messages, agent_name=agent_name)
|
||||
# 权限审查模式:越界操作注册审批(复用 tool_call 审批卡片),并携带越界原因
|
||||
if _policy_decision(tool_call.get("name"), tool_call.get("args") or {}) == "approve":
|
||||
meta = build_approval_meta(session_id, tool_call, messages, agent_name=agent_name)
|
||||
approval_id = (meta.get("pending_action") or {}).get("id")
|
||||
if approval_id and approval_id in PENDING_TOOL_APPROVALS:
|
||||
reason = (_policy_check_violation(tool_call.get("name"), tool_call.get("args") or {})
|
||||
or "检测到超出工作空间范围的操作")
|
||||
PENDING_TOOL_APPROVALS[approval_id]["policy_note"] = reason
|
||||
pa = meta["pending_action"]
|
||||
pa["title"] = "待审批:检测到超出工作空间范围的操作"
|
||||
pa["policy_note"] = reason
|
||||
return meta
|
||||
return None
|
||||
@@ -264,13 +264,6 @@ def _persist_messages_from_pending_turn(
|
||||
history.add_message(final_assistant)
|
||||
|
||||
|
||||
def _persist_pending_rejection(session_id: str, pending: dict[str, Any], content: str) -> None:
|
||||
"""将用户拒绝待确认操作这一轮写入历史,避免下一轮上下文断层。"""
|
||||
prior_messages = messages_from_dict(pending.get("messages") or [])
|
||||
prior_messages.append(AIMessage(content=content))
|
||||
_persist_messages_from_pending_turn(session_id, prior_messages, pending)
|
||||
|
||||
|
||||
def _invoke_after_manual_tool_result(
|
||||
runtime: AgentRuntime,
|
||||
messages: list[Any],
|
||||
@@ -791,6 +784,9 @@ def continue_after_human_action(
|
||||
approval_id: pending_action.id。
|
||||
decision: approve | reject | edit | skip。
|
||||
- approve: 执行工具/确认续跑;skip: 不执行工具但继续图(ToolMessage 标记跳过)。
|
||||
- reject: 与手动暂停一致——暂停当前 Agent 运行、保留之前的运行记录、
|
||||
前端追加"已被用户手动暂停"消息(对三种审批统一生效:权限不足 /
|
||||
workspace 越界 / 人机交互)。
|
||||
- edit: human_interaction selection 提交文本。
|
||||
instruction: selection 时为用户输入文本。
|
||||
|
||||
@@ -814,18 +810,21 @@ def continue_after_human_action(
|
||||
|
||||
agent_mode = pending.get("agent_mode", "agent")
|
||||
|
||||
# ===== 子 Agent HITL 恢复(approve / skip,无 reject)=====
|
||||
if pending.get("is_sub_agent"):
|
||||
return _resume_sub_agent(runtime, pending, chat_history, session_id, approval_id, decision, instruction, agent_mode)
|
||||
|
||||
if decision == "reject" and pending.get("type") != "human_interaction":
|
||||
content = "已拒绝,本次待确认操作不会执行。"
|
||||
chat_history.append({"role": "assistant", "content": content, "meta": {}})
|
||||
_persist_pending_rejection(session_id, pending, content)
|
||||
# ===== 拒绝:统一三种审批(权限不足 / workspace 越界 / 人机交互)=====
|
||||
# 与手动暂停(/api/chat/abort)一致:暂停当前 Agent 运行、保留之前的运行记录,
|
||||
# 前端收到"已被用户手动暂停"消息。不恢复图执行。
|
||||
if decision == "reject":
|
||||
last_msg = chat_history[-1] if chat_history else None
|
||||
if not (last_msg and last_msg.get("role") == "assistant" and last_msg.get("content") == "已被用户手动暂停"):
|
||||
chat_history.append({"role": "assistant", "content": "已被用户手动暂停", "meta": {"paused": True}})
|
||||
if session_id:
|
||||
sync_turn_from_chat_history(session_id, chat_history, "")
|
||||
return chat_history
|
||||
|
||||
# ===== 子 Agent HITL 恢复(approve / skip,无 reject)=====
|
||||
if pending.get("is_sub_agent"):
|
||||
return _resume_sub_agent(runtime, pending, chat_history, session_id, approval_id, decision, instruction, agent_mode)
|
||||
|
||||
# ===== 跳过:仅 tool_call 类型支持,human_interaction 不支持跳过 =====
|
||||
if decision == "skip" and pending.get("type") != "human_interaction":
|
||||
tool_call = dict(pending.get("tool_call") or {})
|
||||
|
||||
@@ -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"请将目标路径调整到工作空间目录下后重试。"
|
||||
)
|
||||
@@ -192,6 +192,14 @@ def main() -> int:
|
||||
_post_finished(task_id, "failed", f"存储初始化失败: {e}", "")
|
||||
return 1
|
||||
|
||||
# 1.1 标记无人值守:工作空间访问策略的 approval 模式在 cron 下退化为拦截
|
||||
# (待审批项存于子进程内存、随进程退出丢失,且无人在线审批)
|
||||
try:
|
||||
from agent.core.workspace_policy import mark_headless
|
||||
mark_headless()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. 构建 AgentRuntime
|
||||
try:
|
||||
from agent.agents.agent_runtime import build_all_agent_runtimes, get_main_agent_runtime
|
||||
|
||||
@@ -100,6 +100,7 @@ _subscribers_lock = threading.Lock()
|
||||
# ---------- 当前会话 token 用量(来自 LLM response usage_metadata) ----------
|
||||
_session_tokens_lock = threading.Lock()
|
||||
_session_tokens: dict[str, int] = {} # session_id → total_tokens
|
||||
_session_token_breakdowns: dict[str, dict] = {} # session_id → {messages, tools, system}
|
||||
_session_tokens_loaded: set[str] = set() # 已从磁盘加载过的 session_id
|
||||
|
||||
|
||||
@@ -108,7 +109,7 @@ def _token_file(session_id: str) -> Path:
|
||||
return TOOL_CALLING_ROOT / session_id / "_session_tokens.json"
|
||||
|
||||
|
||||
def _persist_token(session_id: str, tokens: int) -> None:
|
||||
def _persist_token(session_id: str, tokens: int, breakdown: dict | None = None) -> None:
|
||||
"""将 token 写入存储(优先 MySQL,回退 JSON 文件)。"""
|
||||
# mysql 后端:写入 sessions.extra.tokens
|
||||
try:
|
||||
@@ -116,6 +117,8 @@ def _persist_token(session_id: str, tokens: int) -> None:
|
||||
if session_storage.is_mysql():
|
||||
existing = session_storage.load_session_extra(session_id) or {}
|
||||
existing["tokens"] = tokens
|
||||
if breakdown:
|
||||
existing["token_breakdown"] = breakdown
|
||||
session_storage.save_session_extra(session_id, existing)
|
||||
return
|
||||
except Exception:
|
||||
@@ -123,7 +126,10 @@ def _persist_token(session_id: str, tokens: int) -> None:
|
||||
# json 后端:写入文件
|
||||
tf = _token_file(session_id)
|
||||
tf.parent.mkdir(parents=True, exist_ok=True)
|
||||
tf.write_text(json.dumps({"session_id": session_id, "tokens": tokens}), encoding="utf-8")
|
||||
payload = {"session_id": session_id, "tokens": tokens}
|
||||
if breakdown:
|
||||
payload["breakdown"] = breakdown
|
||||
tf.write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
|
||||
def _load_token_from_disk(session_id: str) -> int:
|
||||
@@ -147,11 +153,17 @@ def _load_token_from_disk(session_id: str) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def set_session_tokens(session_id: str, total_tokens: int) -> None:
|
||||
"""存储当前会话的 LLM 最新 token 用量(内存 + 磁盘)。"""
|
||||
def set_session_tokens(session_id: str, total_tokens: int, breakdown: dict | None = None) -> None:
|
||||
"""存储当前会话的 LLM 最新 token 用量(内存 + 磁盘)。
|
||||
|
||||
breakdown: {"messages": float, "tools": float, "system": float} 三类估算占比,
|
||||
由 nodes._estimate_token_breakdown 计算。
|
||||
"""
|
||||
with _session_tokens_lock:
|
||||
_session_tokens[session_id] = total_tokens
|
||||
_persist_token(session_id, total_tokens)
|
||||
if breakdown:
|
||||
_session_token_breakdowns[session_id] = dict(breakdown)
|
||||
_persist_token(session_id, total_tokens, breakdown)
|
||||
|
||||
|
||||
def get_session_tokens(session_id: str) -> int:
|
||||
@@ -170,16 +182,47 @@ def get_session_tokens(session_id: str) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def get_session_token_breakdown(session_id: str) -> dict:
|
||||
"""获取当前会话的三类 token 估算占比(消息/工具/系统提示词)。
|
||||
|
||||
内存优先,回退磁盘(JSON 文件的 breakdown / MySQL 的 token_breakdown)。
|
||||
"""
|
||||
with _session_tokens_lock:
|
||||
if session_id in _session_token_breakdowns:
|
||||
return dict(_session_token_breakdowns[session_id])
|
||||
try:
|
||||
from agent.history import session_storage
|
||||
if session_storage.is_mysql():
|
||||
extra = session_storage.load_session_extra(session_id) or {}
|
||||
bd = extra.get("token_breakdown")
|
||||
if isinstance(bd, dict):
|
||||
return bd
|
||||
except Exception:
|
||||
pass
|
||||
tf = _token_file(session_id)
|
||||
if tf.exists():
|
||||
try:
|
||||
data = json.loads(tf.read_text(encoding="utf-8"))
|
||||
bd = data.get("breakdown")
|
||||
if isinstance(bd, dict):
|
||||
return bd
|
||||
except Exception:
|
||||
pass
|
||||
return {"messages": 0.0, "tools": 0.0, "system": 0.0}
|
||||
|
||||
|
||||
def clear_session_tokens(session_id: str) -> None:
|
||||
"""清除指定会话的 token 记录(内存 + 存储)。"""
|
||||
with _session_tokens_lock:
|
||||
_session_tokens.pop(session_id, None)
|
||||
_session_token_breakdowns.pop(session_id, None)
|
||||
# mysql 后端:设置 tokens=0
|
||||
try:
|
||||
from agent.history import session_storage
|
||||
if session_storage.is_mysql():
|
||||
existing = session_storage.load_session_extra(session_id) or {}
|
||||
existing["tokens"] = 0
|
||||
existing.pop("token_breakdown", None)
|
||||
session_storage.save_session_extra(session_id, existing)
|
||||
return
|
||||
except Exception:
|
||||
|
||||
@@ -5,7 +5,7 @@ _BASE_PROMPT = """你是一个具备多模态理解能力的桌面操作助手
|
||||
- 只有在信息确实无法从当前对话或上传内容中获取,且必须通过外部工具才能获得时,才调用工具。
|
||||
- 当你调用工具收到截图或返回结果后,必须仔细观察返回内容,基于实际结果继续推进任务,严禁脱离实际结果凭空编造。
|
||||
- 对于同一个页面下的GUI操作,必须将连续操作合并为一次 gui_tool 调用(actions 列表传入多个动作),严禁逐个单步调用。仅当页面发生跳转/加载后需要重新观察时,才允许分步调用。
|
||||
- 对于需要多次操作才能完成的任务,应主动使用 todo_list 工具规划步骤并逐步推进,并在任务完成后将todo_list中的所有项标记为已完成。
|
||||
- 【任务规划】收到任务后,先判断任务复杂度:仅当任务为复杂任务(如多步骤、多阶段、流程较长或产出物较多)时,才调用 todo_list 工具将任务拆解为清晰的执行步骤列表,然后按步骤逐步执行;每完成一步,用 todo_list 标记进度(done_step),任务完成后将所有步骤标记为已完成。简单任务可直接执行,无需调用 todo_list。
|
||||
- 当任务完成时,向用户总结执行过程和结果。
|
||||
""".strip()
|
||||
|
||||
@@ -15,6 +15,7 @@ _WS_TAIL = """\n\n【工作空间配置】
|
||||
- 你产生的代码、脚本、图片、文档等文件,默认保存到工作空间目录下。
|
||||
- 使用 terminal_execute 工具时,如果不指定 working_dir,默认在工作空间执行命令。
|
||||
- 使用 doc_tool 创建/修改文件时,文件路径请使用工作空间的绝对路径。
|
||||
- 写操作限制:doc_tool 的创建/修改/删除、terminal_execute 的文件变更目标必须位于工作空间内;超出范围的访问会被拦截或需要用户审批(取决于当前访问模式)。
|
||||
"""
|
||||
|
||||
# 当前运行时的工作空间路径(可通过 API 动态修改)
|
||||
@@ -131,14 +132,14 @@ DB_SYSTEM_PROMT = """
|
||||
请记住,你是一个协作伙伴,目标是让用户以最小的学习成本从数据库中获得有价值的信息。开始工作吧!
|
||||
""".strip()
|
||||
|
||||
# Plan 模式核心规则(todo_list 强制绑定)
|
||||
# Plan 模式核心规则(复杂任务:写计划到 md 文件 -> send_file 交给用户确认 -> 确认后执行)
|
||||
_PLAN_CORE_RULES = """
|
||||
【Plan 模式核心规则】
|
||||
- **首要任务**:收到用户请求后,你必须**第一步就调用 todo_list 工具**,将用户的任务拆解为非常细致、可执行的步骤列表。每个步骤应该具体到单个操作(如"打开浏览器"、"搜索XXX"、"截取屏幕"、"分析结果"等)。
|
||||
- **规划要求**:步骤拆解必须详尽,覆盖从开始到完成的完整流程。不要跳过任何中间步骤。步骤描述要清晰具体,便于后续逐一执行和跟踪。
|
||||
- **规划完成后**:生成 todolist 后,向用户简要汇报规划结果,然后**立即开始按步骤执行**,不要等待用户确认。
|
||||
- **执行过程中**:每完成一个步骤,调用 todo_list 标记该步骤为已完成(done_step),让用户实时看到进度。
|
||||
- **任务完成后**:确认所有步骤已完成,向用户总结执行过程和结果。
|
||||
【Plan 模式核心规则】(适用于复杂任务)
|
||||
- **信息确认**:制定计划前,若用户的任务描述模糊、存在歧义,或任务中有任何需要注意并与用户确认的点(如目标范围、偏好、时间限制、交付格式等),**必须先调用 human_interaction 工具**,交由用户确认并补充信息,确认清楚后再制定计划。
|
||||
- **计划落盘**:将完整执行计划直接写入工作空间下的 `.minorAgent` 目录,文件名为「任务名.md」(如 `周报生成.md`),使用 doc_tool 创建/更新该文件(`.minorAgent` 目录不存在时 doc_tool 会自动创建)。计划内容必须包含:任务目标、执行步骤、每步的产出物。
|
||||
- **计划确认**:计划写好后,**调用 send_file 工具将计划文件发送给用户查看**,并说明该计划需用户确认无异议后才会执行;在用户明确确认之前,不得开始执行计划。
|
||||
- **执行推进**:用户确认无异议后,按计划逐步执行。每完成一步,用 doc_tool 更新 `.minorAgent\\{任务名}.md` 中的进度(将已完成步骤标记为 [x]),让用户实时看到进展。
|
||||
- **任务完成**:确认所有步骤已完成,向用户总结执行过程和结果,并告知计划文件的存放位置。
|
||||
""".strip()
|
||||
|
||||
# Plan 模式提示词 = Plan 模式核心规则 + 当前 Agent 模式基础提示词
|
||||
@@ -183,6 +184,7 @@ CRON_MODE_PROMPT = """
|
||||
- 你产生的代码、脚本、图片、文档等文件,默认保存到工作空间目录下。
|
||||
- 使用 terminal_execute 工具时,如果不指定 working_dir,默认在工作空间执行命令。
|
||||
- 使用 doc_tool 创建/修改文件时,文件路径请使用工作空间的绝对路径。
|
||||
- 写操作限制:doc_tool 的创建/修改/删除、terminal_execute 的文件变更目标必须位于工作空间内。无人值守场景下超出范围的访问会被直接拦截,请务必使用工作空间内的路径。
|
||||
""".strip()
|
||||
|
||||
# ---------- 反思注入提示(作为 synthetic HumanMessage 注入到对话上下文)----------
|
||||
|
||||
@@ -1,29 +1,35 @@
|
||||
"""Skill 管理器:负责 skill 的增删改查及附件管理。
|
||||
"""Skill 管理器:负责 skill 的增删改查、附件管理及 zip 导入。
|
||||
|
||||
每个 skill 是一个文件夹,位于 Skills 目录下,结构如下::
|
||||
|
||||
skills/
|
||||
{skill_name}/
|
||||
skill.json - 基本信息与描述
|
||||
skill.json - 基本信息与描述(name/description/tags/enable)
|
||||
skill.md - 主体内容(指南)
|
||||
attachments/ - 可选附件(.py/.docx/.csv/.xlsx 等)
|
||||
... - 可选附件(直接放在 skill 文件夹下,不强制 attachments/ 子目录)
|
||||
|
||||
系统定位:
|
||||
为 skill_router 工具和前端 API 提供统一的数据读写接口。
|
||||
|
||||
可扩展性:
|
||||
可增加版本校验、导入/导出、标签过滤等功能。
|
||||
可增加标签过滤、导出等功能。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SKILLS_DIR = Path(__file__).resolve().parent
|
||||
|
||||
# 视为元数据而非附件的文件(附件列举时排除)
|
||||
_SKILL_META_FILES = {"skill.json", "skill.md", "SKILL.md"}
|
||||
|
||||
|
||||
def _ensure_skills_dir() -> None:
|
||||
"""确保 skills 目录存在。"""
|
||||
@@ -32,20 +38,16 @@ def _ensure_skills_dir() -> None:
|
||||
|
||||
# ---------- Skill 信息数据类 ----------
|
||||
class SkillInfo:
|
||||
"""单个 Skill 的基本信息。"""
|
||||
"""单个 Skill 的基本信息(不含 version/author)。"""
|
||||
name: str
|
||||
description: str
|
||||
version: str
|
||||
author: str
|
||||
tags: list[str]
|
||||
enable: bool
|
||||
|
||||
def __init__(self, name: str = "", description: str = "", version: str = "1.0.0",
|
||||
author: str = "", tags: list[str] | None = None, enable: bool = True):
|
||||
def __init__(self, name: str = "", description: str = "", tags: list[str] | None = None,
|
||||
enable: bool = True):
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.version = version
|
||||
self.author = author
|
||||
self.tags = tags or []
|
||||
self.enable = enable
|
||||
|
||||
@@ -53,8 +55,6 @@ class SkillInfo:
|
||||
return {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"version": self.version,
|
||||
"author": self.author,
|
||||
"tags": self.tags,
|
||||
"enable": self.enable,
|
||||
}
|
||||
@@ -64,20 +64,39 @@ class SkillInfo:
|
||||
return cls(
|
||||
name=d.get("name", name),
|
||||
description=d.get("description", ""),
|
||||
version=d.get("version", "1.0.0"),
|
||||
author=d.get("author", ""),
|
||||
tags=d.get("tags", []),
|
||||
enable=d.get("enable", True),
|
||||
)
|
||||
|
||||
|
||||
# ---------- 元数据清理(历史遗留的 version/author 不再保留) ----------
|
||||
|
||||
def _strip_legacy_meta(meta: dict, json_path: Path | None = None) -> dict:
|
||||
"""从元数据中剥离历史遗留的 version/author 字段("不保留"语义)。
|
||||
|
||||
若 json_path 提供且文件确含这两个字段,则原地重写文件完成物理清理。
|
||||
"""
|
||||
changed = False
|
||||
for key in ("version", "author"):
|
||||
if key in meta:
|
||||
meta.pop(key, None)
|
||||
changed = True
|
||||
if changed and json_path is not None:
|
||||
try:
|
||||
with open(json_path, "w", encoding="utf-8") as f:
|
||||
json.dump(meta, f, ensure_ascii=False, indent=2)
|
||||
except OSError:
|
||||
pass
|
||||
return meta
|
||||
|
||||
|
||||
# ---------- CRUD ----------
|
||||
|
||||
def list_skills() -> list[dict]:
|
||||
"""列出所有 skill 的基本信息(不含主体内容)。
|
||||
|
||||
输出:
|
||||
list[dict]: 每项含 name, description, version, author, tags, has_attachments
|
||||
list[dict]: 每项含 name, description, tags, has_attachments, attachment_files
|
||||
"""
|
||||
_ensure_skills_dir()
|
||||
result = []
|
||||
@@ -92,16 +111,14 @@ def list_skills() -> list[dict]:
|
||||
meta = json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
meta = _strip_legacy_meta(meta, json_path)
|
||||
meta["name"] = entry.name
|
||||
meta.setdefault("description", "")
|
||||
meta.setdefault("version", "1.0.0")
|
||||
meta.setdefault("author", "")
|
||||
meta.setdefault("tags", [])
|
||||
meta.setdefault("enable", True)
|
||||
# 检查是否有附件
|
||||
attach_dir = entry / "attachments"
|
||||
meta["has_attachments"] = attach_dir.is_dir() and bool(list(attach_dir.iterdir()))
|
||||
meta["attachment_files"] = _list_attachments(entry.name)
|
||||
attachment_files = _list_attachments(entry.name)
|
||||
meta["has_attachments"] = bool(attachment_files)
|
||||
meta["attachment_files"] = attachment_files
|
||||
result.append(meta)
|
||||
return result
|
||||
|
||||
@@ -110,7 +127,7 @@ def get_skill(name: str) -> dict | None:
|
||||
"""获取单个 skill 的完整信息(含主体内容和附件列表)。
|
||||
|
||||
输出:
|
||||
dict 或 None,字段: name, description, version, author, tags,
|
||||
dict 或 None,字段: name, description, tags, enable,
|
||||
content (skill.md 内容), attachment_files
|
||||
"""
|
||||
skill_dir = SKILLS_DIR / name
|
||||
@@ -126,6 +143,7 @@ def get_skill(name: str) -> dict | None:
|
||||
meta = json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
meta = _strip_legacy_meta(meta, json_path)
|
||||
|
||||
content = ""
|
||||
if md_path.is_file():
|
||||
@@ -138,8 +156,6 @@ def get_skill(name: str) -> dict | None:
|
||||
return {
|
||||
"name": name,
|
||||
"description": meta.get("description", ""),
|
||||
"version": meta.get("version", "1.0.0"),
|
||||
"author": meta.get("author", ""),
|
||||
"tags": meta.get("tags", []),
|
||||
"enable": meta.get("enable", True),
|
||||
"content": content,
|
||||
@@ -148,7 +164,6 @@ def get_skill(name: str) -> dict | None:
|
||||
|
||||
|
||||
def create_or_update_skill(name: str, description: str = "", content: str = "",
|
||||
version: str = "0.0", author: str = "minor",
|
||||
tags: list[str] | None = None, enable: bool = True) -> bool:
|
||||
"""创建或更新一个 skill。
|
||||
|
||||
@@ -156,9 +171,7 @@ def create_or_update_skill(name: str, description: str = "", content: str = "",
|
||||
name: skill 名称(即文件夹名)。
|
||||
description: 简述。
|
||||
content: skill.md 的内容。
|
||||
version: 版本号。
|
||||
author: 作者。
|
||||
tags: 标签列表。
|
||||
tags: 标签列表(可选,便于分级筛选)。
|
||||
enable: 是否启用此 skill,默认 True。
|
||||
|
||||
输出:
|
||||
@@ -171,12 +184,10 @@ def create_or_update_skill(name: str, description: str = "", content: str = "",
|
||||
skill_dir = SKILLS_DIR / name
|
||||
os.makedirs(skill_dir, exist_ok=True)
|
||||
|
||||
# 写 skill.json
|
||||
# 写 skill.json(不保留 version/author)
|
||||
meta = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"version": version,
|
||||
"author": author,
|
||||
"tags": tags or [],
|
||||
"enable": enable,
|
||||
}
|
||||
@@ -211,26 +222,38 @@ def delete_skill(name: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
# ---------- 附件管理 ----------
|
||||
# ---------- 附件管理(以 skill 目录为根,不强制 attachments/ 子目录) ----------
|
||||
|
||||
def _attachments_dir(skill_name: str) -> Path:
|
||||
return SKILLS_DIR / skill_name / "attachments"
|
||||
def _skill_dir(skill_name: str) -> Path:
|
||||
return SKILLS_DIR / skill_name
|
||||
|
||||
|
||||
def _list_attachments(skill_name: str) -> list[str]:
|
||||
"""列出某 skill 的所有附件文件名。"""
|
||||
ad = _attachments_dir(skill_name)
|
||||
if not ad.is_dir():
|
||||
"""列出某 skill 的所有附件相对路径(排除 skill.json/skill.md)。"""
|
||||
sd = _skill_dir(skill_name)
|
||||
if not sd.is_dir():
|
||||
return []
|
||||
result = []
|
||||
for root, dirs, files in os.walk(ad):
|
||||
for root, dirs, files in os.walk(sd):
|
||||
# 跳过 __pycache__ 等缓存目录
|
||||
dirs[:] = [d for d in dirs if d != "__pycache__"]
|
||||
for fname in files:
|
||||
rel = os.path.relpath(os.path.join(root, fname), ad)
|
||||
if fname in _SKILL_META_FILES and root == str(sd):
|
||||
continue
|
||||
rel = os.path.relpath(os.path.join(root, fname), sd)
|
||||
result.append(rel.replace("\\", "/"))
|
||||
result.sort()
|
||||
return result
|
||||
|
||||
|
||||
def _safe_relpath(filename: str) -> str | None:
|
||||
"""规范化附件相对路径并做穿越防护;非法返回 None。"""
|
||||
safe = filename.replace("\\", "/").strip("/")
|
||||
if not safe or safe.startswith("/") or ".." in safe.split("/"):
|
||||
return None
|
||||
return safe
|
||||
|
||||
|
||||
def add_attachment(skill_name: str, filename: str, content_bytes: bytes) -> bool:
|
||||
"""上传/更新一个附件文件到 skill。
|
||||
|
||||
@@ -242,16 +265,13 @@ def add_attachment(skill_name: str, filename: str, content_bytes: bytes) -> bool
|
||||
输出:
|
||||
成功返回 True。
|
||||
"""
|
||||
skill_dir = SKILLS_DIR / skill_name
|
||||
if not skill_dir.is_dir():
|
||||
sd = _skill_dir(skill_name)
|
||||
if not sd.is_dir():
|
||||
return False
|
||||
ad = _attachments_dir(skill_name)
|
||||
os.makedirs(ad, exist_ok=True)
|
||||
# 安全检查:防止路径穿越
|
||||
safe_name = filename.replace("\\", "/").strip("/")
|
||||
if ".." in safe_name:
|
||||
safe_name = _safe_relpath(filename)
|
||||
if safe_name is None or safe_name.lower() in _SKILL_META_FILES:
|
||||
return False
|
||||
target = ad / safe_name
|
||||
target = sd / safe_name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
with open(target, "wb") as f:
|
||||
@@ -267,18 +287,18 @@ def delete_attachment(skill_name: str, filename: str) -> bool:
|
||||
输出:
|
||||
存在且删除成功返回 True。
|
||||
"""
|
||||
ad = _attachments_dir(skill_name)
|
||||
safe_name = filename.replace("\\", "/").strip("/")
|
||||
if ".." in safe_name:
|
||||
sd = _skill_dir(skill_name)
|
||||
safe_name = _safe_relpath(filename)
|
||||
if safe_name is None:
|
||||
return False
|
||||
target = ad / safe_name
|
||||
target = sd / safe_name
|
||||
if not target.is_file():
|
||||
return False
|
||||
try:
|
||||
os.remove(target)
|
||||
# 移除空父目录
|
||||
parent = target.parent
|
||||
while parent != ad and parent != SKILLS_DIR:
|
||||
while parent != sd and parent != SKILLS_DIR:
|
||||
try:
|
||||
parent.rmdir()
|
||||
except OSError:
|
||||
@@ -291,11 +311,11 @@ def delete_attachment(skill_name: str, filename: str) -> bool:
|
||||
|
||||
def get_attachment_bytes(skill_name: str, filename: str) -> bytes | None:
|
||||
"""获取附件文件的二进制内容。"""
|
||||
ad = _attachments_dir(skill_name)
|
||||
safe_name = filename.replace("\\", "/").strip("/")
|
||||
if ".." in safe_name:
|
||||
sd = _skill_dir(skill_name)
|
||||
safe_name = _safe_relpath(filename)
|
||||
if safe_name is None:
|
||||
return None
|
||||
target = ad / safe_name
|
||||
target = sd / safe_name
|
||||
if not target.is_file():
|
||||
return None
|
||||
try:
|
||||
@@ -305,6 +325,197 @@ def get_attachment_bytes(skill_name: str, filename: str) -> bytes | None:
|
||||
return None
|
||||
|
||||
|
||||
# ---------- zip 导入(前端先解析展示,点保存后才落盘) ----------
|
||||
|
||||
def _parse_frontmatter(md: str) -> tuple[dict, str]:
|
||||
"""解析 skill.md 头部的 frontmatter,仅保留 name/description,其余丢弃。
|
||||
|
||||
支持 ``description: >`` 折叠多行风格与普通单行 ``key: value``。
|
||||
|
||||
输入:
|
||||
md: skill.md 全文。
|
||||
|
||||
输出:
|
||||
(meta, body);meta 含 name/description(可能为空),body 为 frontmatter 之后的正文。
|
||||
"""
|
||||
if not md.startswith("---"):
|
||||
return {}, md
|
||||
end = md.find("\n---", 3)
|
||||
if end == -1:
|
||||
return {}, md
|
||||
head = md[3:end]
|
||||
body = md[end + 4:].lstrip("\n")
|
||||
|
||||
meta: dict[str, list[str]] = {}
|
||||
cur_key: str | None = None
|
||||
for line in head.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
m = re.match(r"^([A-Za-z_][\w-]*)\s*:\s*(.*)$", line)
|
||||
if m:
|
||||
key = m.group(1).strip().lower()
|
||||
val = m.group(2).strip()
|
||||
if key in ("name", "description"):
|
||||
cur_key = key
|
||||
meta[key] = [val]
|
||||
else:
|
||||
cur_key = None # 其余字段直接丢弃
|
||||
elif cur_key in ("name", "description") and (line.startswith(" ") or line.startswith("\t")):
|
||||
# 折叠/续行(description: > 后缩进的各行)
|
||||
meta[cur_key].append(line.strip())
|
||||
|
||||
result = {}
|
||||
for key in ("name", "description"):
|
||||
vals = [v for v in meta.get(key, []) if v]
|
||||
if not vals:
|
||||
result[key] = ""
|
||||
continue
|
||||
if key == "name":
|
||||
result[key] = vals[0].strip().strip("\"'")
|
||||
else:
|
||||
# description 折叠续行以空格连接
|
||||
result[key] = " ".join(vals).strip().strip("\"'")
|
||||
return result, body
|
||||
|
||||
|
||||
def _find_skill_md_in_zip(zf: zipfile.ZipFile) -> tuple[str | None, str | None]:
|
||||
"""在 zip 中定位 skill.md/SKILL.md(一级子目录下优先,根目录兜底)。
|
||||
|
||||
输出:
|
||||
(entry_name, top_dir);top_dir 为共同的一级目录前缀(可能为空)。
|
||||
"""
|
||||
names = [n for n in zf.namelist() if not n.endswith("/")]
|
||||
if not names:
|
||||
return None, None
|
||||
# 根目录下的 skill.md
|
||||
for n in names:
|
||||
if "/" not in n and n.lower() == "skill.md":
|
||||
return n, ""
|
||||
# 一级子目录下的 skill.md({dir}/skill.md)
|
||||
for n in names:
|
||||
parts = n.split("/")
|
||||
if len(parts) == 2 and parts[1].lower() == "skill.md":
|
||||
return n, parts[0]
|
||||
return None, None
|
||||
|
||||
|
||||
def parse_skill_zip(zip_bytes: bytes) -> dict | None:
|
||||
"""解析 skill zip 包(不落盘),提取 name/description/content 与附件列表。
|
||||
|
||||
输入:
|
||||
zip_bytes: zip 文件字节。
|
||||
|
||||
输出:
|
||||
{"name", "description", "content", "attachments": [相对路径...]};
|
||||
未找到 skill.md/SKILL.md 时返回 None。
|
||||
"""
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(zip_bytes))
|
||||
except zipfile.BadZipFile:
|
||||
return None
|
||||
with zf:
|
||||
md_entry, top_dir = _find_skill_md_in_zip(zf)
|
||||
if md_entry is None:
|
||||
return None
|
||||
try:
|
||||
md_text = zf.read(md_entry).decode("utf-8", errors="replace")
|
||||
except KeyError:
|
||||
return None
|
||||
meta, body = _parse_frontmatter(md_text)
|
||||
# 附件:一级子目录下除 skill.md/skill.json 之外的其余文件(不深入展开子目录内部)
|
||||
attachments = []
|
||||
for n in zf.namelist():
|
||||
if n.endswith("/") or n == md_entry:
|
||||
continue
|
||||
parts = n.split("/")
|
||||
if top_dir:
|
||||
if parts[0] != top_dir:
|
||||
continue
|
||||
rel = "/".join(parts[1:])
|
||||
else:
|
||||
rel = n
|
||||
if rel.lower() in ("skill.md", "skill.json"):
|
||||
continue
|
||||
attachments.append(rel)
|
||||
attachments.sort()
|
||||
return {
|
||||
"name": meta.get("name", ""),
|
||||
"description": meta.get("description", ""),
|
||||
"content": body,
|
||||
"attachments": attachments,
|
||||
}
|
||||
|
||||
|
||||
def import_skill_zip(name: str, zip_bytes: bytes, description: str = "",
|
||||
content: str = "", tags: list[str] | None = None,
|
||||
enable: bool = True) -> bool:
|
||||
"""导入 skill zip 包到 skills 目录(含附件,保持原相对路径)。
|
||||
|
||||
输入:
|
||||
name: skill 名称(前端解析后用户可修改)。
|
||||
zip_bytes: zip 文件字节。
|
||||
description/content/tags/enable: 用户在前端编辑后的覆盖值;
|
||||
content 为空时使用 zip 内 skill.md 原文。
|
||||
|
||||
输出:
|
||||
成功返回 True。
|
||||
"""
|
||||
if not name or not name.strip():
|
||||
return False
|
||||
name = name.strip().replace(" ", "_")
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(zip_bytes))
|
||||
except zipfile.BadZipFile:
|
||||
return False
|
||||
with zf:
|
||||
md_entry, top_dir = _find_skill_md_in_zip(zf)
|
||||
if md_entry is None:
|
||||
return False
|
||||
try:
|
||||
md_text = zf.read(md_entry).decode("utf-8", errors="replace")
|
||||
except KeyError:
|
||||
return False
|
||||
meta, body = _parse_frontmatter(md_text)
|
||||
final_content = content if content else body
|
||||
final_description = description if description else meta.get("description", "")
|
||||
|
||||
skill_dir = SKILLS_DIR / name
|
||||
if skill_dir.exists():
|
||||
shutil.rmtree(skill_dir, ignore_errors=True)
|
||||
os.makedirs(skill_dir, exist_ok=True)
|
||||
|
||||
ok = create_or_update_skill(name, final_description, final_content, tags or [], enable)
|
||||
if not ok:
|
||||
return False
|
||||
|
||||
# 提取附件(跳过 skill.md/skill.json;剥离一级目录前缀;穿越防护)
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(zip_bytes))
|
||||
with zf:
|
||||
for n in zf.namelist():
|
||||
if n.endswith("/") or n == md_entry:
|
||||
continue
|
||||
parts = n.split("/")
|
||||
if top_dir:
|
||||
if parts[0] != top_dir:
|
||||
continue
|
||||
rel = "/".join(parts[1:])
|
||||
else:
|
||||
rel = n
|
||||
if rel.lower() in ("skill.md", "skill.json") or not rel:
|
||||
continue
|
||||
safe = _safe_relpath(rel)
|
||||
if safe is None:
|
||||
continue
|
||||
target = skill_dir / safe
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(target, "wb") as f:
|
||||
f.write(zf.read(n))
|
||||
except (zipfile.BadZipFile, OSError):
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
# ---------- 供 skill_router 使用的批量加载 ----------
|
||||
|
||||
def load_all_skill_contents() -> dict[str, str]:
|
||||
@@ -347,7 +558,7 @@ def load_all_skill_metas() -> dict[str, dict]:
|
||||
skill.json 中 enable 为 false 的 skill 会被跳过。
|
||||
|
||||
输出:
|
||||
{skill_name: {name, description, version, author, tags, enable}, ...}
|
||||
{skill_name: {name, description, tags, enable}, ...}
|
||||
"""
|
||||
_ensure_skills_dir()
|
||||
result = {}
|
||||
@@ -365,10 +576,9 @@ def load_all_skill_metas() -> dict[str, dict]:
|
||||
# 跳过禁用的 skill
|
||||
if meta.get("enable") is False:
|
||||
continue
|
||||
meta = _strip_legacy_meta(meta, json_path)
|
||||
meta["name"] = entry.name
|
||||
meta.setdefault("description", "")
|
||||
meta.setdefault("version", "0.0")
|
||||
meta.setdefault("author", "minor")
|
||||
meta.setdefault("tags", [])
|
||||
meta.setdefault("enable", True)
|
||||
result[entry.name] = meta
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
{
|
||||
"name": "netease_mail_read",
|
||||
"description": "网易邮箱邮件读取操作指南。当用户任务涉及查看邮件、读取邮件、收邮件、查阅163邮箱等操作时匹配此 Skill。",
|
||||
"version": "1.0.0",
|
||||
"author": "minor",
|
||||
"tags": [
|
||||
"网易邮箱",
|
||||
"邮件",
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
{
|
||||
"name": "netease_mail_send",
|
||||
"description": "网易邮箱邮件发送操作指南。当用户任务涉及发送邮件、写邮件、通过163邮箱发信等操作时匹配此 Skill。",
|
||||
"version": "1.0.0",
|
||||
"author": "minor",
|
||||
"tags": [
|
||||
"网易邮箱",
|
||||
"邮件",
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
{
|
||||
"name": "ppt_maker",
|
||||
"description": "PPT 制作 Skill v3.0 — HTML-only 路线,生成视觉精美、带动态效果的演示文稿 HTML 页面。内置 15+ 预设风格、完整设计系统和 data-anim 动效引擎。触发词: 做PPT、生成PPT、写幻灯片、演示文稿、presentation。",
|
||||
"version": "3.0.0",
|
||||
"author": "minor",
|
||||
"tags": ["ppt", "html", "演示文稿", "幻灯片", "动画", "生产力"],
|
||||
"tags": [
|
||||
"ppt",
|
||||
"html",
|
||||
"演示文稿",
|
||||
"幻灯片",
|
||||
"动画",
|
||||
"生产力"
|
||||
],
|
||||
"enable": true
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
{
|
||||
"name": "taste",
|
||||
"description": "设计审美 Skill — 反 AI 模板化设计规范。适用于 PPT 制作中的配色/字体/排版审美决策,以及前端落地页、作品集、品牌站的 UI 设计。触发词: 美观、好看、设计感、审美、不模板化、漂亮、改进样式。详细规范见 skill.md。",
|
||||
"version": "1.0.0",
|
||||
"author": "minor",
|
||||
"tags": [
|
||||
"审美",
|
||||
"设计",
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
{
|
||||
"name": "wechat_send_message",
|
||||
"description": "微信消息发送操作指南。当用户任务涉及通过微信发送消息、给某人发微信、微信通知等操作时匹配此 Skill。",
|
||||
"version": "1.0.0",
|
||||
"author": "minor",
|
||||
"tags": [
|
||||
"微信",
|
||||
"消息",
|
||||
|
||||
@@ -139,6 +139,10 @@ def _read_plain_text(file_path: str, grep_pattern: str | None = None,
|
||||
def _create_plain_text(file_path: str, content: str) -> str:
|
||||
if os.path.exists(file_path):
|
||||
return f"[ERROR] 文件已存在: {file_path}"
|
||||
# 自动创建缺失的父目录(配合 plan 模式写入 .minorAgent 等场景)
|
||||
parent = os.path.dirname(file_path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
return f"[OK] 已创建文件: {file_path}"
|
||||
@@ -259,6 +263,10 @@ def _read_tabular(file_path: str, sheet_name: str | None = None,
|
||||
def _create_tabular(file_path: str, content: str) -> str:
|
||||
if os.path.exists(file_path):
|
||||
return f"[ERROR] 文件已存在: {file_path}"
|
||||
# 自动创建缺失的父目录
|
||||
parent = os.path.dirname(file_path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
ext = pathlib.Path(file_path).suffix.lower()
|
||||
if ext == ".csv":
|
||||
with open(file_path, "w", encoding="utf-8-sig", newline="") as f:
|
||||
@@ -384,6 +392,10 @@ def _create_docx(file_path: str, content: str, image_paths: list[str] | None = N
|
||||
return f"[ERROR] 文件已存在: {file_path}"
|
||||
from docx import Document
|
||||
from docx.shared import Inches
|
||||
# 自动创建缺失的父目录
|
||||
parent = os.path.dirname(file_path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
doc = Document()
|
||||
# 清洗 Markdown 标记后写入文本段落
|
||||
cleaned = _strip_markdown(content)
|
||||
|
||||
@@ -52,7 +52,7 @@ def _refresh_globals():
|
||||
global WORKING_DIR, WORKSPACE_DIR, USER_PYTHON_PATH
|
||||
global LLM_BASE_URL, LLM_API_KEY, LLM_MODEL, LLM_TIMEOUT
|
||||
global STREAMING_TTS_URL, ASR_BASE_URL, RAG_BASE_URL
|
||||
global LLM_CONTEXT_WINDOW, COMPRESS_RATE
|
||||
global LLM_CONTEXT_WINDOW, COMPRESS_RATE, THINKING_LEVEL
|
||||
global IMG_SIZE, RAG_CHUNK_SIZE, RAG_CHUNK_OVERLAP, GROUNDING_WIDTH, GROUNDING_HEIGHT
|
||||
global SEND_FILE_SIZE_LIMIT
|
||||
|
||||
@@ -71,6 +71,9 @@ def _refresh_globals():
|
||||
RAG_BASE_URL = _get_env("RAG_BASE_URL")
|
||||
LLM_CONTEXT_WINDOW = int(os.getenv("LLM_CONTEXT_WINDOW", 262144))
|
||||
COMPRESS_RATE = float(os.getenv("COMPRESS_RATE", 0.6))
|
||||
THINKING_LEVEL = _get_env("THINKING_LEVEL", default="low")
|
||||
if THINKING_LEVEL not in THINKING_LEVELS:
|
||||
THINKING_LEVEL = "low"
|
||||
IMG_SIZE = int(os.getenv("IMG_SIZE", 768))
|
||||
RAG_CHUNK_SIZE = int(os.getenv("RAG_CHUNK_SIZE", 500))
|
||||
RAG_CHUNK_OVERLAP = int(os.getenv("RAG_CHUNK_OVERLAP", 50))
|
||||
@@ -79,6 +82,28 @@ def _refresh_globals():
|
||||
SEND_FILE_SIZE_LIMIT = int(os.getenv("SEND_FILE_SIZE_LIMIT", 30)) * 1024 * 1024
|
||||
|
||||
|
||||
# ---------- 深度思考档位 ----------
|
||||
# low=agent+不思考 high=plan+不思考 xhigh=agent+思考 max=plan+思考
|
||||
# ultra 为未来 react→审批图预留占位,暂按"agent+思考"处理
|
||||
THINKING_LEVELS: tuple[str, ...] = ("low", "high", "xhigh", "max", "ultra")
|
||||
_THINKING_ENABLED_LEVELS: frozenset[str] = frozenset({"xhigh", "max", "ultra"})
|
||||
THINKING_LEVEL: str = "low"
|
||||
|
||||
|
||||
def thinking_enabled(level: str | None = None) -> bool:
|
||||
"""判断思考档位是否启用深度思考。"""
|
||||
lv = (level or THINKING_LEVEL or "low").lower()
|
||||
return lv in _THINKING_ENABLED_LEVELS
|
||||
|
||||
|
||||
def get_thinking_extra_body(level: str | None = None) -> dict | None:
|
||||
"""根据思考档位返回 ChatQwen 的 extra_body。
|
||||
|
||||
返回空 dict {} 表示恢复模型原生行为(启用思考);None 表示禁用思考(默认)。
|
||||
"""
|
||||
return {} if thinking_enabled(level) else None
|
||||
|
||||
|
||||
def _load_config() -> dict:
|
||||
"""加载 env_config.json,不存在时回退到 .env 文件。
|
||||
|
||||
|
||||
@@ -67,9 +67,20 @@ def invoke_tool_and_build_message(tool, tool_name: str, tool_args: dict, tool_ca
|
||||
|
||||
系统定位:
|
||||
由 ``core/runtime`` 和 ``tools/agent_call`` 在需要手动 invoke 工具时调用。
|
||||
同时是工具执行前的唯一钳点:工作空间访问策略(限制访问 / 无人值守审批
|
||||
退化)在此拦截越界操作,覆盖图内执行、人工确认恢复循环与审批后重放路径。
|
||||
"""
|
||||
from langchain_core.messages import ToolMessage
|
||||
|
||||
# 工作空间越界拦截:策略决策为 block 时在工具真正执行前返回错误
|
||||
try:
|
||||
from agent.core.workspace_policy import build_block_message as _policy_block_message
|
||||
from agent.core.workspace_policy import decision as _policy_decision
|
||||
if _policy_decision(tool_name, tool_args) == "block":
|
||||
return _policy_block_message(tool_name, tool_args), None
|
||||
except Exception:
|
||||
pass # 策略模块异常不应阻断工具执行
|
||||
|
||||
tool_call_input = {
|
||||
"name": tool_name,
|
||||
"args": tool_args,
|
||||
|
||||
@@ -13,7 +13,7 @@ import { setRenderAttachmentChips as audioSetRenderChips } from './js/audio.js';
|
||||
import {
|
||||
renderMessages, renderMessage, renderAttachmentChips, scrollChatToBottom,
|
||||
appendThinkingIndicator, showPendingActionInThinking, streamAssistantMessage,
|
||||
renderAudioBubble,
|
||||
renderAudioBubble, addTextQuote, initChatQuoteMenu,
|
||||
} from './js/chat-render.js';
|
||||
import { setRenderPersistentToolCalls } from './js/chat-render.js';
|
||||
|
||||
@@ -47,7 +47,7 @@ import {
|
||||
import { setRenderSkills } from './js/settings.js';
|
||||
|
||||
// ---- Skills ----
|
||||
import { renderSkills, bindSkillEditorEvents } from './js/skills.js';
|
||||
import { renderSkills, bindSkillEditorEvents, bindImportMenu } from './js/skills.js';
|
||||
|
||||
// ---- Edit 模式 ----
|
||||
import { initEditMode, sendEditChat, switchToMode, acceptAllModifications, rejectAllModifications, getDocModifications, getCurrentTaskModifications, renderDocTabs, loadDocContent, setCronModeHooks } from './js/edit-mode.js';
|
||||
@@ -75,12 +75,18 @@ import { saveDocSnapshot, undoDocSnapshot, getDocSnapshots, setDocContent, getDo
|
||||
import { initTheme } from './js/themes.js';
|
||||
import { initI18n } from './js/i18n.js';
|
||||
|
||||
// ---- Token 进度条 & 压缩 ----
|
||||
import { initTokenBar, refreshTokens, stopTokenPolling } from './js/token-bar.js';
|
||||
// ---- Token 环形指示器(替代原进度条)----
|
||||
import { initTokenRing, refreshTokens, stopTokenPolling } from './js/token-ring.js';
|
||||
|
||||
// ---- 工作空间 ----
|
||||
import { initWorkspace, bindWorkspaceEvents } from './js/workspace.js';
|
||||
|
||||
// ---- 工作空间访问模式 ----
|
||||
import { initAccessMode, bindAccessModeEvents } from './js/access-mode.js';
|
||||
|
||||
// ---- 思考模式档位 ----
|
||||
import { initThinkLevel, bindThinkModeEvents } from './js/think-level.js';
|
||||
|
||||
// ===== 连线:注入跨模块依赖 =====
|
||||
// toolcalls 的持久化渲染由 setRenderPersistentToolCalls 注入
|
||||
import { renderPersistentToolCalls } from './js/toolcalls.js';
|
||||
@@ -147,7 +153,6 @@ setRollbackDeps({
|
||||
// ===== Composer 居中/底部动画逻辑 =====
|
||||
export function setComposerCentered(centered) {
|
||||
const composer = document.querySelector(".composer");
|
||||
const tokenBar = document.getElementById("tokenBar");
|
||||
const chatPlaceholder = document.getElementById("chatPlaceholder");
|
||||
const chatMessages = document.getElementById("chatMessages");
|
||||
const mainContentWrap = document.getElementById("mainContentWrap");
|
||||
@@ -157,7 +162,6 @@ export function setComposerCentered(centered) {
|
||||
if (centered) {
|
||||
composer.classList.add("composer--centered");
|
||||
composer.classList.remove("composer--bottom");
|
||||
if (tokenBar) tokenBar.classList.add("token-bar--hidden");
|
||||
if (chatPlaceholder) chatPlaceholder.style.display = "none";
|
||||
if (mainContentWrap) mainContentWrap.classList.add("main-content-wrap--collapsed");
|
||||
if (welcomeView) {
|
||||
@@ -171,7 +175,6 @@ export function setComposerCentered(centered) {
|
||||
} else {
|
||||
composer.classList.remove("composer--centered");
|
||||
composer.classList.add("composer--bottom");
|
||||
if (tokenBar) tokenBar.classList.remove("token-bar--hidden");
|
||||
if (chatPlaceholder) chatPlaceholder.style.display = "";
|
||||
if (mainContentWrap) mainContentWrap.classList.remove("main-content-wrap--collapsed");
|
||||
if (welcomeView) welcomeView.hidden = true;
|
||||
@@ -528,6 +531,15 @@ function _setupComposerDrop() {
|
||||
// edit 模式内部文件拖拽(application/doc-path)由 edit-mode 处理,不重复添加
|
||||
if (e.dataTransfer.types.includes("application/doc-path")) return;
|
||||
|
||||
// 聊天区选中文字拖拽 → 添加为引用(用户引用)
|
||||
if (e.dataTransfer.types.includes("application/x-chat-quote")) {
|
||||
const q = e.dataTransfer.getData("application/x-chat-quote");
|
||||
if (q && q.trim()) {
|
||||
addTextQuote(q.trim());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 聊天区图片拖拽:从自定义数据创建 File(避免浏览器生成的通用文件名)
|
||||
if (e.dataTransfer.types.includes("application/x-chat-image")) {
|
||||
const imgUrl = e.dataTransfer.getData("application/x-chat-image");
|
||||
@@ -728,9 +740,91 @@ initActionBar({
|
||||
},
|
||||
});
|
||||
|
||||
// 设置内联下拉框:输出模式、Agent 模式
|
||||
setupInlineDropdown("outputModeTrigger", "outputModePanel");
|
||||
setupInlineDropdown("agentModeTrigger", "agentModePanel");
|
||||
// 工作空间访问模式(图标触发器,document 委托,含连击保护)
|
||||
bindAccessModeEvents();
|
||||
|
||||
// 思考模式档位(图标触发器 + 渐变色滑条,document 委托)
|
||||
bindThinkModeEvents();
|
||||
|
||||
// 文字/语音回复开关(document 委托 + 连击保护)
|
||||
// 默认文字模式;切换到语音模式前先检查 TTS 服务连通性,不通则提示并保持文字模式
|
||||
let _voiceToggling = false;
|
||||
let _lastVoiceToggleAt = 0;
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!e.target.closest || !e.target.closest("#voiceToggleBtn")) return;
|
||||
toggleOutputMode();
|
||||
});
|
||||
|
||||
async function toggleOutputMode() {
|
||||
const now = Date.now();
|
||||
if (_voiceToggling || now - _lastVoiceToggleAt < 400) return;
|
||||
_lastVoiceToggleAt = now;
|
||||
_voiceToggling = true;
|
||||
try {
|
||||
const target = state.outputMode === "voice" ? "text" : "voice";
|
||||
if (target === "voice" && !(await checkTtsService())) {
|
||||
return; // TTS 不可用:提示并保持文字模式
|
||||
}
|
||||
state.outputMode = target;
|
||||
updateVoiceToggleUi();
|
||||
} finally {
|
||||
_voiceToggling = false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateVoiceToggleUi() {
|
||||
const btn = $("voiceToggleBtn");
|
||||
if (!btn) return;
|
||||
const isVoice = state.outputMode === "voice";
|
||||
btn.classList.toggle("is-active", isVoice);
|
||||
btn.setAttribute("aria-pressed", isVoice ? "true" : "false");
|
||||
btn.title = isVoice ? "语音回复(点击切换为文字)" : "文字回复(点击切换为语音)";
|
||||
}
|
||||
|
||||
async function checkTtsService() {
|
||||
try {
|
||||
const st = await api("/api/services/status");
|
||||
if (st && st.tts && st.tts.ok) return true;
|
||||
showToast("语音(TTS)服务不可用,无法切换为语音模式");
|
||||
} catch {
|
||||
showToast("无法检测语音(TTS)服务状态");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ===== 启动时检测 ASR / TTS 服务,决定麦克风与语音开关是否可用 =====
|
||||
function applyServicesStatus(st) {
|
||||
const asrOk = !!(st && st.asr && st.asr.ok);
|
||||
const ttsOk = !!(st && st.tts && st.tts.ok);
|
||||
|
||||
const mic = $("micBtn");
|
||||
if (mic) {
|
||||
mic.disabled = !asrOk;
|
||||
mic.title = asrOk ? "使用麦克风录音" : "语音转文字(ASR)服务不可用";
|
||||
}
|
||||
const voice = $("voiceToggleBtn");
|
||||
if (voice) {
|
||||
voice.disabled = !ttsOk;
|
||||
voice.title = ttsOk
|
||||
? (state.outputMode === "voice" ? "语音回复(点击切换为文字)" : "文字回复(点击切换为语音)")
|
||||
: "语音(TTS)服务不可用";
|
||||
if (!ttsOk && state.outputMode === "voice") {
|
||||
// 启动时 TTS 不可用则回退为文字模式
|
||||
state.outputMode = "text";
|
||||
updateVoiceToggleUi();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function initServicesStatus() {
|
||||
try {
|
||||
const st = await api("/api/services/status");
|
||||
applyServicesStatus(st);
|
||||
} catch {
|
||||
// 检测失败时不限制按钮,点击时仍有兜底检查
|
||||
}
|
||||
}
|
||||
|
||||
// 模型选择在 populateModelSelect 中动态构建,只需绑定触发器
|
||||
const modelTrigger = $("modelTrigger");
|
||||
@@ -869,10 +963,14 @@ if (drawerBackdrop) {
|
||||
|
||||
// Skills 编辑器事件绑定
|
||||
bindSkillEditorEvents();
|
||||
bindImportMenu();
|
||||
|
||||
// Edit 模式初始化
|
||||
initEditMode();
|
||||
|
||||
// 聊天区文字引用(左键选中 + 右键引用 / 拖拽到输入区)
|
||||
initChatQuoteMenu();
|
||||
|
||||
// Cron 模式:注入进入/离开钩子,避免 edit-mode ↔ cron 循环依赖
|
||||
setCronModeHooks({ onEnter: enterCronMode, onLeave: leaveCronMode });
|
||||
// Cron 模式初始化(绑定列表/弹窗事件)
|
||||
@@ -907,10 +1005,13 @@ initI18n();
|
||||
|
||||
// ===== 启动 =====
|
||||
bootstrap().then(() => {
|
||||
initTokenBar();
|
||||
initTokenRing();
|
||||
populateModelSelect();
|
||||
initWorkspace();
|
||||
initAccessMode();
|
||||
initThinkLevel();
|
||||
loadNickname();
|
||||
initServicesStatus();
|
||||
}).catch((e) => {
|
||||
const chatPlaceholder = $("chatPlaceholder");
|
||||
if (chatPlaceholder) {
|
||||
|
||||
@@ -381,39 +381,63 @@
|
||||
overflow-x: visible;
|
||||
}
|
||||
|
||||
/* ===== 回退按钮 ===== */
|
||||
/* ===== 消息操作按钮行(复制 / 撤回 / 重试,无边框图标按钮) ===== */
|
||||
.msg.user {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.chat-rollback-btn {
|
||||
.msg-actions {
|
||||
display: inline-flex;
|
||||
gap: 0.15rem;
|
||||
align-items: center;
|
||||
opacity: 0.45;
|
||||
transition: opacity 0.18s ease;
|
||||
padding-top: 0.05rem;
|
||||
}
|
||||
.msg-actions--user {
|
||||
align-self: flex-end;
|
||||
}
|
||||
.msg-actions--assistant {
|
||||
align-self: flex-start;
|
||||
}
|
||||
.msg:hover .msg-actions,
|
||||
.msg-actions:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
.chat-action-btn {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-right: 6px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--muted);
|
||||
outline: none;
|
||||
background: transparent;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.35;
|
||||
transition: opacity 0.18s, background 0.18s, color 0.18s, transform 0.18s, border-color 0.18s;
|
||||
z-index: 3;
|
||||
transition: background 0.15s ease, transform 0.12s ease;
|
||||
}
|
||||
.msg.user:hover .chat-rollback-btn {
|
||||
.chat-action-btn img {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: block;
|
||||
}
|
||||
.chat-action-btn:hover {
|
||||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
}
|
||||
.chat-action-btn:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
/* 点赞 / 点踩 激活态 */
|
||||
.chat-feedback-btn.is-active {
|
||||
opacity: 1;
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
}
|
||||
.chat-rollback-btn:hover {
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
color: var(--accent);
|
||||
transform: scale(1.1);
|
||||
.chat-feedback-btn.is-active img {
|
||||
filter: invert(24%) sepia(70%) saturate(2000%) hue-rotate(160deg) brightness(94%) contrast(92%);
|
||||
}
|
||||
|
||||
/* ===== 欢迎页(空会话居中,在 main-content-wrap 外) ===== */
|
||||
@@ -573,3 +597,13 @@
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
|
||||
/* ===== AI 回复开始时间(分支按钮右侧)===== */
|
||||
.msg-start-time {
|
||||
font-size: 0.66rem;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
align-self: center;
|
||||
margin-left: 0.15rem;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -189,10 +189,6 @@
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.composer--centered .token-bar {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 过渡到正常状态 */
|
||||
.composer--bottom {
|
||||
margin-top: 0.45rem;
|
||||
@@ -901,6 +897,15 @@
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* 聊天区文字引用 chip(用户引用) */
|
||||
.ref-chip--quote {
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
|
||||
.ref-chip--quote .ref-chip-icon {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* ===== 引用 bar ===== */
|
||||
.composer-refs {
|
||||
display: flex;
|
||||
@@ -1323,107 +1328,265 @@
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ===== Token 上下文进度条 ===== */
|
||||
.token-bar {
|
||||
display: flex;
|
||||
/* ===== 语音开关按钮(文字/语音)===== */
|
||||
.voice-toggle-btn {
|
||||
width: 1.7rem;
|
||||
height: 1.7rem;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
padding: 0 4px;
|
||||
transition: opacity 0.25s ease;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
opacity: 0.4;
|
||||
transition: opacity 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.token-bar.token-bar--hidden {
|
||||
display: none !important;
|
||||
.voice-toggle-btn:hover {
|
||||
opacity: 0.75;
|
||||
background: color-mix(in srgb, var(--text) 6%, transparent);
|
||||
}
|
||||
.voice-toggle-btn.is-active {
|
||||
opacity: 0.95;
|
||||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
}
|
||||
.voice-toggle-btn:disabled,
|
||||
.mic-btn:disabled {
|
||||
opacity: 0.25;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.voice-toggle-btn img {
|
||||
width: 0.95rem;
|
||||
height: 0.95rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.token-bar-track {
|
||||
flex: 1;
|
||||
/* ===== 图标型内联下拉触发器(访问模式 / 思考模式)===== */
|
||||
.inline-drop-trigger--icon {
|
||||
width: 1.7rem;
|
||||
height: 1.7rem;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.inline-drop-trigger--icon img {
|
||||
width: 0.95rem;
|
||||
height: 0.95rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ===== 访问模式面板:三种模式彩色文字选项(与图标颜色一致:绿/黄/橙红)===== */
|
||||
.access-mode-item--restricted { color: var(--success); }
|
||||
.access-mode-item--restricted.is-selected { background: color-mix(in srgb, var(--success) 14%, transparent); }
|
||||
.access-mode-item--approval { color: var(--warning); }
|
||||
.access-mode-item--approval.is-selected { background: color-mix(in srgb, var(--warning) 16%, transparent); }
|
||||
.access-mode-item--full { color: var(--danger); }
|
||||
.access-mode-item--full.is-selected { background: color-mix(in srgb, var(--danger) 14%, transparent); }
|
||||
|
||||
/* ===== 思考模式面板:档位标签 + 横向渐变色滑动条 =====
|
||||
面板右对齐向左展开(按钮位于右侧工具栏,避免伸出窗口右缘被截断) */
|
||||
.think-mode-panel {
|
||||
width: 164px;
|
||||
right: 0;
|
||||
left: auto;
|
||||
padding: 0.6rem 0.65rem 0.55rem;
|
||||
}
|
||||
.think-mode-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.think-mode-label {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 0.62rem;
|
||||
font-family: var(--font-family, inherit);
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.1rem;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.think-mode-label:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
.think-mode-label.is-active {
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
}
|
||||
.think-mode-track {
|
||||
position: relative;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
/* 渐变色滑动条:冷色(不思考) → 暖色(深度思考) */
|
||||
background: linear-gradient(90deg,
|
||||
color-mix(in srgb, var(--text-tertiary) 55%, transparent),
|
||||
var(--accent) 35%,
|
||||
var(--warning) 65%,
|
||||
var(--danger) 100%);
|
||||
}
|
||||
.think-mode-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(90deg,
|
||||
color-mix(in srgb, var(--text-tertiary) 55%, transparent),
|
||||
var(--accent) 35%,
|
||||
var(--warning) 65%,
|
||||
var(--danger) 100%);
|
||||
opacity: 0.9;
|
||||
}
|
||||
.think-mode-handle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface);
|
||||
border: 2px solid var(--accent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 22%, transparent), var(--shadow);
|
||||
transform: translate(-50%, -50%);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
.think-mode-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* ===== Token 环形指示器(模型选择器左侧)===== */
|
||||
.token-ring-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.token-ring-btn {
|
||||
width: 1.7rem;
|
||||
height: 1.7rem;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
opacity: 0.55;
|
||||
transition: opacity 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.token-ring-btn:hover {
|
||||
opacity: 0.95;
|
||||
background: color-mix(in srgb, var(--text) 6%, transparent);
|
||||
}
|
||||
.token-ring-svg {
|
||||
display: block;
|
||||
}
|
||||
.token-ring-bg {
|
||||
fill: none;
|
||||
stroke: var(--border);
|
||||
stroke-width: 7;
|
||||
}
|
||||
.token-ring-fill {
|
||||
fill: none;
|
||||
stroke: var(--accent);
|
||||
stroke-width: 7;
|
||||
stroke-linecap: round;
|
||||
/* 从顶部开始:旋转 -90° 由 transform 实现 */
|
||||
transform: rotate(-90deg);
|
||||
transform-origin: 50% 50%;
|
||||
transition: stroke-dashoffset 0.5s ease;
|
||||
}
|
||||
|
||||
/* ===== Token 环形 hover 面板 ===== */
|
||||
.token-ring-panel {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: 232px;
|
||||
padding: 0.6rem 0.7rem;
|
||||
border-radius: var(--radius, 12px);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 8px 24px var(--shadow);
|
||||
z-index: 60;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.token-ring-capacity {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 0.45rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.token-ring-rate-track {
|
||||
position: relative;
|
||||
height: 5px;
|
||||
border-radius: 3px;
|
||||
background: var(--divider);
|
||||
overflow: visible;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.token-bar-seg {
|
||||
.token-ring-rate-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
border-radius: 3px 0 0 3px;
|
||||
transition: width 0.35s ease;
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: var(--accent);
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
.token-bar-seg--green {
|
||||
background: var(--success, #22c55e);
|
||||
opacity: 0.45;
|
||||
z-index: 1;
|
||||
border-radius: 3px 0 0 3px;
|
||||
}
|
||||
.token-bar-seg--red {
|
||||
background: var(--danger, #ef4444);
|
||||
opacity: 0.85;
|
||||
z-index: 1;
|
||||
border-radius: 0 3px 3px 0;
|
||||
}
|
||||
|
||||
.token-bar-fill {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.token-handle {
|
||||
/* 压缩阈值刻度线(静态展示,阈值在环境变量中修改) */
|
||||
.token-ring-threshold {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 10;
|
||||
cursor: ew-resize;
|
||||
user-select: none;
|
||||
transition: transform 0.15s ease;
|
||||
top: -2px;
|
||||
width: 2px;
|
||||
height: 9px;
|
||||
border-radius: 1px;
|
||||
background: var(--warning);
|
||||
opacity: 0.85;
|
||||
pointer-events: none;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.token-handle:hover {
|
||||
transform: translate(-50%, -50%) scale(1.3);
|
||||
}
|
||||
.token-handle.dragging {
|
||||
transform: translate(-50%, -50%) scale(1.45);
|
||||
}
|
||||
|
||||
.token-handle-dot {
|
||||
width: 8px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: var(--accent, #0e7490);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
transition: background 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.token-handle:hover .token-handle-dot,
|
||||
.token-handle.dragging .token-handle-dot {
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
|
||||
.token-handle--compress .token-handle-dot {
|
||||
background: var(--accent, #0e7490);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent);
|
||||
}
|
||||
|
||||
.token-handle.overshot .token-handle-dot {
|
||||
animation: handlePulse 0.6s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes handlePulse {
|
||||
from { box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger) 15%, transparent); }
|
||||
to { box-shadow: 0 0 0 5px color-mix(in srgb, var(--danger) 10%, transparent); }
|
||||
}
|
||||
|
||||
.token-label {
|
||||
font-size: 0.66rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
min-width: 78px;
|
||||
text-align: right;
|
||||
.token-ring-rate-label {
|
||||
font-size: 0.68rem;
|
||||
color: var(--muted);
|
||||
margin: 0.3rem 0 0.4rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.token-ring-breakdown {
|
||||
border-top: 1px solid var(--divider);
|
||||
padding-top: 0.35rem;
|
||||
}
|
||||
.token-ring-breakdown-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.35rem;
|
||||
padding: 0.12rem 0;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.token-ring-breakdown-dot {
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
width: 0.6rem;
|
||||
text-align: center;
|
||||
flex: none;
|
||||
}
|
||||
.token-ring-breakdown-key {
|
||||
flex: 1;
|
||||
color: var(--text);
|
||||
}
|
||||
.token-ring-breakdown-val {
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.25s, color 0.25s;
|
||||
}
|
||||
|
||||
.token-label--safe { color: var(--success, #22c55e); }
|
||||
.token-label--warn { color: var(--warning, #f59e0b); }
|
||||
.token-label--danger { color: var(--danger, #ef4444); }
|
||||
@@ -1015,3 +1015,10 @@
|
||||
}
|
||||
|
||||
/* once 选择器中的 time-picker 复用 .cron-time-picker 样式 */
|
||||
|
||||
/* ===== 访问模式提示(警告样式)===== */
|
||||
.cron-field-hint--warn {
|
||||
display: block;
|
||||
margin-top: 0.3rem;
|
||||
color: var(--warning);
|
||||
}
|
||||
@@ -146,6 +146,20 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* 工作空间越界审批提示(访问策略 policy_note) */
|
||||
.pending-overlay-note {
|
||||
font-size: 0.78rem;
|
||||
color: #b45309;
|
||||
background: color-mix(in srgb, #f59e0b 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, #f59e0b 45%, transparent);
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 0.55rem;
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ===== tool_call 卡片:参数只读展示 ===== */
|
||||
.pending-overlay-tool-name {
|
||||
font-size: 0.8rem;
|
||||
|
||||
@@ -519,6 +519,7 @@
|
||||
|
||||
/* ===== 环境变量配置 ===== */
|
||||
.env-config-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
@@ -526,6 +527,47 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 环境变量 "?" 帮助按钮与说明 popover */
|
||||
.env-help-btn {
|
||||
flex: none;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--text) 5%, transparent);
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.env-help-btn:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
}
|
||||
.env-help-popover {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 6px);
|
||||
z-index: 80;
|
||||
width: 260px;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 8px 24px var(--shadow);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.env-config-item label {
|
||||
min-width: 150px;
|
||||
font-size: 0.8rem;
|
||||
@@ -947,3 +989,37 @@
|
||||
.settings-panel .btn-ghost:hover {
|
||||
background: var(--divider);
|
||||
}
|
||||
|
||||
/* ===== Skill 导入菜单 ===== */
|
||||
.skill-import-menu {
|
||||
position: absolute;
|
||||
z-index: 90;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
min-width: 130px;
|
||||
padding: 0.3rem;
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 8px 24px var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.skill-import-menu button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
text-align: left;
|
||||
padding: 0.4rem 0.55rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.skill-import-menu button:hover {
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
}
|
||||
.settings-toolbar {
|
||||
position: relative;
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -10,7 +10,7 @@
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="icon" href="/image/favicon.svg" sizes="any" />
|
||||
<link rel="stylesheet" href="/app.css?v=2" />
|
||||
<link rel="stylesheet" href="/app.css?v=6" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-scene" id="bgScene" aria-hidden="true">
|
||||
@@ -167,18 +167,17 @@
|
||||
></textarea>
|
||||
<div class="composer-input-bottom-row">
|
||||
<div class="composer-input-bottom-left">
|
||||
<div class="inline-drop-wrap" id="outputModeWrap">
|
||||
<button type="button" class="inline-drop-trigger" id="outputModeTrigger" aria-label="Agent 输出模式">文字模式</button>
|
||||
<div class="inline-drop-panel" id="outputModePanel" hidden>
|
||||
<div class="inline-drop-item is-selected" data-value="text">文字模式</div>
|
||||
<div class="inline-drop-item" data-value="voice">语音模式</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inline-drop-wrap" id="agentModeWrap">
|
||||
<button type="button" class="inline-drop-trigger" id="agentModeTrigger" aria-label="Agent 工作模式">agent</button>
|
||||
<div class="inline-drop-panel" id="agentModePanel" hidden>
|
||||
<div class="inline-drop-item is-selected" data-value="agent">agent</div>
|
||||
<div class="inline-drop-item" data-value="plan">plan</div>
|
||||
<button type="button" class="voice-toggle-btn" id="voiceToggleBtn" title="文字/语音回复开关" aria-label="语音回复开关" aria-pressed="false">
|
||||
<img src="/image/文本语音 .svg" alt="语音" aria-hidden="true" />
|
||||
</button>
|
||||
<div class="inline-drop-wrap" id="accessModeWrap">
|
||||
<button type="button" class="inline-drop-trigger inline-drop-trigger--icon" id="accessModeTrigger" aria-label="工作空间访问模式" title="工作空间访问模式:限制访问 / 权限审查 / 完全访问">
|
||||
<img src="/image/限制访问.svg" alt="访问模式" id="accessModeIcon" />
|
||||
</button>
|
||||
<div class="inline-drop-panel" id="accessModePanel" hidden>
|
||||
<div class="inline-drop-item is-selected access-mode-item--restricted" data-value="restricted">限制访问</div>
|
||||
<div class="inline-drop-item access-mode-item--approval" data-value="approval">权限审查</div>
|
||||
<div class="inline-drop-item access-mode-item--full" data-value="full">完全访问</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="input-actions-sep"></span>
|
||||
@@ -196,10 +195,39 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="composer-input-bottom-right">
|
||||
<div class="token-ring-wrap" id="tokenRingWrap">
|
||||
<button type="button" class="token-ring-btn" id="tokenRingBtn" title="上下文 Token 占用" aria-label="上下文 Token 占用">
|
||||
<svg class="token-ring-svg" id="tokenRingSvg" viewBox="0 0 30 30" width="13" height="13">
|
||||
<circle class="token-ring-bg" cx="15" cy="15" r="11"></circle>
|
||||
<circle class="token-ring-fill" id="tokenRingFill" cx="15" cy="15" r="11"></circle>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="token-ring-panel" id="tokenRingPanel" hidden>
|
||||
<div class="token-ring-capacity" id="tokenRingCapacity">上下文容量 0k / 0k (0%)</div>
|
||||
<div class="token-ring-rate-track" id="tokenRingRateTrack">
|
||||
<div class="token-ring-rate-fill" id="tokenRingRateFill"></div>
|
||||
<div class="token-ring-threshold" id="tokenRingThreshold"></div>
|
||||
</div>
|
||||
<div class="token-ring-rate-label" id="tokenRingRateLabel">压缩阈值 60%(在环境变量中修改)</div>
|
||||
<div class="token-ring-breakdown" id="tokenRingBreakdown"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inline-drop-wrap" id="modelSelectWrap">
|
||||
<button type="button" class="inline-drop-trigger inline-drop-trigger--model" id="modelTrigger" aria-label="模型选择">模型</button>
|
||||
<div class="inline-drop-panel" id="modelPanel" hidden></div>
|
||||
</div>
|
||||
<div class="inline-drop-wrap" id="thinkModeWrap">
|
||||
<button type="button" class="inline-drop-trigger inline-drop-trigger--icon" id="thinkModeTrigger" aria-label="思考模式" title="思考模式:low / high / xhigh / max / ultra">
|
||||
<img src="/image/思考模式.svg" alt="思考" id="thinkModeIcon" />
|
||||
</button>
|
||||
<div class="inline-drop-panel think-mode-panel" id="thinkModePanel" hidden>
|
||||
<div class="think-mode-labels" id="thinkModeLabels"></div>
|
||||
<div class="think-mode-track" id="thinkModeTrack">
|
||||
<div class="think-mode-fill" id="thinkModeFill"></div>
|
||||
<div class="think-mode-handle" id="thinkModeHandle"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ws-btn-wrap" id="wsBtnWrap">
|
||||
<button type="button" class="ws-btn" id="wsBtn" title="选择工作空间" aria-label="选择工作空间">
|
||||
<img src="/image/文件夹.svg" alt="" aria-hidden="true" />
|
||||
@@ -227,18 +255,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Token 上下文进度条 -->
|
||||
<div class="token-bar" id="tokenBar">
|
||||
<div class="token-bar-track" id="tokenBarTrack">
|
||||
<div class="token-bar-seg token-bar-seg--green" id="tokenBarSegGreen"></div>
|
||||
<div class="token-bar-seg token-bar-seg--red" id="tokenBarSegRed"></div>
|
||||
<div class="token-bar-fill" id="tokenBarFill"></div>
|
||||
<div class="token-handle token-handle--compress" id="tokenHandleCompress" title="压缩阈值(拖拽调整)">
|
||||
<div class="token-handle-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="token-label token-label--safe" id="tokenLabel">0 / 0 tokens</span>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
@@ -275,6 +291,7 @@
|
||||
<label class="cron-field-label" for="cronEditPrompt">任务提示词</label>
|
||||
<textarea id="cronEditPrompt" placeholder="触发时投递给 Agent 的指令,如:查询今日天气并生成晨报"></textarea>
|
||||
<span class="cron-field-hint">该提示词将在每次触发时投递给 Agent 自主执行。</span>
|
||||
<span class="cron-field-hint cron-field-hint--warn" id="cronAccessModeHint" hidden>⚠️ 定时任务无人值守执行:当前访问模式为「限制访问/权限审查」时,超出工作空间的操作会被拦截或退化拦截,建议先切换为「完全访问」。</span>
|
||||
</div>
|
||||
<div class="cron-field">
|
||||
<span class="cron-field-label">触发方式</span>
|
||||
@@ -373,8 +390,10 @@
|
||||
<div class="settings-panel" data-panel="skills" id="skillsPanel" hidden>
|
||||
<div class="settings-toolbar">
|
||||
<button type="button" class="btn btn-primary" id="addSkillBtn">+ 创建 Skill</button>
|
||||
<span class="settings-path">Skills 目录:agent/skills/</span>
|
||||
<button type="button" class="btn btn-ghost" id="importSkillBtn" title="导入 zip 或文件夹(自动解析 skill.md)">导入 Skill</button>
|
||||
</div>
|
||||
<input type="file" id="importSkillZipInput" accept=".zip" hidden />
|
||||
<input type="file" id="importSkillFolderInput" webkitdirectory multiple hidden />
|
||||
<div class="skills-list" id="skillsList"></div>
|
||||
<!-- Skill 编辑/创建弹窗 -->
|
||||
<div class="skill-editor-overlay" id="skillEditorOverlay" hidden>
|
||||
@@ -393,15 +412,7 @@
|
||||
<input type="text" id="skillDescInput" placeholder="简要描述此 skill 的用途" />
|
||||
</div>
|
||||
<div class="setting-field">
|
||||
<label>版本 (version)</label>
|
||||
<input type="text" id="skillVersionInput" value="1.0.0" placeholder="版本号" />
|
||||
</div>
|
||||
<div class="setting-field">
|
||||
<label>作者 (author)</label>
|
||||
<input type="text" id="skillAuthorInput" placeholder="作者名" />
|
||||
</div>
|
||||
<div class="setting-field">
|
||||
<label>标签 (tags,逗号分隔)</label>
|
||||
<label>标签 (tags,逗号分隔,可选)</label>
|
||||
<input type="text" id="skillTagsInput" placeholder="e.g. python, 自动化, GUI" />
|
||||
</div>
|
||||
<div class="setting-field">
|
||||
@@ -574,6 +585,6 @@
|
||||
_showJsError('Promise: ' + (e.reason?.message || String(e.reason)));
|
||||
});
|
||||
</script>
|
||||
<script type="module" src="/app.js?v=2"></script>
|
||||
<script type="module" src="/app.js?v=6"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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]}」`);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ function setRecordingUi(recording) {
|
||||
micBtn.title = "停止录音";
|
||||
micBtn.setAttribute("aria-label", "停止录音");
|
||||
} else {
|
||||
micBtn.title = "使用麦克风录音";
|
||||
micBtn.title = micBtn.disabled ? "语音转文字(ASR)服务不可用" : "使用麦克风录音";
|
||||
micBtn.setAttribute("aria-label", "使用麦克风录音");
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,19 @@ async function startMicRecording() {
|
||||
}
|
||||
if (state.sending) return;
|
||||
|
||||
// 语音转文字依赖 ASR 服务:先检查连通性,不通则提示并中止录音
|
||||
try {
|
||||
const { api } = await import('./api.js');
|
||||
const st = await api("/api/services/status");
|
||||
if (!st || !st.asr || !st.asr.ok) {
|
||||
showToast("语音转文字(ASR)服务不可用,无法录音");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
showToast("无法检测语音转文字(ASR)服务状态");
|
||||
return;
|
||||
}
|
||||
|
||||
const preferredTypes = [
|
||||
"audio/wav",
|
||||
"audio/mp4",
|
||||
@@ -134,7 +147,7 @@ async function toggleMicRecording() {
|
||||
} else {
|
||||
const { sendChat } = await import('./send.js');
|
||||
await sendChat();
|
||||
const { refreshTokens } = await import('./token-bar.js');
|
||||
const { refreshTokens } = await import('./token-ring.js');
|
||||
refreshTokens();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// chat-render.js -- 消息渲染 / 流式输出 / 音频气泡(新版:无气泡 + 媒体行 + agent-run-block)
|
||||
|
||||
import { $, escapeHtml, getTextFromContent, getMediaParts, isAudioOnlyAssistantMessage, clamp, formatAudioDuration, getAudioBubbleWidth, codeBlockNode, inferUploadKind, dedupePendingFiles } from './utils.js';
|
||||
import { $, escapeHtml, getTextFromContent, getMediaParts, isAudioOnlyAssistantMessage, clamp, formatAudioDuration, getAudioBubbleWidth, codeBlockNode, inferUploadKind, dedupePendingFiles, showToast } from './utils.js';
|
||||
import { state } from './state.js';
|
||||
import {
|
||||
showImagePreview,
|
||||
@@ -50,11 +50,13 @@ function renderAttachmentChips() {
|
||||
const refImages = [];
|
||||
const refFiles = [];
|
||||
const refFolderItems = [];
|
||||
const refQuotes = [];
|
||||
|
||||
state.pendingFiles.forEach((f, i) => {
|
||||
if (f.__isRef) {
|
||||
if (f.type === 'ref/image') refImages.push({ file: f, idx: i });
|
||||
else if (f.type === 'ref/folder') refFolderItems.push({ file: f, idx: i });
|
||||
else if (f.type === 'ref/quote') refQuotes.push({ file: f, idx: i });
|
||||
else refFiles.push({ file: f, idx: i });
|
||||
} else {
|
||||
const kind = inferUploadKind(f);
|
||||
@@ -189,6 +191,23 @@ function renderAttachmentChips() {
|
||||
hasContent = true;
|
||||
});
|
||||
|
||||
// 聊天区文字引用 chips(复用 ref-chip UI,注明为用户引用)
|
||||
refQuotes.forEach(({ file, idx }) => {
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "ref-chip ref-chip--quote";
|
||||
chip.title = "用户引用:" + (file.refText || file.refName || "");
|
||||
chip.innerHTML = `<img src="/image/回复.svg" alt="" class="ref-chip-icon" /> <span class="ref-chip-label">${escapeHtml(file.refName || file.name)}</span> <button type="button" class="ref-chip-close" aria-label="移除">\u00D7</button>`;
|
||||
chip.querySelector("button").addEventListener("click", () => {
|
||||
state.pendingFiles = state.pendingFiles.filter((_, j) => j !== idx);
|
||||
if (file.refPath) {
|
||||
state.pendingRefs = state.pendingRefs.filter((r) => r.path !== file.refPath);
|
||||
}
|
||||
renderAttachmentChips();
|
||||
});
|
||||
chipsArea.appendChild(chip);
|
||||
hasContent = true;
|
||||
});
|
||||
|
||||
if (hasContent || imagesArea.children.length > 0 || chipsArea.children.length > 0) {
|
||||
wrap.classList.add("has-content");
|
||||
} else {
|
||||
@@ -955,29 +974,15 @@ function renderMessage(msg, allowAutoplay = false) {
|
||||
};
|
||||
|
||||
if (msg.role === "user") {
|
||||
// 用户消息:回退按钮 + 内容(无气泡)
|
||||
const rollbackBtn = document.createElement("button");
|
||||
rollbackBtn.className = "chat-rollback-btn";
|
||||
rollbackBtn.textContent = "\u21A9";
|
||||
rollbackBtn.title = "回退到此消息";
|
||||
rollbackBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (!chatMessages) return;
|
||||
const children = Array.from(chatMessages.children);
|
||||
const idx = children.indexOf(wrap);
|
||||
if (idx < 0) return;
|
||||
if (rollbackChatFn) {
|
||||
rollbackChatFn(idx);
|
||||
}
|
||||
});
|
||||
wrap.appendChild(rollbackBtn);
|
||||
|
||||
// 用户消息:内容 + 下方操作按钮(复制 / 撤回)
|
||||
const contentEl = renderUserContent(msg.content, baseOptions);
|
||||
wrap.appendChild(contentEl);
|
||||
wrap.appendChild(renderMessageActions("user", msg, wrap));
|
||||
} else {
|
||||
// 助手消息
|
||||
// 助手消息:内容 + 下方操作按钮(复制 / 重试)
|
||||
const contentEl = renderAssistantContent(msg, allowAutoplay);
|
||||
wrap.appendChild(contentEl);
|
||||
wrap.appendChild(renderMessageActions("assistant", msg, wrap));
|
||||
if (msg.meta && msg.meta.pending_action) {
|
||||
// pending_action 不再内嵌在气泡中,由 pending-overlay 浮窗展示
|
||||
}
|
||||
@@ -986,6 +991,362 @@ function renderMessage(msg, allowAutoplay = false) {
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/* ===== 消息下方操作按钮行(无边框图标按钮) ===== */
|
||||
function renderMessageActions(role, msg, wrap) {
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "msg-actions msg-actions--" + role;
|
||||
|
||||
// 复制到剪贴板
|
||||
const copyBtn = document.createElement("button");
|
||||
copyBtn.type = "button";
|
||||
copyBtn.className = "chat-action-btn";
|
||||
copyBtn.title = "复制";
|
||||
copyBtn.setAttribute("aria-label", "复制消息内容");
|
||||
const copyImg = document.createElement("img");
|
||||
copyImg.src = "/image/复制.svg";
|
||||
copyImg.alt = "";
|
||||
copyImg.draggable = false;
|
||||
copyBtn.appendChild(copyImg);
|
||||
copyBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const text = getTextFromContent(msg.content);
|
||||
if (!text) {
|
||||
showToast("没有可复制的内容");
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showToast("已复制到剪贴板");
|
||||
}).catch(() => {
|
||||
showToast("复制失败");
|
||||
});
|
||||
});
|
||||
actions.appendChild(copyBtn);
|
||||
|
||||
if (role === "user") {
|
||||
// 撤回:回退到此消息
|
||||
const rollbackBtn = document.createElement("button");
|
||||
rollbackBtn.type = "button";
|
||||
rollbackBtn.className = "chat-action-btn";
|
||||
rollbackBtn.title = "撤回";
|
||||
rollbackBtn.setAttribute("aria-label", "撤回此消息");
|
||||
const rbImg = document.createElement("img");
|
||||
rbImg.src = "/image/撤回.svg";
|
||||
rbImg.alt = "";
|
||||
rbImg.draggable = false;
|
||||
rollbackBtn.appendChild(rbImg);
|
||||
rollbackBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (!chatMessages) return;
|
||||
const children = Array.from(chatMessages.children);
|
||||
const idx = children.indexOf(wrap);
|
||||
if (idx < 0) return;
|
||||
if (rollbackChatFn) rollbackChatFn(idx);
|
||||
});
|
||||
actions.appendChild(rollbackBtn);
|
||||
} else {
|
||||
// 点赞 / 点踩(本地持久化,刷新/切换会话后保留)
|
||||
const msgIdx = (() => {
|
||||
if (!chatMessages) return -1;
|
||||
return Array.from(chatMessages.children).indexOf(wrap);
|
||||
})();
|
||||
const fbKey = _feedbackKey(msg, msgIdx);
|
||||
const fbMap0 = _loadFeedbackMap();
|
||||
const curVal = fbMap0[fbKey] || "";
|
||||
|
||||
const likeBtn = document.createElement("button");
|
||||
likeBtn.type = "button";
|
||||
likeBtn.className = "chat-action-btn chat-feedback-btn";
|
||||
likeBtn.title = "点赞";
|
||||
likeBtn.setAttribute("aria-label", "点赞");
|
||||
const likeImg = document.createElement("img");
|
||||
likeImg.src = "/image/点赞.svg";
|
||||
likeImg.alt = "";
|
||||
likeImg.draggable = false;
|
||||
likeBtn.appendChild(likeImg);
|
||||
if (curVal === "like") likeBtn.classList.add("is-active");
|
||||
|
||||
const dislikeBtn = document.createElement("button");
|
||||
dislikeBtn.type = "button";
|
||||
dislikeBtn.className = "chat-action-btn chat-feedback-btn";
|
||||
dislikeBtn.title = "点踩";
|
||||
dislikeBtn.setAttribute("aria-label", "点踩");
|
||||
const dislikeImg = document.createElement("img");
|
||||
dislikeImg.src = "/image/点踩.svg";
|
||||
dislikeImg.alt = "";
|
||||
dislikeImg.draggable = false;
|
||||
dislikeBtn.appendChild(dislikeImg);
|
||||
if (curVal === "dislike") dislikeBtn.classList.add("is-active");
|
||||
|
||||
const applyFeedback = (val) => {
|
||||
const map = _loadFeedbackMap();
|
||||
const next = map[fbKey] === val ? "" : val;
|
||||
map[fbKey] = next;
|
||||
_saveFeedbackMap(map);
|
||||
likeBtn.classList.toggle("is-active", next === "like");
|
||||
dislikeBtn.classList.toggle("is-active", next === "dislike");
|
||||
showToast(next === "like" ? "已点赞" : next === "dislike" ? "已点踩" : "已取消评价");
|
||||
};
|
||||
|
||||
likeBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
applyFeedback("like");
|
||||
});
|
||||
dislikeBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
applyFeedback("dislike");
|
||||
});
|
||||
|
||||
actions.appendChild(likeBtn);
|
||||
actions.appendChild(dislikeBtn);
|
||||
|
||||
// 重试:删除本轮记录(撤回)并重新执行 agent(撤回 + 发送)
|
||||
const retryBtn = document.createElement("button");
|
||||
retryBtn.type = "button";
|
||||
retryBtn.className = "chat-action-btn";
|
||||
retryBtn.title = "重试";
|
||||
retryBtn.setAttribute("aria-label", "重试本轮回复");
|
||||
const rtImg = document.createElement("img");
|
||||
rtImg.src = "/image/重试.svg";
|
||||
rtImg.alt = "";
|
||||
rtImg.draggable = false;
|
||||
retryBtn.appendChild(rtImg);
|
||||
retryBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
handleRetry(wrap);
|
||||
});
|
||||
actions.appendChild(retryBtn);
|
||||
|
||||
// 分支:将当前回复及之前的所有历史复制到新会话
|
||||
const branchBtn = document.createElement("button");
|
||||
branchBtn.type = "button";
|
||||
branchBtn.className = "chat-action-btn";
|
||||
branchBtn.title = "分支";
|
||||
branchBtn.setAttribute("aria-label", "复制历史到新会话");
|
||||
const brImg = document.createElement("img");
|
||||
brImg.src = "/image/分支.svg";
|
||||
brImg.alt = "";
|
||||
brImg.draggable = false;
|
||||
branchBtn.appendChild(brImg);
|
||||
branchBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
handleBranch(wrap, msg);
|
||||
});
|
||||
actions.appendChild(branchBtn);
|
||||
|
||||
// 分支右侧:SSE 开始时间(即该轮 AI 回复开始的时间,时:分)
|
||||
const turnMeta = (msg && msg.meta && msg.meta.turn_meta) || {};
|
||||
if (turnMeta.started_at) {
|
||||
const timeEl = document.createElement("span");
|
||||
timeEl.className = "msg-start-time";
|
||||
timeEl.textContent = _formatStartTime(turnMeta.started_at);
|
||||
timeEl.title = "本轮回复开始时间";
|
||||
actions.appendChild(timeEl);
|
||||
}
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
/** 将 Unix 秒时间戳格式化为 时:分(24 小时制,补零) */
|
||||
function _formatStartTime(startedAt) {
|
||||
const ts = Number(startedAt);
|
||||
if (!Number.isFinite(ts) || ts <= 0) return "";
|
||||
const d = new Date(ts * 1000);
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${hh}:${mm}`;
|
||||
}
|
||||
|
||||
/** 分支:将当前回复及之前的所有历史复制到新会话并跳转 */
|
||||
async function handleBranch(wrap, msg) {
|
||||
if (state.sending) {
|
||||
showToast("Agent 运行中,无法分支。请先暂停任务。");
|
||||
return;
|
||||
}
|
||||
if (!state.sessionId) {
|
||||
showToast("无当前会话");
|
||||
return;
|
||||
}
|
||||
const turnId = msg && msg.meta && msg.meta.turn_id;
|
||||
if (!turnId) {
|
||||
showToast("无法获取该回复的回合信息");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { api } = await import("./api.js");
|
||||
const fd = new FormData();
|
||||
fd.append("session_id", state.sessionId);
|
||||
fd.append("branch_turn_id", turnId);
|
||||
const data = await api("/api/sessions/branch", { method: "POST", body: fd });
|
||||
const { applySessionData } = await import("./sessions.js");
|
||||
await applySessionData(data);
|
||||
showToast("已创建分支会话,历史已复制到新会话");
|
||||
} catch (err) {
|
||||
showToast("分支失败: " + (err.message || "未知错误"));
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 点赞 / 点踩 本地持久化 ===== */
|
||||
const FEEDBACK_STORAGE_KEY = "minor_agent_msg_feedback";
|
||||
|
||||
function _loadFeedbackMap() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(FEEDBACK_STORAGE_KEY) || "{}");
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function _saveFeedbackMap(map) {
|
||||
try {
|
||||
localStorage.setItem(FEEDBACK_STORAGE_KEY, JSON.stringify(map));
|
||||
} catch {
|
||||
/* 忽略写入失败 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 生成消息级反馈的持久化键:优先 turn_id,缺失时退回消息索引 */
|
||||
function _feedbackKey(msg, msgIndex) {
|
||||
const tid = msg && msg.meta && msg.meta.turn_id;
|
||||
if (tid) return `${state.sessionId}:turn:${tid}:assistant`;
|
||||
return `${state.sessionId}:idx:${msgIndex}:assistant`;
|
||||
}
|
||||
|
||||
/** 重试:回退到本轮的用户消息(含该消息),恢复输入后重新发送 */
|
||||
async function handleRetry(wrap) {
|
||||
if (state.sending) {
|
||||
showToast("Agent 运行中,无法重试。请先暂停任务。");
|
||||
return;
|
||||
}
|
||||
if (!state.sessionId) {
|
||||
showToast("无当前会话");
|
||||
return;
|
||||
}
|
||||
// 找到本轮对应的用户消息(当前回复之前最近的一条 user 消息)
|
||||
let prev = wrap.previousElementSibling;
|
||||
while (prev && !prev.classList.contains("user")) {
|
||||
prev = prev.previousElementSibling;
|
||||
}
|
||||
if (!prev) {
|
||||
showToast("未找到对应的用户消息");
|
||||
return;
|
||||
}
|
||||
if (!chatMessages) return;
|
||||
const children = Array.from(chatMessages.children);
|
||||
const userIdx = children.indexOf(prev);
|
||||
if (userIdx < 0) return;
|
||||
|
||||
// 撤回该轮(删除本轮用户消息及其后的记录,内容恢复到输入区)
|
||||
let rolledBack = false;
|
||||
if (rollbackChatFn) {
|
||||
rolledBack = await rollbackChatFn(userIdx, { skipConfirm: true });
|
||||
}
|
||||
if (!rolledBack) return;
|
||||
// 撤回成功后重新执行 agent(相当于撤回 + 发送)
|
||||
const textInput = $("textInput");
|
||||
const { sendChat } = await import("./send.js");
|
||||
if (textInput && textInput.value.trim()) {
|
||||
sendChat();
|
||||
} else {
|
||||
showToast("已撤回本轮消息,请在输入框确认内容后重新发送");
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 聊天区文字引用(引用为"用户引用",复用 ref-chip UI) ===== */
|
||||
let _quoteSeq = 0;
|
||||
|
||||
/** 将选中的聊天文字添加为引用 chip(发送时以「用户引用」形式注入文本) */
|
||||
function addTextQuote(text) {
|
||||
const clean = String(text || "").trim();
|
||||
if (!clean) {
|
||||
showToast("请先选中要引用的文字");
|
||||
return;
|
||||
}
|
||||
const id = "quote:" + Date.now().toString(36) + ":" + (_quoteSeq++).toString(36);
|
||||
state.pendingRefs.push({ type: "quote", path: id, text: clean });
|
||||
state.pendingFiles.push({
|
||||
__isRef: true,
|
||||
refPath: id,
|
||||
refName: clean.length > 28 ? clean.slice(0, 28) + "…" : clean,
|
||||
type: "ref/quote",
|
||||
name: "引用",
|
||||
refText: clean,
|
||||
});
|
||||
renderAttachmentChips();
|
||||
const textInput = $("textInput");
|
||||
if (textInput) textInput.focus();
|
||||
showToast("已添加引用");
|
||||
}
|
||||
|
||||
let _quoteMenuCloseBound = null;
|
||||
|
||||
function removeQuoteMenu() {
|
||||
const m = document.querySelector(".chat-quote-menu");
|
||||
if (m) m.remove();
|
||||
// 清理文档级关闭监听,避免旧菜单的监听误删新菜单
|
||||
if (_quoteMenuCloseBound) {
|
||||
document.removeEventListener("mousedown", _quoteMenuCloseBound);
|
||||
_quoteMenuCloseBound = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 初始化聊天区引用:右键选中文字显示「引用」菜单 + 选中文字拖拽到输入区 */
|
||||
function initChatQuoteMenu() {
|
||||
if (!chatMessages) return;
|
||||
|
||||
// 左键选中文字后右键 → 显示"引用"菜单
|
||||
chatMessages.addEventListener("contextmenu", (e) => {
|
||||
const sel = window.getSelection();
|
||||
const text = sel ? sel.toString().trim() : "";
|
||||
if (!text) return; // 无选中内容时保留默认右键菜单
|
||||
if (!chatMessages.contains(sel.anchorNode)) return;
|
||||
e.preventDefault();
|
||||
removeQuoteMenu();
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "doc-context-menu chat-quote-menu";
|
||||
// 限制菜单在视口内,避免靠右/靠底时溢出不可点击
|
||||
const menuWidth = 168; // 约等于 .doc-context-menu min-width(160) + 边距
|
||||
const menuHeight = 48; // 单个菜单项的高度余量
|
||||
const posX = Math.max(4, Math.min(e.clientX, window.innerWidth - menuWidth));
|
||||
const posY = Math.max(4, Math.min(e.clientY, window.innerHeight - menuHeight));
|
||||
menu.style.left = posX + "px";
|
||||
menu.style.top = posY + "px";
|
||||
const item = document.createElement("div");
|
||||
item.className = "doc-context-menu-item";
|
||||
item.textContent = "引用所选文字";
|
||||
item.addEventListener("click", () => {
|
||||
addTextQuote(text);
|
||||
removeQuoteMenu();
|
||||
});
|
||||
menu.appendChild(item);
|
||||
document.body.appendChild(menu);
|
||||
const close = (ev) => {
|
||||
// 点击菜单项时 item 的 click 处理器先执行(移除菜单),此处检测到已移除则跳过
|
||||
if (menu.contains(ev.target)) return;
|
||||
removeQuoteMenu();
|
||||
};
|
||||
_quoteMenuCloseBound = close;
|
||||
setTimeout(() => document.addEventListener("click", close), 0);
|
||||
});
|
||||
|
||||
// 左键选中文字后拖拽到输入区
|
||||
chatMessages.addEventListener("dragstart", (e) => {
|
||||
// 图片 / 文件 / 文档引用拖拽走各自的自定义数据,不干扰
|
||||
if (
|
||||
e.dataTransfer.types.includes("application/x-chat-image") ||
|
||||
e.dataTransfer.types.includes("application/x-chat-file") ||
|
||||
e.dataTransfer.types.includes("application/doc-path")
|
||||
) return;
|
||||
const sel = window.getSelection();
|
||||
const text = sel ? sel.toString().trim() : "";
|
||||
if (!text) return;
|
||||
if (!chatMessages.contains(sel.anchorNode)) return;
|
||||
e.dataTransfer.setData("application/x-chat-quote", text);
|
||||
e.dataTransfer.setData("text/plain", text);
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
});
|
||||
}
|
||||
|
||||
function renderMessages(messages, allowAutoplay = false) {
|
||||
if (!chatMessages || !chatPlaceholder) return;
|
||||
const list = messages || [];
|
||||
@@ -1110,6 +1471,9 @@ async function streamAssistantMessage(msg) {
|
||||
// pending_action 不再内嵌在气泡中,由 pending-overlay 浮窗展示
|
||||
}
|
||||
|
||||
// 操作按钮行(复制 / 重试)
|
||||
wrap.appendChild(renderMessageActions("assistant", msg, wrap));
|
||||
|
||||
scrollChatToBottom();
|
||||
}
|
||||
|
||||
@@ -1149,4 +1513,5 @@ export {
|
||||
appendThinkingIndicator, showPendingActionInThinking,
|
||||
streamAssistantMessage,
|
||||
renderVoiceTranscribingPlaceholder,
|
||||
addTextQuote, initChatQuoteMenu,
|
||||
};
|
||||
@@ -1170,6 +1170,18 @@ export function openCronEditor(taskId) {
|
||||
state._cronEditingTaskId = taskId || "";
|
||||
$("cronEditTitle").textContent = taskId ? "编辑定时任务" : "新建定时任务";
|
||||
|
||||
// 访问模式提示:定时任务无人值守,非「完全访问」时越界会被拦截/退化拦截
|
||||
const accessHint = $("cronAccessModeHint");
|
||||
if (accessHint) {
|
||||
const modeLabel = { restricted: "限制访问", approval: "权限审查", full: "完全访问" }[state.accessMode] || state.accessMode;
|
||||
if (state.accessMode === "full") {
|
||||
accessHint.hidden = true;
|
||||
} else {
|
||||
accessHint.hidden = false;
|
||||
accessHint.textContent = `⚠️ 定时任务无人值守执行:当前访问模式为「${modeLabel}」,超出工作空间的操作会被拦截或退化拦截,建议先切换为「完全访问」。`;
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const setVal = (id, v) => { const el = $(id); if (el) el.value = v ?? ""; };
|
||||
|
||||
|
||||
@@ -145,6 +145,13 @@ function buildPendingCard(pending, isSubAgent) {
|
||||
|
||||
// ===== tool_call 类型:展示参数(只读)+ 决策按钮 =====
|
||||
if (pendingType === "tool_call") {
|
||||
// 工作空间越界审批提示(访问策略注入的 policy_note)
|
||||
if (pending.policy_note) {
|
||||
const noteEl = document.createElement("div");
|
||||
noteEl.className = "pending-overlay-note";
|
||||
noteEl.textContent = "⚠️ 越界操作待审批:" + unescapeNewlines(pending.policy_note);
|
||||
cardBody.appendChild(noteEl);
|
||||
}
|
||||
// 参数 JSON(只读)
|
||||
if (pending.args !== undefined && pending.args !== null) {
|
||||
let argsStr;
|
||||
@@ -285,6 +292,16 @@ function buildPendingCard(pending, isSubAgent) {
|
||||
}
|
||||
}
|
||||
|
||||
// 人机交互卡片统一提供"拒绝"按钮:与手动暂停一致,拒绝后暂停本轮运行
|
||||
if (pendingType === "human_interaction") {
|
||||
const rejectRow = document.createElement("div");
|
||||
rejectRow.className = "pending-overlay-btn-row";
|
||||
rejectRow.appendChild(makeDecisionBtn("拒绝", "reject", "", () => {
|
||||
submitSingle(pending.id, "reject", "", card);
|
||||
}));
|
||||
cardBody.appendChild(rejectRow);
|
||||
}
|
||||
|
||||
card.appendChild(cardBody);
|
||||
return card;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ async function handleHumanAction(approvalId, decision, instruction, card) {
|
||||
const sendBtn = $("sendBtn");
|
||||
if (sendBtn) sendBtn.disabled = true;
|
||||
if (card) card.classList.add("pending-action-card--resolved");
|
||||
const outputModeTrigger = $("outputModeTrigger");
|
||||
|
||||
// 复用 sendChat 留下的现有 thinking 气泡(不删除不重建)
|
||||
const chatMessages = $("chatMessages");
|
||||
@@ -54,7 +53,7 @@ async function handleHumanAction(approvalId, decision, instruction, card) {
|
||||
fd.append("approval_id", approvalId);
|
||||
fd.append("decision", decision);
|
||||
fd.append("instruction", instruction || "");
|
||||
fd.append("output_type", outputModeTrigger ? (outputModeTrigger.dataset.value || "text") : "text");
|
||||
fd.append("output_type", state.outputMode || "text");
|
||||
|
||||
const stopToolPoll = startPollLiveToolCalls(state.sessionId, typingEl, injectLiveToolCallsFn);
|
||||
|
||||
|
||||
@@ -45,22 +45,25 @@ export function setRollbackDeps(deps) {
|
||||
/**
|
||||
* 将聊天历史回撤到指定消息处
|
||||
* @param {number} rollbackIndex - 要回退到的消息在渲染列表中的索引(包含该消息)
|
||||
* @param {object} [options] - { skipConfirm: boolean } 跳过确认弹窗(重试等场景)
|
||||
*/
|
||||
export async function rollbackChat(rollbackIndex) {
|
||||
export async function rollbackChat(rollbackIndex, options = {}) {
|
||||
// 保护:Agent 运行中不允许回撤
|
||||
if (state.sending) {
|
||||
showToast("Agent 运行中,无法回撤。请先暂停任务。");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (!state.sessionId) {
|
||||
showToast("无当前会话");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const confirmed = await showConfirm(
|
||||
"确认回退到此消息?\n\n将清除此消息之后的所有对话历史、TodoList、人机交互内容、工具调用记录。"
|
||||
);
|
||||
if (!confirmed) return;
|
||||
if (!options.skipConfirm) {
|
||||
const confirmed = await showConfirm(
|
||||
"确认回退到此消息?\n\n将清除此消息之后的所有对话历史、TodoList、人机交互内容、工具调用记录。"
|
||||
);
|
||||
if (!confirmed) return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 调用后端回撤 API
|
||||
@@ -92,8 +95,10 @@ export async function rollbackChat(rollbackIndex) {
|
||||
}
|
||||
|
||||
showToast("已回退到该消息");
|
||||
return true;
|
||||
} catch (e) {
|
||||
showToast("回退失败: " + (e.message || "未知错误"));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -93,8 +93,6 @@ export function setSendDeps(deps) {
|
||||
const textInput = $("textInput");
|
||||
const fileInput = $("fileInput");
|
||||
const sendBtn = $("sendBtn");
|
||||
const outputModeTrigger = $("outputModeTrigger");
|
||||
const agentModeTrigger = $("agentModeTrigger");
|
||||
|
||||
function setSendBtnPauseMode(pauseMode) {
|
||||
const btn = sendBtn || $("sendBtn");
|
||||
@@ -150,11 +148,28 @@ async function handlePauseClick() {
|
||||
_pausing = false;
|
||||
}
|
||||
|
||||
/** 将待发送的聊天区引用(用户引用)拼接为注入给 agent 的文本 */
|
||||
function buildQuotesText() {
|
||||
const quotes = (state.pendingRefs || []).filter((r) => r && r.type === "quote" && r.text && r.text.trim());
|
||||
if (quotes.length === 0) return "";
|
||||
return quotes.map((q) => "【用户引用】\n" + q.text.trim()).join("\n\n");
|
||||
}
|
||||
|
||||
/** 清除已发送的引用(pendingRefs 与 pendingFiles 中的 ref/quote 条目) */
|
||||
function clearQuoteRefs() {
|
||||
state.pendingRefs = (state.pendingRefs || []).filter((r) => r && r.type !== "quote");
|
||||
state.pendingFiles = state.pendingFiles.filter((f) => !(f && f.__isRef && f.type === "ref/quote"));
|
||||
}
|
||||
|
||||
async function sendChat() {
|
||||
if (!state.sessionId || state.sending) return;
|
||||
const myGen = ++_sendGeneration; // 标记本次 sendChat,防止旧实例的 finally/catch 污染状态
|
||||
const trimmed = (textInput || {}).value ? textInput.value.trim() : "";
|
||||
if (!trimmed && state.pendingFiles.length === 0) return;
|
||||
// 聊天区引用:以「用户引用」形式并入发送文本(agent 收到的内容为具体文字并注明来源)
|
||||
const quotesText = buildQuotesText();
|
||||
const effectiveText = quotesText ? (trimmed ? trimmed + "\n\n" + quotesText : quotesText) : trimmed;
|
||||
const savedQuotes = (state.pendingRefs || []).filter((r) => r && r.type === "quote");
|
||||
if (!effectiveText && state.pendingFiles.length === 0) return;
|
||||
// 首次发送时,输入框从居中移至底部
|
||||
if (onSendStartFn) onSendStartFn();
|
||||
_lastSendTime = Date.now();
|
||||
@@ -208,7 +223,7 @@ async function sendChat() {
|
||||
|
||||
if (hasVoiceInput) {
|
||||
// 语音输入:先渲染已有消息(含普通文件 + 用户输入的文本),再追加"语音转文字中"占位
|
||||
const optimisticContent = buildOptimisticUserContent(trimmed, fileParts);
|
||||
const optimisticContent = buildOptimisticUserContent(effectiveText, fileParts);
|
||||
if (renderMessagesFn) {
|
||||
if (optimisticContent != null) {
|
||||
renderMessagesFn([...priorMessages, { role: "user", content: optimisticContent }]);
|
||||
@@ -219,27 +234,29 @@ async function sendChat() {
|
||||
renderVoiceTranscribingPlaceholder();
|
||||
} else {
|
||||
// 仅普通文件:乐观渲染图片/文件
|
||||
const optimisticContent = buildOptimisticUserContent(trimmed, fileParts);
|
||||
const optimisticContent = buildOptimisticUserContent(effectiveText, fileParts);
|
||||
if (optimisticContent != null && renderMessagesFn) {
|
||||
renderMessagesFn([...priorMessages, { role: "user", content: optimisticContent }]);
|
||||
}
|
||||
}
|
||||
if (textInput) textInput.value = "";
|
||||
state.pendingFiles.length = 0;
|
||||
clearQuoteRefs();
|
||||
if (fileInput) fileInput.value = "";
|
||||
if (renderAttachmentChipsFn) renderAttachmentChipsFn();
|
||||
} else if (trimmed) {
|
||||
// 纯文本消息:立即乐观渲染用户消息,避免等待 API /start 响应
|
||||
} else if (effectiveText) {
|
||||
// 纯文本/引用消息:立即乐观渲染用户消息,避免等待 API /start 响应
|
||||
if (renderMessagesFn) {
|
||||
renderMessagesFn([...priorMessages, { role: "user", content: trimmed }]);
|
||||
renderMessagesFn([...priorMessages, { role: "user", content: effectiveText }]);
|
||||
}
|
||||
if (textInput) textInput.value = "";
|
||||
clearQuoteRefs();
|
||||
}
|
||||
|
||||
state.abortController = new AbortController();
|
||||
const fd = new FormData();
|
||||
fd.append("session_id", state.sessionId);
|
||||
fd.append("text", trimmed);
|
||||
fd.append("text", effectiveText);
|
||||
// 普通文件追加到 files 字段;语音输入单独走 voice_input 字段(后端阻塞 ASR)
|
||||
regularFiles.forEach((f) => fd.append("files", f, f.name));
|
||||
if (voiceInputFile) {
|
||||
@@ -255,11 +272,12 @@ async function sendChat() {
|
||||
if (!hadOptimisticMedia) {
|
||||
if (textInput) textInput.value = "";
|
||||
state.pendingFiles.length = 0;
|
||||
clearQuoteRefs();
|
||||
if (fileInput) fileInput.value = "";
|
||||
if (renderAttachmentChipsFn) renderAttachmentChipsFn();
|
||||
}
|
||||
if (renderSessionsFn) renderSessionsFn();
|
||||
const _isVoice = (outputModeTrigger && outputModeTrigger.dataset.value === "voice");
|
||||
const _isVoice = state.outputMode === "voice";
|
||||
// 语音模式下只追加用户消息气泡(保留旧消息),完整列表等流式 text 事件再刷新
|
||||
// 但若已通过乐观渲染显示了用户消息(含语音输入占位),则重新渲染全部消息以替换占位
|
||||
if (_isVoice && renderMessagesFn && !hadOptimisticMedia) {
|
||||
@@ -306,8 +324,10 @@ async function sendChat() {
|
||||
|
||||
const fd2 = new FormData();
|
||||
fd2.append("session_id", state.sessionId);
|
||||
fd2.append("output_type", outputModeTrigger ? (outputModeTrigger.dataset.value || "text") : "text");
|
||||
fd2.append("agent_mode", agentModeTrigger ? (agentModeTrigger.dataset.value || "agent") : "agent");
|
||||
fd2.append("output_type", state.outputMode || "text");
|
||||
// agent/plan 模式由思考档位派生(low/xhigh/ultra→agent,high/max→plan)
|
||||
const { getAgentModeFromLevel } = await import('./think-level.js');
|
||||
fd2.append("agent_mode", getAgentModeFromLevel(state.thinkingLevel));
|
||||
if (state.selectedModelId) {
|
||||
fd2.append("model_id", state.selectedModelId);
|
||||
}
|
||||
@@ -485,6 +505,19 @@ async function sendChat() {
|
||||
if (renderAttachmentChipsFn) renderAttachmentChipsFn();
|
||||
if (renderMessagesFn) renderMessagesFn(priorMessages);
|
||||
}
|
||||
// 发送失败:恢复被清除的引用 chips(引用已并入文本并清空,失败时补回)
|
||||
if (savedQuotes.length > 0) {
|
||||
state.pendingRefs.push(...savedQuotes);
|
||||
const haveRefs = new Set(
|
||||
state.pendingFiles.filter((f) => f && f.__isRef && f.type === "ref/quote").map((f) => f.refPath)
|
||||
);
|
||||
savedFiles.forEach((f) => {
|
||||
if (f && f.__isRef && f.type === "ref/quote" && !haveRefs.has(f.refPath)) {
|
||||
state.pendingFiles.push(f);
|
||||
}
|
||||
});
|
||||
if (renderAttachmentChipsFn) renderAttachmentChipsFn();
|
||||
}
|
||||
if (typingEl) _cleanupTypingEl(typingEl);
|
||||
if (!(e && e.name === "AbortError")) {
|
||||
showToast(e.message || String(e));
|
||||
|
||||
@@ -5,7 +5,7 @@ import { showConfirm, showPrompt } from './dialog.js';
|
||||
import { api } from './api.js';
|
||||
import { state } from './state.js';
|
||||
import { updatePendingOverlay, clearPendingOverlay } from './pending-overlay.js';
|
||||
import { refreshTokens } from './token-bar.js';
|
||||
import { refreshTokens } from './token-ring.js';
|
||||
|
||||
let renderSessionsFnRef = null;
|
||||
let renderMessagesFn = null;
|
||||
@@ -175,6 +175,15 @@ async function selectSession(id) {
|
||||
if (!id || id === state.sessionId) return;
|
||||
if (state.sending) return; // Agent 运行中禁止切换会话
|
||||
const data = await api(`/api/sessions/${encodeURIComponent(id)}/messages`);
|
||||
await applySessionData(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用一次会话数据(sessionId / sessions / messages / tokens / pending_actions)并刷新界面。
|
||||
* 供「分支」等创建新会话后直接跳转使用。
|
||||
*/
|
||||
export async function applySessionData(data) {
|
||||
if (!data || !data.sessionId) return;
|
||||
state.sessionId = data.sessionId;
|
||||
state.sessions.length = 0;
|
||||
state.sessions.push(...(data.sessions || []));
|
||||
@@ -182,7 +191,7 @@ async function selectSession(id) {
|
||||
if (clearTodoOverlayFn) clearTodoOverlayFn();
|
||||
clearPendingOverlay();
|
||||
if (renderSessionsFnRef) renderSessionsFnRef();
|
||||
if (renderMessagesFn) renderMessagesFn(data.messages);
|
||||
if (renderMessagesFn) renderMessagesFn(data.messages || []);
|
||||
updatePendingOverlay(data.pending_actions || null);
|
||||
refreshTokens();
|
||||
// 设置居中状态
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// settings.js -- 设置面板:Agent / Tool / Env / GUI / Models / Theme
|
||||
|
||||
import { $, escapeHtml, makeId, showToast } from './utils.js';
|
||||
import { $, escapeHtml, makeId, showToast, withClickGuard } from './utils.js';
|
||||
import { showConfirm } from './dialog.js';
|
||||
import { api } from './api.js';
|
||||
import { state } from './state.js';
|
||||
@@ -694,6 +694,41 @@ function buildMysqlConfigSection() {
|
||||
return section;
|
||||
}
|
||||
|
||||
/**
|
||||
* 给环境变量行右侧添加 "?" 帮助按钮(点击显示 ENV_DESC 说明的 popover,含连击保护)。
|
||||
* @param {HTMLElement} row - .env-config-item 行容器
|
||||
* @param {string} key - 环境变量名
|
||||
*/
|
||||
let _envHelpDocListenerBound = false;
|
||||
function attachEnvHelp(row, key) {
|
||||
const desc = ENV_DESC[key] || "该环境变量的用途暂无说明";
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "env-help-btn";
|
||||
btn.textContent = "?";
|
||||
btn.title = "查看说明";
|
||||
const pop = document.createElement("div");
|
||||
pop.className = "env-help-popover";
|
||||
pop.textContent = desc;
|
||||
pop.hidden = true;
|
||||
btn.addEventListener("click", withClickGuard((e) => {
|
||||
e.stopPropagation();
|
||||
document.querySelectorAll(".env-help-popover").forEach((p) => { if (p !== pop) p.hidden = true; });
|
||||
pop.hidden = !pop.hidden;
|
||||
}));
|
||||
row.appendChild(btn);
|
||||
row.appendChild(pop);
|
||||
|
||||
if (!_envHelpDocListenerBound) {
|
||||
_envHelpDocListenerBound = true;
|
||||
document.addEventListener("click", (e) => {
|
||||
if (e.target.closest && e.target.closest(".env-help-btn, .env-help-popover")) return;
|
||||
document.querySelectorAll(".env-help-popover").forEach((p) => { p.hidden = true; });
|
||||
});
|
||||
}
|
||||
return btn;
|
||||
}
|
||||
|
||||
function renderEnvConfig() {
|
||||
if (!envListEl) return;
|
||||
envListEl.innerHTML = "";
|
||||
@@ -725,15 +760,14 @@ function renderEnvConfig() {
|
||||
table.className = "model-table";
|
||||
const thead = document.createElement("div");
|
||||
thead.className = "model-table-head";
|
||||
thead.innerHTML = '<span class="col-name">\u540D\u79F0</span><span class="col-model">Model</span><span class="col-url">Base URL</span><span class="col-think">\u601D\u8003</span><span class="col-actions" style="justify-content:flex-end;color:inherit;font-size:inherit;">\u64CD\u4F5C</span>';
|
||||
thead.innerHTML = '<span class="col-name">\u540D\u79F0</span><span class="col-model">Model</span><span class="col-url">Base URL</span><span class="col-actions" style="justify-content:flex-end;color:inherit;font-size:inherit;">\u64CD\u4F5C</span>';
|
||||
table.appendChild(thead);
|
||||
state.cachedModels.forEach((m, mi) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "model-table-row";
|
||||
row.innerHTML = `<span class="col-name" title="${escapeHtml(m.name)}">${escapeHtml(m.name)}</span>
|
||||
<span class="col-model" title="${escapeHtml(m.model)}">${escapeHtml(m.model)}</span>
|
||||
<span class="col-url" title="${escapeHtml(m.base_url)}">${escapeHtml(m.base_url)}</span>
|
||||
<span class="col-think" style="text-align:center;">${m.enable_thinking ? '<span title="\u5DF2\u542F\u7528\u601D\u8003" style="color:#f59e0b;">ON</span>' : '<span title="\u601D\u8003\u5DF2\u5173\u95ED" style="color:#94a3b8;">OFF</span>'}</span>`;
|
||||
<span class="col-url" title="${escapeHtml(m.base_url)}">${escapeHtml(m.base_url)}</span>`;
|
||||
const actions = document.createElement("span");
|
||||
actions.className = "col-actions";
|
||||
const editBtn = document.createElement("button");
|
||||
@@ -785,26 +819,8 @@ function renderEnvConfig() {
|
||||
modalFields[key] = inp;
|
||||
modalBody.appendChild(div);
|
||||
});
|
||||
const thinkingDiv = document.createElement("div");
|
||||
thinkingDiv.className = "modal-field";
|
||||
const thinkingLabel = document.createElement("label");
|
||||
thinkingLabel.textContent = "\u601D\u8003\u6A21\u5F0F";
|
||||
const thinkingWrap = document.createElement("label");
|
||||
thinkingWrap.className = "toggle-switch";
|
||||
const thinkingCheckbox = document.createElement("input");
|
||||
thinkingCheckbox.type = "checkbox";
|
||||
const thinkingSlider = document.createElement("span");
|
||||
thinkingSlider.className = "toggle-slider";
|
||||
thinkingWrap.appendChild(thinkingCheckbox);
|
||||
thinkingWrap.appendChild(thinkingSlider);
|
||||
const thinkingHint = document.createElement("span");
|
||||
thinkingHint.className = "toggle-hint";
|
||||
thinkingHint.textContent = " \u5173\u95ED\u540E\u7981\u7528\u6A21\u578B\u601D\u8003\uFF08\u9ED8\u8BA4\u5173\u95ED\uFF0C\u51CF\u5C11\u5EF6\u8FDF\uFF09";
|
||||
thinkingDiv.appendChild(thinkingLabel);
|
||||
thinkingDiv.appendChild(thinkingWrap);
|
||||
thinkingDiv.appendChild(thinkingHint);
|
||||
modalFields["enable_thinking"] = thinkingCheckbox;
|
||||
modalBody.appendChild(thinkingDiv);
|
||||
// 深度思考改由 composer 的思考档位(low/high/xhigh/max/ultra)全局控制,此处不再提供开关
|
||||
modalBody.appendChild(document.createComment("thinking-toggle-removed"));
|
||||
modalBox.appendChild(modalBody);
|
||||
const modalActions = document.createElement("div");
|
||||
modalActions.className = "model-modal-actions";
|
||||
@@ -826,7 +842,6 @@ function renderEnvConfig() {
|
||||
base_url: modalFields.base_url.value.trim(),
|
||||
timeout: parseFloat(modalFields.timeout.value) || 60,
|
||||
max_retries: 0,
|
||||
enable_thinking: modalFields.enable_thinking.checked,
|
||||
};
|
||||
if (!data.name || !data.model) { showToast("\u540D\u79F0\u548C\u6A21\u578B\u540D\u4E0D\u80FD\u4E3A\u7A7A"); return; }
|
||||
if (state.editingModelIdx >= 0) {
|
||||
@@ -893,6 +908,7 @@ function renderEnvConfig() {
|
||||
});
|
||||
row.appendChild(lbl);
|
||||
row.appendChild(sel);
|
||||
attachEnvHelp(row, key);
|
||||
// 选 mysql 时紧随其后渲染连接配置区块
|
||||
if (sel.value === "mysql") {
|
||||
const mysqlSection = buildMysqlConfigSection();
|
||||
@@ -936,6 +952,7 @@ function renderEnvConfig() {
|
||||
wrap.appendChild(applyBtn);
|
||||
row.appendChild(lbl);
|
||||
row.appendChild(wrap);
|
||||
attachEnvHelp(row, key);
|
||||
envListEl.appendChild(row);
|
||||
return;
|
||||
}
|
||||
@@ -986,6 +1003,8 @@ function renderEnvConfig() {
|
||||
row.appendChild(fileBtn);
|
||||
}
|
||||
|
||||
attachEnvHelp(row, key);
|
||||
|
||||
envListEl.appendChild(row);
|
||||
});
|
||||
}
|
||||
@@ -1002,14 +1021,12 @@ function openModelModal(idx) {
|
||||
fields.api_key.value = m.api_key || "";
|
||||
fields.base_url.value = m.base_url || "";
|
||||
fields.timeout.value = m.timeout || 60;
|
||||
fields.enable_thinking.checked = !!m.enable_thinking;
|
||||
} else {
|
||||
fields.name.value = "";
|
||||
fields.model.value = "";
|
||||
fields.api_key.value = "";
|
||||
fields.base_url.value = "";
|
||||
fields.timeout.value = 60;
|
||||
fields.enable_thinking.checked = false;
|
||||
}
|
||||
overlay.classList.add("show");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// skills.js -- Skills 管理面板
|
||||
// skills.js -- Skills 管理面板(创建/编辑/删除/导入 zip 或文件夹)
|
||||
// 导入流程:前端先解析(zip 走后端 /api/skills/parse,文件夹纯前端解析),
|
||||
// 填充编辑器并展示附件树(一级子目录,不深入展开),点击保存后才落盘。
|
||||
|
||||
import { $, escapeHtml } from './utils.js';
|
||||
import { $, escapeHtml, withClickGuard } from './utils.js';
|
||||
import { showConfirm } from './dialog.js';
|
||||
import { api } from './api.js';
|
||||
import { state } from './state.js';
|
||||
@@ -10,8 +12,6 @@ const skillEditorOverlay = $("skillEditorOverlay");
|
||||
const skillEditorTitle = $("skillEditorTitle");
|
||||
const skillNameInput = $("skillNameInput");
|
||||
const skillDescInput = $("skillDescInput");
|
||||
const skillVersionInput = $("skillVersionInput");
|
||||
const skillAuthorInput = $("skillAuthorInput");
|
||||
const skillTagsInput = $("skillTagsInput");
|
||||
const skillContentInput = $("skillContentInput");
|
||||
const skillAttachmentsList = $("skillAttachmentsList");
|
||||
@@ -22,6 +22,9 @@ const skillEditorCancel = $("skillEditorCancel");
|
||||
const skillEditorClose = $("skillEditorClose");
|
||||
const skillEnableCheck = $("skillEnableCheck");
|
||||
|
||||
// 导入临时状态:kind = "zip" | "folder";folder 时 files 为 {相对路径: File}
|
||||
let _pendingImport = null;
|
||||
|
||||
function setSkillEditorStatus(msg, type) {
|
||||
if (!skillEditorStatus) return;
|
||||
skillEditorStatus.textContent = msg;
|
||||
@@ -31,35 +34,31 @@ function setSkillEditorStatus(msg, type) {
|
||||
function openSkillEditor(skillNameToEdit) {
|
||||
state.editingSkillName = skillNameToEdit || "";
|
||||
state.editingSkillAttachments.length = 0;
|
||||
_pendingImport = null;
|
||||
|
||||
if (state.editingSkillName) {
|
||||
skillEditorTitle.textContent = "\u7F16\u8F91 Skill: " + state.editingSkillName;
|
||||
skillEditorTitle.textContent = "编辑 Skill: " + state.editingSkillName;
|
||||
skillNameInput.value = state.editingSkillName;
|
||||
skillNameInput.disabled = true;
|
||||
api("/api/skills/" + encodeURIComponent(state.editingSkillName)).then((res) => {
|
||||
const s = res.skill;
|
||||
skillDescInput.value = s.description || "";
|
||||
skillVersionInput.value = s.version || "1.0.0";
|
||||
skillAuthorInput.value = s.author || "";
|
||||
skillTagsInput.value = (s.tags || []).join(", ");
|
||||
skillContentInput.value = s.content || "";
|
||||
skillEnableCheck.checked = s.enable !== false;
|
||||
state.editingSkillAttachments.push(...(s.attachment_files || []));
|
||||
renderSkillAttachments();
|
||||
}).catch((e) => {
|
||||
setSkillEditorStatus("\u52A0\u8F7D\u5931\u8D25: " + e.message, "error");
|
||||
setSkillEditorStatus("加载失败: " + e.message, "error");
|
||||
});
|
||||
} else {
|
||||
skillEditorTitle.textContent = "\u521B\u5EFA Skill";
|
||||
skillEditorTitle.textContent = "创建 Skill";
|
||||
skillNameInput.value = "";
|
||||
skillNameInput.disabled = false;
|
||||
skillDescInput.value = "";
|
||||
skillVersionInput.value = "1.0.0";
|
||||
skillAuthorInput.value = "";
|
||||
skillTagsInput.value = "";
|
||||
skillContentInput.value = "";
|
||||
skillEnableCheck.checked = true;
|
||||
state.editingSkillAttachments.length = 0;
|
||||
renderSkillAttachments();
|
||||
}
|
||||
setSkillEditorStatus("");
|
||||
@@ -70,8 +69,10 @@ function closeSkillEditor() {
|
||||
skillEditorOverlay.hidden = true;
|
||||
state.editingSkillName = "";
|
||||
state.editingSkillAttachments.length = 0;
|
||||
_pendingImport = null;
|
||||
}
|
||||
|
||||
/** 渲染附件 chips;导入预览模式下:zip 只读展示,folder 可移除 */
|
||||
function renderSkillAttachments() {
|
||||
if (!skillAttachmentsList) return;
|
||||
skillAttachmentsList.innerHTML = "";
|
||||
@@ -80,22 +81,38 @@ function renderSkillAttachments() {
|
||||
chip.className = "skill-attachment-item";
|
||||
const nameSpan = document.createElement("span");
|
||||
nameSpan.textContent = fname;
|
||||
chip.appendChild(nameSpan);
|
||||
const isFolderPreview = _pendingImport && _pendingImport.kind === "folder";
|
||||
const isZipPreview = _pendingImport && _pendingImport.kind === "zip";
|
||||
if (isZipPreview) {
|
||||
// zip 导入预览:附件随包原样落盘,不可单独删除
|
||||
chip.title = "导入后随 zip 原样保存";
|
||||
skillAttachmentsList.appendChild(chip);
|
||||
return;
|
||||
}
|
||||
const delBtn = document.createElement("button");
|
||||
delBtn.textContent = "\u00D7";
|
||||
delBtn.title = "\u5220\u9664\u9644\u4EF6";
|
||||
delBtn.title = "删除附件";
|
||||
delBtn.addEventListener("click", async () => {
|
||||
if (!await showConfirm("\u786E\u5B9A\u5220\u9664\u9644\u4EF6 " + fname + " \uFF1F")) return;
|
||||
if (!await showConfirm("确定删除附件 " + fname + " ?")) return;
|
||||
if (isFolderPreview && _pendingImport) {
|
||||
// 文件夹导入预览:仅从待上传集合移除
|
||||
delete _pendingImport.files[fname];
|
||||
const idx = state.editingSkillAttachments.indexOf(fname);
|
||||
if (idx >= 0) state.editingSkillAttachments.splice(idx, 1);
|
||||
renderSkillAttachments();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api("/api/skills/" + encodeURIComponent(state.editingSkillName) + "/attachments/" + encodeURIComponent(fname), { method: "DELETE" });
|
||||
const idx = state.editingSkillAttachments.indexOf(fname);
|
||||
if (idx >= 0) state.editingSkillAttachments.splice(idx, 1);
|
||||
renderSkillAttachments();
|
||||
setSkillEditorStatus("\u9644\u4EF6\u5DF2\u5220\u9664", "success");
|
||||
setSkillEditorStatus("附件已删除", "success");
|
||||
} catch (e) {
|
||||
setSkillEditorStatus("\u5220\u9664\u5931\u8D25: " + e.message, "error");
|
||||
setSkillEditorStatus("删除失败: " + e.message, "error");
|
||||
}
|
||||
});
|
||||
chip.appendChild(nameSpan);
|
||||
chip.appendChild(delBtn);
|
||||
skillAttachmentsList.appendChild(chip);
|
||||
});
|
||||
@@ -104,42 +121,75 @@ function renderSkillAttachments() {
|
||||
async function saveSkill() {
|
||||
const name = (skillNameInput.value || "").trim();
|
||||
if (!name) {
|
||||
setSkillEditorStatus("\u540D\u79F0\u4E0D\u80FD\u4E3A\u7A7A", "error");
|
||||
setSkillEditorStatus("名称不能为空", "error");
|
||||
return;
|
||||
}
|
||||
setSkillEditorStatus("\u6B63\u5728\u4FDD\u5B58...", "");
|
||||
setSkillEditorStatus("正在保存...", "");
|
||||
const tags = skillTagsInput.value;
|
||||
const enable = skillEnableCheck.checked ? "true" : "false";
|
||||
|
||||
// zip 导入:整包提交给 /api/skills/import(附件保持原相对路径)
|
||||
if (_pendingImport && _pendingImport.kind === "zip" && _pendingImport.file) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", _pendingImport.file);
|
||||
fd.append("name", name);
|
||||
fd.append("description", skillDescInput.value);
|
||||
fd.append("content", skillContentInput.value);
|
||||
fd.append("tags", tags);
|
||||
fd.append("enable", enable);
|
||||
try {
|
||||
await api("/api/skills/import", { method: "POST", body: fd });
|
||||
setSkillEditorStatus("导入成功", "success");
|
||||
state.editingSkillName = name;
|
||||
skillNameInput.disabled = true;
|
||||
_pendingImport = null;
|
||||
await renderSkills();
|
||||
return;
|
||||
} catch (e) {
|
||||
setSkillEditorStatus("导入失败: " + e.message, "error");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("name", name);
|
||||
formData.append("description", skillDescInput.value);
|
||||
formData.append("content", skillContentInput.value);
|
||||
formData.append("version", skillVersionInput.value);
|
||||
formData.append("author", skillAuthorInput.value);
|
||||
formData.append("tags", skillTagsInput.value);
|
||||
formData.append("enable", skillEnableCheck.checked ? "true" : "false");
|
||||
formData.append("tags", tags);
|
||||
formData.append("enable", enable);
|
||||
|
||||
try {
|
||||
await api("/api/skills", { method: "POST", body: formData });
|
||||
if (state.editingSkillName && state.editingSkillName !== name) {
|
||||
try { await api("/api/skills/" + encodeURIComponent(state.editingSkillName), { method: "DELETE" }); } catch {}
|
||||
}
|
||||
setSkillEditorStatus("\u4FDD\u5B58\u6210\u529F", "success");
|
||||
// 文件夹导入:逐个上传附件(filename 携带相对路径,如 templates/x.py)
|
||||
if (_pendingImport && _pendingImport.kind === "folder" && _pendingImport.files) {
|
||||
for (const [rel, file] of Object.entries(_pendingImport.files)) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file, rel);
|
||||
await api("/api/skills/" + encodeURIComponent(name) + "/attachments", { method: "POST", body: fd });
|
||||
}
|
||||
}
|
||||
setSkillEditorStatus("保存成功", "success");
|
||||
state.editingSkillName = name;
|
||||
skillNameInput.disabled = true;
|
||||
_pendingImport = null;
|
||||
await renderSkills();
|
||||
} catch (e) {
|
||||
setSkillEditorStatus("\u4FDD\u5B58\u5931\u8D25: " + e.message, "error");
|
||||
setSkillEditorStatus("保存失败: " + e.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSkill(name) {
|
||||
if (!await showConfirm("\u786E\u5B9A\u5220\u9664 Skill \"" + name + "\" \u53CA\u5176\u6240\u6709\u9644\u4EF6\uFF1F\u6B64\u64CD\u4F5C\u4E0D\u53EF\u6062\u590D\u3002")) return;
|
||||
if (!await showConfirm("确定删除 Skill \"" + name + "\" 及其所有附件?此操作不可恢复。")) return;
|
||||
try {
|
||||
await api("/api/skills/" + encodeURIComponent(name), { method: "DELETE" });
|
||||
await renderSkills();
|
||||
const setStatus = (await import('./settings.js')).setStatus;
|
||||
if (setStatus) setStatus("Skill \u5DF2\u5220\u9664", "success");
|
||||
if (setStatus) setStatus("Skill 已删除", "success");
|
||||
} catch (e) {
|
||||
console.error("\u5220\u9664 Skill \u5931\u8D25:", e);
|
||||
console.error("删除 Skill 失败:", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,13 +200,13 @@ async function renderSkills() {
|
||||
state.cachedSkills.length = 0;
|
||||
state.cachedSkills.push(...(res.skills || []));
|
||||
} catch (e) {
|
||||
skillsListEl.innerHTML = '<p style="color:#dc2626;">\u52A0\u8F7D Skills \u5931\u8D25: ' + escapeHtml(e.message) + '</p>';
|
||||
skillsListEl.innerHTML = '<p style="color:#dc2626;">加载 Skills 失败: ' + escapeHtml(e.message) + '</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
skillsListEl.innerHTML = "";
|
||||
if (state.cachedSkills.length === 0) {
|
||||
skillsListEl.innerHTML = '<p style="color:#94a3b8;text-align:center;padding:2rem;">\u6682\u65E0 Skill\u3002<br>\u70B9\u51FB"+ \u521B\u5EFA Skill"\u5F00\u59CB\u521B\u5EFA\u4F60\u7684\u7B2C\u4E00\u4E2A\u64CD\u4F5C\u6307\u5357\u3002</p>';
|
||||
skillsListEl.innerHTML = '<p style="color:#94a3b8;text-align:center;padding:2rem;">暂无 Skill。<br>点击"+ 创建 Skill"或"导入 Skill"开始。</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -171,7 +221,7 @@ async function renderSkills() {
|
||||
if (skill.enable === false) {
|
||||
const badge = document.createElement("span");
|
||||
badge.style.cssText = "font-size:0.65rem;color:#dc2626;background:rgba(220,38,38,0.1);padding:0.1rem 0.4rem;border-radius:999px;margin-left:0.4rem;font-weight:400;";
|
||||
badge.textContent = "\u5DF2\u7981\u7528";
|
||||
badge.textContent = "已禁用";
|
||||
h4.appendChild(badge);
|
||||
}
|
||||
const actions = document.createElement("div");
|
||||
@@ -179,12 +229,12 @@ async function renderSkills() {
|
||||
const editBtn = document.createElement("button");
|
||||
editBtn.className = "btn btn-secondary";
|
||||
editBtn.style.cssText = "padding:0.2rem 0.5rem;font-size:0.75rem;";
|
||||
editBtn.textContent = "\u7F16\u8F91";
|
||||
editBtn.textContent = "编辑";
|
||||
editBtn.addEventListener("click", () => openSkillEditor(skill.name));
|
||||
const delBtn = document.createElement("button");
|
||||
delBtn.className = "btn btn-danger";
|
||||
delBtn.style.cssText = "padding:0.2rem 0.5rem;font-size:0.75rem;";
|
||||
delBtn.textContent = "\u5220\u9664";
|
||||
delBtn.textContent = "删除";
|
||||
delBtn.addEventListener("click", () => deleteSkill(skill.name));
|
||||
actions.appendChild(editBtn);
|
||||
actions.appendChild(delBtn);
|
||||
@@ -193,12 +243,7 @@ async function renderSkills() {
|
||||
|
||||
const desc = document.createElement("div");
|
||||
desc.className = "skill-card-desc";
|
||||
desc.textContent = skill.description || "(\u65E0\u63CF\u8FF0)";
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "skill-card-meta";
|
||||
meta.innerHTML = (skill.version ? '<span>v' + escapeHtml(skill.version) + '</span>' : '') +
|
||||
(skill.author ? '<span>by ' + escapeHtml(skill.author) + '</span>' : '');
|
||||
desc.textContent = skill.description || "(无描述)";
|
||||
|
||||
const tagsWrap = document.createElement("div");
|
||||
tagsWrap.className = "skill-card-tags";
|
||||
@@ -223,13 +268,165 @@ async function renderSkills() {
|
||||
|
||||
card.appendChild(header);
|
||||
card.appendChild(desc);
|
||||
card.appendChild(meta);
|
||||
card.appendChild(tagsWrap);
|
||||
if (attachWrap) card.appendChild(attachWrap);
|
||||
skillsListEl.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 导入 ----------
|
||||
|
||||
/** 解析 skill.md 头部 frontmatter(仅 name/description,其余丢弃;支持 > 折叠多行) */
|
||||
function parseSkillMdFrontmatter(md) {
|
||||
let name = "";
|
||||
let description = "";
|
||||
let body = md;
|
||||
const m = md.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
||||
if (m) {
|
||||
body = md.slice(m[0].length);
|
||||
const collected = {};
|
||||
let curKey = null;
|
||||
m[1].split(/\r?\n/).forEach((line) => {
|
||||
if (!line.trim()) return;
|
||||
const kv = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
|
||||
if (kv) {
|
||||
curKey = kv[1].toLowerCase();
|
||||
collected[curKey] = [kv[2].trim()];
|
||||
} else if (curKey === "name" || curKey === "description") {
|
||||
if (line.startsWith(" ") || line.startsWith("\t")) {
|
||||
(collected[curKey] || (collected[curKey] = [])).push(line.trim());
|
||||
}
|
||||
}
|
||||
});
|
||||
const nameVals = (collected.name || []).filter(Boolean);
|
||||
if (nameVals.length) name = nameVals[0].replace(/^["']|["']$/g, "");
|
||||
const descVals = (collected.description || []).filter(Boolean);
|
||||
if (descVals.length) description = descVals.join(" ").replace(/^["']|["']$/g, "");
|
||||
}
|
||||
return { name, description, body };
|
||||
}
|
||||
|
||||
/** 打开导入菜单(zip / 文件夹 二选一) */
|
||||
function bindImportMenu() {
|
||||
const importBtn = $("importSkillBtn");
|
||||
const zipInput = $("importSkillZipInput");
|
||||
const folderInput = $("importSkillFolderInput");
|
||||
if (!importBtn) return;
|
||||
let menu = null;
|
||||
|
||||
const closeMenu = () => { if (menu && menu.parentNode) menu.parentNode.removeChild(menu); menu = null; };
|
||||
document.addEventListener("click", (e) => {
|
||||
if (menu && !menu.contains(e.target) && e.target !== importBtn) closeMenu();
|
||||
});
|
||||
|
||||
importBtn.addEventListener("click", withClickGuard((e) => {
|
||||
e.stopPropagation();
|
||||
if (menu) { closeMenu(); return; }
|
||||
menu = document.createElement("div");
|
||||
menu.className = "skill-import-menu";
|
||||
const zipItem = document.createElement("button");
|
||||
zipItem.type = "button";
|
||||
zipItem.textContent = "从 zip 导入";
|
||||
zipItem.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
closeMenu();
|
||||
if (zipInput) zipInput.click();
|
||||
});
|
||||
const folderItem = document.createElement("button");
|
||||
folderItem.type = "button";
|
||||
folderItem.textContent = "从文件夹导入";
|
||||
folderItem.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
closeMenu();
|
||||
if (folderInput) folderInput.click();
|
||||
});
|
||||
menu.appendChild(zipItem);
|
||||
menu.appendChild(folderItem);
|
||||
importBtn.parentNode.appendChild(menu);
|
||||
}));
|
||||
|
||||
// zip:后端解析(不落盘),填充编辑器 + 展示附件树
|
||||
if (zipInput) {
|
||||
zipInput.addEventListener("change", withClickGuard(async () => {
|
||||
const file = zipInput.files && zipInput.files[0];
|
||||
zipInput.value = "";
|
||||
if (!file) return;
|
||||
setSkillEditorStatus("正在解析 zip...", "");
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const res = await api("/api/skills/parse", { method: "POST", body: fd });
|
||||
if (!res.found || !res.skill) {
|
||||
setSkillEditorStatus(res.error || "未找到 skill.md/SKILL.md", "error");
|
||||
return;
|
||||
}
|
||||
const s = res.skill;
|
||||
openSkillEditor("");
|
||||
skillNameInput.value = s.name || "";
|
||||
skillDescInput.value = s.description || "";
|
||||
skillContentInput.value = s.content || "";
|
||||
state.editingSkillAttachments.length = 0;
|
||||
state.editingSkillAttachments.push(...(s.attachments || []));
|
||||
_pendingImport = { kind: "zip", file, attachments: s.attachments || [] };
|
||||
renderSkillAttachments();
|
||||
setSkillEditorStatus("解析完成:请核对名称/描述/内容后点击保存(附件随 zip 原样保存)", "");
|
||||
skillEditorOverlay.hidden = false;
|
||||
} catch (err) {
|
||||
setSkillEditorStatus("解析失败: " + (err.message || err), "error");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// 文件夹:纯前端解析(webkitRelativePath)
|
||||
if (folderInput) {
|
||||
folderInput.addEventListener("change", withClickGuard(async () => {
|
||||
const files = Array.from(folderInput.files || []);
|
||||
folderInput.value = "";
|
||||
if (files.length === 0) return;
|
||||
|
||||
// 定位一级目录下的 skill.md/SKILL.md
|
||||
let skillMdFile = null;
|
||||
const mdFiles = files.filter((f) => f.webkitRelativePath.split("/").length <= 2 && /^skill\.md$/i.test(f.name));
|
||||
if (mdFiles.length === 0) {
|
||||
setSkillEditorStatus("未在一级子目录下找到 skill.md/SKILL.md", "error");
|
||||
return;
|
||||
}
|
||||
skillMdFile = mdFiles[0];
|
||||
const mdText = await skillMdFile.text();
|
||||
const { name, description, body } = parseSkillMdFrontmatter(mdText);
|
||||
|
||||
// 附件:一级子目录下除 skill.md 之外的树(不深入展开,子文件夹折叠为 "文件夹名/")
|
||||
const dirName = skillMdFile.webkitRelativePath.split("/")[0];
|
||||
const filesByRel = {};
|
||||
const seenFirstLevel = new Set();
|
||||
files.forEach((f) => {
|
||||
const parts = f.webkitRelativePath.split("/");
|
||||
if (parts[0] !== dirName) return;
|
||||
if (parts.length === 2 && /^skill\.md$/i.test(f.name)) return; // 跳过 skill.md
|
||||
if (parts.length === 2) {
|
||||
filesByRel[parts[1]] = f;
|
||||
seenFirstLevel.add(parts[1]);
|
||||
} else if (parts.length > 2) {
|
||||
// 子文件夹内的文件:折叠为 "子文件夹/" 一级展示,仍按完整相对路径上传
|
||||
filesByRel[parts.slice(1).join("/")] = f;
|
||||
seenFirstLevel.add(parts[1] + "/");
|
||||
}
|
||||
});
|
||||
|
||||
openSkillEditor("");
|
||||
skillNameInput.value = name || dirName;
|
||||
skillDescInput.value = description || "";
|
||||
skillContentInput.value = body || "";
|
||||
state.editingSkillAttachments.length = 0;
|
||||
state.editingSkillAttachments.push(...Array.from(seenFirstLevel).sort());
|
||||
_pendingImport = { kind: "folder", files: filesByRel };
|
||||
renderSkillAttachments();
|
||||
setSkillEditorStatus("解析完成:请核对名称/描述/内容后点击保存(附件将上传到 skill 文件夹下)", "");
|
||||
skillEditorOverlay.hidden = false;
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function bindSkillEditorEvents() {
|
||||
const addSkillBtn = $("addSkillBtn");
|
||||
if (addSkillBtn) addSkillBtn.addEventListener("click", () => openSkillEditor(""));
|
||||
@@ -240,7 +437,7 @@ function bindSkillEditorEvents() {
|
||||
if (e.target === skillEditorOverlay) closeSkillEditor();
|
||||
});
|
||||
}
|
||||
if (skillEditorSave) skillEditorSave.addEventListener("click", saveSkill);
|
||||
if (skillEditorSave) skillEditorSave.addEventListener("click", withClickGuard(saveSkill));
|
||||
|
||||
const skillEnableLabel = $("skillEnableLabel");
|
||||
if (skillEnableLabel && skillEnableCheck) {
|
||||
@@ -250,13 +447,13 @@ function bindSkillEditorEvents() {
|
||||
if (skillFileInput) {
|
||||
skillFileInput.addEventListener("change", async () => {
|
||||
if (!state.editingSkillName) {
|
||||
setSkillEditorStatus("\u8BF7\u5148\u4FDD\u5B58 Skill \u540E\u518D\u4E0A\u4F20\u9644\u4EF6", "error");
|
||||
setSkillEditorStatus("请先保存 Skill 后再上传附件", "error");
|
||||
skillFileInput.value = "";
|
||||
return;
|
||||
}
|
||||
const files = skillFileInput.files;
|
||||
if (!files || files.length === 0) return;
|
||||
setSkillEditorStatus("\u6B63\u5728\u4E0A\u4F20...", "");
|
||||
setSkillEditorStatus("正在上传...", "");
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
for (const file of files) {
|
||||
@@ -275,9 +472,9 @@ function bindSkillEditorEvents() {
|
||||
skillFileInput.value = "";
|
||||
renderSkillAttachments();
|
||||
if (failCount === 0) {
|
||||
setSkillEditorStatus("\u4E0A\u4F20\u6210\u529F " + successCount + " \u4E2A\u6587\u4EF6", "success");
|
||||
setSkillEditorStatus("上传成功 " + successCount + " 个文件", "success");
|
||||
} else {
|
||||
setSkillEditorStatus("\u4E0A\u4F20: " + successCount + " \u6210\u529F, " + failCount + " \u5931\u8D25", "error");
|
||||
setSkillEditorStatus("上传: " + successCount + " 成功, " + failCount + " 失败", "error");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -295,5 +492,5 @@ function bindSkillEditorEvents() {
|
||||
export {
|
||||
renderSkills, openSkillEditor, closeSkillEditor,
|
||||
saveSkill, deleteSkill, renderSkillAttachments,
|
||||
setSkillEditorStatus, bindSkillEditorEvents,
|
||||
setSkillEditorStatus, bindSkillEditorEvents, bindImportMenu,
|
||||
};
|
||||
@@ -74,10 +74,17 @@ export const state = {
|
||||
// 模型选择
|
||||
selectedModelId: "",
|
||||
|
||||
// 输出模式(文字/语音,由 文本语音.svg 按钮开关控制)
|
||||
outputMode: "text",
|
||||
|
||||
// 思考模式档位:low | high | xhigh | max | ultra
|
||||
thinkingLevel: "low",
|
||||
|
||||
// 工作空间
|
||||
workspacePath: "", // 当前选中的工作空间路径(空=使用默认)
|
||||
workspaceList: [], // 已保存的工作空间路径列表
|
||||
workspaceDefault: "", // 默认工作空间路径
|
||||
accessMode: "restricted", // 工作空间访问模式:restricted(限制访问) | approval(权限审查) | full(完全访问)
|
||||
|
||||
// Edit 模式
|
||||
editMode: false,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -163,6 +163,41 @@ function dedupePendingFiles(files) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用连击保护:在 timeWindow 毫秒内忽略重复触发。
|
||||
* 用于按钮/选项等一次性交互,防止双击导致重复请求或重复弹窗。
|
||||
* @param {Function} fn - 要执行的函数(可用 async)
|
||||
* @param {number} ms - 保护时间窗(毫秒),默认 400
|
||||
* @returns {Function} 包裹后的函数
|
||||
*/
|
||||
function withClickGuard(fn, ms = 400) {
|
||||
let lastAt = 0;
|
||||
let running = false;
|
||||
return function (...args) {
|
||||
const now = Date.now();
|
||||
if (running || now - lastAt < ms) return;
|
||||
lastAt = now;
|
||||
const result = fn.apply(this, args);
|
||||
if (result && typeof result.then === "function") {
|
||||
running = true;
|
||||
return result.finally(() => { running = false; });
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭除 keepEl 外的所有内联下拉面板(.inline-drop-panel),保证同时只开一个。
|
||||
* @param {HTMLElement|null} keepEl - 保持打开的面板元素
|
||||
*/
|
||||
function closeInlinePanelsExcept(keepEl) {
|
||||
document.querySelectorAll(".inline-drop-panel").forEach((p) => {
|
||||
if (p === keepEl) return;
|
||||
p.hidden = true;
|
||||
if (p._trigger) p._trigger.classList.remove("is-open");
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
$,
|
||||
escapeHtml,
|
||||
@@ -173,4 +208,6 @@ export {
|
||||
makeId, codeBlockNode,
|
||||
showToast,
|
||||
dedupePendingFiles,
|
||||
withClickGuard,
|
||||
closeInlinePanelsExcept,
|
||||
};
|
||||
@@ -64,7 +64,7 @@ def _read_ws_config() -> dict:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return {"current": "", "list": []}
|
||||
return {"current": "", "list": [], "access_mode": "restricted"}
|
||||
|
||||
|
||||
def _write_ws_config(data: dict) -> bool:
|
||||
@@ -294,7 +294,7 @@ def _serialize_messages(hist: list[dict[str, Any]] | None, session_id: str = "")
|
||||
except Exception:
|
||||
return out
|
||||
|
||||
# 按 turn_id 为每条 assistant 消息挂载对应轮次的工具调用
|
||||
# 按 turn_id 为每条 assistant 消息挂载对应轮次的工具调用与轮次级元数据
|
||||
has_turn_ids = False
|
||||
for m in out:
|
||||
if m.get("role") != "assistant":
|
||||
@@ -303,20 +303,21 @@ def _serialize_messages(hist: list[dict[str, Any]] | None, session_id: str = "")
|
||||
if not turn_id:
|
||||
continue
|
||||
has_turn_ids = True
|
||||
m_meta = m.setdefault("meta", {})
|
||||
try:
|
||||
tool_calls = get_tool_calls_for_turn(session_id, turn_id)
|
||||
except Exception:
|
||||
tool_calls = []
|
||||
if tool_calls:
|
||||
_inject_sub_tool_calls(tool_calls, session_id, turn_id, get_sub_agent_traces_for_turn)
|
||||
m_meta = m.setdefault("meta", {})
|
||||
m_meta["tool_calls"] = tool_calls
|
||||
try:
|
||||
turn_meta = get_turn_meta(session_id, turn_id)
|
||||
except Exception:
|
||||
turn_meta = None
|
||||
if turn_meta:
|
||||
m_meta["turn_meta"] = turn_meta
|
||||
# 无条件挂载 turn_meta(started_at/ended_at/duration),供前端显示回复开始时间
|
||||
try:
|
||||
turn_meta = get_turn_meta(session_id, turn_id)
|
||||
except Exception:
|
||||
turn_meta = None
|
||||
if turn_meta:
|
||||
m_meta["turn_meta"] = turn_meta
|
||||
|
||||
# 兼容旧消息(无 turn_id):将最新轮工具调用附加到最后一条 assistant
|
||||
if not has_turn_ids:
|
||||
@@ -485,6 +486,153 @@ def api_new_session() -> dict[str, Any]:
|
||||
return {"sessionId": sid, "sessions": ids, "messages": [], "tokens": 0, "pending_actions": []}
|
||||
|
||||
|
||||
def _branch_copy_attachment(abs_src: str, dst_dir: str, new_sid: str) -> str:
|
||||
"""将附件复制到新会话目录,返回目标绝对路径(必要时避免重名)。
|
||||
|
||||
输入:
|
||||
abs_src: 源附件绝对路径。
|
||||
dst_dir: 新会话目录。
|
||||
new_sid: 新会话 ID(用于冲突重命名)。
|
||||
|
||||
输出:
|
||||
目标绝对路径。
|
||||
"""
|
||||
base = os.path.basename(abs_src)
|
||||
dest = os.path.join(dst_dir, base)
|
||||
if os.path.exists(dest) and os.path.normpath(dest) != os.path.normpath(abs_src):
|
||||
stem, ext = os.path.splitext(base)
|
||||
i = 2
|
||||
while os.path.exists(dest):
|
||||
dest = os.path.join(dst_dir, f"{stem}_{new_sid}_{i}{ext}")
|
||||
i += 1
|
||||
if os.path.normpath(dest) != os.path.normpath(abs_src):
|
||||
shutil.copy2(abs_src, dest)
|
||||
return dest
|
||||
|
||||
|
||||
def _branch_rewrite_attachments(dst_dir: str, new_sid: str) -> None:
|
||||
"""将分支复制后的 turn 文件中的附件引用复制到新会话目录并重写 relpath。
|
||||
|
||||
输入:
|
||||
dst_dir: 新会话目录。
|
||||
new_sid: 新会话 ID。
|
||||
"""
|
||||
from agent.utils.agent_utils import SESSIONS_ROOT, TURN_JSON_PREFIX
|
||||
|
||||
for name in os.listdir(dst_dir):
|
||||
if not (name.startswith(TURN_JSON_PREFIX) and name.endswith(".json")):
|
||||
continue
|
||||
tp = os.path.join(dst_dir, name)
|
||||
try:
|
||||
with open(tp, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
changed = False
|
||||
for msg in data.get("messages", []):
|
||||
role = msg.get("role")
|
||||
if role == "user":
|
||||
content = (msg.get("user") or {}).get("content") or []
|
||||
for piece in content:
|
||||
if not isinstance(piece, dict):
|
||||
continue
|
||||
rel = piece.get("relpath")
|
||||
if not rel:
|
||||
continue
|
||||
abs_src = os.path.normpath(os.path.join(SESSIONS_ROOT, rel))
|
||||
if not os.path.isfile(abs_src):
|
||||
continue
|
||||
dest = _branch_copy_attachment(abs_src, dst_dir, new_sid)
|
||||
new_rel = os.path.relpath(dest, SESSIONS_ROOT).replace("\\", "/")
|
||||
if new_rel != rel:
|
||||
piece["relpath"] = new_rel
|
||||
changed = True
|
||||
elif role == "assistant":
|
||||
asst = msg.get("assistant") or {}
|
||||
audio_rel = asst.get("audio_relpath")
|
||||
if audio_rel:
|
||||
abs_src = os.path.normpath(os.path.join(SESSIONS_ROOT, audio_rel.replace("/", os.sep)))
|
||||
if os.path.isfile(abs_src):
|
||||
dest = _branch_copy_attachment(abs_src, dst_dir, new_sid)
|
||||
new_rel = os.path.relpath(dest, SESSIONS_ROOT).replace("\\", "/")
|
||||
if new_rel != audio_rel:
|
||||
asst["audio_relpath"] = new_rel
|
||||
changed = True
|
||||
if changed:
|
||||
with open(tp, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
@app.post("/api/sessions/branch")
|
||||
def api_session_branch(
|
||||
session_id: str = Form(...),
|
||||
branch_turn_id: str = Form(""),
|
||||
) -> dict[str, Any]:
|
||||
"""将源会话中 branch_turn_id 及之前的所有历史复制到新会话(分支)。
|
||||
|
||||
输入:
|
||||
session_id: 源会话 ID。
|
||||
branch_turn_id: 分支点回合 ID(该回合及其之前的历史会被复制到新会话)。
|
||||
|
||||
输出:
|
||||
{"sessionId": str, "sessions": list[str], "messages": list[dict], "tokens": int,
|
||||
"pending_actions": list[dict]}
|
||||
|
||||
系统定位:
|
||||
AI 回复的「分支」按钮:复制一份历史到新会话,用户可在此基础上继续对话。
|
||||
"""
|
||||
if not session_id:
|
||||
raise HTTPException(status_code=400, detail="缺少 session_id")
|
||||
|
||||
# mysql 后端不落 turn/tool 文件,暂不支持文件级分支
|
||||
try:
|
||||
from agent.history import session_storage
|
||||
if session_storage.is_mysql():
|
||||
raise HTTPException(status_code=400, detail="mysql 存储后端暂不支持分支")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from agent.utils.agent_utils import SESSIONS_ROOT, TURN_JSON_PREFIX
|
||||
|
||||
src_dir = os.path.join(SESSIONS_ROOT, session_id)
|
||||
if not os.path.isdir(src_dir):
|
||||
raise HTTPException(status_code=400, detail="源会话不存在")
|
||||
|
||||
# 创建新会话并复制 branch_turn_id 及之前的 turn/tool 文件
|
||||
new_sid = ui.new_timestamp_session_id()
|
||||
dst_dir = os.path.join(SESSIONS_ROOT, new_sid)
|
||||
os.makedirs(dst_dir, exist_ok=True)
|
||||
|
||||
branch_turn_full = f"{TURN_JSON_PREFIX}{branch_turn_id}.json"
|
||||
tool_branch_full = f"tool_{branch_turn_id}.json"
|
||||
copied_turns = 0
|
||||
try:
|
||||
for name in sorted(os.listdir(src_dir)):
|
||||
src_path = os.path.join(src_dir, name)
|
||||
if name.startswith(TURN_JSON_PREFIX) and name.endswith(".json") and name <= branch_turn_full:
|
||||
shutil.copy2(src_path, os.path.join(dst_dir, name))
|
||||
copied_turns += 1
|
||||
elif name.startswith("tool_") and name.endswith(".json") and name <= tool_branch_full:
|
||||
shutil.copy2(src_path, os.path.join(dst_dir, name))
|
||||
if copied_turns == 0:
|
||||
raise ValueError("分支点回合不存在")
|
||||
# 复制附件引用并重写 relpath,使新会话自包含
|
||||
_branch_rewrite_attachments(dst_dir, new_sid)
|
||||
except Exception:
|
||||
shutil.rmtree(dst_dir, ignore_errors=True)
|
||||
raise HTTPException(status_code=400, detail="无效的分支回合")
|
||||
|
||||
ids = _ensure_session_in_list(new_sid, ui.list_session_ids_ordered())
|
||||
from agent.history.tool_call_recorder import get_session_tokens
|
||||
return {
|
||||
"sessionId": new_sid,
|
||||
"sessions": ids,
|
||||
"messages": _serialize_messages(ui.load_ui_messages_for_session(new_sid), new_sid),
|
||||
"tokens": get_session_tokens(new_sid),
|
||||
"pending_actions": _get_pending_actions(new_sid),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/sessions/{session_id}/messages")
|
||||
def api_session_messages(session_id: str) -> dict[str, Any]:
|
||||
"""切换会话:加载指定会话的消息列表与全局会话列表。
|
||||
@@ -1150,15 +1298,18 @@ def api_save_models(data: dict[str, Any]) -> dict[str, Any]:
|
||||
def api_get_workspace() -> dict[str, Any]:
|
||||
"""获取工作空间配置。
|
||||
|
||||
输出: {"current": str, "list": list[str], "default": str, "snapshots_base": str}
|
||||
输出: {"current": str, "list": list[str], "default": str, "snapshots_base": str,
|
||||
"access_mode": str}
|
||||
current: 当前选中的工作空间路径(空字符串表示使用默认值)
|
||||
list: 已保存的工作空间路径列表
|
||||
default: env_config 中的默认工作空间路径
|
||||
snapshots_base: 快照存储基础目录
|
||||
access_mode: 工作空间访问模式(restricted | approval | full)
|
||||
"""
|
||||
from agent.utils.env_utils import get_workspace_dir
|
||||
from agent.memory.system_prompt import _current_workspace_dir
|
||||
from agent.utils.agent_utils import SESSIONS_ROOT
|
||||
from agent.core.workspace_policy import get_access_mode
|
||||
import os
|
||||
cfg = _read_ws_config()
|
||||
current = _current_workspace_dir or cfg.get("current", "")
|
||||
@@ -1175,9 +1326,27 @@ def api_get_workspace() -> dict[str, Any]:
|
||||
"list": valid,
|
||||
"default": get_workspace_dir(),
|
||||
"snapshots_base": os.path.join(SESSIONS_ROOT, ".minor_snapshots").replace("\\", "/"),
|
||||
"access_mode": get_access_mode(),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/workspace/access-mode")
|
||||
def api_set_access_mode(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""设置工作空间访问模式。
|
||||
|
||||
输入: {"mode": "restricted"|"approval"|"full"}
|
||||
输出: {"success": bool, "mode": str}
|
||||
"""
|
||||
from agent.core.workspace_policy import _ACCESS_MODES, set_access_mode
|
||||
mode = data.get("mode", "") if isinstance(data, dict) else ""
|
||||
if mode not in _ACCESS_MODES:
|
||||
raise HTTPException(status_code=400, detail="无效的访问模式(restricted | approval | full)")
|
||||
ok = set_access_mode(mode)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=500, detail="保存访问模式失败")
|
||||
return {"success": True, "mode": mode}
|
||||
|
||||
|
||||
@app.post("/api/workspace/select")
|
||||
def api_select_workspace(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""选择工作空间。
|
||||
@@ -1331,6 +1500,33 @@ def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/api/services/status")
|
||||
def api_services_status() -> dict[str, Any]:
|
||||
"""探测 ASR 与 TTS 服务的连通性(供前端麦克风/语音模式切换前检查)。
|
||||
|
||||
探测方式: 对服务根路径发起 GET,任何 HTTP 响应(含 404)都视为服务在线;
|
||||
连接失败/超时视为服务不可用。
|
||||
|
||||
输出: {"asr": {"ok": bool, "url": str}, "tts": {"ok": bool, "url": str}}
|
||||
"""
|
||||
import requests as _req
|
||||
from agent.utils.env_utils import ASR_BASE_URL, STREAMING_TTS_URL
|
||||
|
||||
def _probe(url: str) -> bool:
|
||||
if not url or not str(url).strip():
|
||||
return False
|
||||
try:
|
||||
_req.get(str(url).rstrip("/") + "/", timeout=3)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
return {
|
||||
"asr": {"ok": _probe(ASR_BASE_URL), "url": ASR_BASE_URL or ""},
|
||||
"tts": {"ok": _probe(STREAMING_TTS_URL), "url": STREAMING_TTS_URL or ""},
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/pick-folder")
|
||||
def api_pick_folder() -> dict[str, Any]:
|
||||
"""打开原生文件夹选择对话框,返回用户选择的文件夹绝对路径。
|
||||
@@ -1432,6 +1628,37 @@ def api_save_compress_settings(data: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"success": updated}
|
||||
|
||||
|
||||
# ---------- 深度思考档位 API ----------
|
||||
@app.post("/api/config/thinking-level")
|
||||
def api_set_thinking_level(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""设置深度思考档位(low | high | xhigh | max | ultra),重建 Agent 运行时。
|
||||
|
||||
档位同时决定运行模式与思考开关:
|
||||
low = agent + 不思考;high = plan + 不思考;
|
||||
xhigh = agent + 思考;max = plan + 思考;
|
||||
ultra 为未来 react→审批图预留占位(暂按 agent + 思考)。
|
||||
|
||||
输入: {"level": str}
|
||||
输出: {"success": bool, "level": str}
|
||||
"""
|
||||
from agent.utils.env_utils import THINKING_LEVELS, _load_config
|
||||
level = data.get("level", "") if isinstance(data, dict) else ""
|
||||
if level not in THINKING_LEVELS:
|
||||
raise HTTPException(status_code=400, detail="无效的思考档位(low | high | xhigh | max | ultra)")
|
||||
config = load_env_config()
|
||||
config["THINKING_LEVEL"] = level
|
||||
if not save_env_config(config):
|
||||
raise HTTPException(status_code=500, detail="保存思考档位失败")
|
||||
_load_config()
|
||||
# 重建 Agent 运行时使新的 extra_body 生效
|
||||
try:
|
||||
from agent.agents.agent_runtime import reload_all_agent_runtimes
|
||||
reload_all_agent_runtimes()
|
||||
except Exception:
|
||||
pass
|
||||
return {"success": True, "level": level}
|
||||
|
||||
|
||||
# ---------- 会话 Token 用量 API ----------
|
||||
@app.get("/api/chat/tokens/{session_id}")
|
||||
def api_get_session_tokens(session_id: str) -> dict[str, Any]:
|
||||
@@ -1441,7 +1668,7 @@ def api_get_session_tokens(session_id: str) -> dict[str, Any]:
|
||||
"compress_rate": float}
|
||||
"""
|
||||
from agent.utils.env_utils import LLM_CONTEXT_WINDOW, COMPRESS_RATE
|
||||
from agent.history.tool_call_recorder import get_session_tokens
|
||||
from agent.history.tool_call_recorder import get_session_tokens, get_session_token_breakdown
|
||||
|
||||
# 直接读取 LLM 每次调用后存储的真实 total_tokens
|
||||
tokens = get_session_tokens(session_id)
|
||||
@@ -1452,6 +1679,8 @@ def api_get_session_tokens(session_id: str) -> dict[str, Any]:
|
||||
"context_window": LLM_CONTEXT_WINDOW,
|
||||
"compress_threshold": compress_threshold,
|
||||
"compress_rate": COMPRESS_RATE,
|
||||
# 三类估算占比(消息/工具/系统提示词),供前端环形指示展示
|
||||
"breakdown": get_session_token_breakdown(session_id),
|
||||
}
|
||||
|
||||
|
||||
@@ -1613,20 +1842,16 @@ async def api_create_or_update_skill(
|
||||
name: str = Form(...),
|
||||
description: str = Form(""),
|
||||
content: str = Form(""),
|
||||
version: str = Form("1.0.0"),
|
||||
author: str = Form(""),
|
||||
tags: str = Form(""),
|
||||
enable: str = Form("true"),
|
||||
) -> dict[str, Any]:
|
||||
"""创建或更新一个 skill。
|
||||
"""创建或更新一个 skill(不保留 version/author)。
|
||||
|
||||
输入:
|
||||
name: skill 名称(必填)。
|
||||
description: 简述。
|
||||
content: skill.md 的 markdown 内容。
|
||||
version: 版本号。
|
||||
author: 作者。
|
||||
tags: 逗号分隔的标签字符串。
|
||||
tags: 逗号分隔的标签字符串(可选,便于分级筛选)。
|
||||
enable: 是否启用,默认 "true"。
|
||||
|
||||
输出: {"success": bool, "skill": dict}
|
||||
@@ -1640,8 +1865,6 @@ async def api_create_or_update_skill(
|
||||
name=name.strip(),
|
||||
description=description,
|
||||
content=content,
|
||||
version=version,
|
||||
author=author,
|
||||
tags=tag_list,
|
||||
enable=enable_bool,
|
||||
)
|
||||
@@ -1652,6 +1875,59 @@ async def api_create_or_update_skill(
|
||||
return {"success": True, "skill": skill}
|
||||
|
||||
|
||||
# ---------- Skill 导入(zip:先解析展示,保存时才落盘) ----------
|
||||
@app.post("/api/skills/parse")
|
||||
async def api_parse_skill_zip(file: UploadFile = File(...)) -> dict[str, Any]:
|
||||
"""解析 skill zip 包(不落盘),返回 name/description/content 与附件列表。
|
||||
|
||||
输入: multipart file(zip)
|
||||
输出: {"found": bool, "skill": {name, description, content, attachments} | None,
|
||||
"error": str | None}
|
||||
"""
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="文件名为空")
|
||||
zip_bytes = await file.read()
|
||||
from agent.skills import parse_skill_zip
|
||||
parsed = parse_skill_zip(zip_bytes)
|
||||
if parsed is None:
|
||||
return {"found": False, "skill": None,
|
||||
"error": "未在 zip 的一级子目录下找到 skill.md/SKILL.md"}
|
||||
return {"found": True, "skill": parsed, "error": None}
|
||||
|
||||
|
||||
@app.post("/api/skills/import")
|
||||
async def api_import_skill_zip(
|
||||
file: UploadFile = File(...),
|
||||
name: str = Form(""),
|
||||
description: str = Form(""),
|
||||
content: str = Form(""),
|
||||
tags: str = Form(""),
|
||||
enable: str = Form("true"),
|
||||
) -> dict[str, Any]:
|
||||
"""导入 skill zip 包到 skills 目录(skill.md/skill.json + 附件保持原相对路径)。
|
||||
|
||||
输入: multipart file(zip)+ name/description/content/tags/enable 覆盖值
|
||||
输出: {"success": bool, "skill": dict | None}
|
||||
"""
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="文件名为空")
|
||||
zip_bytes = await file.read()
|
||||
from agent.skills import import_skill_zip, get_skill
|
||||
tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else []
|
||||
enable_bool = enable.lower() not in ("false", "0", "no", "")
|
||||
ok = import_skill_zip(
|
||||
name=name.strip(),
|
||||
zip_bytes=zip_bytes,
|
||||
description=description,
|
||||
content=content,
|
||||
tags=tag_list,
|
||||
enable=enable_bool,
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=500, detail="导入 skill 失败(zip 无效或未找到 skill.md)")
|
||||
return {"success": True, "skill": get_skill(name.strip())}
|
||||
|
||||
|
||||
@app.delete("/api/skills/{name}")
|
||||
def api_delete_skill(name: str) -> dict[str, Any]:
|
||||
"""删除一个 skill 及其所有附件。
|
||||
|
||||