优化agent配置,修复已知问题
This commit is contained in:
1 parent
744cae8f54
commit
9acb38c9e6
55 files changed
+3009
-623
No files matched your search
+295
-19
@@ -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 及其所有附件。
|
||||
|
||||
Reference in new issue
Block a user