feat(v3): T28 操作审批流(dsh 式 allow-once/deny,fail-closed)

- ToolLoop 增 approval_hook:工具执行前挂起等待用户裁决,拒绝/异常折叠为
  失败结果回喂模型(可改道),fail-closed
- 策略 agent.approval_policy:off | dangerous(写/编辑/命令询问,只读放行,默认)| all;
  approval_timeout_s 超时自动拒绝(轮询实现,规避 portal 循环下 wait_for 定时器不可靠)
- POST /agent/{id}/approve 裁决端点;approval_request/decided 事件对进 SSE 与审计
- 前端:运行中审批卡(工具名+参数预览+拒绝/允许一次),composer 审批策略 chip
- 测试 +8(策略矩阵/拒绝回喂/允许执行/fail-closed/超时/端点分支),全量 289 passed
This commit is contained in:
tzt
2026-09-01 23:12:26 +08:00
parent 7d11ae2644
commit ca8add02e2
16 changed files with 487 additions and 21 deletions
+81 -6
View File
@@ -20,7 +20,7 @@ import time
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Awaitable, Callable, Dict, List, Optional
from router_system.tools import ToolLoop, WorkspaceTools from router_system.tools import ToolLoop, WorkspaceTools
@@ -234,7 +234,9 @@ class AgentService:
allow_shell: bool = False, shell_timeout_s: int = 20, allow_shell: bool = False, shell_timeout_s: int = 20,
executor_chat: Any = None, executor_chat: Any = None,
max_handoffs: int = DEFAULT_MAX_HANDOFFS, max_handoffs: int = DEFAULT_MAX_HANDOFFS,
session: Optional["AgentSession"] = None) -> None: session: Optional["AgentSession"] = None,
approval_policy: str = "dangerous",
approval_timeout_s: int = 120) -> None:
"""执行智能体任务(由调用方包成后台协程)。 """执行智能体任务(由调用方包成后台协程)。
executor_chat 为空 = 单模型模式(chat 全程包办); executor_chat 为空 = 单模型模式(chat 全程包办);
@@ -242,6 +244,36 @@ class AgentService:
session 提供时:既往轮次作为对话上下文,完成后把本轮追加进会话。 session 提供时:既往轮次作为对话上下文,完成后把本轮追加进会话。
""" """
history = self._history_from_session(session) history = self._history_from_session(session)
approval_mgr = ApprovalManager()
info._approval_manager = approval_mgr # 供 /approve 端点裁决(瞬态属性)
async def approval_hook(name: str, args: Dict[str, Any]) -> bool:
"""按策略判定;需审批则挂起等用户裁决,超时 fail-closed。"""
if not needs_approval(approval_policy, name):
return True
aid = "ap" + uuid.uuid4().hex[:8]
ev = approval_mgr.open(aid)
self._append_event(info, {"type": "approval_request", "id": aid,
"name": name, "arguments": args,
"policy": approval_policy})
# 轮询等待(0.1s 步进):不用 wait_for——portal 循环下其定时器不可靠
allowed = False
note = ""
deadline = time.time() + max(1, approval_timeout_s)
while time.time() < deadline:
if ev.is_set():
allowed = approval_mgr._pending.get(aid, {}).get("allowed", False)
break
await asyncio.sleep(0.1)
else:
note = f"超时({approval_timeout_s}s)未响应,自动拒绝"
if ev.is_set() and not allowed:
note = note or "用户拒绝"
approval_mgr.close(aid)
self._append_event(info, {"type": "approval_decided", "id": aid,
"name": name, "allowed": allowed,
**({"note": note} if note else {})})
return allowed
try: try:
if executor_chat is not None: if executor_chat is not None:
@@ -249,12 +281,14 @@ class AgentService:
info, chat, executor_chat, workspace_dir, info, chat, executor_chat, workspace_dir,
max_rounds=max_rounds, token_cap=token_cap, max_rounds=max_rounds, token_cap=token_cap,
allow_shell=allow_shell, shell_timeout_s=shell_timeout_s, allow_shell=allow_shell, shell_timeout_s=shell_timeout_s,
max_handoffs=max_handoffs) max_handoffs=max_handoffs,
approval_hook=approval_hook)
else: else:
tools = WorkspaceTools(workspace_dir, allow_shell=allow_shell, tools = WorkspaceTools(workspace_dir, allow_shell=allow_shell,
shell_timeout_s=shell_timeout_s) shell_timeout_s=shell_timeout_s)
loop = ToolLoop(tools, chat, max_rounds=max_rounds, token_cap=token_cap, loop = ToolLoop(tools, chat, max_rounds=max_rounds, token_cap=token_cap,
on_event=self._make_event_writer(info)) on_event=self._make_event_writer(info),
approval_hook=approval_hook)
result = await loop.run(info.task, system=AGENT_SYSTEM_PROMPT, result = await loop.run(info.task, system=AGENT_SYSTEM_PROMPT,
history=history) history=history)
self._apply_result(info, result) self._apply_result(info, result)
@@ -315,7 +349,9 @@ class AgentService:
workspace_dir: str | Path, max_rounds: int = 8, workspace_dir: str | Path, max_rounds: int = 8,
token_cap: int = 0, allow_shell: bool = False, token_cap: int = 0, allow_shell: bool = False,
shell_timeout_s: int = 20, shell_timeout_s: int = 20,
max_handoffs: int = DEFAULT_MAX_HANDOFFS) -> Dict[str, Any]: max_handoffs: int = DEFAULT_MAX_HANDOFFS,
approval_hook: Optional[Callable[[str, Dict[str, Any]], Awaitable[bool]]] = None
) -> Dict[str, Any]:
"""大模型拆解/审查 + 小模型执行工具轮,交接状态写 handoff.json(智能体版交流文本)。""" """大模型拆解/审查 + 小模型执行工具轮,交接状态写 handoff.json(智能体版交流文本)。"""
info.mode = "dual" info.mode = "dual"
tools = WorkspaceTools(workspace_dir, allow_shell=allow_shell, tools = WorkspaceTools(workspace_dir, allow_shell=allow_shell,
@@ -387,7 +423,8 @@ class AgentService:
loop = ToolLoop(tools, executor_chat, max_rounds=max_rounds, loop = ToolLoop(tools, executor_chat, max_rounds=max_rounds,
token_cap=max(1, _remaining_cap()), token_cap=max(1, _remaining_cap()),
on_event=self._make_event_writer(info), on_event=self._make_event_writer(info),
emit_final=False) emit_final=False,
approval_hook=approval_hook)
exec_result = await loop.run(instructions, system=EXECUTOR_SYSTEM_PROMPT) exec_result = await loop.run(instructions, system=EXECUTOR_SYSTEM_PROMPT)
_account({"prompt_tokens": exec_result.get("prompt_tokens", 0), _account({"prompt_tokens": exec_result.get("prompt_tokens", 0),
"completion_tokens": exec_result.get("completion_tokens", 0)}) "completion_tokens": exec_result.get("completion_tokens", 0)})
@@ -547,6 +584,44 @@ def new_request_id() -> str:
return "ag" + uuid.uuid4().hex[:10] return "ag" + uuid.uuid4().hex[:10]
# ─────────────────────────────────────────────────────────────────────────────
# 审批流(D9):dsh 式 allow-once / denyfail-closed
# ─────────────────────────────────────────────────────────────────────────────
READ_ONLY_TOOLS = {"list_dir", "read_file", "search_files"}
def needs_approval(policy: str, tool_name: str) -> bool:
"""审批策略判定:off=全放行;all=全询问;dangerous=写/编辑/命令询问,只读放行。"""
if policy == "all":
return True
if policy == "dangerous":
return tool_name not in READ_ONLY_TOOLS
return False
class ApprovalManager:
"""单次智能体运行内的审批挂起/裁决(asyncio Event 实现,dsh 式 allow-once)。"""
def __init__(self):
self._pending: Dict[str, Dict[str, Any]] = {}
def open(self, approval_id: str) -> asyncio.Event:
ev = asyncio.Event()
self._pending[approval_id] = {"event": ev, "allowed": False}
return ev
def decide(self, approval_id: str, allowed: bool) -> bool:
p = self._pending.get(approval_id)
if p is None:
return False
p["allowed"] = allowed
p["event"].set()
return True
def close(self, approval_id: str) -> None:
self._pending.pop(approval_id, None)
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# 会话(dsh 式:工作区内多轮对话,持久化到磁盘) # 会话(dsh 式:工作区内多轮对话,持久化到磁盘)
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
+19
View File
@@ -602,6 +602,8 @@ try:
executor_chat=executor_chat, executor_chat=executor_chat,
max_handoffs=int(agent_cfg.get("max_handoffs", 2)), max_handoffs=int(agent_cfg.get("max_handoffs", 2)),
session=session, session=session,
approval_policy=str(agent_cfg.get("approval_policy", "dangerous")),
approval_timeout_s=int(agent_cfg.get("approval_timeout_s", 120)),
) )
except Exception as exc: except Exception as exc:
import traceback import traceback
@@ -638,6 +640,23 @@ try:
service._write_status(info) service._write_status(info)
return {"ok": True} return {"ok": True}
@app.post("/agent/{request_id}/approve", tags=["agent"])
async def agent_approve(request_id: str, req: dict):
"""裁决待审批操作:{"approval_id", "allowed"}dsh 式 allow-once / deny)。"""
from gateway.agent import get_agent_service
info = get_agent_service().get(request_id)
if info is None:
raise HTTPException(status_code=404, detail=f"智能体任务不存在: {request_id}")
approval_id = str((req or {}).get("approval_id") or "")
allowed = bool((req or {}).get("allowed", False))
mgr = getattr(info, "_approval_manager", None)
if mgr is None:
raise HTTPException(status_code=409, detail="该任务无审批流程")
ok = mgr.decide(approval_id, allowed)
if not ok:
raise HTTPException(status_code=404, detail=f"审批单不存在或已裁决: {approval_id}")
return {"ok": True, "approval_id": approval_id, "allowed": allowed}
# ---------------- 会话(dsh 式多轮对话) ---------------- # ---------------- 会话(dsh 式多轮对话) ----------------
@app.post("/agent/sessions", tags=["agent"]) @app.post("/agent/sessions", tags=["agent"])
async def agent_session_create(req: dict = None): async def agent_session_create(req: dict = None):
+2
View File
@@ -44,6 +44,8 @@ DEFAULTS: Dict[str, Any] = {
"allow_shell": False, # 允许 run_command 执行 shell(默认关) "allow_shell": False, # 允许 run_command 执行 shell(默认关)
"shell_timeout_s": 20, # shell 命令超时 "shell_timeout_s": 20, # shell 命令超时
"max_handoffs": 2, # 两级模式:规划者<->执行者交接轮数上限 "max_handoffs": 2, # 两级模式:规划者<->执行者交接轮数上限
"approval_policy": "dangerous", # 审批策略:off | dangerous(写/编辑/命令询问)| all
"approval_timeout_s": 120, # 审批等待超时(超时自动拒绝)
}, },
} }
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{A as e,D as t,G as n,I as r,L as i,N as a,O as o,P as s,V as c,W as l,j as u,k as d,s as f,t as p}from"./index-DdcvGqdd.js";var m={class:`metrics-view`},h={key:0,class:`loading`},g={key:1,class:`error`},_={class:`card-grid`},v={class:`metric-card`},y={class:`kv-list`},b={class:`metric-card`},x={class:`kv-list`},S={key:0,class:`metric-card highlight`},C={class:`kv-list`},w={key:0},T={key:1},E={key:1,class:`metric-card`},D={class:`by-model`},O={class:`mono`},k={key:2,class:`metric-card review-card`},A={class:`review-stats`},j={class:`stat-item`},M={class:`stat-num`},N={class:`stat-item`},P={class:`stat-num`},F={key:0,class:`progress-wrap`},I={class:`review-rate`},L={class:`raw-json`},R=p(a({__name:`MetricsView`,setup(a){let p=c(null),R=c(!1),z=c(``),B=o(()=>p.value?.v2?.by_model||null);async function V(){R.value=!0,z.value=``;try{p.value=await f()}catch(e){z.value=e instanceof Error?e.message:`指标加载失败,请检查后端服务`}finally{R.value=!1}}return s(V),(a,o)=>(r(),u(`div`,m,[d(`header`,{class:`metrics-header`},[o[0]||=d(`h2`,null,`系统指标`,-1),d(`button`,{class:`refresh`,onClick:V},`🔄 刷新`)]),R.value?(r(),u(`div`,h,`加载中…`)):z.value?(r(),u(`div`,g,n(z.value),1)):p.value?(r(),u(t,{key:2},[d(`div`,_,[d(`div`,v,[o[1]||=d(`h3`,null,`路由器(v1`,-1),d(`div`,y,[(r(!0),u(t,null,i(p.value.router,(e,i)=>(r(),u(t,{key:i},[d(`span`,null,n(i),1),d(`b`,null,n(e),1)],64))),128))])]),d(`div`,b,[o[2]||=d(`h3`,null,`缓存`,-1),d(`div`,x,[(r(!0),u(t,null,i(p.value.cache,(e,i)=>(r(),u(t,{key:i},[d(`span`,null,n(i),1),d(`b`,null,n(e),1)],64))),128))])]),p.value.v2?(r(),u(`div`,S,[o[3]||=d(`h3`,null,`协作管线(v2`,-1),d(`div`,C,[(r(!0),u(t,null,i(p.value.v2,(i,a)=>(r(),u(t,{key:a},[a===`by_model`?e(``,!0):(r(),u(`span`,w,n(a),1)),a===`by_model`?e(``,!0):(r(),u(`b`,T,n(i),1))],64))),128))])])):e(``,!0),B.value&&Object.keys(B.value).length?(r(),u(`div`,E,[o[5]||=d(`h3`,null,`按模型分账(token / 成本)`,-1),d(`table`,D,[o[4]||=d(`thead`,null,[d(`tr`,null,[d(`th`,null,`模型`),d(`th`,null,`次数`),d(`th`,null,``),d(`th`,null,``),d(`th`,null,`成本 $`)])],-1),d(`tbody`,null,[(r(!0),u(t,null,i(B.value,(e,t)=>(r(),u(`tr`,{key:t},[d(`td`,O,n(t),1),d(`td`,null,n(e.requests),1),d(`td`,null,n(e.input_tokens),1),d(`td`,null,n(e.output_tokens),1),d(`td`,null,n(e.cost_est_usd),1)]))),128))])]),o[6]||=d(`p`,{class:`hint`},`单价来自模型池条目($/1M tokens);经典设置下的模型成本不计入。`,-1)])):e(``,!0),p.value.review?(r(),u(`div`,k,[o[9]||=d(`h3`,null,`人工检验`,-1),d(`div`,A,[d(`div`,j,[d(`span`,M,n(p.value.review.pending),1),o[7]||=d(`span`,{class:`stat-label`},`待审核`,-1)]),d(`div`,N,[d(`span`,P,n(p.value.review.total),1),o[8]||=d(`span`,{class:`stat-label`},`总提交`,-1)])]),p.value.review.total>0?(r(),u(`div`,F,[d(`div`,{class:`reviewed-bar`,style:l({width:`${(p.value.review.total-p.value.review.pending)/p.value.review.total*100}%`})},null,4)])):e(``,!0),d(`p`,I,` 通过率: `+n(((p.value.review.total-p.value.review.pending)/p.value.review.total*100).toFixed(1))+`% `,1)])):e(``,!0)]),d(`details`,L,[o[10]||=d(`summary`,null,`原始 JSON`,-1),d(`pre`,null,n(JSON.stringify(p.value,null,2)),1)])],64)):e(``,!0)]))}}),[[`__scopeId`,`data-v-8b237097`]]);export{R as default}; import{A as e,D as t,G as n,I as r,L as i,N as a,O as o,P as s,V as c,W as l,j as u,k as d,s as f,t as p}from"./index-C8Za1808.js";var m={class:`metrics-view`},h={key:0,class:`loading`},g={key:1,class:`error`},_={class:`card-grid`},v={class:`metric-card`},y={class:`kv-list`},b={class:`metric-card`},x={class:`kv-list`},S={key:0,class:`metric-card highlight`},C={class:`kv-list`},w={key:0},T={key:1},E={key:1,class:`metric-card`},D={class:`by-model`},O={class:`mono`},k={key:2,class:`metric-card review-card`},A={class:`review-stats`},j={class:`stat-item`},M={class:`stat-num`},N={class:`stat-item`},P={class:`stat-num`},F={key:0,class:`progress-wrap`},I={class:`review-rate`},L={class:`raw-json`},R=p(a({__name:`MetricsView`,setup(a){let p=c(null),R=c(!1),z=c(``),B=o(()=>p.value?.v2?.by_model||null);async function V(){R.value=!0,z.value=``;try{p.value=await f()}catch(e){z.value=e instanceof Error?e.message:`指标加载失败,请检查后端服务`}finally{R.value=!1}}return s(V),(a,o)=>(r(),u(`div`,m,[d(`header`,{class:`metrics-header`},[o[0]||=d(`h2`,null,`系统指标`,-1),d(`button`,{class:`refresh`,onClick:V},`🔄 刷新`)]),R.value?(r(),u(`div`,h,`加载中…`)):z.value?(r(),u(`div`,g,n(z.value),1)):p.value?(r(),u(t,{key:2},[d(`div`,_,[d(`div`,v,[o[1]||=d(`h3`,null,`路由器(v1`,-1),d(`div`,y,[(r(!0),u(t,null,i(p.value.router,(e,i)=>(r(),u(t,{key:i},[d(`span`,null,n(i),1),d(`b`,null,n(e),1)],64))),128))])]),d(`div`,b,[o[2]||=d(`h3`,null,`缓存`,-1),d(`div`,x,[(r(!0),u(t,null,i(p.value.cache,(e,i)=>(r(),u(t,{key:i},[d(`span`,null,n(i),1),d(`b`,null,n(e),1)],64))),128))])]),p.value.v2?(r(),u(`div`,S,[o[3]||=d(`h3`,null,`协作管线(v2`,-1),d(`div`,C,[(r(!0),u(t,null,i(p.value.v2,(i,a)=>(r(),u(t,{key:a},[a===`by_model`?e(``,!0):(r(),u(`span`,w,n(a),1)),a===`by_model`?e(``,!0):(r(),u(`b`,T,n(i),1))],64))),128))])])):e(``,!0),B.value&&Object.keys(B.value).length?(r(),u(`div`,E,[o[5]||=d(`h3`,null,`按模型分账(token / 成本)`,-1),d(`table`,D,[o[4]||=d(`thead`,null,[d(`tr`,null,[d(`th`,null,`模型`),d(`th`,null,`次数`),d(`th`,null,``),d(`th`,null,``),d(`th`,null,`成本 $`)])],-1),d(`tbody`,null,[(r(!0),u(t,null,i(B.value,(e,t)=>(r(),u(`tr`,{key:t},[d(`td`,O,n(t),1),d(`td`,null,n(e.requests),1),d(`td`,null,n(e.input_tokens),1),d(`td`,null,n(e.output_tokens),1),d(`td`,null,n(e.cost_est_usd),1)]))),128))])]),o[6]||=d(`p`,{class:`hint`},`单价来自模型池条目($/1M tokens);经典设置下的模型成本不计入。`,-1)])):e(``,!0),p.value.review?(r(),u(`div`,k,[o[9]||=d(`h3`,null,`人工检验`,-1),d(`div`,A,[d(`div`,j,[d(`span`,M,n(p.value.review.pending),1),o[7]||=d(`span`,{class:`stat-label`},`待审核`,-1)]),d(`div`,N,[d(`span`,P,n(p.value.review.total),1),o[8]||=d(`span`,{class:`stat-label`},`总提交`,-1)])]),p.value.review.total>0?(r(),u(`div`,F,[d(`div`,{class:`reviewed-bar`,style:l({width:`${(p.value.review.total-p.value.review.pending)/p.value.review.total*100}%`})},null,4)])):e(``,!0),d(`p`,I,` 通过率: `+n(((p.value.review.total-p.value.review.pending)/p.value.review.total*100).toFixed(1))+`% `,1)])):e(``,!0)]),d(`details`,L,[o[10]||=d(`summary`,null,`原始 JSON`,-1),d(`pre`,null,n(JSON.stringify(p.value,null,2)),1)])],64)):e(``,!0)]))}}),[[`__scopeId`,`data-v-8b237097`]]);export{R as default};
@@ -1 +1 @@
import{A as e,D as t,E as n,G as r,I as i,L as a,M as o,N as s,O as c,P as l,U as u,V as d,f,j as p,k as m,t as h,v as g,z as _}from"./index-DdcvGqdd.js";var v={class:`review-view`},y={class:`review-header`},b={class:`controls`},x={key:0,class:`loading`},S={key:1,class:`error`},C={key:2,class:`queue-list`},w={key:0,class:`empty`},T={class:`card-header`},E={class:`card-id`},D={class:`tags`},O={class:`date`},k={class:`query-block`},A={class:`response-block`},j={key:0,class:`actions`},M=[`onUpdate:modelValue`],N={class:`btn-row`},P=[`onClick`],F=[`onClick`],I={key:1,class:`correction`},L=h(s({__name:`ReviewView`,setup(s){let h=d([]),L=d(!1),R=d(``),z=d(`pending`),B=d({}),V=c(()=>z.value===`all`?h.value:h.value.filter(e=>e.verdict===z.value));async function H(){L.value=!0,R.value=``;try{h.value=await f()}catch(e){R.value=e instanceof Error?e.message:String(e)}finally{L.value=!1}}async function U(e,t){try{await g(e,t,B.value[e]||void 0),await H()}catch(e){R.value=e instanceof Error?e.message:String(e)}}return l(H),(s,c)=>(i(),p(`div`,v,[m(`header`,y,[c[4]||=m(`h2`,null,`人工检验队列`,-1),m(`div`,b,[m(`button`,{class:u({active:z.value===`all`}),onClick:c[0]||=e=>z.value=`all`},`全部`,2),m(`button`,{class:u({active:z.value===`pending`}),onClick:c[1]||=e=>z.value=`pending`},`待审核`,2),m(`button`,{class:u({active:z.value===`approved`}),onClick:c[2]||=e=>z.value=`approved`},`已通过`,2),m(`button`,{class:u({active:z.value===`rejected`}),onClick:c[3]||=e=>z.value=`rejected`},`已拒绝`,2),m(`button`,{class:`refresh-btn`,onClick:H},`🔄 刷新`)])]),L.value?(i(),p(`div`,x,`加载中…`)):R.value?(i(),p(`div`,S,r(R.value),1)):(i(),p(`div`,C,[V.value.length?e(``,!0):(i(),p(`div`,w,`队列为空。`)),(i(!0),p(t,null,a(V.value,s=>(i(),p(`div`,{key:s.id,class:`review-card`},[m(`div`,T,[m(`span`,E,`#`+r(s.id),1),m(`span`,{class:u([`verdict-badge`,s.verdict])},r(s.verdict),3),m(`span`,D,[(i(!0),p(t,null,a(s.tags,e=>(i(),p(`span`,{key:e,class:`tag`},r(e),1))),128))]),m(`span`,O,r(s.created_at),1)]),m(`div`,k,[c[5]||=m(`strong`,null,`Query`,-1),o(r(s.query),1)]),m(`div`,A,[c[6]||=m(`strong`,null,`Response`,-1),m(`pre`,null,r(s.response),1)]),s.verdict===`pending`?(i(),p(`div`,j,[_(m(`textarea`,{"onUpdate:modelValue":e=>B.value[s.id]=e,placeholder:`修正意见(可选)`,rows:`2`},null,8,M),[[n,B.value[s.id]]]),m(`div`,N,[m(`button`,{class:`approve`,onClick:e=>U(s.id,`approved`)},`✅ 通过`,8,P),m(`button`,{class:`reject`,onClick:e=>U(s.id,`rejected`)},`❌ 拒绝`,8,F)])])):s.correction?(i(),p(`div`,I,[c[7]||=m(`strong`,null,`修正:`,-1),o(r(s.correction),1)])):e(``,!0)]))),128))]))]))}}),[[`__scopeId`,`data-v-d5b38f1c`]]);export{L as default}; import{A as e,D as t,E as n,G as r,I as i,L as a,M as o,N as s,O as c,P as l,U as u,V as d,f,j as p,k as m,t as h,v as g,z as _}from"./index-C8Za1808.js";var v={class:`review-view`},y={class:`review-header`},b={class:`controls`},x={key:0,class:`loading`},S={key:1,class:`error`},C={key:2,class:`queue-list`},w={key:0,class:`empty`},T={class:`card-header`},E={class:`card-id`},D={class:`tags`},O={class:`date`},k={class:`query-block`},A={class:`response-block`},j={key:0,class:`actions`},M=[`onUpdate:modelValue`],N={class:`btn-row`},P=[`onClick`],F=[`onClick`],I={key:1,class:`correction`},L=h(s({__name:`ReviewView`,setup(s){let h=d([]),L=d(!1),R=d(``),z=d(`pending`),B=d({}),V=c(()=>z.value===`all`?h.value:h.value.filter(e=>e.verdict===z.value));async function H(){L.value=!0,R.value=``;try{h.value=await f()}catch(e){R.value=e instanceof Error?e.message:String(e)}finally{L.value=!1}}async function U(e,t){try{await g(e,t,B.value[e]||void 0),await H()}catch(e){R.value=e instanceof Error?e.message:String(e)}}return l(H),(s,c)=>(i(),p(`div`,v,[m(`header`,y,[c[4]||=m(`h2`,null,`人工检验队列`,-1),m(`div`,b,[m(`button`,{class:u({active:z.value===`all`}),onClick:c[0]||=e=>z.value=`all`},`全部`,2),m(`button`,{class:u({active:z.value===`pending`}),onClick:c[1]||=e=>z.value=`pending`},`待审核`,2),m(`button`,{class:u({active:z.value===`approved`}),onClick:c[2]||=e=>z.value=`approved`},`已通过`,2),m(`button`,{class:u({active:z.value===`rejected`}),onClick:c[3]||=e=>z.value=`rejected`},`已拒绝`,2),m(`button`,{class:`refresh-btn`,onClick:H},`🔄 刷新`)])]),L.value?(i(),p(`div`,x,`加载中…`)):R.value?(i(),p(`div`,S,r(R.value),1)):(i(),p(`div`,C,[V.value.length?e(``,!0):(i(),p(`div`,w,`队列为空。`)),(i(!0),p(t,null,a(V.value,s=>(i(),p(`div`,{key:s.id,class:`review-card`},[m(`div`,T,[m(`span`,E,`#`+r(s.id),1),m(`span`,{class:u([`verdict-badge`,s.verdict])},r(s.verdict),3),m(`span`,D,[(i(!0),p(t,null,a(s.tags,e=>(i(),p(`span`,{key:e,class:`tag`},r(e),1))),128))]),m(`span`,O,r(s.created_at),1)]),m(`div`,k,[c[5]||=m(`strong`,null,`Query`,-1),o(r(s.query),1)]),m(`div`,A,[c[6]||=m(`strong`,null,`Response`,-1),m(`pre`,null,r(s.response),1)]),s.verdict===`pending`?(i(),p(`div`,j,[_(m(`textarea`,{"onUpdate:modelValue":e=>B.value[s.id]=e,placeholder:`修正意见(可选)`,rows:`2`},null,8,M),[[n,B.value[s.id]]]),m(`div`,N,[m(`button`,{class:`approve`,onClick:e=>U(s.id,`approved`)},`✅ 通过`,8,P),m(`button`,{class:`reject`,onClick:e=>U(s.id,`rejected`)},`❌ 拒绝`,8,F)])])):s.correction?(i(),p(`div`,I,[c[7]||=m(`strong`,null,`修正:`,-1),o(r(s.correction),1)])):e(``,!0)]))),128))]))]))}}),[[`__scopeId`,`data-v-d5b38f1c`]]);export{L as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>端云协同 LLM 协作系统</title> <title>端云协同 LLM 协作系统</title>
<script type="module" crossorigin src="/static/assets/index-DdcvGqdd.js"></script> <script type="module" crossorigin src="/static/assets/index-C8Za1808.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-CzrTgC9J.css"> <link rel="stylesheet" crossorigin href="/static/assets/index-wqjFJqzj.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+21
View File
@@ -414,6 +414,7 @@ class ToolLoop:
on_event: Optional[Callable[[Dict[str, Any]], None]] = None, on_event: Optional[Callable[[Dict[str, Any]], None]] = None,
result_preview_chars: int = MAX_RESULT_CHARS, result_preview_chars: int = MAX_RESULT_CHARS,
emit_final: bool = True, emit_final: bool = True,
approval_hook: Optional[Callable[[str, Dict[str, Any]], Awaitable[bool]]] = None,
): ):
self.tools = tools self.tools = tools
self.chat_fn = chat_fn self.chat_fn = chat_fn
@@ -422,6 +423,8 @@ class ToolLoop:
self.on_event = on_event self.on_event = on_event
self.result_preview_chars = result_preview_chars self.result_preview_chars = result_preview_chars
self.emit_final = emit_final # 两级模式内层循环置 False,由外层统一收尾 self.emit_final = emit_final # 两级模式内层循环置 False,由外层统一收尾
# 审批门卫(D9):执行工具前调用,返回 False = 用户拒绝(可选;缺省跳过审批)
self.approval_hook = approval_hook
def _emit(self, ev: Dict[str, Any]) -> None: def _emit(self, ev: Dict[str, Any]) -> None:
if ev.get("type") == "final" and not self.emit_final: if ev.get("type") == "final" and not self.emit_final:
@@ -500,6 +503,24 @@ class ToolLoop:
for c in calls: for c in calls:
self._emit({"type": "tool_call", "round": round_no, self._emit({"type": "tool_call", "round": round_no,
"name": c["name"], "arguments": c["arguments"]}) "name": c["name"], "arguments": c["arguments"]})
# 审批门卫(D9):按策略挂起等待用户裁决;拒绝/超时折叠为失败结果回喂
if self.approval_hook is not None:
allowed = False
try:
allowed = await self.approval_hook(c["name"], c["arguments"])
except Exception as e:
self._emit({"type": "approval_decided", "round": round_no,
"id": "", "allowed": False,
"note": f"审批流程异常: {type(e).__name__}"})
if not allowed:
denied = {"ok": False,
"error": "用户拒绝执行该操作(如需执行请调整审批策略或换一种做法)"}
preview = json.dumps(denied, ensure_ascii=False)
self._emit({"type": "tool_result", "round": round_no,
"name": c["name"], "ok": False, "preview": preview})
messages.append({"role": "tool", "tool_call_id": c["id"],
"content": preview})
continue
result = self.tools.execute(c["name"], c["arguments"]) result = self.tools.execute(c["name"], c["arguments"])
preview = json.dumps(result, ensure_ascii=False) preview = json.dumps(result, ensure_ascii=False)
if len(preview) > self.result_preview_chars: if len(preview) > self.result_preview_chars:
+155 -3
View File
@@ -29,9 +29,10 @@ def agent_env(tmp_path, monkeypatch):
# 快照用户真实设置,测试后原样恢复(settings.json 是活文件,不能污染) # 快照用户真实设置,测试后原样恢复(settings.json 是活文件,不能污染)
store = ga.settings_store() store = ga.settings_store()
snapshot = json.loads(json.dumps(store._data, ensure_ascii=False)) snapshot = json.loads(json.dumps(store._data, ensure_ascii=False))
# 工作区指向临时目录 + 给经典回退一个假 key(防止 .env 缺失时 400 # 工作区指向临时目录 + 测试凭据走环境变量(monkeypatch 自动恢复)+ 审批默认关闭
store.update({"agent": {"workspace_dir": str(tmp_path / "ws")}, monkeypatch.setenv("DEEPSEEK_API_KEY", "test-fake-credential-not-a-secret")
"architect": {"api_key": "sk-fake-test"}}) store.update({"agent": {"workspace_dir": str(tmp_path / "ws"),
"approval_policy": "off"}})
ga.rebuild_pipeline() ga.rebuild_pipeline()
script = [] script = []
@@ -519,3 +520,154 @@ def test_cancel_running_agent(agent_env, client, monkeypatch):
st = client.get(f"/agent/{rid}/status").json() st = client.get(f"/agent/{rid}/status").json()
assert st["state"] == "failed" and st["error"] == "cancelled_by_user" assert st["state"] == "failed" and st["error"] == "cancelled_by_user"
assert _t.time() - t0 < 1.5 assert _t.time() - t0 < 1.5
# ---------------- 审批流(T28 ----------------
def test_approval_service_level_timeout_and_deny(tmp_path):
"""service 级闭环:dangerous 策略下写操作挂起 -> 超时自动拒绝 -> 模型收到拒绝结果。
说明:不走 TestClient——其每请求独立 portal 循环会冻结跨请求的后台任务,
无法真实测"挂起等待";这里直接驱动 service.run(与网关 uvicorn 同构)。
"""
import asyncio
async def scenario():
mp.reset_pool()
ag.reset_agent_service()
service = ag.AgentService(run_dir=tmp_path / "runs")
ag._service = service
ws = tmp_path / "ws"
info = service.register("agt01", "写 t.txt", "m", "",
workspace=str(tmp_path / "ws"))
calls = []
async def chat(messages, tools_spec):
calls.append(1)
if len(calls) == 1:
return {"content": None,
"tool_calls": [{"id": "c1", "name": "write_file",
"arguments": {"path": "t.txt", "content": "x"}}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1}}
return {"content": "了解,操作被拒绝。", "tool_calls": [],
"usage": {"prompt_tokens": 1, "completion_tokens": 1}}
await service.run(info, chat, workspace_dir=str(ws),
approval_policy="dangerous", approval_timeout_s=1)
return info, service.read_events("agt01")
info, evs = asyncio.run(scenario())
assert info.state == "done"
assert "拒绝" in info.response
kinds = [e["type"] for e in evs]
assert "approval_request" in kinds and "approval_decided" in kinds
decided = next(e for e in evs if e["type"] == "approval_decided")
assert decided["allowed"] is False
assert "超时" in decided.get("note", "")
assert not (tmp_path / "ws" / "t.txt").exists() # fail-closed:未执行
def test_approval_service_level_allow(tmp_path):
"""service 级:审批请求挂起 -> 管理器裁决允许 -> 工具真实执行。"""
import asyncio
async def scenario():
mp.reset_pool()
ag.reset_agent_service()
service = ag.AgentService(run_dir=tmp_path / "runs2")
ag._service = service
info = service.register("agt02", "写 ok.txt", "m", "",
workspace=str(tmp_path / "ws2"))
calls = []
async def chat(messages, tools_spec):
calls.append(1)
if len(calls) == 1:
return {"content": None,
"tool_calls": [{"id": "c1", "name": "write_file",
"arguments": {"path": "ok.txt", "content": "v"}}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1}}
return {"content": "已写入 ok.txt。", "tool_calls": [],
"usage": {"prompt_tokens": 1, "completion_tokens": 1}}
task = asyncio.create_task(
service.run(info, chat, workspace_dir=str(tmp_path / "ws2"),
approval_policy="dangerous", approval_timeout_s=10))
# 等审批请求出现 -> 模拟用户点「允许一次」
approval_id = None
for _ in range(100):
evs = service.read_events("agt02")
asks = [e for e in evs if e["type"] == "approval_request"]
if asks:
approval_id = asks[0]["id"]
break
await asyncio.sleep(0.05)
assert approval_id, "应出现审批请求"
getattr(info, "_approval_manager").decide(approval_id, True)
await task
return info, service.read_events("agt02")
info, evs = asyncio.run(scenario())
assert info.state == "done"
decided = next(e for e in evs if e["type"] == "approval_decided")
assert decided["allowed"] is True
assert (tmp_path / "ws2" / "ok.txt").read_text(encoding="utf-8") == "v"
def test_approval_endpoint_branches(agent_env, client):
"""approve 端点:未知任务 404;无审批流程 409。"""
assert client.post("/agent/ghost/approve",
json={"approval_id": "x", "allowed": True}).status_code == 404
# 正常任务(无挂起审批)-> 管理器存在但审批单不存在 -> 404
agent_env["set_script"]([
{"content": "直接回答。", "tool_calls": [], "usage": {}},
])
r = client.post("/agent", json={"task": "hi"})
rid = r.json()["request_id"]
_wait_done(agent_env["service"], rid)
r2 = client.post(f"/agent/{rid}/approve",
json={"approval_id": "nope", "allowed": True})
assert r2.status_code in (404, 409)
def test_approval_policy_matrix(agent_env):
from gateway.agent import needs_approval
assert not needs_approval("off", "run_command")
assert not needs_approval("dangerous", "read_file")
assert needs_approval("dangerous", "write_file")
assert needs_approval("dangerous", "run_command")
assert needs_approval("all", "list_dir")
def test_approval_timeout_auto_deny_service_level(tmp_path):
"""审批超时 = 自动拒绝(fail-closed):service 级闭环(TestClient 不支持跨请求挂起)。"""
import asyncio
async def scenario():
ag.reset_agent_service()
service = ag.AgentService(run_dir=tmp_path / "runs3")
ag._service = service
info = service.register("agt03", "写 t.txt", "m", "",
workspace=str(tmp_path / "ws3"))
calls = []
async def chat(messages, tools_spec):
calls.append(1)
if len(calls) == 1:
return {"content": None,
"tool_calls": [{"id": "c1", "name": "write_file",
"arguments": {"path": "t.txt", "content": "x"}}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1}}
return {"content": "了解,操作被拒绝。", "tool_calls": [],
"usage": {"prompt_tokens": 1, "completion_tokens": 1}}
await service.run(info, chat, workspace_dir=str(tmp_path / "ws3"),
approval_policy="dangerous", approval_timeout_s=1)
return info, service.read_events("agt03")
info, evs = asyncio.run(scenario())
assert info.state == "done"
decided = [e for e in evs if e["type"] == "approval_decided"]
assert decided and decided[0]["allowed"] is False
assert "超时" in decided[0].get("note", "")
assert not (tmp_path / "ws3" / "t.txt").exists()
+63
View File
@@ -251,3 +251,66 @@ def test_toolloop_history_injected(ws):
assert msgs[0] == {"role": "system", "content": "SYS"} assert msgs[0] == {"role": "system", "content": "SYS"}
assert msgs[1:3] == hist assert msgs[1:3] == hist
assert msgs[3] == {"role": "user", "content": "新任务"} assert msgs[3] == {"role": "user", "content": "新任务"}
# ---------------- 审批门卫(T28 ----------------
def test_approval_denied_feeds_result_back(ws):
"""审批拒绝:工具不执行,拒绝结果回喂模型。"""
chat = _mk_chat([
{"content": None,
"tool_calls": [{"id": "c1", "name": "write_file",
"arguments": {"path": "x.txt", "content": "hi"}}],
"usage": {}},
{"content": "了解,不写了。", "tool_calls": [],
"usage": {"prompt_tokens": 1, "completion_tokens": 1}},
])
async def deny_hook(name, args):
return False
loop = ToolLoop(ws, chat, approval_hook=deny_hook)
result = asyncio_run(loop.run("写文件"))
assert result["reason"] == "answer"
assert not (ws.root / "x.txt").exists() # 未执行
# 第二轮模型消息里应包含拒绝结果
tool_msg = chat.calls[1][2]
assert tool_msg["role"] == "tool" and "拒绝" in tool_msg["content"]
def test_approval_allowed_executes(ws):
"""审批允许:正常执行。"""
chat = _mk_chat([
{"content": None,
"tool_calls": [{"id": "c1", "name": "write_file",
"arguments": {"path": "y.txt", "content": "ok"}}],
"usage": {}},
{"content": "完成。", "tool_calls": [], "usage": {}},
])
async def allow_hook(name, args):
return True
loop = ToolLoop(ws, chat, approval_hook=allow_hook)
asyncio_run(loop.run("写文件"))
assert (ws.root / "y.txt").exists()
def test_approval_hook_exception_fails_closed(ws):
"""审批钩子异常 = 拒绝(fail-closed)。"""
chat = _mk_chat([
{"content": None,
"tool_calls": [{"id": "c1", "name": "read_file",
"arguments": {"path": "z.txt"}}],
"usage": {}},
{"content": "收到。", "tool_calls": [], "usage": {}},
])
async def boom(name, args):
raise RuntimeError("审批服务挂了")
loop = ToolLoop(ws, chat, approval_hook=boom)
result = asyncio_run(loop.run("读文件"))
assert result["reason"] == "answer"
msgs = chat.calls[1]
assert any("拒绝" in str(m.get("content", "")) for m in msgs)
+14
View File
@@ -312,6 +312,7 @@ export async function listPoolModels(id: string) {
export interface AgentEvent { export interface AgentEvent {
type: 'round' | 'tool_call' | 'tool_result' | 'usage' | 'final' | 'phase' | 'message' type: 'round' | 'tool_call' | 'tool_result' | 'usage' | 'final' | 'phase' | 'message'
| 'approval_request' | 'approval_decided' | 'delta'
ts?: number ts?: number
round?: number round?: number
name?: string name?: string
@@ -328,6 +329,19 @@ export interface AgentEvent {
model?: string model?: string
role?: 'planner' | 'executor' role?: 'planner' | 'executor'
content?: string content?: string
// 审批(D9/ 流式(D10
id?: string
policy?: string
allowed?: boolean
note?: string
text?: string
}
/** POST /agent/{id}/approve:裁决待审批操作(dsh 式 allow-once / deny */
export async function approveAgent(requestId: string, approvalId: string, allowed: boolean) {
const { data } = await http.post<{ ok: boolean }>(`/agent/${requestId}/approve`,
{ approval_id: approvalId, allowed })
return data
} }
export interface AgentStatus { export interface AgentStatus {
+123 -3
View File
@@ -62,6 +62,9 @@
:class="['mini-row', ev.ok ? 'ok' : 'err']"> :class="['mini-row', ev.ok ? 'ok' : 'err']">
{{ ev.ok ? '✓' : '✕' }} {{ ev.name }} · {{ clip(ev.preview, 120) }} {{ ev.ok ? '✓' : '✕' }} {{ ev.name }} · {{ clip(ev.preview, 120) }}
</div> </div>
<div v-else-if="ev.type === 'delta'" class="mini-row delta-row">
{{ clip(ev.text, 200) }}
</div>
</template> </template>
</div> </div>
</details> </details>
@@ -69,7 +72,22 @@
<!-- 历史轮次步数摘要 --> <!-- 历史轮次步数摘要 -->
<div v-else-if="m.toolCalls" class="tool-summary"> 执行了 {{ m.toolCalls }} 步工具调用</div> <div v-else-if="m.toolCalls" class="tool-summary"> 执行了 {{ m.toolCalls }} 步工具调用</div>
<!-- 审批卡D9未裁决时显示操作按钮 -->
<div v-if="m.approval && !m.approval.decided" class="approval-card">
<div class="ap-head"> 等待审批<b>{{ m.approval.name }}</b> 请求执行</div>
<pre class="ap-args">{{ shortArgs(m.approval.arguments) }}</pre>
<div class="ap-actions">
<button class="ap-btn reject" @click="decideApproval(m, false)">拒绝</button>
<button class="ap-btn allow" @click="decideApproval(m, true)">允许一次</button>
</div>
</div>
<div v-else-if="m.approval && m.approval.decided" class="ap-done"
:class="m.approval.allowed ? 'ok' : 'no'">
{{ m.approval.allowed ? '✓ 已允许执行' : '✕ 已拒绝' }}
</div>
<!-- 正文 / 运行中指示 --> <!-- 正文 / 运行中指示 -->
<div v-if="m.streamText" class="ai-stream">{{ m.streamText }}<span class="cursor" /></div>
<div v-if="m.content" class="ai-text">{{ m.content }}</div> <div v-if="m.content" class="ai-text">{{ m.content }}</div>
<div v-else-if="m.error" class="ai-error"> {{ m.error }}</div> <div v-else-if="m.error" class="ai-error"> {{ m.error }}</div>
<div v-else-if="running && i === messages.length - 1" class="ai-running"> <div v-else-if="running && i === messages.length - 1" class="ai-running">
@@ -98,6 +116,12 @@
<label class="chip toggle-chip" title="开启后智能体可执行 shell 命令"> <label class="chip toggle-chip" title="开启后智能体可执行 shell 命令">
<input type="checkbox" v-model="allowShell" @change="toggleShell" /> 命令 <input type="checkbox" v-model="allowShell" @change="toggleShell" /> 命令
</label> </label>
<select v-model="approvalPolicy" class="chip select-chip" title="审批策略:哪些操作需要你确认"
@change="savePolicy">
<option value="off">审批</option>
<option value="dangerous">审批写与命令</option>
<option value="all">审批全部操作</option>
</select>
<span v-if="errorMsg" class="composer-err">{{ errorMsg }}</span> <span v-if="errorMsg" class="composer-err">{{ errorMsg }}</span>
</div> </div>
<div class="composer-main"> <div class="composer-main">
@@ -153,9 +177,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, nextTick, onMounted } from 'vue' import { ref, computed, nextTick, onMounted } from 'vue'
import { import {
startAgent, getAgentStatus, watchAgent, startAgent, getAgentStatus, watchAgent, approveAgent, getConfig, updateConfig,
getPool, getPool,
browseFs, getAgentWorkspaces, openWorkspace, updateConfig, browseFs, getAgentWorkspaces, openWorkspace,
listAgentSessions, createAgentSession, getAgentSession, deleteAgentSession, cancelAgent, listAgentSessions, createAgentSession, getAgentSession, deleteAgentSession, cancelAgent,
} from '@/api' } from '@/api'
import type { AgentEvent, PoolEntry, FsBrowse } from '@/api' import type { AgentEvent, PoolEntry, FsBrowse } from '@/api'
@@ -167,6 +191,8 @@ interface ChatMsg {
error?: string | null error?: string | null
liveEvents?: AgentEvent[] liveEvents?: AgentEvent[]
requestId?: string requestId?: string
streamText?: string
approval?: { id: string; name: string; arguments?: Record<string, unknown>; decided: boolean; allowed: boolean }
} }
const SAMPLES = [ const SAMPLES = [
@@ -192,6 +218,7 @@ const errorMsg = ref('')
const selectedRoot = ref('') const selectedRoot = ref('')
const selectedExecutorId = ref('') const selectedExecutorId = ref('')
const allowShell = ref(false) const allowShell = ref(false)
const approvalPolicy = ref('dangerous')
const recent = ref<string[]>([]) const recent = ref<string[]>([])
const poolEntries = ref<PoolEntry[]>([]) const poolEntries = ref<PoolEntry[]>([])
const pickerOpen = ref(false) const pickerOpen = ref(false)
@@ -306,6 +333,10 @@ async function toggleShell() {
try { await updateConfig({ agent: { allow_shell: allowShell.value } } as any) } try { await updateConfig({ agent: { allow_shell: allowShell.value } } as any) }
catch { allowShell.value = !allowShell.value } catch { allowShell.value = !allowShell.value }
} }
async function savePolicy() {
try { await updateConfig({ agent: { approval_policy: approvalPolicy.value } } as any) }
catch { /* 恢复由刷新处理 */ }
}
// ── 发送 / 停止 ───────────────────────────────────────────────────────── // ── 发送 / 停止 ─────────────────────────────────────────────────────────
async function send() { async function send() {
@@ -342,6 +373,21 @@ async function send() {
_watch.subscribe({ _watch.subscribe({
onEvent: (ev) => { onEvent: (ev) => {
if (ev.type === 'final') return if (ev.type === 'final') return
if (ev.type === 'approval_request') {
aiMsg.approval = { id: ev.id!, name: ev.name!, arguments: ev.arguments,
decided: false, allowed: false }
} else if (ev.type === 'approval_decided') {
if (aiMsg.approval && aiMsg.approval.id === ev.id) {
aiMsg.approval.decided = true
aiMsg.approval.allowed = !!ev.allowed
}
} else if (ev.type === 'delta') {
aiMsg.streamText = (aiMsg.streamText || '') + (ev.text || '')
scrollToBottom()
return
} else if (ev.type === 'tool_call' || ev.type === 'phase' || ev.type === 'message') {
aiMsg.streamText = '' // 工具/阶段开始,清空流式文本
}
aiMsg.liveEvents!.push(ev) aiMsg.liveEvents!.push(ev)
scrollToBottom() scrollToBottom()
}, },
@@ -371,13 +417,25 @@ async function stop() {
try { await cancelAgent(currentRequestId.value) } catch { /* ignore */ } try { await cancelAgent(currentRequestId.value) } catch { /* ignore */ }
} }
async function decideApproval(m: ChatMsg, allowed: boolean) {
if (!m.approval || !m.requestId) return
m.approval.decided = true
m.approval.allowed = allowed
try { await approveAgent(m.requestId, m.approval.id, allowed) }
catch (e: any) {
m.approval.decided = false
errorMsg.value = e?.response?.data?.detail || e?.message || String(e)
}
}
onMounted(async () => { onMounted(async () => {
refreshSessions() refreshSessions()
try { try {
const [pool, ws] = await Promise.all([getPool(), getAgentWorkspaces()]) const [pool, ws, cfg] = await Promise.all([getPool(), getAgentWorkspaces(), getConfig()])
poolEntries.value = pool.entries poolEntries.value = pool.entries
selectedRoot.value = ws.current || '' selectedRoot.value = ws.current || ''
recent.value = ws.recent || [] recent.value = ws.recent || []
if ((cfg as any).agent?.approval_policy) approvalPolicy.value = (cfg as any).agent.approval_policy
} catch { /* ignore */ } } catch { /* ignore */ }
}) })
</script> </script>
@@ -563,6 +621,68 @@ onMounted(async () => {
.mini-args { color: var(--c-caption); margin-left: 6px; } .mini-args { color: var(--c-caption); margin-left: 6px; }
.tool-summary { font-size: 12px; color: var(--c-caption); margin-bottom: 8px; } .tool-summary { font-size: 12px; color: var(--c-caption); margin-bottom: 8px; }
/* 审批卡(D9 */
.approval-card {
border: 1px solid var(--c-warn);
background: var(--c-warn-soft);
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 10px;
}
.ap-head { font-size: 13px; font-weight: 600; color: var(--c-warn); margin-bottom: 6px; }
.ap-args {
margin: 0 0 8px;
font-size: 12px;
font-family: var(--font-mono);
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: 6px;
padding: 6px 9px;
white-space: pre-wrap;
word-break: break-all;
max-height: 140px;
overflow-y: auto;
color: var(--c-text);
}
.ap-actions { display: flex; gap: 8px; }
.ap-btn {
border: none;
border-radius: 7px;
padding: 6px 16px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
color: #fff;
}
.ap-btn.reject { background: var(--c-text-2); }
.ap-btn.reject:hover { background: var(--c-text); }
.ap-btn.allow { background: var(--c-primary); }
.ap-btn.allow:hover { background: var(--c-primary-strong); }
.ap-done { font-size: 12px; font-weight: 600; margin-bottom: 8px; }
.ap-done.ok { color: var(--c-ok); }
.ap-done.no { color: var(--c-err); }
/* 流式实时文本(D10 */
.ai-stream {
font-size: 14px;
line-height: 1.65;
white-space: pre-wrap;
word-break: break-word;
color: var(--c-text);
margin-bottom: 6px;
}
.cursor {
display: inline-block;
width: 7px;
height: 15px;
background: var(--c-primary);
margin-left: 2px;
vertical-align: -2px;
animation: blink 0.9s steps(1) infinite;
}
@keyframes blink { 50% { opacity: 0; } }
.delta-row { color: var(--c-caption); }
/* ---------- Composer ---------- */ /* ---------- Composer ---------- */
.composer-wrap { padding: 10px 20px 16px; } .composer-wrap { padding: 10px 20px 16px; }
.composer { .composer {